#153: Stabile Task-IDs + Toggle-Rückschreibpfad über den Collab-Server
task_item bekommt ein optionales id-Attr (default null — Bestandsdocs
bleiben gültig), durchgereicht in toDOM/parseDOM und dem Lese-HTML;
der Editor vergibt/entdoppelt IDs lazy per appendTransaction
(TaskItemIds-Extension, auch gegen Copy/Paste). Neuer Kanal
TASK_TOGGLE_CHANNEL; POST /pages/:id/tasks/:taskId {checked} prüft
Schreibrecht, registriert den Toggler als pending contributor und
feuert pg_notify; neuer collab task-toggle-listener (Struktur =
restore-listener) öffnet eine DirectConnection und flippt das
checked-Attribut in einer Transaktion — offene Editoren konvergieren,
unbekannte taskId = geloggter No-op. DB-Test (NOTIFY-Payload,
Attribution, 403/404/400).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
This commit is contained in:
parent
92a3b2f6d5
commit
3f7190ebcc
@ -26,7 +26,9 @@ import {
|
||||
pageDeleteQuerySchema,
|
||||
pageListQuerySchema,
|
||||
repositionPageInputSchema,
|
||||
toggleTaskInputSchema,
|
||||
updatePageInputSchema,
|
||||
type ToggleTaskInput,
|
||||
} from '@dorfteich/shared';
|
||||
import type { Response } from 'express';
|
||||
|
||||
@ -76,6 +78,21 @@ export class PagesController {
|
||||
return this.pages.getState(request.user!, id);
|
||||
}
|
||||
|
||||
/** Toggles one task-list checkbox (issue #153). Applied asynchronously
|
||||
* through the collab server, so open editors converge — callers toggle
|
||||
* optimistically and refetch. */
|
||||
@Post('pages/:id/tasks/:taskId')
|
||||
@RequiresPagePermission('write', { idParam: 'id' })
|
||||
@HttpCode(202)
|
||||
async toggleTask(
|
||||
@Param('id') id: string,
|
||||
@Param('taskId') taskId: string,
|
||||
@Body(new ZodValidationPipe(toggleTaskInputSchema)) input: ToggleTaskInput,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<void> {
|
||||
await this.pages.toggleTask(request.user!, id, taskId, input.checked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-lived collaboration token for the collab server (issue #34).
|
||||
* `@Public()` so an anonymous visitor to a public page can obtain a token
|
||||
|
||||
@ -17,6 +17,8 @@ import {
|
||||
PluginPageSummary,
|
||||
RepositionPageInput,
|
||||
SidebarSortMode,
|
||||
TASK_TOGGLE_CHANNEL,
|
||||
TaskToggleRequest,
|
||||
TreeItem,
|
||||
UpdatePageInput,
|
||||
collectSubtreeIds,
|
||||
@ -110,6 +112,24 @@ export class PagesService {
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles one task-list checkbox (issue #153). The collab server owns the
|
||||
* live document, so this only records the toggler as a pending contributor
|
||||
* (version attribution) and emits the NOTIFY — the listener applies the
|
||||
* attribute change as a normal edit and every open client converges.
|
||||
*/
|
||||
async toggleTask(user: User, pageId: string, taskId: string, checked: boolean): Promise<void> {
|
||||
await this.findLivePage(pageId);
|
||||
await this.prisma.pagePendingContributor.upsert({
|
||||
where: { pageId_userId: { pageId, userId: user.id } },
|
||||
update: {},
|
||||
create: { pageId, userId: user.id },
|
||||
});
|
||||
const payload: TaskToggleRequest = { pageId, taskId, checked, userId: user.id };
|
||||
await this.prisma
|
||||
.$executeRaw`SELECT pg_notify(${TASK_TOGGLE_CHANNEL}, ${JSON.stringify(payload)})`;
|
||||
}
|
||||
|
||||
/** The live `{id, parentId}` skeleton of a pond — input to the tree walks
|
||||
* (issue #106). Trashed pages keep their `parentId` but never count here. */
|
||||
private async livePageTree(pondId: string): Promise<TreeItem[]> {
|
||||
|
||||
154
apps/api/src/pages/task-toggle.e2e.db.test.ts
Normal file
154
apps/api/src/pages/task-toggle.e2e.db.test.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { TASK_TOGGLE_CHANNEL, TaskToggleRequest } from '@dorfteich/shared';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { Client } from 'pg';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
/**
|
||||
* Task-toggle request path (issue #153): the api checks write permission,
|
||||
* records the toggler as a pending contributor, and emits the NOTIFY the
|
||||
* collab server consumes. The Yjs application itself lives in the collab
|
||||
* listener (verified through the collab e2e stack).
|
||||
*/
|
||||
describe.skipIf(!hasTestDb)('task toggle endpoint (e2e, issue #153)', () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaClient;
|
||||
const suffix = uniqueSuffix();
|
||||
const password = 'aufgaben sind erledigt 1';
|
||||
|
||||
let editorId: string;
|
||||
let editorCookie: string;
|
||||
let readerCookie: string;
|
||||
let pageId: string;
|
||||
|
||||
const notifies: TaskToggleRequest[] = [];
|
||||
let listenClient: Client;
|
||||
|
||||
const api = () => request(app.getHttpServer());
|
||||
|
||||
beforeAll(async () => {
|
||||
prisma = createTestPrisma();
|
||||
app = await createTestApp();
|
||||
const users = app.get(UsersService);
|
||||
|
||||
const mkUser = async (handle: string) => {
|
||||
const username = `task-${handle}-${suffix}`;
|
||||
const user = await users.createUser({
|
||||
username,
|
||||
email: `${username}@example.test`,
|
||||
displayName: `Task ${handle}`,
|
||||
password,
|
||||
locale: 'en',
|
||||
});
|
||||
await users.markEmailVerified(user.id);
|
||||
const cookie = sessionCookieOf(
|
||||
await api()
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ usernameOrEmail: username, password })
|
||||
.expect(200),
|
||||
);
|
||||
return { id: user.id, cookie };
|
||||
};
|
||||
const editor = await mkUser('editor');
|
||||
editorId = editor.id;
|
||||
editorCookie = editor.cookie;
|
||||
const reader = await mkUser('reader');
|
||||
readerCookie = reader.cookie;
|
||||
|
||||
const pond = await prisma.pond.create({
|
||||
data: { slug: `task-pond-${suffix}`, name: 'Task Pond', type: 'SHARED', ownerId: editorId },
|
||||
});
|
||||
const page = await prisma.page.create({
|
||||
data: {
|
||||
pondId: pond.id,
|
||||
slug: `tasks-${suffix}`,
|
||||
title: 'Tasks',
|
||||
createdBy: editorId,
|
||||
sortKey: 'a0',
|
||||
ydocState: new Uint8Array(),
|
||||
},
|
||||
});
|
||||
pageId = page.id;
|
||||
for (const [userId, role] of [
|
||||
[editorId, 'EDITOR'],
|
||||
[reader.id, 'READER'],
|
||||
] as const) {
|
||||
await prisma.roleGrant.create({
|
||||
data: {
|
||||
pondId: pond.id,
|
||||
subjectType: 'USER',
|
||||
subjectId: userId,
|
||||
role,
|
||||
scopeType: 'POND',
|
||||
scopeId: null,
|
||||
effect: 'ALLOW',
|
||||
createdBy: editorId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
listenClient = new Client({ connectionString: process.env.TEST_DATABASE_URL });
|
||||
await listenClient.connect();
|
||||
listenClient.on('notification', (message) => {
|
||||
if (message.channel === TASK_TOGGLE_CHANNEL && message.payload) {
|
||||
notifies.push(JSON.parse(message.payload) as TaskToggleRequest);
|
||||
}
|
||||
});
|
||||
await listenClient.query(`LISTEN ${TASK_TOGGLE_CHANNEL}`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await listenClient.end().catch(() => undefined);
|
||||
await prisma.pagePendingContributor.deleteMany({ where: { pageId } });
|
||||
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: editorId } } });
|
||||
await prisma.page.deleteMany({ where: { pond: { ownerId: editorId } } });
|
||||
await prisma.pond.deleteMany({ where: { ownerId: editorId } });
|
||||
await prisma.session.deleteMany({ where: { user: { username: { contains: suffix } } } });
|
||||
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||
await prisma.$disconnect();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('emits the toggle NOTIFY and records the pending contributor', async () => {
|
||||
await api()
|
||||
.post(`/api/v1/pages/${pageId}/tasks/abc123defg`)
|
||||
.set('Cookie', editorCookie)
|
||||
.send({ checked: true })
|
||||
.expect(202);
|
||||
|
||||
await expect.poll(() => notifies.length, { timeout: 5000 }).toBeGreaterThan(0);
|
||||
expect(notifies[0]).toEqual({
|
||||
pageId,
|
||||
taskId: 'abc123defg',
|
||||
checked: true,
|
||||
userId: editorId,
|
||||
});
|
||||
|
||||
const pending = await prisma.pagePendingContributor.findMany({ where: { pageId } });
|
||||
expect(pending.map((row) => row.userId)).toContain(editorId);
|
||||
});
|
||||
|
||||
it('refuses read-only users and hides unknown pages', async () => {
|
||||
// A reader may see the page, so the write refusal is a 403 (#60).
|
||||
await api()
|
||||
.post(`/api/v1/pages/${pageId}/tasks/abc123defg`)
|
||||
.set('Cookie', readerCookie)
|
||||
.send({ checked: true })
|
||||
.expect(403);
|
||||
await api()
|
||||
.post(`/api/v1/pages/00000000-0000-4000-8000-000000000000/tasks/x`)
|
||||
.set('Cookie', editorCookie)
|
||||
.send({ checked: true })
|
||||
.expect(404);
|
||||
await api()
|
||||
.post(`/api/v1/pages/${pageId}/tasks/abc123defg`)
|
||||
.set('Cookie', editorCookie)
|
||||
.send({ checked: 'yes' })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
@ -7,6 +7,7 @@ import { createLogger } from './logger.js';
|
||||
import { createMaintenanceListener, type MaintenanceListener } from './maintenance-listener.js';
|
||||
import { PostgresPagePersistence } from './persistence.js';
|
||||
import { createRestoreListener } from './restore-listener.js';
|
||||
import { createTaskToggleListener } from './task-toggle-listener.js';
|
||||
import { closeDocumentConnections, createCollabServer } from './server.js';
|
||||
import { PostgresSessionRegistry } from './session-registry.js';
|
||||
import { PostgresVersionStore } from './version-store.js';
|
||||
@ -75,9 +76,18 @@ async function bootstrap(): Promise<void> {
|
||||
logger,
|
||||
});
|
||||
|
||||
// Applies api-requested task-checkbox toggles (issue #153).
|
||||
const taskToggleListener = createTaskToggleListener({
|
||||
createClient: () => new Client({ connectionString: env.DATABASE_URL }),
|
||||
openDirectConnection: (documentName) =>
|
||||
server.hocuspocus.openDirectConnection(documentName, { userId: 'task-toggle', mode: 'rw' }),
|
||||
logger,
|
||||
});
|
||||
|
||||
await server.listen(env.PORT);
|
||||
await accessListener.start();
|
||||
await restoreListener.start();
|
||||
await taskToggleListener.start();
|
||||
await maintenanceListener.start();
|
||||
sessionRegistry.start(() => [...server.hocuspocus.documents.keys()]);
|
||||
logger.info({ event: 'listen', port: env.PORT }, 'collaboration server listening');
|
||||
@ -88,6 +98,7 @@ async function bootstrap(): Promise<void> {
|
||||
void Promise.allSettled([
|
||||
accessListener.stop(),
|
||||
restoreListener.stop(),
|
||||
taskToggleListener.stop(),
|
||||
maintenanceListener.stop(),
|
||||
server.destroy(),
|
||||
pool.end(),
|
||||
|
||||
156
apps/collab/src/task-toggle-listener.ts
Normal file
156
apps/collab/src/task-toggle-listener.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { TASK_TOGGLE_CHANNEL, TaskToggleRequest } from '@dorfteich/shared';
|
||||
import type { Client } from 'pg';
|
||||
import type { Logger } from 'pino';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import type { DirectDocumentConnection } from './restore-listener.js';
|
||||
|
||||
export interface TaskToggleListenerDeps {
|
||||
/** Dedicated `LISTEN` connection factory (connection-bound, not pooled). */
|
||||
createClient: () => Client;
|
||||
/** Opens a server-side connection to a document so edits broadcast + persist. */
|
||||
openDirectConnection: (documentName: string) => Promise<DirectDocumentConnection>;
|
||||
logger: Logger;
|
||||
reconnectDelayMs?: number;
|
||||
}
|
||||
|
||||
export interface TaskToggleListener {
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_RECONNECT_DELAY_MS = 1000;
|
||||
const FRAGMENT_NAME = 'default';
|
||||
|
||||
/** Depth-first search for the task item carrying the wanted stable id. */
|
||||
function findTaskItem(fragment: Y.XmlFragment, taskId: string): Y.XmlElement | null {
|
||||
let found: Y.XmlElement | null = null;
|
||||
const walk = (element: Y.XmlElement | Y.XmlFragment): void => {
|
||||
for (const child of element.toArray()) {
|
||||
if (found) return;
|
||||
if (child instanceof Y.XmlElement) {
|
||||
if (child.nodeName === 'task_item' && child.getAttribute('id') === taskId) {
|
||||
found = child;
|
||||
return;
|
||||
}
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(fragment);
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies task-checkbox toggles requested by the api (issue #153). The api
|
||||
* checks write permission and emits a {@link TASK_TOGGLE_CHANNEL}
|
||||
* notification; this listener owns the live document, opens a direct
|
||||
* connection (loading the document if nobody has it open) and flips the
|
||||
* `checked` attribute in a transaction — a normal edit that Hocuspocus
|
||||
* broadcasts to all clients and persists. An unknown task id is a warn-level
|
||||
* no-op (the source line may have been deleted meanwhile).
|
||||
*/
|
||||
export function createTaskToggleListener(deps: TaskToggleListenerDeps): TaskToggleListener {
|
||||
const reconnectDelayMs = deps.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS;
|
||||
let client: Client | null = null;
|
||||
let stopped = false;
|
||||
let reconnectTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
async function toggle(request: TaskToggleRequest): Promise<void> {
|
||||
const { pageId, taskId, checked, userId } = request;
|
||||
const connection = await deps.openDirectConnection(pageId);
|
||||
try {
|
||||
let applied = false;
|
||||
await connection.transact((doc) => {
|
||||
const item = findTaskItem(doc.getXmlFragment(FRAGMENT_NAME), taskId);
|
||||
if (!item) return;
|
||||
item.setAttribute('checked', checked as unknown as string);
|
||||
applied = true;
|
||||
});
|
||||
if (applied) {
|
||||
deps.logger.info(
|
||||
{ event: 'task_toggle.applied', pageId, taskId, checked, userId },
|
||||
'toggled task item',
|
||||
);
|
||||
} else {
|
||||
deps.logger.warn(
|
||||
{ event: 'task_toggle.target_missing', pageId, taskId },
|
||||
'task item not found; toggle skipped',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (stopped || reconnectTimer) return;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
void connect();
|
||||
}, reconnectDelayMs);
|
||||
reconnectTimer.unref?.();
|
||||
}
|
||||
|
||||
async function connect(): Promise<void> {
|
||||
if (stopped) return;
|
||||
const next = deps.createClient();
|
||||
next.on('error', (error) => {
|
||||
deps.logger.warn(
|
||||
{ event: 'task_toggle.listen.error', err: error.message },
|
||||
'task toggle listener connection error; will reconnect',
|
||||
);
|
||||
if (client === next) client = null;
|
||||
scheduleReconnect();
|
||||
});
|
||||
next.on('notification', (message) => {
|
||||
if (message.channel !== TASK_TOGGLE_CHANNEL || !message.payload) return;
|
||||
let request: TaskToggleRequest;
|
||||
try {
|
||||
request = JSON.parse(message.payload) as TaskToggleRequest;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
void toggle(request).catch((error: unknown) => {
|
||||
deps.logger.error(
|
||||
{ event: 'task_toggle.failed', err: (error as Error).message },
|
||||
'failed to apply task toggle',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await next.connect();
|
||||
await next.query(`LISTEN ${TASK_TOGGLE_CHANNEL}`);
|
||||
client = next;
|
||||
deps.logger.info(
|
||||
{ event: 'task_toggle.listen.ready', channel: TASK_TOGGLE_CHANNEL },
|
||||
'listening for task toggle requests',
|
||||
);
|
||||
} catch (error) {
|
||||
deps.logger.warn(
|
||||
{ event: 'task_toggle.listen.connect_failed', err: (error as Error).message },
|
||||
'could not start task toggle listener; will retry',
|
||||
);
|
||||
await next.end().catch(() => undefined);
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async start(): Promise<void> {
|
||||
stopped = false;
|
||||
await connect();
|
||||
},
|
||||
async stop(): Promise<void> {
|
||||
stopped = true;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
const current = client;
|
||||
client = null;
|
||||
if (current) await current.end().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -8,6 +8,7 @@ import { PluginBlock } from './nodes/plugin-block';
|
||||
import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
|
||||
import { TaskItem } from './nodes/task-item';
|
||||
import { DateMarker } from './nodes/date-marker';
|
||||
import { TaskItemIds } from './task-item-ids';
|
||||
import { Mention } from './nodes/mention';
|
||||
import { Transclusion } from './nodes/transclusion';
|
||||
import { Wikilink } from './nodes/wikilink';
|
||||
@ -48,6 +49,7 @@ export const documentExtensions: AnyExtension[] = [
|
||||
Wikilink,
|
||||
Mention,
|
||||
DateMarker,
|
||||
TaskItemIds,
|
||||
Transclusion,
|
||||
Table,
|
||||
TableRow,
|
||||
|
||||
48
apps/web/src/editor/task-item-ids.ts
Normal file
48
apps/web/src/editor/task-item-ids.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
|
||||
/** ~10 URL-safe random chars — plenty for per-document uniqueness. */
|
||||
function freshTaskId(): string {
|
||||
const bytes = new Uint8Array(8);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (byte) => (byte % 36).toString(36)).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily assigns stable ids to task items (issue #153): every `task_item`
|
||||
* without an id — and every duplicate created by copy/paste — gets a fresh
|
||||
* one in an appended transaction. Runs through the normal editing pipeline,
|
||||
* so ids replicate via the collaboration document like any other change and
|
||||
* never conflict. Existing documents pick up ids the next time they are
|
||||
* opened for editing.
|
||||
*/
|
||||
export const TaskItemIds = Extension.create({
|
||||
name: 'taskItemIds',
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('taskItemIds'),
|
||||
appendTransaction: (transactions, _oldState, newState) => {
|
||||
if (!transactions.some((tr) => tr.docChanged)) return null;
|
||||
const seen = new Set<string>();
|
||||
let tr = null as ReturnType<typeof newState.tr.setNodeMarkup> | null;
|
||||
newState.doc.descendants((node, pos) => {
|
||||
if (node.type.name !== 'task_item') return;
|
||||
const id = node.attrs.id as string | null;
|
||||
if (id && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
return;
|
||||
}
|
||||
const next = freshTaskId();
|
||||
seen.add(next);
|
||||
tr = (tr ?? newState.tr).setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
id: next,
|
||||
});
|
||||
});
|
||||
return tr;
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@ -47,6 +47,25 @@ export interface PageVersionCreatedEvent {
|
||||
contributorIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* PostgreSQL `NOTIFY` channel over which the api asks the collab server to
|
||||
* toggle a single task-list checkbox (issue #153). The api has already
|
||||
* checked write permission; the collab server owns the live document and
|
||||
* applies the attribute change as a normal edit, so every open client
|
||||
* converges. Payload is a JSON {@link TaskToggleRequest}.
|
||||
*/
|
||||
export const TASK_TOGGLE_CHANNEL = 'task_toggle';
|
||||
|
||||
/** JSON payload carried on {@link TASK_TOGGLE_CHANNEL}. */
|
||||
export interface TaskToggleRequest {
|
||||
pageId: string;
|
||||
/** The task item's stable `id` attribute (issue #153). */
|
||||
taskId: string;
|
||||
checked: boolean;
|
||||
/** The user who toggled — recorded as a pending contributor. */
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/** JSON payload carried on {@link PAGE_RESTORE_CHANNEL}. */
|
||||
export interface PageRestoreRequest {
|
||||
pageId: string;
|
||||
|
||||
@ -94,7 +94,8 @@ function renderListItems(node: Node): string {
|
||||
node.forEach((item) => {
|
||||
if (item.type.name === 'task_item') {
|
||||
const checked = item.attrs.checked === true;
|
||||
out += `<li data-type="task_item" data-checked="${checked}"><input type="checkbox" disabled${checked ? ' checked' : ''}>${renderBlocks(item)}</li>`;
|
||||
const id = item.attrs.id ? ` data-task-id="${escapeHtml(item.attrs.id as string)}"` : '';
|
||||
out += `<li data-type="task_item" data-checked="${checked}"${id}><input type="checkbox" disabled${checked ? ' checked' : ''}>${renderBlocks(item)}</li>`;
|
||||
} else {
|
||||
out += `<li>${renderBlocks(item)}</li>`;
|
||||
}
|
||||
|
||||
@ -167,13 +167,31 @@ export const editorSchema = new Schema({
|
||||
|
||||
task_item: {
|
||||
content: 'paragraph block*',
|
||||
attrs: { checked: { default: false, validate: 'boolean' } },
|
||||
parseDOM: [{ tag: 'li[data-type="task_item"]' }],
|
||||
toDOM: (node) => [
|
||||
'li',
|
||||
{ 'data-type': 'task_item', 'data-checked': String(node.attrs.checked) },
|
||||
0,
|
||||
// `id` (issue #153): a stable per-line id (assigned lazily in the
|
||||
// editor) so the task overview (#154) can address a single checkbox for
|
||||
// display and server-side toggling. `default: null` keeps every
|
||||
// existing document valid; Markdown stays id-less by design.
|
||||
attrs: {
|
||||
checked: { default: false, validate: 'boolean' },
|
||||
id: { default: null },
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: 'li[data-type="task_item"]',
|
||||
getAttrs: (dom) => ({
|
||||
checked: dom.getAttribute('data-checked') === 'true',
|
||||
id: dom.getAttribute('data-task-id') || null,
|
||||
}),
|
||||
},
|
||||
],
|
||||
toDOM: (node) => {
|
||||
const attrs: Record<string, string> = {
|
||||
'data-type': 'task_item',
|
||||
'data-checked': String(node.attrs.checked),
|
||||
};
|
||||
if (node.attrs.id) attrs['data-task-id'] = node.attrs.id as string;
|
||||
return ['li', attrs, 0];
|
||||
},
|
||||
},
|
||||
|
||||
text: { group: 'inline' },
|
||||
|
||||
@ -66,6 +66,10 @@ export type RepositionPageInput = z.infer<typeof repositionPageInputSchema>;
|
||||
* nothing disappears but the page itself); `subtree` trashes every live
|
||||
* descendant along with it, which requires write permission on all of them.
|
||||
*/
|
||||
/** Body of `POST /pages/:id/tasks/:taskId` (issue #153). */
|
||||
export const toggleTaskInputSchema = z.object({ checked: z.boolean() });
|
||||
export type ToggleTaskInput = z.infer<typeof toggleTaskInputSchema>;
|
||||
|
||||
export const PAGE_DELETE_MODES = ['promote', 'subtree'] as const;
|
||||
export type PageDeleteMode = (typeof PAGE_DELETE_MODES)[number];
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user