#154: Aufgabenübersicht als Kern-Block (Seite + Unterseiten)
Neuer Block-Atom task_overview (Markdown-Fence dorfteich-tasks, HTML-Placeholder). Shared extractTaskRows liest Task-Zeilen mit Text, Mentions (#150) und Start-/Zieldaten (#152); TasksService sammelt zur Lesezeit den Teilbaum (rekursiv via collectSubtreeIds, canAccessPage- Filter je Quellseite) aus Basis-State + page_updates-Log — KEINE abgeleitete Tabelle nötig (Teilbäume sind klein, kein Drift). Neuer auth-Endpoint GET /read/:pond/:slug/tasks; die öffentliche Ansicht expandiert den Placeholder serverseitig zur statischen Tabelle (Instanz-Sprache). NodeView mit Live-Tabelle und Rückschreib-Checkboxen (optimistisch, Override bis der debounced Collab-Persist nachzieht); Einfügen über die Block-Auswahl (eingebauter Eintrag). Unit- + DB-Tests, neuer CI-Pack tasks.spec (voller Loop inkl. Rückschreiben end-to-end), User-Guide-Doku en+de. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
This commit is contained in:
parent
3f7190ebcc
commit
e164370691
@ -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%';" | \
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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 {}
|
||||
|
||||
170
apps/api/src/pages/task-overview.e2e.db.test.ts
Normal file
170
apps/api/src/pages/task-overview.e2e.db.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
180
apps/api/src/pages/tasks.service.ts
Normal file
180
apps/api/src/pages/tasks.service.ts
Normal 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>`
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -21,7 +21,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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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).
|
||||
|
||||
119
apps/web/e2e/tasks.spec.ts
Normal file
119
apps/web/e2e/tasks.spec.ts
Normal 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();
|
||||
});
|
||||
@ -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 (
|
||||
|
||||
@ -8,6 +8,7 @@ import { PluginBlock } from './nodes/plugin-block';
|
||||
import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
|
||||
import { TaskItem } from './nodes/task-item';
|
||||
import { DateMarker } from './nodes/date-marker';
|
||||
import { TaskOverview } from './nodes/task-overview';
|
||||
import { TaskItemIds } from './task-item-ids';
|
||||
import { Mention } from './nodes/mention';
|
||||
import { Transclusion } from './nodes/transclusion';
|
||||
@ -49,6 +50,7 @@ export const documentExtensions: AnyExtension[] = [
|
||||
Wikilink,
|
||||
Mention,
|
||||
DateMarker,
|
||||
TaskOverview,
|
||||
TaskItemIds,
|
||||
Transclusion,
|
||||
Table,
|
||||
|
||||
155
apps/web/src/editor/nodes/task-overview.tsx
Normal file
155
apps/web/src/editor/nodes/task-overview.tsx
Normal 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);
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -3843,3 +3843,32 @@ ul[data-type='task_list'] li p:last-of-type {
|
||||
.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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
11
packages/shared/i18n/de/tasks.json
Normal file
11
packages/shared/i18n/de/tasks.json
Normal 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."
|
||||
}
|
||||
11
packages/shared/i18n/en/tasks.json
Normal file
11
packages/shared/i18n/en/tasks.json
Normal 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."
|
||||
}
|
||||
@ -148,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
|
||||
|
||||
@ -6,3 +6,4 @@ export * from './plain-text';
|
||||
export * from './outline';
|
||||
export * from './wikilinks';
|
||||
export * from './mentions';
|
||||
export * from './tasks';
|
||||
|
||||
@ -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) {
|
||||
@ -415,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: {
|
||||
@ -557,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);
|
||||
|
||||
@ -274,6 +274,19 @@ 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
|
||||
|
||||
45
packages/shared/src/editor-schema/task-overview.test.ts
Normal file
45
packages/shared/src/editor-schema/task-overview.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
80
packages/shared/src/editor-schema/tasks.ts
Normal file
80
packages/shared/src/editor-schema/tasks.ts
Normal 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[];
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user