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 \
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
run: |
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[]
apiTokens ApiToken[]
feedTokens FeedToken[]
mentionRows PageMention[]
ponds Pond[]
pages Page[]
attachments Attachment[]
@ -282,6 +283,7 @@ model Page {
attachments Attachment[]
versions PageVersion[]
pendingContributors PagePendingContributor[]
mentionRows PageMention[]
labels PageLabel[]
outgoingLinks PageLink[] @relation("outgoingLinks")
comments Comment[]
@ -349,6 +351,21 @@ model PageVersion {
/// Collab flushes the current session's contributors here (deduplicated by 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).
/// 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 {
pageId String @map("page_id")
userId String @map("user_id")

View File

@ -1,9 +1,11 @@
import deErrors from '@dorfteich/shared/i18n/de/errors.json';
import deLegal from '@dorfteich/shared/i18n/de/legal.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 enLegal from '@dorfteich/shared/i18n/en/legal.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';
/**
@ -15,8 +17,8 @@ export const apiI18n: I18n = createInstance();
void apiI18n.init({
resources: {
en: { errors: enErrors, mails: enMails, legal: enLegal },
de: { errors: deErrors, mails: deMails, legal: deLegal },
en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks },
de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks },
},
fallbackLng: '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> {
const where = { userId: user.id };
const [total, unreadCount] = [

View File

@ -1,5 +1,10 @@
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 { Client } from 'pg';
@ -49,12 +54,17 @@ export class VersionEventListener implements OnModuleInit, OnModuleDestroy {
client.on('error', () => this.scheduleReconnect());
client.on('end', () => this.scheduleReconnect());
client.on('notification', (message) => {
if (message.channel !== PAGE_VERSION_CREATED_CHANNEL || !message.payload) return;
void this.handle(message.payload);
if (!message.payload) return;
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 {
await client.connect();
await client.query(`LISTEN ${PAGE_VERSION_CREATED_CHANNEL}`);
await client.query(`LISTEN ${PAGE_MENTIONS_CHANGED_CHANNEL}`);
this.logger.info({}, 'listening for collab version events');
} catch (error) {
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');
}
}
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,
pageListQuerySchema,
repositionPageInputSchema,
toggleTaskInputSchema,
updatePageInputSchema,
type ToggleTaskInput,
} from '@dorfteich/shared';
import type { Response } from 'express';
@ -76,6 +78,21 @@ export class PagesController {
return this.pages.getState(request.user!, id);
}
/** Toggles one task-list checkbox (issue #153). Applied asynchronously
* through the collab server, so open editors converge callers toggle
* optimistically and refetch. */
@Post('pages/:id/tasks/:taskId')
@RequiresPagePermission('write', { idParam: 'id' })
@HttpCode(202)
async toggleTask(
@Param('id') id: string,
@Param('taskId') taskId: string,
@Body(new ZodValidationPipe(toggleTaskInputSchema)) input: ToggleTaskInput,
@Req() request: AuthedRequest,
): Promise<void> {
await this.pages.toggleTask(request.user!, id, taskId, input.checked);
}
/**
* Short-lived collaboration token for the collab server (issue #34).
* `@Public()` so an anonymous visitor to a public page can obtain a token

View File

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

View File

@ -17,6 +17,8 @@ import {
PluginPageSummary,
RepositionPageInput,
SidebarSortMode,
TASK_TOGGLE_CHANNEL,
TaskToggleRequest,
TreeItem,
UpdatePageInput,
collectSubtreeIds,
@ -110,6 +112,24 @@ export class PagesService {
return page;
}
/**
* Toggles one task-list checkbox (issue #153). The collab server owns the
* live document, so this only records the toggler as a pending contributor
* (version attribution) and emits the NOTIFY the listener applies the
* attribute change as a normal edit and every open client converges.
*/
async toggleTask(user: User, pageId: string, taskId: string, checked: boolean): Promise<void> {
await this.findLivePage(pageId);
await this.prisma.pagePendingContributor.upsert({
where: { pageId_userId: { pageId, userId: user.id } },
update: {},
create: { pageId, userId: user.id },
});
const payload: TaskToggleRequest = { pageId, taskId, checked, userId: user.id };
await this.prisma
.$executeRaw`SELECT pg_notify(${TASK_TOGGLE_CHANNEL}, ${JSON.stringify(payload)})`;
}
/** The live `{id, parentId}` skeleton of a pond input to the tree walks
* (issue #106). Trashed pages keep their `parentId` but never count here. */
private async livePageTree(pondId: string): Promise<TreeItem[]> {

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,
extractOutline,
OutlineEntry,
extractMentionUserIds,
extractWikilinkSlugs,
} from '@dorfteich/shared';
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. */
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();
try {
Y.applyUpdate(ydoc, state);
@ -73,6 +94,9 @@ export interface DerivedPageContent {
* api (imports, phantom-create) seed their `page_links` rows from this
* collab, the content writer, rewrites them on every later save. */
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[] {
@ -99,5 +123,6 @@ export function deriveContent(state: Uint8Array): DerivedPageContent {
outline: extractOutline(doc),
imageFileIds: imageFileIdsOf(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 { CommentsService } from '../comments/comments.service';
import { TasksService } from '../pages/tasks.service';
import { PermissionService } from '../permissions/permission.service';
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
import { PrismaService } from '../prisma/prisma.service';
@ -41,6 +42,7 @@ export class PublicService {
private readonly fallbacks: PluginFallbackRenderer,
private readonly settings: InstanceSettingsService,
private readonly commentsService: CommentsService,
private readonly tasks: TasksService,
) {}
private async resolve(
@ -99,7 +101,23 @@ export class PublicService {
): Promise<string> {
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
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 type { TaskOverviewPage } from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard';
import { TasksService } from '../pages/tasks.service';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { PublicPageContent, PublicService } from './public.service';
@ -15,7 +17,22 @@ import { PublicPageContent, PublicService } from './public.service';
*/
@Controller('read')
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
// 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 { SessionsModule } from '../auth/sessions.module';
import { UserSearchController } from './user-search.controller';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
imports: [SessionsModule],
controllers: [UsersController],
controllers: [UsersController, UserSearchController],
providers: [UsersService],
exports: [UsersService],
})

View File

@ -7,6 +7,7 @@ import { createLogger } from './logger.js';
import { createMaintenanceListener, type MaintenanceListener } from './maintenance-listener.js';
import { PostgresPagePersistence } from './persistence.js';
import { createRestoreListener } from './restore-listener.js';
import { createTaskToggleListener } from './task-toggle-listener.js';
import { closeDocumentConnections, createCollabServer } from './server.js';
import { PostgresSessionRegistry } from './session-registry.js';
import { PostgresVersionStore } from './version-store.js';
@ -75,9 +76,18 @@ async function bootstrap(): Promise<void> {
logger,
});
// Applies api-requested task-checkbox toggles (issue #153).
const taskToggleListener = createTaskToggleListener({
createClient: () => new Client({ connectionString: env.DATABASE_URL }),
openDirectConnection: (documentName) =>
server.hocuspocus.openDirectConnection(documentName, { userId: 'task-toggle', mode: 'rw' }),
logger,
});
await server.listen(env.PORT);
await accessListener.start();
await restoreListener.start();
await taskToggleListener.start();
await maintenanceListener.start();
sessionRegistry.start(() => [...server.hocuspocus.documents.keys()]);
logger.info({ event: 'listen', port: env.PORT }, 'collaboration server listening');
@ -88,6 +98,7 @@ async function bootstrap(): Promise<void> {
void Promise.allSettled([
accessListener.stop(),
restoreListener.stop(),
taskToggleListener.stop(),
maintenanceListener.stop(),
server.destroy(),
pool.end(),

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 * 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');
this.lastStoredVector.set(pageId, nextVector);
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,
editorSchema,
extractOutline,
extractMentionUserIds,
extractWikilinkSlugs,
type OutlineEntry,
} from '@dorfteich/shared';
@ -44,6 +45,9 @@ export interface DerivedPageContent {
/** Distinct target slugs of every `[[wikilink]]`, for the `page_links`
* index (issue #47). */
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[] {
@ -70,5 +74,6 @@ export function deriveContentFromDoc(ydoc: Y.Doc): DerivedPageContent {
outline: extractOutline(doc),
imageFileIds: imageFileIdsOf(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');
// 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`);
const section = content.locator(`.dt-section.dt-style-${PLUGIN_ID}-boxed`);
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 page = await context.newPage();
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');
await expect(nav).toBeVisible();
@ -31,7 +34,8 @@ test('user settings show the jump nav and clicking scrolls + activates', async (
const last = links.last();
await last.click();
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 context.close();
@ -45,6 +49,7 @@ test('pond settings derive the nav from their sections', async ({ browser }) =>
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/settings`);
await page.waitForLoadState('networkidle');
const links = page.locator('.settings-nav .settings-nav__link');
// 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);
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();
});

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 => {
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);
};

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[];
}): React.JSX.Element | null {
const { t, i18n } = useTranslation('editor');
if (options.length === 0) return null;
const { t: tTasks } = useTranslation('tasks');
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('/');
if (!pluginId || !blockType) return;
editor.chain().focus().insertPluginBlock({ pluginId, blockType }).run();
@ -47,6 +51,7 @@ export function PluginBlockMenu({
<option value="" disabled>
{t('toolbar.pluginBlock.placeholder')}
</option>
<option value="builtin/tasks">{tTasks('title')}</option>
{options.map((option) => {
const key = `${option.pluginId}/${option.blockType}`;
return (

View File

@ -7,6 +7,10 @@ import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
import { PluginBlock } from './nodes/plugin-block';
import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
import { TaskItem } from './nodes/task-item';
import { DateMarker } from './nodes/date-marker';
import { TaskOverview } from './nodes/task-overview';
import { TaskItemIds } from './task-item-ids';
import { Mention } from './nodes/mention';
import { Transclusion } from './nodes/transclusion';
import { Wikilink } from './nodes/wikilink';
import {
@ -44,6 +48,10 @@ export const documentExtensions: AnyExtension[] = [
Image,
PluginBlock,
Wikilink,
Mention,
DateMarker,
TaskOverview,
TaskItemIds,
Transclusion,
Table,
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 deApiTokens from '@dorfteich/shared/i18n/de/apiTokens.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 deWatches from '@dorfteich/shared/i18n/de/watches.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 enApiTokens from '@dorfteich/shared/i18n/en/apiTokens.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 enWatches from '@dorfteich/shared/i18n/en/watches.json';
import enSettings from '@dorfteich/shared/i18n/en/settings.json';
@ -90,6 +92,7 @@ void i18n
setup: enSetup,
apiTokens: enApiTokens,
system: enSystem,
tasks: enTasks,
users: enUsers,
watches: enWatches,
},
@ -118,6 +121,7 @@ void i18n
setup: deSetup,
apiTokens: deApiTokens,
system: deSystem,
tasks: deTasks,
users: deUsers,
watches: deWatches,
},

View File

@ -27,6 +27,7 @@ import { ImageUpload } from '../editor/image-upload';
import { PresenceStrip } from '../editor/PresenceStrip';
import { Toolbar } from '../editor/Toolbar';
import { useCollabProvider } from '../editor/use-collab-provider';
import { MentionAutocomplete } from '../editor/MentionAutocomplete';
import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete';
import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context';
import { usePageActionsSlot } from '../layout/page-actions';
@ -337,6 +338,7 @@ function PageEditor({
)}
<EditorContent editor={editor} className="editor-content" />
{canEdit && <WikilinkAutocomplete editor={editor} />}
{canEdit && <MentionAutocomplete editor={editor} />}
</div>
</PluginBlockContext.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);
}
}
/* `@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
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)
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
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)
Every pond has an Atom feed of its recently created and updated pages at

View File

@ -181,5 +181,13 @@
"notFound": {
"hint": "Du kannst sie direkt hier anlegen — alle Wikilinks auf diese Adresse zeigen dann auf die neue Seite.",
"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",
"types": {
"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": {
"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": {
"hint": "You can create it right here — every wikilink pointing at this address will resolve to the new page.",
"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",
"types": {
"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": {
"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[];
}
/**
* 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}. */
export interface PageRestoreRequest {
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 displayAttr = display ? ` data-display="${escapeHtml(display)}"` : '';
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;
@ -79,7 +94,8 @@ function renderListItems(node: Node): string {
node.forEach((item) => {
if (item.type.name === 'task_item') {
const checked = item.attrs.checked === true;
out += `<li data-type="task_item" data-checked="${checked}"><input type="checkbox" disabled${checked ? ' checked' : ''}>${renderBlocks(item)}</li>`;
const id = item.attrs.id ? ` data-task-id="${escapeHtml(item.attrs.id as string)}"` : '';
out += `<li data-type="task_item" data-checked="${checked}"${id}><input type="checkbox" disabled${checked ? ' checked' : ''}>${renderBlocks(item)}</li>`;
} else {
out += `<li>${renderBlocks(item)}</li>`;
}
@ -132,6 +148,10 @@ function renderBlock(node: Node): string {
` 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': {
// A page embed (#135). The static HTML is a placeholder carrying the
// 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 './outline';
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') {
const info = PLUGIN_BLOCK_INFO.exec(tok.info.trim());
if (info) {
@ -192,6 +199,108 @@ function wikilinkRule(state: StateInline, silent: boolean): boolean {
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
* (#135); the `$[[slug]]` prefix embeds without frame or title (#146). */
const TRANSCLUSION_LINE = /^([!$])\[\[([^[\]\n|]+)(?:\|([^[\]\n]+))?\]\]\s*$/;
@ -280,6 +389,12 @@ function createTokenizer(): MarkdownIt {
const md = new MarkdownIt('default', { html: false });
// Run before `link` so `[[…]]` is not first eaten as two nested `[…]` links.
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
// as plain text (issue #135).
md.block.ruler.before('paragraph', 'transclusion', transclusionRule);
@ -307,6 +422,7 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
data: pluginBlockData(tok.content.trim()),
}),
},
task_overview: { node: 'task_overview' },
paragraph: { block: 'paragraph' },
list_item: { block: 'list_item' },
task_item: {
@ -347,6 +463,21 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
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: {
node: 'transclusion',
getAttrs: (tok) => ({
@ -434,6 +565,11 @@ const markdownSerializer = new MarkdownSerializer(
state.write(fence);
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) {
state.write(`${state.repeat('#', node.attrs.level as number)} `);
state.renderInline(node, false);
@ -478,6 +614,13 @@ const markdownSerializer = new MarkdownSerializer(
const display = node.attrs.displayText as string | null;
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) {
const slug = node.attrs.targetSlug as string;
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') {
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 text.replace(/\n{3,}/g, '\n\n').trim();

View File

@ -167,13 +167,31 @@ export const editorSchema = new Schema({
task_item: {
content: 'paragraph block*',
attrs: { checked: { default: false, validate: 'boolean' } },
parseDOM: [{ tag: 'li[data-type="task_item"]' }],
toDOM: (node) => [
'li',
{ 'data-type': 'task_item', 'data-checked': String(node.attrs.checked) },
0,
// `id` (issue #153): a stable per-line id (assigned lazily in the
// editor) so the task overview (#154) can address a single checkbox for
// display and server-side toggling. `default: null` keeps every
// existing document valid; Markdown stays id-less by design.
attrs: {
checked: { default: false, validate: 'boolean' },
id: { default: null },
},
parseDOM: [
{
tag: 'li[data-type="task_item"]',
getAttrs: (dom) => ({
checked: dom.getAttribute('data-checked') === 'true',
id: dom.getAttribute('data-task-id') || null,
}),
},
],
toDOM: (node) => {
const attrs: Record<string, string> = {
'data-type': 'task_item',
'data-checked': String(node.attrs.checked),
};
if (node.attrs.id) attrs['data-task-id'] = node.attrs.id as string;
return ['li', attrs, 0];
},
},
text: { group: 'inline' },
@ -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
// references another page by slug; the read view / public renderer expands
// 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 './links';
export * from './members';
export * from './users';
export * from './notifications';
export * from './pages';
export * from './permissions';

View File

@ -7,7 +7,7 @@ import { z } from 'zod';
* 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 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
* descendant along with it, which requires write permission on all of them.
*/
/** Body of `POST /pages/:id/tasks/:taskId` (issue #153). */
export const toggleTaskInputSchema = z.object({ checked: z.boolean() });
export type ToggleTaskInput = z.infer<typeof toggleTaskInputSchema>;
export const PAGE_DELETE_MODES = ['promote', 'subtree'] as const;
export type PageDeleteMode = (typeof PAGE_DELETE_MODES)[number];

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