M22: Aufgaben-Paket — Mentions (#150/#151), Datums-Marker (#152), Task-IDs+Toggle (#153), Aufgabenübersicht (#154) #158

Merged
fable-5 merged 7 commits from m22-aufgaben into main 2026-07-20 02:25:40 +02:00
59 changed files with 2489 additions and 28 deletions

View File

@ -406,6 +406,16 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/settings-nav.spec.ts pnpm --filter @dorfteich/web exec playwright test e2e/settings-nav.spec.ts
- name: Reset login rate limit before tasks pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run tasks pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/tasks.spec.ts
- name: Reset login rate limit before create-missing-page pack - name: Reset login rate limit before create-missing-page pack
run: | run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \

View File

@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "page_mentions" (
"page_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
CONSTRAINT "page_mentions_pkey" PRIMARY KEY ("page_id","user_id")
);
-- CreateIndex
CREATE INDEX "page_mentions_user_id_idx" ON "page_mentions"("user_id");
-- AddForeignKey
ALTER TABLE "page_mentions" ADD CONSTRAINT "page_mentions_page_id_fkey" FOREIGN KEY ("page_id") REFERENCES "pages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "page_mentions" ADD CONSTRAINT "page_mentions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -52,6 +52,7 @@ model User {
authTokens AuthToken[] authTokens AuthToken[]
apiTokens ApiToken[] apiTokens ApiToken[]
feedTokens FeedToken[] feedTokens FeedToken[]
mentionRows PageMention[]
ponds Pond[] ponds Pond[]
pages Page[] pages Page[]
attachments Attachment[] attachments Attachment[]
@ -282,6 +283,7 @@ model Page {
attachments Attachment[] attachments Attachment[]
versions PageVersion[] versions PageVersion[]
pendingContributors PagePendingContributor[] pendingContributors PagePendingContributor[]
mentionRows PageMention[]
labels PageLabel[] labels PageLabel[]
outgoingLinks PageLink[] @relation("outgoingLinks") outgoingLinks PageLink[] @relation("outgoingLinks")
comments Comment[] comments Comment[]
@ -349,6 +351,21 @@ model PageVersion {
/// Collab flushes the current session's contributors here (deduplicated by the /// Collab flushes the current session's contributors here (deduplicated by the
/// composite key); version creation on either side reads and clears it in the /// composite key); version creation on either side reads and clears it in the
/// same transaction as writing the snapshot. Cascades on page purge (ADR 0013). /// same transaction as writing the snapshot. Cascades on page purge (ADR 0013).
/// Derived mention index (issue #151): one row per user currently
/// mentioned in the page's document. Rewritten on every collab persist;
/// the diff against the previous rows drives the `mentioned` notifications.
model PageMention {
pageId String @map("page_id")
userId String @map("user_id")
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([pageId, userId])
@@index([userId])
@@map("page_mentions")
}
model PagePendingContributor { model PagePendingContributor {
pageId String @map("page_id") pageId String @map("page_id")
userId String @map("user_id") userId String @map("user_id")

View File

@ -1,9 +1,11 @@
import deErrors from '@dorfteich/shared/i18n/de/errors.json'; import deErrors from '@dorfteich/shared/i18n/de/errors.json';
import deLegal from '@dorfteich/shared/i18n/de/legal.json'; import deLegal from '@dorfteich/shared/i18n/de/legal.json';
import deMails from '@dorfteich/shared/i18n/de/mails.json'; import deMails from '@dorfteich/shared/i18n/de/mails.json';
import deTasks from '@dorfteich/shared/i18n/de/tasks.json';
import enErrors from '@dorfteich/shared/i18n/en/errors.json'; import enErrors from '@dorfteich/shared/i18n/en/errors.json';
import enLegal from '@dorfteich/shared/i18n/en/legal.json'; import enLegal from '@dorfteich/shared/i18n/en/legal.json';
import enMails from '@dorfteich/shared/i18n/en/mails.json'; import enMails from '@dorfteich/shared/i18n/en/mails.json';
import enTasks from '@dorfteich/shared/i18n/en/tasks.json';
import { createInstance, type i18n as I18n } from 'i18next'; import { createInstance, type i18n as I18n } from 'i18next';
/** /**
@ -15,8 +17,8 @@ export const apiI18n: I18n = createInstance();
void apiI18n.init({ void apiI18n.init({
resources: { resources: {
en: { errors: enErrors, mails: enMails, legal: enLegal }, en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks },
de: { errors: deErrors, mails: deMails, legal: deLegal }, de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks },
}, },
fallbackLng: 'en', fallbackLng: 'en',
supportedLngs: ['de', 'en'], supportedLngs: ['de', 'en'],

View File

@ -0,0 +1,111 @@
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { NotificationsService } from './notifications.service';
/**
* Mention notifications (issue #151): newly mentioned users get a
* `mentioned` notification but only with read access (no leak), and the
* mention's author (a pending contributor) never notifies themselves.
*/
describe.skipIf(!hasTestDb)('mention notifications (e2e, issue #151)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
let authorId: string;
let readerId: string;
let outsiderId: string;
let pageId: string;
beforeAll(async () => {
prisma = createTestPrisma();
app = await createTestApp();
const mkUser = async (handle: string) =>
(
await prisma.user.create({
data: {
username: `mention-${handle}-${suffix}`,
email: `mention-${handle}-${suffix}@example.test`,
displayName: `Mention ${handle}`,
status: 'ACTIVE',
},
})
).id;
authorId = await mkUser('author');
readerId = await mkUser('reader');
outsiderId = await mkUser('outsider');
const pond = await prisma.pond.create({
data: {
slug: `mention-pond-${suffix}`,
name: 'Mention Pond',
type: 'SHARED',
ownerId: authorId,
},
});
const page = await prisma.page.create({
data: {
pondId: pond.id,
slug: `notes-${suffix}`,
title: 'Notes',
createdBy: authorId,
sortKey: 'a0',
ydocState: new Uint8Array(),
},
});
pageId = page.id;
for (const [userId, role] of [
[authorId, 'EDITOR'],
[readerId, 'READER'],
] as const) {
await prisma.roleGrant.create({
data: {
pondId: pond.id,
subjectType: 'USER',
subjectId: userId,
role,
scopeType: 'POND',
scopeId: null,
effect: 'ALLOW',
createdBy: authorId,
},
});
}
// The author edited last — pending contributor, i.e. the acting user.
await prisma.pagePendingContributor.create({ data: { pageId, userId: authorId } });
});
afterAll(async () => {
await prisma.notification.deleteMany({
where: { userId: { in: [authorId, readerId, outsiderId] } },
});
await prisma.pagePendingContributor.deleteMany({ where: { pageId } });
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: authorId } } });
await prisma.page.deleteMany({ where: { pond: { ownerId: authorId } } });
await prisma.pond.deleteMany({ where: { ownerId: authorId } });
await prisma.user.deleteMany({ where: { id: { in: [authorId, readerId, outsiderId] } } });
await prisma.$disconnect();
await app.close();
});
it('notifies mentioned readers, skips outsiders and the author', async () => {
await app.get(NotificationsService).fanoutMentions(pageId, [readerId, outsiderId, authorId]);
const readerRows = await prisma.notification.findMany({ where: { userId: readerId } });
expect(readerRows).toHaveLength(1);
expect(readerRows[0]!.type).toBe('mentioned');
expect(readerRows[0]!.payload).toMatchObject({
pageTitle: 'Notes',
actorNames: ['Mention author'],
});
// No read access → nothing; the author never notifies themselves.
expect(await prisma.notification.count({ where: { userId: outsiderId } })).toBe(0);
expect(await prisma.notification.count({ where: { userId: authorId } })).toBe(0);
});
});

View File

@ -94,6 +94,64 @@ export class NotificationsService {
} }
} }
/**
* Notifies newly mentioned users (issue #151). Independent of the watch
* table a mention addresses the person directly but with the same
* delivery-time read-permission re-check: whoever may not read the page
* gets nothing (no leak). The mention's authors (the page's pending
* contributors at persist time) never notify themselves.
*/
async fanoutMentions(pageId: string, mentionedUserIds: string[]): Promise<void> {
try {
const page = await this.prisma.page.findFirst({
where: { id: pageId, deletedAt: null },
select: { id: true, pondId: true, title: true, slug: true },
});
if (!page) return;
const pond = await this.prisma.pond.findFirst({
where: { id: page.pondId, deletedAt: null },
select: { name: true, slug: true },
});
if (!pond) return;
const contributors = await this.prisma.pagePendingContributor.findMany({
where: { pageId },
select: { userId: true },
});
const actorIds = contributors.map((row) => row.userId);
const actors = actorIds.length
? await this.prisma.user.findMany({
where: { id: { in: actorIds } },
select: { displayName: true },
})
: [];
const payload: NotificationPayload = {
pageId: page.id,
pageTitle: page.title,
pageSlug: page.slug,
pondSlug: pond.slug,
pondName: pond.name,
actorNames: actors.slice(0, 3).map((actor) => actor.displayName),
};
const targets = await this.prisma.user.findMany({
where: { id: { in: mentionedUserIds.filter((id) => !actorIds.includes(id)) } },
});
for (const target of targets) {
if (!(await this.permissions.canAccessPage(target, page, 'read'))) continue;
await this.prisma.notification.create({
data: {
userId: target.id,
type: 'mentioned',
payload: payload as unknown as Prisma.InputJsonObject,
},
});
}
} catch (error) {
this.logger.warn({ pageId, err: error }, 'mention fan-out failed');
}
}
async list(user: User, page: number): Promise<NotificationListView> { async list(user: User, page: number): Promise<NotificationListView> {
const where = { userId: user.id }; const where = { userId: user.id };
const [total, unreadCount] = [ const [total, unreadCount] = [

View File

@ -1,5 +1,10 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PAGE_VERSION_CREATED_CHANNEL, type PageVersionCreatedEvent } from '@dorfteich/shared'; import {
PAGE_MENTIONS_CHANGED_CHANNEL,
PAGE_VERSION_CREATED_CHANNEL,
type PageMentionsChangedEvent,
type PageVersionCreatedEvent,
} from '@dorfteich/shared';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { Client } from 'pg'; import { Client } from 'pg';
@ -49,12 +54,17 @@ export class VersionEventListener implements OnModuleInit, OnModuleDestroy {
client.on('error', () => this.scheduleReconnect()); client.on('error', () => this.scheduleReconnect());
client.on('end', () => this.scheduleReconnect()); client.on('end', () => this.scheduleReconnect());
client.on('notification', (message) => { client.on('notification', (message) => {
if (message.channel !== PAGE_VERSION_CREATED_CHANNEL || !message.payload) return; if (!message.payload) return;
void this.handle(message.payload); if (message.channel === PAGE_VERSION_CREATED_CHANNEL) void this.handle(message.payload);
// Newly added mentions from a collab persist (issue #151).
if (message.channel === PAGE_MENTIONS_CHANGED_CHANNEL) {
void this.handleMentions(message.payload);
}
}); });
try { try {
await client.connect(); await client.connect();
await client.query(`LISTEN ${PAGE_VERSION_CREATED_CHANNEL}`); await client.query(`LISTEN ${PAGE_VERSION_CREATED_CHANNEL}`);
await client.query(`LISTEN ${PAGE_MENTIONS_CHANGED_CHANNEL}`);
this.logger.info({}, 'listening for collab version events'); this.logger.info({}, 'listening for collab version events');
} catch (error) { } catch (error) {
this.logger.warn({ err: error }, 'version-event listener could not connect'); this.logger.warn({ err: error }, 'version-event listener could not connect');
@ -79,4 +89,16 @@ export class VersionEventListener implements OnModuleInit, OnModuleDestroy {
this.logger.warn({ err: error }, 'ignoring malformed version event'); this.logger.warn({ err: error }, 'ignoring malformed version event');
} }
} }
private async handleMentions(payload: string): Promise<void> {
try {
const event = JSON.parse(payload) as PageMentionsChangedEvent;
if (!event.pageId || !Array.isArray(event.addedUserIds) || event.addedUserIds.length === 0) {
return;
}
await this.notifications.fanoutMentions(event.pageId, event.addedUserIds);
} catch (error) {
this.logger.warn({ err: error }, 'ignoring malformed mentions event');
}
}
} }

View File

@ -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

View File

@ -7,11 +7,12 @@ import { SearchModule } from '../search/search.module';
import { PagesController } from './pages.controller'; import { PagesController } from './pages.controller';
import { PagesService } from './pages.service'; import { PagesService } from './pages.service';
import { PluginApiController } from './plugin-api.controller'; import { PluginApiController } from './plugin-api.controller';
import { TasksService } from './tasks.service';
@Module({ @Module({
imports: [PondsModule, SearchModule, WatchesModule], imports: [PondsModule, SearchModule, WatchesModule],
controllers: [PagesController, PluginApiController], controllers: [PagesController, PluginApiController],
providers: [PagesService], providers: [PagesService, TasksService],
exports: [PagesService], exports: [PagesService, TasksService],
}) })
export class PagesModule {} export class PagesModule {}

View File

@ -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[]> {

View File

@ -0,0 +1,170 @@
import { INestApplication } from '@nestjs/common';
import { markdownToDoc } from '@dorfteich/shared';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PondPermissionCache } from '../permissions/pond-permission-cache';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
import { docToState } from './yjs-content';
/**
* The task overview collection (issue #154): tasks of the page and its live
* subtree, permission-filtered per source page; the public rendering expands
* the placeholder into a static table.
*/
describe.skipIf(!hasTestDb)('task overview endpoint (e2e, issue #154)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'uebersicht zeigt alles 1';
let ownerId: string;
let ownerCookie: string;
let pondSlug: string;
let pondId: string;
const api = () => request(app.getHttpServer());
async function makePage(
slug: string,
title: string,
markdown: string,
parentId: string | null = null,
): Promise<string> {
const doc = markdownToDoc(markdown);
const page = await prisma.page.create({
data: {
pondId,
slug,
title,
parentId,
createdBy: ownerId,
sortKey: 'a0',
ydocState: docToState(doc),
contentCache: {
create: { plainText: markdown, markdown, html: '', outline: [] },
},
},
});
return page.id;
}
beforeAll(async () => {
prisma = createTestPrisma();
app = await createTestApp();
const users = app.get(UsersService);
const username = `overview-owner-${suffix}`;
const owner = await users.createUser({
username,
email: `${username}@example.test`,
displayName: 'Overview Owner',
password,
locale: 'en',
});
ownerId = owner.id;
await users.markEmailVerified(ownerId);
ownerCookie = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
pondSlug = `overview-pond-${suffix}`;
const pond = await prisma.pond.create({
data: { slug: pondSlug, name: 'Overview Pond', type: 'SHARED', ownerId },
});
pondId = pond.id;
await prisma.roleGrant.create({
data: {
pondId,
subjectType: 'USER',
subjectId: ownerId,
role: 'EDITOR',
scopeType: 'POND',
scopeId: null,
effect: 'ALLOW',
createdBy: ownerId,
},
});
});
afterAll(async () => {
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId } } });
await prisma.pageContentCache.deleteMany({ where: { page: { pond: { ownerId } } } });
await prisma.page.deleteMany({ where: { pond: { ownerId } } });
await prisma.pond.deleteMany({ where: { ownerId } });
await prisma.session.deleteMany({ where: { userId: ownerId } });
await prisma.user.deleteMany({ where: { id: ownerId } });
await prisma.$disconnect();
await app.close();
});
it('collects tasks of the page and its subtree with mentions and dates', async () => {
const rootId = await makePage(
`plan-${suffix}`,
'Plan',
'- [ ] Bühne buchen >>2026-08-01\n\n```dorfteich-tasks\n```',
);
await makePage(`kabel-${suffix}`, 'Kabel', '- [x] Kabel prüfen <<2026-07-01', rootId);
// A sibling outside the subtree contributes nothing.
await makePage(`anders-${suffix}`, 'Anders', '- [ ] Fremde Aufgabe');
const res = await api()
.get(`/api/v1/read/${pondSlug}/plan-${suffix}/tasks`)
.set('Cookie', ownerCookie)
.expect(200);
const pages = res.body as {
title: string;
tasks: { text: string; checked: boolean; dueDate: string | null }[];
}[];
expect(pages.map((p) => p.title)).toEqual(['Plan', 'Kabel']);
expect(pages[0]!.tasks[0]).toMatchObject({
text: 'Bühne buchen',
checked: false,
dueDate: '2026-08-01',
});
expect(pages[1]!.tasks[0]).toMatchObject({ text: 'Kabel prüfen', checked: true });
const allTexts = pages.flatMap((p) => p.tasks.map((t) => t.text));
expect(allTexts).not.toContain('Fremde Aufgabe');
});
it('requires a session and hides unreadable pages', async () => {
await api().get(`/api/v1/read/${pondSlug}/plan-${suffix}/tasks`).expect(401);
});
it('expands the placeholder into a static table in the public rendering', async () => {
await prisma.roleGrant.create({
data: {
pondId,
subjectType: 'PUBLIC',
subjectId: null,
role: 'READER',
scopeType: 'POND',
scopeId: null,
effect: 'ALLOW',
createdBy: ownerId,
},
});
// Raw grant rows bypass the permission cache — drop the pond's entry.
app.get(PondPermissionCache).invalidate(pondId);
// The public body comes from the content cache — regenerate it with the
// real renderer so the placeholder div is present.
const { docToHtml } = await import('@dorfteich/shared');
const doc = markdownToDoc('- [ ] Bühne buchen >>2026-08-01\n\n```dorfteich-tasks\n```');
await prisma.pageContentCache.updateMany({
where: { page: { pondId, slug: `plan-${suffix}` } },
data: { html: docToHtml(doc) },
});
const res = await api().get(`/api/v1/public/${pondSlug}/plan-${suffix}/content`).expect(200);
const html = (res.body as { html: string }).html;
expect(html).toContain('dt-task-overview-table');
expect(html).toContain('Bühne buchen');
expect(html).toContain('Kabel prüfen');
expect(html).not.toContain('data-task-overview');
});
});

View 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);
});
});

View File

@ -0,0 +1,180 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
collectSubtreeIds,
extractTaskRows,
type TaskOverviewPage,
type TaskRow,
} from '@dorfteich/shared';
import { User } from '@prisma/client';
import { apiI18n } from '../i18n/api-i18n';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service';
import { escapeHtml } from '../public/html-shell';
import { docFromStateAndUpdates } from './yjs-content';
/**
* The task overview's collection (issue #154): every task-list line of a page
* and its live subtree, permission-filtered per source page a page the
* viewer may not read contributes nothing (same no-leak rule as the
* transclusion expansion). Rows are extracted at read time from the stored
* Yjs states; subtrees are small (depth 6), so no derived table is needed.
*/
@Injectable()
export class TasksService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionService,
) {}
async collect(
user: User | null,
pondSlug: string,
pageSlug: string,
): Promise<TaskOverviewPage[]> {
const pond = await this.prisma.pond.findFirst({ where: { slug: pondSlug, deletedAt: null } });
if (!pond) throw new NotFoundException();
const root = await this.prisma.page.findFirst({
where: { pondId: pond.id, slug: pageSlug, deletedAt: null },
select: { id: true, pondId: true },
});
if (!root || !(await this.permissions.canAccessPage(user, root, 'read'))) {
throw new NotFoundException();
}
return this.collectForPage(user, pond.id, root.id);
}
async collectForPage(
user: User | null,
pondId: string,
rootPageId: string,
): Promise<TaskOverviewPage[]> {
const tree = await this.prisma.page.findMany({
where: { pondId, deletedAt: null },
select: { id: true, parentId: true },
});
const subtree = collectSubtreeIds(tree, rootPageId);
const pages = await this.prisma.page.findMany({
where: { id: { in: [...subtree] } },
select: {
id: true,
slug: true,
title: true,
ydocState: true,
labels: { select: { labelId: true } },
},
orderBy: { title: 'asc' },
});
const readable = await this.permissions.filterPages(
user,
pondId,
pages.map((page) => ({ id: page.id, labelIds: page.labels.map((l) => l.labelId) })),
'read',
);
// The live document is the base state plus the update log (merged back
// only at compaction) — apply both, or fresh edits would be invisible.
const updateRows = await this.prisma.pageUpdate.findMany({
where: { pageId: { in: [...subtree] } },
orderBy: { seq: 'asc' },
select: { pageId: true, update: true },
});
const updatesByPage = new Map<string, Uint8Array[]>();
for (const row of updateRows) {
const list = updatesByPage.get(row.pageId) ?? [];
list.push(row.update);
updatesByPage.set(row.pageId, list);
}
const result: TaskOverviewPage[] = [];
// The root page leads; the readable descendants follow alphabetically.
const ordered = [
...pages.filter((page) => page.id === rootPageId),
...pages.filter((page) => page.id !== rootPageId),
];
const mentionIds = new Set<string>();
const raw: { page: (typeof pages)[number]; rows: TaskRow[] }[] = [];
for (const page of ordered) {
if (!readable.has(page.id)) continue;
let rows: TaskRow[] = [];
try {
rows = extractTaskRows(
docFromStateAndUpdates(page.ydocState, updatesByPage.get(page.id) ?? []),
);
} catch {
// An undecodable state contributes nothing rather than failing the view.
rows = [];
}
if (rows.length === 0) continue;
rows.forEach((row) => row.mentions.forEach((m) => m.userId && mentionIds.add(m.userId)));
raw.push({ page, rows });
}
const users = mentionIds.size
? await this.prisma.user.findMany({
where: { id: { in: [...mentionIds] } },
select: { id: true, username: true, displayName: true },
})
: [];
const userById = new Map(users.map((user_) => [user_.id, user_]));
for (const { page, rows } of raw) {
result.push({
pageId: page.id,
slug: page.slug,
title: page.title,
tasks: rows.map((row) => ({
id: row.id,
checked: row.checked,
text: row.text,
mentions: row.mentions
.map((mention) => {
const resolved = mention.userId ? userById.get(mention.userId) : undefined;
return resolved
? {
id: resolved.id,
username: resolved.username,
displayName: resolved.displayName,
}
: { id: '', username: mention.username, displayName: mention.username };
})
.filter((mention) => mention.username),
startDate: row.startDate,
dueDate: row.dueDate,
})),
});
}
return result;
}
/** Static table for the public view / exports (issue #154) — read-only. */
async renderStaticTable(
user: User | null,
pondId: string,
rootPageId: string,
lang: 'de' | 'en',
): Promise<string> {
const pages = await this.collectForPage(user, pondId, rootPageId);
const t = (key: string): string => apiI18n.t(`tasks:${key}`, { lng: lang });
const rows = pages.flatMap((page) =>
page.tasks.map(
(task) =>
`<tr><td><input type="checkbox" disabled${task.checked ? ' checked' : ''}></td>` +
`<td>${escapeHtml(task.text)}</td>` +
`<td>${task.mentions.map((m) => `@${escapeHtml(m.displayName)}`).join(', ')}</td>` +
`<td>${task.startDate ?? ''}</td><td>${task.dueDate ?? ''}</td>` +
`<td><a class="wikilink" href="${escapeHtml(page.slug)}" data-wikilink="${escapeHtml(page.slug)}">${escapeHtml(page.title)}</a></td></tr>`,
),
);
if (rows.length === 0) {
return `<div class="dt-task-overview-empty">${escapeHtml(t('empty'))}</div>`;
}
return (
`<table class="dt-task-overview-table"><thead><tr>` +
`<th></th><th>${escapeHtml(t('colTask'))}</th><th>${escapeHtml(t('colMentions'))}</th>` +
`<th>${escapeHtml(t('colStart'))}</th><th>${escapeHtml(t('colDue'))}</th>` +
`<th>${escapeHtml(t('colPage'))}</th>` +
`</tr></thead><tbody>${rows.join('')}</tbody></table>`
);
}
}

View File

@ -5,6 +5,7 @@ import {
editorSchema, editorSchema,
extractOutline, extractOutline,
OutlineEntry, OutlineEntry,
extractMentionUserIds,
extractWikilinkSlugs, extractWikilinkSlugs,
} from '@dorfteich/shared'; } from '@dorfteich/shared';
import { Node } from 'prosemirror-model'; import { Node } from 'prosemirror-model';
@ -21,7 +22,27 @@ const FRAGMENT_NAME = 'default';
/** Thrown for state bytes that are not a well-formed Yjs update for this schema. */ /** Thrown for state bytes that are not a well-formed Yjs update for this schema. */
export class InvalidPageStateError extends Error {} export class InvalidPageStateError extends Error {}
function docFromState(state: Uint8Array): Node { /**
* Decode a page's full current document from its base state plus the
* `page_updates` log (issue #154) the persisted base alone lags behind
* the live document until the next compaction merges the log back.
*/
export function docFromStateAndUpdates(state: Uint8Array, updates: Uint8Array[]): Node {
const ydoc = new Y.Doc();
try {
Y.applyUpdate(ydoc, state);
for (const update of updates) Y.applyUpdate(ydoc, update);
return yXmlFragmentToProseMirrorRootNode(ydoc.getXmlFragment(FRAGMENT_NAME), editorSchema);
} catch (error) {
throw new InvalidPageStateError(error instanceof Error ? error.message : 'invalid Yjs state');
} finally {
ydoc.destroy();
}
}
/** Decode a page's stored Yjs state back into its ProseMirror document
* also the entry point for read-time task extraction (issue #154). */
export function docFromState(state: Uint8Array): Node {
const ydoc = new Y.Doc(); const ydoc = new Y.Doc();
try { try {
Y.applyUpdate(ydoc, state); Y.applyUpdate(ydoc, state);
@ -73,6 +94,9 @@ export interface DerivedPageContent {
* api (imports, phantom-create) seed their `page_links` rows from this * api (imports, phantom-create) seed their `page_links` rows from this
* collab, the content writer, rewrites them on every later save. */ * collab, the content writer, rewrites them on every later save. */
wikilinkSlugs: string[]; wikilinkSlugs: string[];
/** Resolved user ids of every `@mention` (issue #151) api-created pages
* seed their `page_mentions` rows from this; collab rewrites on save. */
mentionUserIds: string[];
} }
function imageFileIdsOf(doc: Node): string[] { function imageFileIdsOf(doc: Node): string[] {
@ -99,5 +123,6 @@ export function deriveContent(state: Uint8Array): DerivedPageContent {
outline: extractOutline(doc), outline: extractOutline(doc),
imageFileIds: imageFileIdsOf(doc), imageFileIds: imageFileIdsOf(doc),
wikilinkSlugs: extractWikilinkSlugs(doc), wikilinkSlugs: extractWikilinkSlugs(doc),
mentionUserIds: extractMentionUserIds(doc),
}; };
} }

View File

@ -3,6 +3,7 @@ import type { PageCommentsView } from '@dorfteich/shared';
import { Pond, User } from '@prisma/client'; import { Pond, User } from '@prisma/client';
import { CommentsService } from '../comments/comments.service'; import { CommentsService } from '../comments/comments.service';
import { TasksService } from '../pages/tasks.service';
import { PermissionService } from '../permissions/permission.service'; import { PermissionService } from '../permissions/permission.service';
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer'; import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@ -41,6 +42,7 @@ export class PublicService {
private readonly fallbacks: PluginFallbackRenderer, private readonly fallbacks: PluginFallbackRenderer,
private readonly settings: InstanceSettingsService, private readonly settings: InstanceSettingsService,
private readonly commentsService: CommentsService, private readonly commentsService: CommentsService,
private readonly tasks: TasksService,
) {} ) {}
private async resolve( private async resolve(
@ -99,7 +101,23 @@ export class PublicService {
): Promise<string> { ): Promise<string> {
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }); const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
const withFallbacks = await this.fallbacks.applyToHtml(cache?.html ?? ''); const withFallbacks = await this.fallbacks.applyToHtml(cache?.html ?? '');
return this.expandEmbeds(withFallbacks, user, pondId, depth, visited); const withTasks = await this.expandTaskOverviews(withFallbacks, user, pondId, page.id);
return this.expandEmbeds(withTasks, user, pondId, depth, visited);
}
/** Replaces each task-overview placeholder (issue #154) with the static,
* permission-filtered table read-only in the public rendering. */
private async expandTaskOverviews(
html: string,
user: User | null,
pondId: string,
pageId: string,
): Promise<string> {
const placeholder = /<div class="dt-task-overview" data-task-overview="1">[^<]*<\/div>/g;
if (!placeholder.test(html)) return html;
const lang = await this.settings.get('instance.defaultLocale');
const table = await this.tasks.renderStaticTable(user, pondId, pageId, lang);
return html.replace(placeholder, () => table);
} }
/** /**

View File

@ -1,6 +1,8 @@
import { Controller, Get, Param, Req } from '@nestjs/common'; import { Controller, Get, Param, Req } from '@nestjs/common';
import type { TaskOverviewPage } from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard'; import { AuthedRequest } from '../auth/auth.guard';
import { TasksService } from '../pages/tasks.service';
import { AuthenticatedOnly } from '../permissions/permission.decorators'; import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { PublicPageContent, PublicService } from './public.service'; import { PublicPageContent, PublicService } from './public.service';
@ -15,7 +17,22 @@ import { PublicPageContent, PublicService } from './public.service';
*/ */
@Controller('read') @Controller('read')
export class ReadContentController { export class ReadContentController {
constructor(private readonly publicPages: PublicService) {} constructor(
private readonly publicPages: PublicService,
private readonly tasks: TasksService,
) {}
// The task collection (issue #154) — registered before the generic
// two-segment route so `tasks` is not read as a page slug.
@Get(':pondSlug/:pageSlug/tasks')
@AuthenticatedOnly()
async tasksOf(
@Param('pondSlug') pondSlug: string,
@Param('pageSlug') pageSlug: string,
@Req() request: AuthedRequest,
): Promise<TaskOverviewPage[]> {
return this.tasks.collect(request.user ?? null, pondSlug, pageSlug);
}
// Session required (explicit access rule, issue #52); per-page read // Session required (explicit access rule, issue #52); per-page read
// permission is enforced in the service (resolve → canAccessPage → 404). // permission is enforced in the service (resolve → canAccessPage → 404).

View File

@ -0,0 +1,57 @@
import { Controller, Get, Query } from '@nestjs/common';
import type { UserBriefView } from '@dorfteich/shared';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { PrismaService } from '../prisma/prisma.service';
import { RateLimit } from '../rate-limit/rate-limit.guard';
/** Cap for the batch `brief` lookup — a page mentions a handful of people. */
const BRIEF_MAX_IDS = 50;
/**
* Instance-wide user lookup for `@` mentions (issue #150). Deliberately
* minimal: only id/username/displayName, only active accounts, only for
* signed-in users, rate-limited, and never enumerable without a query a
* documented consequence of instance-wide mentions is that logged-in users
* can discover usernames this way.
*/
@Controller('users')
@AuthenticatedOnly()
export class UserSearchController {
constructor(private readonly prisma: PrismaService) {}
@Get('search')
@RateLimit({ scope: 'user-search', limit: 60, windowSeconds: 60 })
async search(@Query('q') q: string | undefined): Promise<UserBriefView[]> {
const query = (q ?? '').trim();
if (query.length < 2) return [];
return this.prisma.user.findMany({
where: {
status: 'ACTIVE',
OR: [
{ username: { contains: query, mode: 'insensitive' } },
{ displayName: { contains: query, mode: 'insensitive' } },
],
},
select: { id: true, username: true, displayName: true },
orderBy: { username: 'asc' },
take: 10,
});
}
/** Batch resolution of mentioned users for live display names; unknown or
* disabled ids are simply absent (the mention renders as a dead chip). */
@Get('brief')
async brief(@Query('ids') ids: string | undefined): Promise<UserBriefView[]> {
const wanted = (ids ?? '')
.split(',')
.map((id) => id.trim())
.filter(Boolean)
.slice(0, BRIEF_MAX_IDS);
if (wanted.length === 0) return [];
return this.prisma.user.findMany({
where: { id: { in: wanted }, status: 'ACTIVE' },
select: { id: true, username: true, displayName: true },
});
}
}

View File

@ -1,12 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { SessionsModule } from '../auth/sessions.module'; import { SessionsModule } from '../auth/sessions.module';
import { UserSearchController } from './user-search.controller';
import { UsersController } from './users.controller'; import { UsersController } from './users.controller';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
@Module({ @Module({
imports: [SessionsModule], imports: [SessionsModule],
controllers: [UsersController], controllers: [UsersController, UserSearchController],
providers: [UsersService], providers: [UsersService],
exports: [UsersService], exports: [UsersService],
}) })

View File

@ -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(),

View File

@ -1,4 +1,8 @@
import { MAX_PAGE_DOCUMENT_BYTES, normalizeForSearch } from '@dorfteich/shared'; import {
MAX_PAGE_DOCUMENT_BYTES,
PAGE_MENTIONS_CHANGED_CHANNEL,
normalizeForSearch,
} from '@dorfteich/shared';
import type { Pool } from 'pg'; import type { Pool } from 'pg';
import * as Y from 'yjs'; import * as Y from 'yjs';
@ -203,6 +207,31 @@ export class PostgresPagePersistence implements PagePersistence {
); );
} }
// Rewrite the mention index (issue #151); newly added user ids become a
// NOTIFY the api turns into `mentioned` notifications. Inside the
// transaction on purpose — pg_notify only fires on COMMIT.
const previousMentions = await client.query<{ user_id: string }>(
'SELECT user_id FROM page_mentions WHERE page_id = $1',
[pageId],
);
await client.query('DELETE FROM page_mentions WHERE page_id = $1', [pageId]);
if (derived.mentionUserIds.length > 0) {
await client.query(
`INSERT INTO page_mentions (page_id, user_id)
SELECT $1, u.id FROM unnest($2::text[]) AS m(user_id)
JOIN users u ON u.id = m.user_id`,
[pageId, derived.mentionUserIds],
);
}
const known = new Set(previousMentions.rows.map((row) => row.user_id));
const added = derived.mentionUserIds.filter((id) => !known.has(id));
if (added.length > 0) {
await client.query('SELECT pg_notify($1, $2)', [
PAGE_MENTIONS_CHANGED_CHANNEL,
JSON.stringify({ pageId, addedUserIds: added }),
]);
}
await client.query('COMMIT'); await client.query('COMMIT');
this.lastStoredVector.set(pageId, nextVector); this.lastStoredVector.set(pageId, nextVector);
return { outcome: 'stored', bytes: full.byteLength, durationMs: durationOf(), merged }; return { outcome: 'stored', bytes: full.byteLength, durationMs: durationOf(), merged };

View 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);
},
};
}

View File

@ -4,6 +4,7 @@ import {
docToPlainText, docToPlainText,
editorSchema, editorSchema,
extractOutline, extractOutline,
extractMentionUserIds,
extractWikilinkSlugs, extractWikilinkSlugs,
type OutlineEntry, type OutlineEntry,
} from '@dorfteich/shared'; } from '@dorfteich/shared';
@ -44,6 +45,9 @@ export interface DerivedPageContent {
/** Distinct target slugs of every `[[wikilink]]`, for the `page_links` /** Distinct target slugs of every `[[wikilink]]`, for the `page_links`
* index (issue #47). */ * index (issue #47). */
wikilinkSlugs: string[]; wikilinkSlugs: string[];
/** Distinct resolved user ids of every `@mention`, for the
* `page_mentions` index and the mention notifications (issue #151). */
mentionUserIds: string[];
} }
function imageFileIdsOf(doc: Node): string[] { function imageFileIdsOf(doc: Node): string[] {
@ -70,5 +74,6 @@ export function deriveContentFromDoc(ydoc: Y.Doc): DerivedPageContent {
outline: extractOutline(doc), outline: extractOutline(doc),
imageFileIds: imageFileIdsOf(doc), imageFileIds: imageFileIdsOf(doc),
wikilinkSlugs: extractWikilinkSlugs(doc), wikilinkSlugs: extractWikilinkSlugs(doc),
mentionUserIds: extractMentionUserIds(doc),
}; };
} }

View File

@ -83,7 +83,9 @@ test('wrap, restyle in read mode, unwrap, and neutral fallback when disabled', a
await page.keyboard.type('Boxed content'); await page.keyboard.type('Boxed content');
// Wrap the paragraph in the plugin's style via the toolbar picker. // Wrap the paragraph in the plugin's style via the toolbar picker.
const picker = page.locator('.editor-toolbar__section-select'); // Not the block picker: it shares the styling class and is always
// visible since the built-in task overview entry (#154).
const picker = page.locator('.editor-toolbar__section-select:not(.editor-toolbar__block-select)');
await picker.selectOption(`${PLUGIN_ID}/boxed`); await picker.selectOption(`${PLUGIN_ID}/boxed`);
const section = content.locator(`.dt-section.dt-style-${PLUGIN_ID}-boxed`); const section = content.locator(`.dt-section.dt-style-${PLUGIN_ID}-boxed`);
await expect(section).toContainText('Boxed content'); await expect(section).toContainText('Boxed content');

View File

@ -20,6 +20,9 @@ test('user settings show the jump nav and clicking scrolls + activates', async (
const context = await contextForUser(browser, BASE_URL, 'fixture-user'); const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const page = await context.newPage(); const page = await context.newPage();
await page.goto('/settings'); await page.goto('/settings');
// Let the async section content (sessions, tokens, …) settle first —
// sections growing above the target would push it out of view again.
await page.waitForLoadState('networkidle');
const nav = page.locator('.settings-nav'); const nav = page.locator('.settings-nav');
await expect(nav).toBeVisible(); await expect(nav).toBeVisible();
@ -31,7 +34,8 @@ test('user settings show the jump nav and clicking scrolls + activates', async (
const last = links.last(); const last = links.last();
await last.click(); await last.click();
const lastSection = page.locator('.settings-layout section[id]').last(); const lastSection = page.locator('.settings-layout section[id]').last();
await expect(lastSection).toBeInViewport(); // Smooth scrolling needs a moment on a loaded CI runner.
await expect(lastSection).toBeInViewport({ timeout: 10_000 });
await expect(last).toHaveClass(/settings-nav__link--active/); await expect(last).toHaveClass(/settings-nav__link--active/);
await context.close(); await context.close();
@ -45,6 +49,7 @@ test('pond settings derive the nav from their sections', async ({ browser }) =>
const page = await context.newPage(); const page = await context.newPage();
await page.goto(`/p/${pond.slug}/settings`); await page.goto(`/p/${pond.slug}/settings`);
await page.waitForLoadState('networkidle');
const links = page.locator('.settings-nav .settings-nav__link'); const links = page.locator('.settings-nav .settings-nav__link');
// The pond owner sees the full section stack — at least members, labels, // The pond owner sees the full section stack — at least members, labels,
@ -54,7 +59,9 @@ test('pond settings derive the nav from their sections', async ({ browser }) =>
expect(await links.count()).toBeGreaterThanOrEqual(8); expect(await links.count()).toBeGreaterThanOrEqual(8);
await links.last().click(); await links.last().click();
await expect(page.locator('.settings-layout section[id]').last()).toBeInViewport(); await expect(page.locator('.settings-layout section[id]').last()).toBeInViewport({
timeout: 10_000,
});
await context.close(); await context.close();
}); });

119
apps/web/e2e/tasks.spec.ts Normal file
View File

@ -0,0 +1,119 @@
import { expect, test } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Tasks pack (issues #150/#152/#153/#154): task lines get stable ids, the
* task overview block collects the page + subtree into a table with mention
* and date columns, and checking a box in the overview writes back to the
* source page through the collab server. Language-independent selectors.
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
type Ctx = Awaited<ReturnType<typeof contextForUser>>;
async function personalPond(context: Ctx): Promise<{ id: string; slug: string }> {
const ponds = await context.request.get('/api/v1/ponds');
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
return { id: pond.id, slug: pond.slug };
}
test('task overview collects subtree tasks and toggles write back', async ({ browser }) => {
test.setTimeout(120_000);
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const ts = Date.now();
const parentRes = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, {
data: { title: `Tasks Parent ${ts}` },
});
const parent = await parentRes.json();
const childRes = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, {
data: { title: `Tasks Child ${ts}`, parentId: parent.id },
});
const child = await childRes.json();
const page = await context.newPage();
// Child: one task line with a due date (typed, so it gets an id, #153).
await page.goto(`/p/${pond.slug}/${child.slug}`);
await page.locator('.editor-page__mode-toggle').click();
const body = page.locator('.editor-content .ProseMirror');
await body.click();
await page.locator('.editor-toolbar button', { hasText: '☑' }).click();
await page.keyboard.type('Kabel prüfen >>31.12.2026 ');
await expect(page.locator('.editor-content .dt-date--due')).toBeVisible();
// The collab server persists debounced (~2 s) — wait until the task shows
// up in the collection before moving on.
await expect
.poll(
async () => {
const res = await context.request.get(`/api/v1/read/${pond.slug}/${child.slug}/tasks`);
return JSON.stringify(await res.json());
},
{ timeout: 20_000 },
)
.toContain('Kabel prüfen');
// Parent: a task line plus the overview block.
await page.goto(`/p/${pond.slug}/${parent.slug}`);
await page.locator('.editor-page__mode-toggle').click();
await page.locator('.editor-content .ProseMirror').click();
await page.locator('.editor-toolbar button', { hasText: '☑' }).click();
await page.keyboard.type('Bühne buchen');
await page.keyboard.press('Enter');
// Leave the task list (an empty item converts back to a paragraph).
await page.keyboard.press('Enter');
await page.locator('.editor-toolbar__block-select').selectOption('builtin/tasks');
await expect(page.locator('.dt-transclusion-card')).toBeVisible();
await expect
.poll(
async () => {
const res = await context.request.get(`/api/v1/read/${pond.slug}/${parent.slug}/tasks`);
return ((await res.json()) as { tasks: unknown[] }[]).reduce(
(sum, p) => sum + p.tasks.length,
0,
);
},
{ timeout: 20_000 },
)
.toBe(2);
// Read mode: the overview table lists both tasks with the date column.
await page.reload();
const table = page.locator('.dt-task-overview-table');
await expect(table).toBeVisible();
await expect(table).toContainText('Bühne buchen');
await expect(table).toContainText('Kabel prüfen');
await expect(table.locator('tbody tr')).toHaveCount(2);
// Toggle the child's task from the overview — it writes back through the
// collab server; after the refetch the box stays checked.
const childRow = table.locator('tbody tr', { hasText: 'Kabel prüfen' });
const checkbox = childRow.locator('input[type="checkbox"]');
await expect(checkbox).toBeEnabled();
await checkbox.check();
// Write-back travels api → NOTIFY → collab → debounced persist.
await expect
.poll(
async () => {
const res = await context.request.get(`/api/v1/read/${pond.slug}/${child.slug}/tasks`);
const pages = (await res.json()) as { tasks: { checked: boolean }[] }[];
return pages[0]?.tasks[0]?.checked ?? false;
},
{ timeout: 20_000 },
)
.toBe(true);
await page.reload();
await expect(
page
.locator('.dt-task-overview-table tbody tr', { hasText: 'Kabel prüfen' })
.locator('input[type="checkbox"]'),
).toBeChecked();
// The source page itself now shows the checked box.
await page.goto(`/p/${pond.slug}/${child.slug}`);
await expect(page.locator('.editor-content li[data-type="task_item"] input')).toBeChecked();
await context.close();
});

View File

@ -102,7 +102,9 @@ export function SettingsLayout({ children }: { children: React.ReactNode }): Rea
}, []); }, []);
const jump = (id: string): void => { const jump = (id: string): void => {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); // Instant, not smooth: async section content (queries) can still grow
// during an animation, leaving it at a stale target position.
document.getElementById(id)?.scrollIntoView({ block: 'start' });
setActive(id); setActive(id);
}; };

View File

@ -0,0 +1,143 @@
import type { UserBriefView } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import type { Editor } from '@tiptap/react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { apiGet } from '../lib/api';
/** An open `@` context: the query typed so far and where the `@` began. */
interface QueryState {
query: string;
from: number;
coords: { left: number; bottom: number };
}
/** Detects `@query` immediately before a collapsed cursor (issue #150)
* only at a word boundary, so typing an e-mail address never opens it. */
function detectQuery(editor: Editor): { query: string; from: number } | null {
const { selection } = editor.state;
if (!selection.empty) return null;
const $from = selection.$from;
if (!$from.parent.isTextblock) return null;
const start = Math.max(0, $from.parentOffset - 40);
const before = $from.parent.textBetween(start, $from.parentOffset, undefined, '');
const match = /(^|[^\w@.-])@([a-zA-Z0-9][\w-]*)$/.exec(before);
if (!match) return null;
const query = match[2] ?? '';
return { query, from: selection.from - query.length - 1 };
}
/**
* Autocomplete popup for `@` mentions (issue #150): instance-wide user
* search (min. two characters), Enter/click inserts the mention node with
* the stable user id. Keyboard handling mirrors the wikilink popup.
*/
export function MentionAutocomplete({ editor }: { editor: Editor }): React.JSX.Element | null {
const { t } = useTranslation('editor');
const [state, setState] = useState<QueryState | null>(null);
const [selected, setSelected] = useState(0);
const search = useQuery({
queryKey: ['user-search', state?.query ?? ''],
queryFn: () => apiGet<UserBriefView[]>(`/users/search?q=${encodeURIComponent(state!.query)}`),
enabled: Boolean(state && state.query.length >= 2),
staleTime: 30 * 1000,
});
const suggestions = state && state.query.length >= 2 ? (search.data ?? []) : [];
const live = useRef({ state, suggestions, selected });
live.current = { state, suggestions, selected };
function close(): void {
setState(null);
setSelected(0);
}
function choose(item: UserBriefView | undefined): void {
const current = live.current.state;
if (!item || !current) return;
const range = { from: current.from, to: editor.state.selection.from };
editor
.chain()
.focus()
.insertContentAt(range, [
{ type: 'mention', attrs: { userId: item.id, username: item.username } },
{ type: 'text', text: ' ' },
])
.run();
close();
}
// Recompute the open query on every doc/selection change.
useEffect(() => {
const update = (): void => {
const found = detectQuery(editor);
if (!found) {
setState(null);
return;
}
const coords = editor.view.coordsAtPos(editor.state.selection.from);
setState({ ...found, coords: { left: coords.left, bottom: coords.bottom } });
setSelected(0);
};
editor.on('transaction', update);
return () => {
editor.off('transaction', update);
};
}, [editor]);
// Keyboard navigation, intercepted before ProseMirror (capture phase).
useEffect(() => {
const dom = editor.view.dom;
const onKeyDown = (event: KeyboardEvent): void => {
const { state: s, suggestions: items, selected: sel } = live.current;
if (!s || items.length === 0) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
setSelected((i) => (i + 1) % items.length);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setSelected((i) => (i - 1 + items.length) % items.length);
} else if (event.key === 'Enter') {
event.preventDefault();
choose(items[sel]);
} else if (event.key === 'Escape') {
event.preventDefault();
close();
}
};
dom.addEventListener('keydown', onKeyDown, true);
return () => dom.removeEventListener('keydown', onKeyDown, true);
}, [editor]);
if (!state || suggestions.length === 0) return null;
return (
<ul
className="wikilink-suggest"
role="listbox"
aria-label={t('mention.suggestLabel')}
style={{ position: 'fixed', left: state.coords.left, top: state.coords.bottom + 4 }}
>
{suggestions.map((user, index) => (
<li key={user.id}>
<button
type="button"
role="option"
aria-selected={index === selected}
className={
index === selected ? 'wikilink-suggest__item is-active' : 'wikilink-suggest__item'
}
onMouseDown={(event) => {
event.preventDefault();
choose(user);
}}
>
<span className="dt-mention">@{user.username}</span> {user.displayName}
</button>
</li>
))}
</ul>
);
}

View File

@ -24,10 +24,14 @@ export function PluginBlockMenu({
options: PluginBlockOption[]; options: PluginBlockOption[];
}): React.JSX.Element | null { }): React.JSX.Element | null {
const { t, i18n } = useTranslation('editor'); const { t, i18n } = useTranslation('editor');
const { t: tTasks } = useTranslation('tasks');
if (options.length === 0) return null;
function insert(key: string): void { function insert(key: string): void {
if (key === 'builtin/tasks') {
// The built-in task overview block (issue #154).
editor.chain().focus().insertContent({ type: 'task_overview' }).run();
return;
}
const [pluginId, blockType] = key.split('/'); const [pluginId, blockType] = key.split('/');
if (!pluginId || !blockType) return; if (!pluginId || !blockType) return;
editor.chain().focus().insertPluginBlock({ pluginId, blockType }).run(); editor.chain().focus().insertPluginBlock({ pluginId, blockType }).run();
@ -47,6 +51,7 @@ export function PluginBlockMenu({
<option value="" disabled> <option value="" disabled>
{t('toolbar.pluginBlock.placeholder')} {t('toolbar.pluginBlock.placeholder')}
</option> </option>
<option value="builtin/tasks">{tTasks('title')}</option>
{options.map((option) => { {options.map((option) => {
const key = `${option.pluginId}/${option.blockType}`; const key = `${option.pluginId}/${option.blockType}`;
return ( return (

View File

@ -7,6 +7,10 @@ import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
import { PluginBlock } from './nodes/plugin-block'; 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 { TaskOverview } from './nodes/task-overview';
import { TaskItemIds } from './task-item-ids';
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';
import { import {
@ -44,6 +48,10 @@ export const documentExtensions: AnyExtension[] = [
Image, Image,
PluginBlock, PluginBlock,
Wikilink, Wikilink,
Mention,
DateMarker,
TaskOverview,
TaskItemIds,
Transclusion, Transclusion,
Table, Table,
TableRow, TableRow,

View File

@ -0,0 +1,91 @@
import { InputRule, Node } from '@tiptap/core';
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
import type { NodeViewProps } from '@tiptap/react';
import { useTranslation } from 'react-i18next';
import { attributesFromSpec, nodeSpec } from '../spec-utils';
/** `>>`/`<<` + ISO or `dd.mm.yyyy`, completed by a space (the trigger). */
const INPUT_PATTERN =
/(?:^|\s)([<>])\1\s?(?:(\d{4})-(\d{2})-(\d{2})|(\d{1,2})\.(\d{1,2})\.(\d{4}))\s$/;
function isoDateOf(match: RegExpMatchArray): string | null {
const [year, month, day] = match[2]
? [Number(match[2]), Number(match[3]), Number(match[4])]
: [Number(match[7]), Number(match[6]), Number(match[5])];
const date = new Date(Date.UTC(year, month - 1, day));
const valid =
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
if (!valid) return null;
const pad = (value: number): string => String(value).padStart(2, '0');
return `${year}-${pad(month)}-${pad(day)}`;
}
/**
* Renders a date marker (issue #152): `»` = due date (`>>`), `«` = start
* date (`<<`), formatted per the viewer's language. A due date in the past
* gets an "overdue" tint. The stored attr is always canonical ISO.
*/
function DateMarkerView({ node }: NodeViewProps): React.JSX.Element {
const { t, i18n } = useTranslation('editor');
const kind = node.attrs.kind as 'due' | 'start';
const iso = node.attrs.date as string;
const formatted = new Intl.DateTimeFormat(i18n.language, { dateStyle: 'medium' }).format(
new Date(`${iso}T00:00:00`),
);
const overdue = kind === 'due' && iso < new Date().toISOString().slice(0, 10);
const classes = ['dt-date', `dt-date--${kind}`];
if (overdue) classes.push('dt-date--overdue');
return (
<NodeViewWrapper as="span" className="dt-date-nodeview">
<span
className={classes.join(' ')}
title={t(kind === 'due' ? 'dateMarker.due' : 'dateMarker.start')}
contentEditable={false}
>
{kind === 'due' ? '»' : '«'} {formatted}
</span>
</NodeViewWrapper>
);
}
const dateMarkerSpec = nodeSpec('date_marker');
export const DateMarker = Node.create({
name: 'date_marker',
group: dateMarkerSpec.group,
inline: dateMarkerSpec.inline,
atom: dateMarkerSpec.atom,
addAttributes() {
return attributesFromSpec(dateMarkerSpec);
},
parseHTML: () => dateMarkerSpec.parseDOM,
renderHTML: ({ node }) => dateMarkerSpec.toDOM!(node),
addInputRules() {
return [
new InputRule({
find: INPUT_PATTERN,
handler: ({ range, match, chain }) => {
const iso = isoDateOf(match);
if (!iso) return;
// Keep a leading boundary character (space/line start) intact.
const full = match[0];
const markerStart = range.from + (full.length - full.trimStart().length);
chain()
.insertContentAt({ from: markerStart, to: range.to }, [
{
type: 'date_marker',
attrs: { kind: match[1] === '>' ? 'due' : 'start', date: iso },
},
{ type: 'text', text: ' ' },
])
.run();
},
}),
];
},
addNodeView() {
return ReactNodeViewRenderer(DateMarkerView);
},
});

View File

@ -0,0 +1,61 @@
import type { UserBriefView } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { Node } from '@tiptap/core';
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
import type { NodeViewProps } from '@tiptap/react';
import { useTranslation } from 'react-i18next';
import { apiGet } from '../../lib/api';
import { attributesFromSpec, nodeSpec } from '../spec-utils';
/**
* Renders an `@username` mention (issue #150). The stable reference is the
* user id; the shown text is the user's *current* display name (a rename
* shows everywhere immediately), falling back to `@username`. A mention whose
* user no longer resolves deleted account, or an unresolved Markdown
* import renders as a muted "dead" chip and never notifies anyone.
*/
function MentionView({ node }: NodeViewProps): React.JSX.Element {
const { t } = useTranslation('editor');
const userId = node.attrs.userId as string;
const username = node.attrs.username as string;
const brief = useQuery({
queryKey: ['user-brief', userId],
queryFn: () => apiGet<UserBriefView[]>(`/users/brief?ids=${encodeURIComponent(userId)}`),
enabled: Boolean(userId),
staleTime: 5 * 60 * 1000,
});
const resolved = brief.data?.find((user) => user.id === userId);
const dead = Boolean(userId) && brief.isSuccess && !resolved;
const label = resolved ? `@${resolved.displayName}` : `@${username}`;
return (
<NodeViewWrapper as="span" className="dt-mention-nodeview">
<span
className={dead || !userId ? 'dt-mention dt-mention--dead' : 'dt-mention'}
title={dead || !userId ? t('mention.unresolved') : `@${username}`}
contentEditable={false}
>
{label}
</span>
</NodeViewWrapper>
);
}
const mentionSpec = nodeSpec('mention');
export const Mention = Node.create({
name: 'mention',
group: mentionSpec.group,
inline: mentionSpec.inline,
atom: mentionSpec.atom,
addAttributes() {
return attributesFromSpec(mentionSpec);
},
parseHTML: () => mentionSpec.parseDOM,
renderHTML: ({ node }) => mentionSpec.toDOM!(node),
addNodeView() {
return ReactNodeViewRenderer(MentionView);
},
});

View File

@ -0,0 +1,155 @@
import type { TaskOverviewPage } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Node } from '@tiptap/core';
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
import type { NodeViewProps } from '@tiptap/react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useParams } from 'react-router-dom';
import { apiGet, apiPost } from '../../lib/api';
import { attributesFromSpec, nodeSpec } from '../spec-utils';
import { useWikilinks } from '../wikilink-context';
/**
* The task overview block (issue #154). Edit mode shows a placeholder card;
* read mode fetches the permission-filtered collection of the current page +
* subtree (`/read/:pond/:slug/tasks`) and renders the table. Checking a box
* posts the toggle (#153) applied asynchronously through the collab
* server so the UI flips optimistically and refetches shortly after.
*/
function TaskOverviewView({ editor }: NodeViewProps): React.JSX.Element {
const { t, i18n } = useTranslation('tasks');
const { pondSlug } = useWikilinks();
const { pageSlug = '' } = useParams<{ pageSlug: string }>();
const queryClient = useQueryClient();
const [optimistic, setOptimistic] = useState<Record<string, boolean>>({});
const editable = editor.isEditable;
const overview = useQuery({
queryKey: ['page-tasks', pondSlug, pageSlug],
queryFn: () => apiGet<TaskOverviewPage[]>(`/read/${pondSlug}/${pageSlug}/tasks`),
enabled: !editable && Boolean(pageSlug),
});
if (editable) {
return (
<NodeViewWrapper className="dt-transclusion-card" contentEditable={false}>
<span className="dt-transclusion-card__icon" aria-hidden>
</span>
<span className="dt-transclusion-card__label">{t('editorCard')}</span>
</NodeViewWrapper>
);
}
const formatDate = (iso: string | null): string =>
iso
? new Intl.DateTimeFormat(i18n.language, { dateStyle: 'medium' }).format(
new Date(`${iso}T00:00:00`),
)
: '';
const toggle = async (pageId: string, taskId: string, checked: boolean): Promise<void> => {
setOptimistic((prev) => ({ ...prev, [taskId]: checked }));
await apiPost(`/pages/${pageId}/tasks/${taskId}`, { checked });
// The collab server applies the edit and persists it debounced (~2 s) —
// re-read a couple of times; the optimistic override stays until the
// server agrees (cleared below when the data catches up).
for (const delay of [2500, 6000]) {
setTimeout(() => {
void queryClient.invalidateQueries({ queryKey: ['page-tasks', pondSlug, pageSlug] });
}, delay);
}
};
const pages = overview.data ?? [];
// Drop optimistic overrides the server data now agrees with.
const agreed = pages
.flatMap((page) => page.tasks)
.filter((task) => task.id && task.id in optimistic && optimistic[task.id] === task.checked)
.map((task) => task.id as string);
if (agreed.length > 0) {
setOptimistic((prev) => {
const next = { ...prev };
for (const id of agreed) delete next[id];
return next;
});
}
const total = pages.reduce((sum, page) => sum + page.tasks.length, 0);
return (
<NodeViewWrapper className="dt-task-overview-view" contentEditable={false}>
{total === 0 ? (
<div className="dt-task-overview-empty">{t('empty')}</div>
) : (
<table className="dt-task-overview-table">
<thead>
<tr>
<th></th>
<th>{t('colTask')}</th>
<th>{t('colMentions')}</th>
<th>{t('colStart')}</th>
<th>{t('colDue')}</th>
<th>{t('colPage')}</th>
</tr>
</thead>
<tbody>
{pages.flatMap((page) =>
page.tasks.map((task, index) => {
const key = task.id ?? `${page.pageId}:${index}`;
const checked =
task.id && task.id in optimistic ? optimistic[task.id]! : task.checked;
return (
<tr key={key}>
<td>
<input
type="checkbox"
checked={checked}
disabled={!task.id}
title={task.id ? undefined : t('unsavedHint')}
onChange={(event) =>
task.id && void toggle(page.pageId, task.id, event.target.checked)
}
/>
</td>
<td>{task.text}</td>
<td>
{task.mentions.map((mention) => (
<span key={mention.id || mention.username} className="dt-mention">
@{mention.displayName}
</span>
))}
</td>
<td>{formatDate(task.startDate)}</td>
<td>{formatDate(task.dueDate)}</td>
<td>
<Link className="wikilink" to={`/p/${pondSlug}/${page.slug}`}>
{page.title}
</Link>
</td>
</tr>
);
}),
)}
</tbody>
</table>
)}
</NodeViewWrapper>
);
}
const taskOverviewSpec = nodeSpec('task_overview');
export const TaskOverview = Node.create({
name: 'task_overview',
group: taskOverviewSpec.group,
atom: taskOverviewSpec.atom,
addAttributes() {
return attributesFromSpec(taskOverviewSpec);
},
parseHTML: () => taskOverviewSpec.parseDOM,
renderHTML: ({ node }) => taskOverviewSpec.toDOM!(node),
addNodeView() {
return ReactNodeViewRenderer(TaskOverviewView);
},
});

View 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;
},
}),
];
},
});

View File

@ -21,6 +21,7 @@ import deSearch from '@dorfteich/shared/i18n/de/search.json';
import deSetup from '@dorfteich/shared/i18n/de/setup.json'; import deSetup from '@dorfteich/shared/i18n/de/setup.json';
import deApiTokens from '@dorfteich/shared/i18n/de/apiTokens.json'; import deApiTokens from '@dorfteich/shared/i18n/de/apiTokens.json';
import deSystem from '@dorfteich/shared/i18n/de/system.json'; import deSystem from '@dorfteich/shared/i18n/de/system.json';
import deTasks from '@dorfteich/shared/i18n/de/tasks.json';
import deUsers from '@dorfteich/shared/i18n/de/users.json'; import deUsers from '@dorfteich/shared/i18n/de/users.json';
import deWatches from '@dorfteich/shared/i18n/de/watches.json'; import deWatches from '@dorfteich/shared/i18n/de/watches.json';
import deSettings from '@dorfteich/shared/i18n/de/settings.json'; import deSettings from '@dorfteich/shared/i18n/de/settings.json';
@ -47,6 +48,7 @@ import enSearch from '@dorfteich/shared/i18n/en/search.json';
import enSetup from '@dorfteich/shared/i18n/en/setup.json'; import enSetup from '@dorfteich/shared/i18n/en/setup.json';
import enApiTokens from '@dorfteich/shared/i18n/en/apiTokens.json'; import enApiTokens from '@dorfteich/shared/i18n/en/apiTokens.json';
import enSystem from '@dorfteich/shared/i18n/en/system.json'; import enSystem from '@dorfteich/shared/i18n/en/system.json';
import enTasks from '@dorfteich/shared/i18n/en/tasks.json';
import enUsers from '@dorfteich/shared/i18n/en/users.json'; import enUsers from '@dorfteich/shared/i18n/en/users.json';
import enWatches from '@dorfteich/shared/i18n/en/watches.json'; import enWatches from '@dorfteich/shared/i18n/en/watches.json';
import enSettings from '@dorfteich/shared/i18n/en/settings.json'; import enSettings from '@dorfteich/shared/i18n/en/settings.json';
@ -90,6 +92,7 @@ void i18n
setup: enSetup, setup: enSetup,
apiTokens: enApiTokens, apiTokens: enApiTokens,
system: enSystem, system: enSystem,
tasks: enTasks,
users: enUsers, users: enUsers,
watches: enWatches, watches: enWatches,
}, },
@ -118,6 +121,7 @@ void i18n
setup: deSetup, setup: deSetup,
apiTokens: deApiTokens, apiTokens: deApiTokens,
system: deSystem, system: deSystem,
tasks: deTasks,
users: deUsers, users: deUsers,
watches: deWatches, watches: deWatches,
}, },

View File

@ -27,6 +27,7 @@ import { ImageUpload } from '../editor/image-upload';
import { PresenceStrip } from '../editor/PresenceStrip'; import { PresenceStrip } from '../editor/PresenceStrip';
import { Toolbar } from '../editor/Toolbar'; import { Toolbar } from '../editor/Toolbar';
import { useCollabProvider } from '../editor/use-collab-provider'; import { useCollabProvider } from '../editor/use-collab-provider';
import { MentionAutocomplete } from '../editor/MentionAutocomplete';
import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete'; import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete';
import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context'; import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context';
import { usePageActionsSlot } from '../layout/page-actions'; import { usePageActionsSlot } from '../layout/page-actions';
@ -337,6 +338,7 @@ function PageEditor({
)} )}
<EditorContent editor={editor} className="editor-content" /> <EditorContent editor={editor} className="editor-content" />
{canEdit && <WikilinkAutocomplete editor={editor} />} {canEdit && <WikilinkAutocomplete editor={editor} />}
{canEdit && <MentionAutocomplete editor={editor} />}
</div> </div>
</PluginBlockContext.Provider> </PluginBlockContext.Provider>
</WikilinkContext.Provider> </WikilinkContext.Provider>

View File

@ -3808,3 +3808,67 @@ ul[data-type='task_list'] li p:last-of-type {
border-bottom-color: var(--color-accent); border-bottom-color: var(--color-accent);
} }
} }
/* `@username` mention chip (issue #150). */
.dt-mention {
display: inline-block;
padding: 0 0.35em;
border-radius: 999px;
background: var(--color-bg-subtle);
color: var(--color-accent);
font-weight: 500;
white-space: nowrap;
}
.dt-mention--dead {
color: var(--color-text-muted);
text-decoration: line-through;
}
/* Date marker chip (issue #152): » = due date, « = start date. */
.dt-date {
display: inline-block;
padding: 0 0.35em;
border-radius: 6px;
background: var(--color-bg-subtle);
color: var(--color-text-muted);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.dt-date--due {
color: var(--color-accent);
}
.dt-date--overdue {
color: #b91c1c;
}
/* Task overview block (issue #154). */
.dt-task-overview-table {
width: 100%;
border-collapse: collapse;
margin: var(--space-3) 0;
font-size: 0.9375rem;
}
.dt-task-overview-table th,
.dt-task-overview-table td {
padding: var(--space-1) var(--space-2);
border-bottom: 1px solid var(--color-border);
text-align: left;
vertical-align: top;
}
.dt-task-overview-table th {
color: var(--color-text-muted);
font-weight: var(--font-weight-heading);
}
.dt-task-overview-empty {
margin: var(--space-3) 0;
padding: var(--space-2) var(--space-3);
border: 1px dashed var(--color-border);
border-radius: 8px;
color: var(--color-text-muted);
}

View File

@ -177,6 +177,18 @@ Hat ein Teich-Admin eine Seite öffentlich geschaltet, ist sie ohne Konto
unter `/public/<teich>/<seite>` lesbar — mit der Typografie des Teichs unter `/public/<teich>/<seite>` lesbar — mit der Typografie des Teichs
und einem Link auf die Rechtsseiten der Instanz. und einem Link auf die Rechtsseiten der Instanz.
## Aufgaben über Seiten hinweg
Aufgabenzeilen können strukturierte Extras tragen: `@nutzername`
erwähnt einen Nutzer (instanzweites Autocomplete; neu Erwähnte mit
Leserecht bekommen eine Benachrichtigung), `>>2026-12-31` setzt ein
Zieldatum, `<<2026-07-01` ein Startdatum (auch `>>31.12.2026` geht —
gespeichert wird kanonisch, angezeigt in deiner Sprache). Der Block
**Aufgabenübersicht** (über die Block-Auswahl einfügen) sammelt alle
Aufgabenzeilen der aktuellen Seite und ihrer Unterseiten in einer
Tabelle — Aufgabe, Wer, Daten, Quellseite — und ein Haken dort ändert
die Quellseite für alle, live.
## Feeds (Atom) ## Feeds (Atom)
Jeder Teich hat einen Atom-Feed seiner zuletzt angelegten und geänderten Jeder Teich hat einen Atom-Feed seiner zuletzt angelegten und geänderten

View File

@ -158,6 +158,17 @@ If a pond admin has published a page for the public, it is readable
without an account at `/public/<pond>/<page>` — with the pond's without an account at `/public/<pond>/<page>` — with the pond's
typography and a link to the instance's legal pages. typography and a link to the instance's legal pages.
## Tasks across pages
Task lines can carry structured extras: `@username` mentions a user
(instance-wide autocomplete; newly mentioned people with read access get
a notification), `>>2026-12-31` sets a due date and `<<2026-07-01` a
start date (type `>>31.12.2026` if you prefer — stored canonically,
shown in your language). The **task overview** block (insert via the
block picker) collects every task line of the current page and its
subpages into a table — task, people, dates, source page — and checking
a box there updates the source page for everyone, live.
## Feeds (Atom) ## Feeds (Atom)
Every pond has an Atom feed of its recently created and updated pages at Every pond has an Atom feed of its recently created and updated pages at

View File

@ -181,5 +181,13 @@
"notFound": { "notFound": {
"hint": "Du kannst sie direkt hier anlegen — alle Wikilinks auf diese Adresse zeigen dann auf die neue Seite.", "hint": "Du kannst sie direkt hier anlegen — alle Wikilinks auf diese Adresse zeigen dann auf die neue Seite.",
"create": "Seite „{{slug}}“ anlegen" "create": "Seite „{{slug}}“ anlegen"
},
"mention": {
"unresolved": "Unbekannter Nutzer",
"suggestLabel": "Nutzer-Vorschläge"
},
"dateMarker": {
"due": "Zieldatum",
"start": "Startdatum"
} }
} }

View File

@ -5,7 +5,8 @@
"someone": "Jemand", "someone": "Jemand",
"types": { "types": {
"page_changed": "{{actor}} hat „{{page}}“ in {{pond}} geändert", "page_changed": "{{actor}} hat „{{page}}“ in {{pond}} geändert",
"comment_added": "{{actor}} hat „{{page}}“ in {{pond}} kommentiert" "comment_added": "{{actor}} hat „{{page}}“ in {{pond}} kommentiert",
"mentioned": "{{actor}} hat dich auf „{{page}}“ in {{pond}} erwähnt"
}, },
"digest": { "digest": {
"label": "E-Mail-Digest", "label": "E-Mail-Digest",

View File

@ -0,0 +1,11 @@
{
"title": "Aufgabenübersicht",
"editorCard": "Aufgabenübersicht — diese Seite und ihre Unterseiten",
"colTask": "Aufgabe",
"colMentions": "Wer",
"colStart": "Start",
"colDue": "Ziel",
"colPage": "Seite",
"empty": "Keine Aufgaben auf dieser Seite oder ihren Unterseiten.",
"unsavedHint": "Öffne die Seite einmal im Editor, damit ihre Checkboxen hier abhakbar werden."
}

View File

@ -181,5 +181,13 @@
"notFound": { "notFound": {
"hint": "You can create it right here — every wikilink pointing at this address will resolve to the new page.", "hint": "You can create it right here — every wikilink pointing at this address will resolve to the new page.",
"create": "Create the page “{{slug}}”" "create": "Create the page “{{slug}}”"
},
"mention": {
"unresolved": "Unknown user",
"suggestLabel": "User suggestions"
},
"dateMarker": {
"due": "Due date",
"start": "Start date"
} }
} }

View File

@ -5,7 +5,8 @@
"someone": "Someone", "someone": "Someone",
"types": { "types": {
"page_changed": "{{actor}} changed “{{page}}” in {{pond}}", "page_changed": "{{actor}} changed “{{page}}” in {{pond}}",
"comment_added": "{{actor}} commented on “{{page}}” in {{pond}}" "comment_added": "{{actor}} commented on “{{page}}” in {{pond}}",
"mentioned": "{{actor}} mentioned you on “{{page}}” in {{pond}}"
}, },
"digest": { "digest": {
"label": "E-mail digest", "label": "E-mail digest",

View File

@ -0,0 +1,11 @@
{
"title": "Task overview",
"editorCard": "Task overview — this page and its subpages",
"colTask": "Task",
"colMentions": "Who",
"colStart": "Start",
"colDue": "Due",
"colPage": "Page",
"empty": "No tasks on this page or its subpages.",
"unsavedHint": "Open this page once in the editor to make its checkboxes toggleable here."
}

View File

@ -47,6 +47,40 @@ 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';
/**
* PostgreSQL `NOTIFY` channel over which the collab server announces that a
* persist added new user mentions to a page (issue #151). The api listens
* and creates the `mentioned` notifications permission-checked there.
* Payload is a JSON {@link PageMentionsChangedEvent}.
*/
export const PAGE_MENTIONS_CHANGED_CHANNEL = 'page_mentions_changed';
/** JSON payload carried on {@link PAGE_MENTIONS_CHANGED_CHANNEL}. */
export interface PageMentionsChangedEvent {
pageId: string;
/** Users newly mentioned by this persist (diff against the stored rows). */
addedUserIds: string[];
}
/** 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;

View File

@ -0,0 +1,73 @@
import { Node } from 'prosemirror-model';
import { describe, expect, it } from 'vitest';
import { docToHtml } from './html';
import { docToMarkdown, markdownToDoc } from './markdown';
import { docToPlainText } from './plain-text';
function markers(doc: Node): { kind: string; date: string }[] {
const found: { kind: string; date: string }[] = [];
doc.descendants((node) => {
if (node.type.name === 'date_marker') {
found.push({ kind: node.attrs.kind as string, date: node.attrs.date as string });
}
});
return found;
}
describe('date markers >>/<< (issue #152)', () => {
it('parses ISO due and start dates and round-trips canonically', () => {
const doc = markdownToDoc('Projekt <<2026-07-01 bis >>2026-12-31 fertig.');
expect(markers(doc)).toEqual([
{ kind: 'start', date: '2026-07-01' },
{ kind: 'due', date: '2026-12-31' },
]);
const markdown = docToMarkdown(doc);
expect(markdown).toContain('<<2026-07-01');
expect(markdown).toContain('>>2026-12-31');
});
it('accepts dd.mm.yyyy as input lenience but serializes ISO', () => {
const doc = markdownToDoc('Zieltermin >>31.12.2026 bitte.');
expect(markers(doc)).toEqual([{ kind: 'due', date: '2026-12-31' }]);
expect(docToMarkdown(doc)).toContain('>>2026-12-31');
});
it('keeps a line-leading >>date out of blockquote parsing', () => {
const doc = markdownToDoc('>>2026-12-31 ist die Deadline.');
expect(markers(doc)).toEqual([{ kind: 'due', date: '2026-12-31' }]);
let quotes = 0;
doc.descendants((node) => {
if (node.type.name === 'blockquote') quotes += 1;
});
expect(quotes).toBe(0);
// A real blockquote still works.
const quote = markdownToDoc('> ein Zitat');
let realQuotes = 0;
quote.descendants((node) => {
if (node.type.name === 'blockquote') realQuotes += 1;
});
expect(realQuotes).toBe(1);
});
it('leaves invalid calendar dates as plain text', () => {
const doc = markdownToDoc('Kaputt: >>31.02.2026 bleibt Text.');
expect(markers(doc)).toEqual([]);
expect(docToMarkdown(doc)).toContain('31.02.2026');
});
it('renders HTML with kind, ISO date, and shows up in plain text', () => {
const doc = markdownToDoc('Bis >>2026-12-31.');
const html = docToHtml(doc);
expect(html).toContain('data-date-marker="due"');
expect(html).toContain('data-date="2026-12-31"');
expect(docToPlainText(doc)).toContain('2026-12-31');
});
it('works inside task list lines', () => {
const doc = markdownToDoc('- [ ] Bühne buchen >>2026-08-01 @nadia');
expect(markers(doc)).toEqual([{ kind: 'due', date: '2026-08-01' }]);
const markdown = docToMarkdown(doc);
expect(markdown).toContain('- [ ] Bühne buchen >>2026-08-01 @nadia');
});
});

View File

@ -69,6 +69,21 @@ function renderInline(node: Node): string {
const text = escapeHtml(display ?? (child.attrs.targetSlug as string)); const text = escapeHtml(display ?? (child.attrs.targetSlug as string));
const displayAttr = display ? ` data-display="${escapeHtml(display)}"` : ''; const displayAttr = display ? ` data-display="${escapeHtml(display)}"` : '';
out += `<a class="wikilink" href="${slug}" data-wikilink="${slug}"${displayAttr}>${text}</a>`; out += `<a class="wikilink" href="${slug}" data-wikilink="${slug}"${displayAttr}>${text}</a>`;
} else if (child.type.name === 'date_marker') {
// A date marker (issue #152): static HTML shows the unambiguous ISO
// date; locale-aware formatting happens where the viewer is known.
const kind = escapeHtml(child.attrs.kind as string);
const date = escapeHtml(child.attrs.date as string);
out +=
`<span class="dt-date dt-date--${kind}" data-date-marker="${kind}"` +
` data-date="${date}">${kind === 'due' ? '»' : '«'} ${date}</span>`;
} else if (child.type.name === 'mention') {
// A user mention (issue #150): static HTML shows the @username; the
// stable user id rides along for consumers that can resolve it.
const username = escapeHtml(child.attrs.username as string);
const userId = child.attrs.userId ? escapeHtml(child.attrs.userId as string) : '';
const idAttr = userId ? ` data-mention-user-id="${userId}"` : '';
out += `<span class="dt-mention" data-mention="${username}"${idAttr}>@${username}</span>`;
} }
}); });
return out; return out;
@ -79,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>`;
} }
@ -132,6 +148,10 @@ function renderBlock(node: Node): string {
` data-plugin-data="${data}">[${pluginId}/${blockType}]</div>` ` data-plugin-data="${data}">[${pluginId}/${blockType}]</div>`
); );
} }
case 'task_overview':
// The task overview (issue #154): a placeholder the permission-aware
// renderers (public view, exports) replace with the static table.
return '<div class="dt-task-overview" data-task-overview="1">[tasks]</div>';
case 'transclusion': { case 'transclusion': {
// A page embed (#135). The static HTML is a placeholder carrying the // A page embed (#135). The static HTML is a placeholder carrying the
// target slug; the read view / public renderer expands it server-side to // target slug; the read view / public renderer expands it server-side to

View File

@ -5,3 +5,5 @@ export * from './html';
export * from './plain-text'; export * from './plain-text';
export * from './outline'; export * from './outline';
export * from './wikilinks'; export * from './wikilinks';
export * from './mentions';
export * from './tasks';

View File

@ -128,6 +128,13 @@ function transformTokens(tokens: Token[]): Token[] {
} }
} }
if (tok.type === 'fence' && tok.info.trim() === 'dorfteich-tasks') {
// The task overview block (issue #154) — an empty fence marker.
out.push(retype(tok, 'task_overview'));
i += 1;
continue;
}
if (tok.type === 'fence') { if (tok.type === 'fence') {
const info = PLUGIN_BLOCK_INFO.exec(tok.info.trim()); const info = PLUGIN_BLOCK_INFO.exec(tok.info.trim());
if (info) { if (info) {
@ -192,6 +199,108 @@ function wikilinkRule(state: StateInline, silent: boolean): boolean {
return true; return true;
} }
/** `>>`/`<<` + ISO or `dd.mm.yyyy` date (issue #152). */
const DATE_MARKER = /^([<>])\1\s?(?:(\d{4})-(\d{2})-(\d{2})|(\d{1,2})\.(\d{1,2})\.(\d{4}))/;
/** Canonical `YYYY-MM-DD` from the regex groups, or null for a non-date. */
function isoDateOf(match: RegExpExecArray): string | null {
const [year, month, day] = match[2]
? [Number(match[2]), Number(match[3]), Number(match[4])]
: [Number(match[7]), Number(match[6]), Number(match[5])];
const date = new Date(Date.UTC(year, month - 1, day));
const valid =
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
if (!valid) return null;
const pad = (value: number): string => String(value).padStart(2, '0');
return `${year}-${pad(month)}-${pad(day)}`;
}
/** markdown-it inline rule for date markers (issue #152): `>>` = due date,
* `<<` = start date; ISO is canonical, `dd.mm.yyyy` is accepted as input
* lenience. An invalid calendar date stays plain text. */
function dateMarkerRule(state: StateInline, silent: boolean): boolean {
const match = DATE_MARKER.exec(state.src.slice(state.pos));
if (!match) return false;
const iso = isoDateOf(match);
if (!iso) return false;
if (!silent) {
const token = state.push('date_marker', '', 0);
token.attrs = [
['kind', match[1] === '>' ? 'due' : 'start'],
['date', iso],
];
}
state.pos += match[0].length;
return true;
}
/** Block guard (issue #152): a line starting with `>>2026-` is a paragraph
* with a due-date marker, not a nested blockquote registered before
* `blockquote` so markdown-it never sees the `>` as a quote. */
function dateLineRule(
state: StateBlock,
startLine: number,
endLine: number,
silent: boolean,
): boolean {
const start = state.bMarks[startLine]! + state.tShift[startLine]!;
const max = state.eMarks[startLine]!;
const match = DATE_MARKER.exec(state.src.slice(start, max));
if (!match || !isoDateOf(match)) return false;
if (silent) return true;
// Consume the paragraph like markdown-it's own paragraph rule would.
const terminatorRules = state.md.block.ruler.getRules('paragraph');
let nextLine = startLine + 1;
for (; nextLine < endLine && !state.isEmpty(nextLine); nextLine += 1) {
if (state.sCount[nextLine]! - state.blkIndent > 3) continue;
if (state.sCount[nextLine]! < 0) continue;
let terminate = false;
for (const rule of terminatorRules) {
if (rule(state, nextLine, endLine, true)) {
terminate = true;
break;
}
}
if (terminate) break;
}
const content = state.getLines(startLine, nextLine, state.blkIndent, false).trim();
const open = state.push('paragraph_open', 'p', 1);
open.map = [startLine, nextLine];
const inline = state.push('inline', '', 0);
inline.content = content;
inline.map = [startLine, nextLine];
inline.children = [];
state.push('paragraph_close', 'p', -1);
state.line = nextLine;
return true;
}
/** Username shape after the `@` (mirrors the signup `usernameSchema`). */
const MENTION_NAME = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/i;
/** markdown-it inline rule for `@username` mentions (issue #150). Only fires
* at a word boundary `stefan@example.org` stays plain text. The parser is
* database-less, so the user id stays empty (purely visual) until the editor
* or a resolver fills it in. */
function mentionRule(state: StateInline, silent: boolean): boolean {
const start = state.pos;
if (state.src.charCodeAt(start) !== 0x40 /* @ */) return false;
const before = start > 0 ? state.src[start - 1]! : '';
if (/[\w@.-]/.test(before)) return false;
const match = MENTION_NAME.exec(state.src.slice(start + 1));
if (!match || match[0].length < 3) return false;
const username = match[0];
// Trailing user-ish characters would make this an address, not a mention.
const after = state.src[start + 1 + username.length] ?? '';
if (after === '@') return false;
if (!silent) {
const token = state.push('mention', '', 0);
token.attrs = [['username', username]];
}
state.pos = start + 1 + username.length;
return true;
}
/** A whole line that is only `![[slug]]` / `![[slug|display]]` embeds a page /** A whole line that is only `![[slug]]` / `![[slug|display]]` embeds a page
* (#135); the `$[[slug]]` prefix embeds without frame or title (#146). */ * (#135); the `$[[slug]]` prefix embeds without frame or title (#146). */
const TRANSCLUSION_LINE = /^([!$])\[\[([^[\]\n|]+)(?:\|([^[\]\n]+))?\]\]\s*$/; const TRANSCLUSION_LINE = /^([!$])\[\[([^[\]\n|]+)(?:\|([^[\]\n]+))?\]\]\s*$/;
@ -280,6 +389,12 @@ function createTokenizer(): MarkdownIt {
const md = new MarkdownIt('default', { html: false }); const md = new MarkdownIt('default', { html: false });
// Run before `link` so `[[…]]` is not first eaten as two nested `[…]` links. // Run before `link` so `[[…]]` is not first eaten as two nested `[…]` links.
md.inline.ruler.before('link', 'wikilink', wikilinkRule); md.inline.ruler.before('link', 'wikilink', wikilinkRule);
// `@username` mentions (issue #150).
md.inline.ruler.before('link', 'mention', mentionRule);
// `>>`/`<<` date markers (issue #152) …
md.inline.ruler.before('link', 'date_marker', dateMarkerRule);
// … and the guard that keeps a line-leading `>>date` out of blockquote.
md.block.ruler.before('blockquote', 'date_line', dateLineRule);
// Run before `paragraph` so a lone `![[slug]]` line embeds rather than reads // Run before `paragraph` so a lone `![[slug]]` line embeds rather than reads
// as plain text (issue #135). // as plain text (issue #135).
md.block.ruler.before('paragraph', 'transclusion', transclusionRule); md.block.ruler.before('paragraph', 'transclusion', transclusionRule);
@ -307,6 +422,7 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
data: pluginBlockData(tok.content.trim()), data: pluginBlockData(tok.content.trim()),
}), }),
}, },
task_overview: { node: 'task_overview' },
paragraph: { block: 'paragraph' }, paragraph: { block: 'paragraph' },
list_item: { block: 'list_item' }, list_item: { block: 'list_item' },
task_item: { task_item: {
@ -347,6 +463,21 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
displayText: tok.attrGet('display') || null, displayText: tok.attrGet('display') || null,
}), }),
}, },
mention: {
node: 'mention',
getAttrs: (tok) => ({
username: tok.attrGet('username') ?? '',
// A Markdown import cannot resolve users — the mention stays visual.
userId: tok.attrGet('userId') ?? '',
}),
},
date_marker: {
node: 'date_marker',
getAttrs: (tok) => ({
kind: tok.attrGet('kind') ?? 'due',
date: tok.attrGet('date') ?? '',
}),
},
transclusion: { transclusion: {
node: 'transclusion', node: 'transclusion',
getAttrs: (tok) => ({ getAttrs: (tok) => ({
@ -434,6 +565,11 @@ const markdownSerializer = new MarkdownSerializer(
state.write(fence); state.write(fence);
state.closeBlock(node); state.closeBlock(node);
}, },
task_overview(state, node) {
// An empty marker fence (issue #154) — the overview has no payload.
state.write('```dorfteich-tasks\n```');
state.closeBlock(node);
},
heading(state, node) { heading(state, node) {
state.write(`${state.repeat('#', node.attrs.level as number)} `); state.write(`${state.repeat('#', node.attrs.level as number)} `);
state.renderInline(node, false); state.renderInline(node, false);
@ -478,6 +614,13 @@ const markdownSerializer = new MarkdownSerializer(
const display = node.attrs.displayText as string | null; const display = node.attrs.displayText as string | null;
state.write(display ? `[[${slug}|${display}]]` : `[[${slug}]]`); state.write(display ? `[[${slug}|${display}]]` : `[[${slug}]]`);
}, },
mention(state, node) {
state.write(`@${node.attrs.username as string}`);
},
date_marker(state, node) {
const prefix = node.attrs.kind === 'due' ? '>>' : '<<';
state.write(`${prefix}${node.attrs.date as string}`);
},
transclusion(state, node) { transclusion(state, node) {
const slug = node.attrs.targetSlug as string; const slug = node.attrs.targetSlug as string;
const display = node.attrs.displayText as string | null; const display = node.attrs.displayText as string | null;

View File

@ -0,0 +1,62 @@
import { Node } from 'prosemirror-model';
import { describe, expect, it } from 'vitest';
import { docToHtml } from './html';
import { docToMarkdown, markdownToDoc } from './markdown';
import { extractMentionUserIds } from './mentions';
import { docToPlainText } from './plain-text';
import { editorSchema } from './schema';
function firstMention(doc: Node): Node | null {
let found: Node | null = null;
doc.descendants((node) => {
if (!found && node.type.name === 'mention') found = node;
});
return found;
}
describe('@username mention (issue #150)', () => {
it('parses @username to a mention node and round-trips', () => {
const doc = markdownToDoc('Hallo @nadia, schau mal.');
const node = firstMention(doc);
expect(node).not.toBeNull();
expect(node!.attrs.username).toBe('nadia');
expect(node!.attrs.userId).toBe('');
expect(docToMarkdown(doc)).toContain('@nadia');
});
it('leaves e-mail addresses untouched', () => {
const doc = markdownToDoc('Schreib an stefan@example.org bitte.');
expect(firstMention(doc)).toBeNull();
expect(docToMarkdown(doc)).toContain('stefan@example.org');
});
it('renders HTML with the username and optional user id', () => {
const doc = markdownToDoc('Ping @zoraya-shahin!');
expect(docToHtml(doc)).toContain('class="dt-mention" data-mention="zoraya-shahin"');
expect(docToHtml(doc)).toContain('@zoraya-shahin');
});
it('appears in the plain text for search', () => {
expect(docToPlainText(markdownToDoc('Frag @nadia.'))).toContain('@nadia');
});
it('extracts only resolved user ids', () => {
const doc = Node.fromJSON(editorSchema, {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'mention', attrs: { userId: 'u-1', username: 'nadia' } },
{ type: 'text', text: ' und ' },
{ type: 'mention', attrs: { userId: '', username: 'import-only' } },
{ type: 'text', text: ' und nochmal ' },
{ type: 'mention', attrs: { userId: 'u-1', username: 'nadia' } },
],
},
],
});
expect(extractMentionUserIds(doc)).toEqual(['u-1']);
});
});

View File

@ -0,0 +1,18 @@
import { Node } from 'prosemirror-model';
/**
* The distinct user ids mentioned in a document (issue #150) the input for
* the derived `page_mentions` rows and the mention notifications (#151).
* Mentions without a resolved user id (e.g. from a Markdown import) are
* purely visual and excluded.
*/
export function extractMentionUserIds(doc: Node): string[] {
const ids = new Set<string>();
doc.descendants((node) => {
if (node.type.name === 'mention') {
const userId = node.attrs.userId as string;
if (userId) ids.add(userId);
}
});
return [...ids];
}

View File

@ -9,6 +9,10 @@ export function docToPlainText(doc: Node): string {
if (leaf.type.name === 'wikilink') { if (leaf.type.name === 'wikilink') {
return (leaf.attrs.displayText as string | null) ?? (leaf.attrs.targetSlug as string); return (leaf.attrs.displayText as string | null) ?? (leaf.attrs.targetSlug as string);
} }
// A mention reads as `@username` (issue #150), so search finds it.
if (leaf.type.name === 'mention') return `@${leaf.attrs.username as string}`;
// A date marker contributes its ISO date (issue #152).
if (leaf.type.name === 'date_marker') return leaf.attrs.date as string;
return ''; return '';
}); });
return text.replace(/\n{3,}/g, '\n\n').trim(); return text.replace(/\n{3,}/g, '\n\n').trim();

View File

@ -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' },
@ -256,6 +274,82 @@ export const editorSchema = new Schema({
}, },
}, },
// Task overview block (issue #154): collects the task-list lines of the
// current page and its subtree into a table (text, mentioned users,
// start/due dates, source page) with write-back checkboxes. A block atom
// without payload — the collection is computed where permissions are
// known (server-side for the public view, the /read tasks endpoint for
// the editor's read mode).
task_overview: {
group: 'block',
atom: true,
parseDOM: [{ tag: 'div[data-task-overview]' }],
toDOM: () => ['div', { 'data-task-overview': '1', class: 'dt-task-overview' }, 'Tasks'],
},
// `>>2026-12-31` / `<<2026-07-01` date marker (issue #152). An inline
// atom carrying a kind ('due' for `>>`, 'start' for `<<`) and the date as
// a canonical ISO `YYYY-MM-DD` string; locale-aware formatting happens in
// the editor, where the viewer's language is known.
date_marker: {
group: 'inline',
inline: true,
atom: true,
attrs: {
kind: { validate: 'string' },
date: { validate: 'string' },
},
parseDOM: [
{
tag: 'span[data-date-marker]',
getAttrs: (dom) => ({
kind: dom.getAttribute('data-date-marker'),
date: dom.getAttribute('data-date'),
}),
},
],
toDOM: (node) => {
const kind = node.attrs.kind as string;
const date = node.attrs.date as string;
return [
'span',
{ 'data-date-marker': kind, 'data-date': date, class: `dt-date dt-date--${kind}` },
`${kind === 'due' ? '»' : '«'} ${date}`,
];
},
},
// `@username` user mention (issue #150). An inline atom carrying the
// mentioned user's stable id plus the username as serialization/display
// fallback. Live display-name resolution happens in the editor where a
// user lookup is available; a Markdown import cannot resolve users, so it
// leaves `userId` empty — such a mention is purely visual.
mention: {
group: 'inline',
inline: true,
atom: true,
attrs: {
userId: { default: '' },
username: { validate: 'string' },
},
parseDOM: [
{
tag: 'span[data-mention]',
getAttrs: (dom) => ({
username: dom.getAttribute('data-mention'),
userId: dom.getAttribute('data-mention-user-id') || '',
}),
},
],
toDOM: (node) => {
const username = node.attrs.username as string;
const userId = node.attrs.userId as string;
const attrs: Record<string, string> = { 'data-mention': username, class: 'dt-mention' };
if (userId) attrs['data-mention-user-id'] = userId;
return ['span', attrs, `@${username}`];
},
},
// Obsidian-style page embed `![[slug]]` (issue #135). A block atom that // Obsidian-style page embed `![[slug]]` (issue #135). A block atom that
// references another page by slug; the read view / public renderer expands // references another page by slug; the read view / public renderer expands
// it to the target page's rendered HTML (permission-checked, recursion // it to the target page's rendered HTML (permission-checked, recursion

View File

@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { extractTaskRows } from './tasks';
import { docToHtml } from './html';
import { docToMarkdown, markdownToDoc } from './markdown';
describe('task overview block + task extraction (issue #154)', () => {
it('round-trips the marker fence', () => {
const doc = markdownToDoc('Intro\n\n```dorfteich-tasks\n```\n\nOutro');
let found = 0;
doc.descendants((node) => {
if (node.type.name === 'task_overview') found += 1;
});
expect(found).toBe(1);
expect(docToMarkdown(doc)).toContain('```dorfteich-tasks');
});
it('renders the placeholder div for the permission-aware expansion', () => {
const html = docToHtml(markdownToDoc('```dorfteich-tasks\n```'));
expect(html).toContain('class="dt-task-overview" data-task-overview="1"');
});
it('extracts rows with text, mentions, and dates from task lists', () => {
const doc = markdownToDoc(
'- [ ] Bühne buchen @nadia >>2026-08-01\n- [x] Kabel prüfen <<2026-07-01\n\nKein Task.',
);
const rows = extractTaskRows(doc);
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({
checked: false,
text: 'Bühne buchen',
dueDate: '2026-08-01',
startDate: null,
});
expect(rows[0]!.mentions).toEqual([{ userId: '', username: 'nadia' }]);
expect(rows[1]).toMatchObject({
checked: true,
text: 'Kabel prüfen',
startDate: '2026-07-01',
dueDate: null,
});
// Markdown-born rows have no stable id yet (assigned in the editor, #153).
expect(rows[0]!.id).toBeNull();
});
});

View File

@ -0,0 +1,80 @@
import { Node } from 'prosemirror-model';
/**
* Task extraction for the task overview block (issue #154): every
* `task_item` of a document as a flat row its stable id (#153, null for
* lines not yet opened in an editor), checked state, the line's text
* (mentions and date markers excluded they become their own columns),
* the mentioned users (#150), and the start/due dates (#152).
*/
export interface TaskRow {
id: string | null;
checked: boolean;
text: string;
mentions: { userId: string; username: string }[];
startDate: string | null;
dueDate: string | null;
}
export function extractTaskRows(doc: Node): TaskRow[] {
const rows: TaskRow[] = [];
doc.descendants((node) => {
if (node.type.name !== 'task_item') return;
const row: TaskRow = {
id: (node.attrs.id as string | null) ?? null,
checked: node.attrs.checked === true,
text: '',
mentions: [],
startDate: null,
dueDate: null,
};
// The line's own content is its first paragraph; nested task lists
// produce their own rows via the outer descendants walk.
const paragraph = node.firstChild;
if (paragraph && paragraph.type.name === 'paragraph') {
paragraph.forEach((child) => {
if (child.isText) {
row.text += child.text ?? '';
} else if (child.type.name === 'mention') {
row.mentions.push({
userId: child.attrs.userId as string,
username: child.attrs.username as string,
});
} else if (child.type.name === 'date_marker') {
if (child.attrs.kind === 'due') row.dueDate = child.attrs.date as string;
else row.startDate = child.attrs.date as string;
} else if (child.type.name === 'wikilink') {
row.text +=
(child.attrs.displayText as string | null) ?? (child.attrs.targetSlug as string);
}
});
}
row.text = row.text.replace(/\s+/g, ' ').trim();
rows.push(row);
});
return rows;
}
/** Wire shapes of `GET /read/:pond/:slug/tasks` (issue #154). */
export interface TaskOverviewMention {
id: string;
username: string;
displayName: string;
}
export interface TaskOverviewRow {
/** Null for lines that never got an id — shown read-only. */
id: string | null;
checked: boolean;
text: string;
mentions: TaskOverviewMention[];
startDate: string | null;
dueDate: string | null;
}
export interface TaskOverviewPage {
pageId: string;
slug: string;
title: string;
tasks: TaskOverviewRow[];
}

View File

@ -20,6 +20,7 @@ export * from './labels';
export * from './legal'; export * from './legal';
export * from './links'; export * from './links';
export * from './members'; export * from './members';
export * from './users';
export * from './notifications'; export * from './notifications';
export * from './pages'; export * from './pages';
export * from './permissions'; export * from './permissions';

View File

@ -7,7 +7,7 @@ import { z } from 'zod';
* permission at delivery time. * permission at delivery time.
*/ */
export const NOTIFICATION_TYPES = ['page_changed', 'comment_added'] as const; export const NOTIFICATION_TYPES = ['page_changed', 'comment_added', 'mentioned'] as const;
export type NotificationType = (typeof NOTIFICATION_TYPES)[number]; export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
export interface NotificationPayload { export interface NotificationPayload {

View File

@ -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];

View File

@ -0,0 +1,10 @@
/**
* Minimal public identity of a user (issue #150): what the instance-wide
* mention search and the mention rendering expose never more than id,
* username, and display name.
*/
export interface UserBriefView {
id: string;
username: string;
displayName: string;
}