#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,
|
pageDeleteQuerySchema,
|
||||||
pageListQuerySchema,
|
pageListQuerySchema,
|
||||||
repositionPageInputSchema,
|
repositionPageInputSchema,
|
||||||
|
toggleTaskInputSchema,
|
||||||
updatePageInputSchema,
|
updatePageInputSchema,
|
||||||
|
type ToggleTaskInput,
|
||||||
} from '@dorfteich/shared';
|
} from '@dorfteich/shared';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
|
||||||
@ -76,6 +78,21 @@ export class PagesController {
|
|||||||
return this.pages.getState(request.user!, id);
|
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).
|
* Short-lived collaboration token for the collab server (issue #34).
|
||||||
* `@Public()` so an anonymous visitor to a public page can obtain a token
|
* `@Public()` so an anonymous visitor to a public page can obtain a token
|
||||||
|
|||||||
@ -17,6 +17,8 @@ import {
|
|||||||
PluginPageSummary,
|
PluginPageSummary,
|
||||||
RepositionPageInput,
|
RepositionPageInput,
|
||||||
SidebarSortMode,
|
SidebarSortMode,
|
||||||
|
TASK_TOGGLE_CHANNEL,
|
||||||
|
TaskToggleRequest,
|
||||||
TreeItem,
|
TreeItem,
|
||||||
UpdatePageInput,
|
UpdatePageInput,
|
||||||
collectSubtreeIds,
|
collectSubtreeIds,
|
||||||
@ -110,6 +112,24 @@ export class PagesService {
|
|||||||
return page;
|
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
|
/** The live `{id, parentId}` skeleton of a pond — input to the tree walks
|
||||||
* (issue #106). Trashed pages keep their `parentId` but never count here. */
|
* (issue #106). Trashed pages keep their `parentId` but never count here. */
|
||||||
private async livePageTree(pondId: string): Promise<TreeItem[]> {
|
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 { createMaintenanceListener, type MaintenanceListener } from './maintenance-listener.js';
|
||||||
import { PostgresPagePersistence } from './persistence.js';
|
import { PostgresPagePersistence } from './persistence.js';
|
||||||
import { createRestoreListener } from './restore-listener.js';
|
import { createRestoreListener } from './restore-listener.js';
|
||||||
|
import { createTaskToggleListener } from './task-toggle-listener.js';
|
||||||
import { closeDocumentConnections, createCollabServer } from './server.js';
|
import { closeDocumentConnections, createCollabServer } from './server.js';
|
||||||
import { PostgresSessionRegistry } from './session-registry.js';
|
import { PostgresSessionRegistry } from './session-registry.js';
|
||||||
import { PostgresVersionStore } from './version-store.js';
|
import { PostgresVersionStore } from './version-store.js';
|
||||||
@ -75,9 +76,18 @@ async function bootstrap(): Promise<void> {
|
|||||||
logger,
|
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 server.listen(env.PORT);
|
||||||
await accessListener.start();
|
await accessListener.start();
|
||||||
await restoreListener.start();
|
await restoreListener.start();
|
||||||
|
await taskToggleListener.start();
|
||||||
await maintenanceListener.start();
|
await maintenanceListener.start();
|
||||||
sessionRegistry.start(() => [...server.hocuspocus.documents.keys()]);
|
sessionRegistry.start(() => [...server.hocuspocus.documents.keys()]);
|
||||||
logger.info({ event: 'listen', port: env.PORT }, 'collaboration server listening');
|
logger.info({ event: 'listen', port: env.PORT }, 'collaboration server listening');
|
||||||
@ -88,6 +98,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
void Promise.allSettled([
|
void Promise.allSettled([
|
||||||
accessListener.stop(),
|
accessListener.stop(),
|
||||||
restoreListener.stop(),
|
restoreListener.stop(),
|
||||||
|
taskToggleListener.stop(),
|
||||||
maintenanceListener.stop(),
|
maintenanceListener.stop(),
|
||||||
server.destroy(),
|
server.destroy(),
|
||||||
pool.end(),
|
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 { Table, TableCell, TableHeader, TableRow } from './nodes/table';
|
||||||
import { TaskItem } from './nodes/task-item';
|
import { TaskItem } from './nodes/task-item';
|
||||||
import { DateMarker } from './nodes/date-marker';
|
import { DateMarker } from './nodes/date-marker';
|
||||||
|
import { TaskItemIds } from './task-item-ids';
|
||||||
import { Mention } from './nodes/mention';
|
import { Mention } from './nodes/mention';
|
||||||
import { Transclusion } from './nodes/transclusion';
|
import { Transclusion } from './nodes/transclusion';
|
||||||
import { Wikilink } from './nodes/wikilink';
|
import { Wikilink } from './nodes/wikilink';
|
||||||
@ -48,6 +49,7 @@ export const documentExtensions: AnyExtension[] = [
|
|||||||
Wikilink,
|
Wikilink,
|
||||||
Mention,
|
Mention,
|
||||||
DateMarker,
|
DateMarker,
|
||||||
|
TaskItemIds,
|
||||||
Transclusion,
|
Transclusion,
|
||||||
Table,
|
Table,
|
||||||
TableRow,
|
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[];
|
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}. */
|
/** JSON payload carried on {@link PAGE_RESTORE_CHANNEL}. */
|
||||||
export interface PageRestoreRequest {
|
export interface PageRestoreRequest {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
|||||||
@ -94,7 +94,8 @@ function renderListItems(node: Node): string {
|
|||||||
node.forEach((item) => {
|
node.forEach((item) => {
|
||||||
if (item.type.name === 'task_item') {
|
if (item.type.name === 'task_item') {
|
||||||
const checked = item.attrs.checked === true;
|
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 {
|
} else {
|
||||||
out += `<li>${renderBlocks(item)}</li>`;
|
out += `<li>${renderBlocks(item)}</li>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -167,13 +167,31 @@ export const editorSchema = new Schema({
|
|||||||
|
|
||||||
task_item: {
|
task_item: {
|
||||||
content: 'paragraph block*',
|
content: 'paragraph block*',
|
||||||
attrs: { checked: { default: false, validate: 'boolean' } },
|
// `id` (issue #153): a stable per-line id (assigned lazily in the
|
||||||
parseDOM: [{ tag: 'li[data-type="task_item"]' }],
|
// editor) so the task overview (#154) can address a single checkbox for
|
||||||
toDOM: (node) => [
|
// display and server-side toggling. `default: null` keeps every
|
||||||
'li',
|
// existing document valid; Markdown stays id-less by design.
|
||||||
{ 'data-type': 'task_item', 'data-checked': String(node.attrs.checked) },
|
attrs: {
|
||||||
0,
|
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' },
|
text: { group: 'inline' },
|
||||||
|
|||||||
@ -66,6 +66,10 @@ export type RepositionPageInput = z.infer<typeof repositionPageInputSchema>;
|
|||||||
* nothing disappears but the page itself); `subtree` trashes every live
|
* nothing disappears but the page itself); `subtree` trashes every live
|
||||||
* descendant along with it, which requires write permission on all of them.
|
* 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 const PAGE_DELETE_MODES = ['promote', 'subtree'] as const;
|
||||||
export type PageDeleteMode = (typeof PAGE_DELETE_MODES)[number];
|
export type PageDeleteMode = (typeof PAGE_DELETE_MODES)[number];
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user