#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 \
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/settings-nav.spec.ts
|
pnpm --filter @dorfteich/web exec playwright test e2e/settings-nav.spec.ts
|
||||||
|
|
||||||
|
- name: Reset login rate limit before tasks pack
|
||||||
|
run: |
|
||||||
|
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||||
|
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
||||||
|
|
||||||
|
- name: Run tasks pack
|
||||||
|
run: |
|
||||||
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
|
pnpm --filter @dorfteich/web exec playwright test e2e/tasks.spec.ts
|
||||||
|
|
||||||
- name: Reset login rate limit before create-missing-page pack
|
- name: Reset login rate limit before create-missing-page pack
|
||||||
run: |
|
run: |
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import deErrors from '@dorfteich/shared/i18n/de/errors.json';
|
import deErrors from '@dorfteich/shared/i18n/de/errors.json';
|
||||||
import deLegal from '@dorfteich/shared/i18n/de/legal.json';
|
import deLegal from '@dorfteich/shared/i18n/de/legal.json';
|
||||||
import deMails from '@dorfteich/shared/i18n/de/mails.json';
|
import deMails from '@dorfteich/shared/i18n/de/mails.json';
|
||||||
|
import deTasks from '@dorfteich/shared/i18n/de/tasks.json';
|
||||||
import enErrors from '@dorfteich/shared/i18n/en/errors.json';
|
import enErrors from '@dorfteich/shared/i18n/en/errors.json';
|
||||||
import enLegal from '@dorfteich/shared/i18n/en/legal.json';
|
import enLegal from '@dorfteich/shared/i18n/en/legal.json';
|
||||||
import enMails from '@dorfteich/shared/i18n/en/mails.json';
|
import enMails from '@dorfteich/shared/i18n/en/mails.json';
|
||||||
|
import enTasks from '@dorfteich/shared/i18n/en/tasks.json';
|
||||||
import { createInstance, type i18n as I18n } from 'i18next';
|
import { createInstance, type i18n as I18n } from 'i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -15,8 +17,8 @@ export const apiI18n: I18n = createInstance();
|
|||||||
|
|
||||||
void apiI18n.init({
|
void apiI18n.init({
|
||||||
resources: {
|
resources: {
|
||||||
en: { errors: enErrors, mails: enMails, legal: enLegal },
|
en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks },
|
||||||
de: { errors: deErrors, mails: deMails, legal: deLegal },
|
de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks },
|
||||||
},
|
},
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
supportedLngs: ['de', 'en'],
|
supportedLngs: ['de', 'en'],
|
||||||
|
|||||||
@ -7,11 +7,12 @@ import { SearchModule } from '../search/search.module';
|
|||||||
import { PagesController } from './pages.controller';
|
import { PagesController } from './pages.controller';
|
||||||
import { PagesService } from './pages.service';
|
import { PagesService } from './pages.service';
|
||||||
import { PluginApiController } from './plugin-api.controller';
|
import { PluginApiController } from './plugin-api.controller';
|
||||||
|
import { TasksService } from './tasks.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PondsModule, SearchModule, WatchesModule],
|
imports: [PondsModule, SearchModule, WatchesModule],
|
||||||
controllers: [PagesController, PluginApiController],
|
controllers: [PagesController, PluginApiController],
|
||||||
providers: [PagesService],
|
providers: [PagesService, TasksService],
|
||||||
exports: [PagesService],
|
exports: [PagesService, TasksService],
|
||||||
})
|
})
|
||||||
export class PagesModule {}
|
export class PagesModule {}
|
||||||
|
|||||||
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. */
|
/** Thrown for state bytes that are not a well-formed Yjs update for this schema. */
|
||||||
export class InvalidPageStateError extends Error {}
|
export class InvalidPageStateError extends Error {}
|
||||||
|
|
||||||
function docFromState(state: Uint8Array): Node {
|
/**
|
||||||
|
* Decode a page's full current document from its base state plus the
|
||||||
|
* `page_updates` log (issue #154) — the persisted base alone lags behind
|
||||||
|
* the live document until the next compaction merges the log back.
|
||||||
|
*/
|
||||||
|
export function docFromStateAndUpdates(state: Uint8Array, updates: Uint8Array[]): Node {
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
|
try {
|
||||||
|
Y.applyUpdate(ydoc, state);
|
||||||
|
for (const update of updates) Y.applyUpdate(ydoc, update);
|
||||||
|
return yXmlFragmentToProseMirrorRootNode(ydoc.getXmlFragment(FRAGMENT_NAME), editorSchema);
|
||||||
|
} catch (error) {
|
||||||
|
throw new InvalidPageStateError(error instanceof Error ? error.message : 'invalid Yjs state');
|
||||||
|
} finally {
|
||||||
|
ydoc.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode a page's stored Yjs state back into its ProseMirror document —
|
||||||
|
* also the entry point for read-time task extraction (issue #154). */
|
||||||
|
export function docFromState(state: Uint8Array): Node {
|
||||||
const ydoc = new Y.Doc();
|
const ydoc = new Y.Doc();
|
||||||
try {
|
try {
|
||||||
Y.applyUpdate(ydoc, state);
|
Y.applyUpdate(ydoc, state);
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import type { PageCommentsView } from '@dorfteich/shared';
|
|||||||
import { Pond, User } from '@prisma/client';
|
import { Pond, User } from '@prisma/client';
|
||||||
|
|
||||||
import { CommentsService } from '../comments/comments.service';
|
import { CommentsService } from '../comments/comments.service';
|
||||||
|
import { TasksService } from '../pages/tasks.service';
|
||||||
import { PermissionService } from '../permissions/permission.service';
|
import { PermissionService } from '../permissions/permission.service';
|
||||||
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
|
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@ -41,6 +42,7 @@ export class PublicService {
|
|||||||
private readonly fallbacks: PluginFallbackRenderer,
|
private readonly fallbacks: PluginFallbackRenderer,
|
||||||
private readonly settings: InstanceSettingsService,
|
private readonly settings: InstanceSettingsService,
|
||||||
private readonly commentsService: CommentsService,
|
private readonly commentsService: CommentsService,
|
||||||
|
private readonly tasks: TasksService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async resolve(
|
private async resolve(
|
||||||
@ -99,7 +101,23 @@ export class PublicService {
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
|
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
|
||||||
const withFallbacks = await this.fallbacks.applyToHtml(cache?.html ?? '');
|
const withFallbacks = await this.fallbacks.applyToHtml(cache?.html ?? '');
|
||||||
return this.expandEmbeds(withFallbacks, user, pondId, depth, visited);
|
const withTasks = await this.expandTaskOverviews(withFallbacks, user, pondId, page.id);
|
||||||
|
return this.expandEmbeds(withTasks, user, pondId, depth, visited);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces each task-overview placeholder (issue #154) with the static,
|
||||||
|
* permission-filtered table — read-only in the public rendering. */
|
||||||
|
private async expandTaskOverviews(
|
||||||
|
html: string,
|
||||||
|
user: User | null,
|
||||||
|
pondId: string,
|
||||||
|
pageId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const placeholder = /<div class="dt-task-overview" data-task-overview="1">[^<]*<\/div>/g;
|
||||||
|
if (!placeholder.test(html)) return html;
|
||||||
|
const lang = await this.settings.get('instance.defaultLocale');
|
||||||
|
const table = await this.tasks.renderStaticTable(user, pondId, pageId, lang);
|
||||||
|
return html.replace(placeholder, () => table);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { Controller, Get, Param, Req } from '@nestjs/common';
|
import { Controller, Get, Param, Req } from '@nestjs/common';
|
||||||
|
import type { TaskOverviewPage } from '@dorfteich/shared';
|
||||||
|
|
||||||
import { AuthedRequest } from '../auth/auth.guard';
|
import { AuthedRequest } from '../auth/auth.guard';
|
||||||
|
import { TasksService } from '../pages/tasks.service';
|
||||||
import { AuthenticatedOnly } from '../permissions/permission.decorators';
|
import { AuthenticatedOnly } from '../permissions/permission.decorators';
|
||||||
import { PublicPageContent, PublicService } from './public.service';
|
import { PublicPageContent, PublicService } from './public.service';
|
||||||
|
|
||||||
@ -15,7 +17,22 @@ import { PublicPageContent, PublicService } from './public.service';
|
|||||||
*/
|
*/
|
||||||
@Controller('read')
|
@Controller('read')
|
||||||
export class ReadContentController {
|
export class ReadContentController {
|
||||||
constructor(private readonly publicPages: PublicService) {}
|
constructor(
|
||||||
|
private readonly publicPages: PublicService,
|
||||||
|
private readonly tasks: TasksService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// The task collection (issue #154) — registered before the generic
|
||||||
|
// two-segment route so `tasks` is not read as a page slug.
|
||||||
|
@Get(':pondSlug/:pageSlug/tasks')
|
||||||
|
@AuthenticatedOnly()
|
||||||
|
async tasksOf(
|
||||||
|
@Param('pondSlug') pondSlug: string,
|
||||||
|
@Param('pageSlug') pageSlug: string,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
): Promise<TaskOverviewPage[]> {
|
||||||
|
return this.tasks.collect(request.user ?? null, pondSlug, pageSlug);
|
||||||
|
}
|
||||||
|
|
||||||
// Session required (explicit access rule, issue #52); per-page read
|
// Session required (explicit access rule, issue #52); per-page read
|
||||||
// permission is enforced in the service (resolve → canAccessPage → 404).
|
// permission is enforced in the service (resolve → canAccessPage → 404).
|
||||||
|
|||||||
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[];
|
options: PluginBlockOption[];
|
||||||
}): React.JSX.Element | null {
|
}): React.JSX.Element | null {
|
||||||
const { t, i18n } = useTranslation('editor');
|
const { t, i18n } = useTranslation('editor');
|
||||||
|
const { t: tTasks } = useTranslation('tasks');
|
||||||
if (options.length === 0) return null;
|
|
||||||
|
|
||||||
function insert(key: string): void {
|
function insert(key: string): void {
|
||||||
|
if (key === 'builtin/tasks') {
|
||||||
|
// The built-in task overview block (issue #154).
|
||||||
|
editor.chain().focus().insertContent({ type: 'task_overview' }).run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const [pluginId, blockType] = key.split('/');
|
const [pluginId, blockType] = key.split('/');
|
||||||
if (!pluginId || !blockType) return;
|
if (!pluginId || !blockType) return;
|
||||||
editor.chain().focus().insertPluginBlock({ pluginId, blockType }).run();
|
editor.chain().focus().insertPluginBlock({ pluginId, blockType }).run();
|
||||||
@ -47,6 +51,7 @@ export function PluginBlockMenu({
|
|||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
{t('toolbar.pluginBlock.placeholder')}
|
{t('toolbar.pluginBlock.placeholder')}
|
||||||
</option>
|
</option>
|
||||||
|
<option value="builtin/tasks">{tTasks('title')}</option>
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
const key = `${option.pluginId}/${option.blockType}`;
|
const key = `${option.pluginId}/${option.blockType}`;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { PluginBlock } from './nodes/plugin-block';
|
|||||||
import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
|
import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
|
||||||
import { TaskItem } from './nodes/task-item';
|
import { TaskItem } from './nodes/task-item';
|
||||||
import { DateMarker } from './nodes/date-marker';
|
import { DateMarker } from './nodes/date-marker';
|
||||||
|
import { TaskOverview } from './nodes/task-overview';
|
||||||
import { TaskItemIds } from './task-item-ids';
|
import { TaskItemIds } from './task-item-ids';
|
||||||
import { Mention } from './nodes/mention';
|
import { Mention } from './nodes/mention';
|
||||||
import { Transclusion } from './nodes/transclusion';
|
import { Transclusion } from './nodes/transclusion';
|
||||||
@ -49,6 +50,7 @@ export const documentExtensions: AnyExtension[] = [
|
|||||||
Wikilink,
|
Wikilink,
|
||||||
Mention,
|
Mention,
|
||||||
DateMarker,
|
DateMarker,
|
||||||
|
TaskOverview,
|
||||||
TaskItemIds,
|
TaskItemIds,
|
||||||
Transclusion,
|
Transclusion,
|
||||||
Table,
|
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 deSetup from '@dorfteich/shared/i18n/de/setup.json';
|
||||||
import deApiTokens from '@dorfteich/shared/i18n/de/apiTokens.json';
|
import deApiTokens from '@dorfteich/shared/i18n/de/apiTokens.json';
|
||||||
import deSystem from '@dorfteich/shared/i18n/de/system.json';
|
import deSystem from '@dorfteich/shared/i18n/de/system.json';
|
||||||
|
import deTasks from '@dorfteich/shared/i18n/de/tasks.json';
|
||||||
import deUsers from '@dorfteich/shared/i18n/de/users.json';
|
import deUsers from '@dorfteich/shared/i18n/de/users.json';
|
||||||
import deWatches from '@dorfteich/shared/i18n/de/watches.json';
|
import deWatches from '@dorfteich/shared/i18n/de/watches.json';
|
||||||
import deSettings from '@dorfteich/shared/i18n/de/settings.json';
|
import deSettings from '@dorfteich/shared/i18n/de/settings.json';
|
||||||
@ -47,6 +48,7 @@ import enSearch from '@dorfteich/shared/i18n/en/search.json';
|
|||||||
import enSetup from '@dorfteich/shared/i18n/en/setup.json';
|
import enSetup from '@dorfteich/shared/i18n/en/setup.json';
|
||||||
import enApiTokens from '@dorfteich/shared/i18n/en/apiTokens.json';
|
import enApiTokens from '@dorfteich/shared/i18n/en/apiTokens.json';
|
||||||
import enSystem from '@dorfteich/shared/i18n/en/system.json';
|
import enSystem from '@dorfteich/shared/i18n/en/system.json';
|
||||||
|
import enTasks from '@dorfteich/shared/i18n/en/tasks.json';
|
||||||
import enUsers from '@dorfteich/shared/i18n/en/users.json';
|
import enUsers from '@dorfteich/shared/i18n/en/users.json';
|
||||||
import enWatches from '@dorfteich/shared/i18n/en/watches.json';
|
import enWatches from '@dorfteich/shared/i18n/en/watches.json';
|
||||||
import enSettings from '@dorfteich/shared/i18n/en/settings.json';
|
import enSettings from '@dorfteich/shared/i18n/en/settings.json';
|
||||||
@ -90,6 +92,7 @@ void i18n
|
|||||||
setup: enSetup,
|
setup: enSetup,
|
||||||
apiTokens: enApiTokens,
|
apiTokens: enApiTokens,
|
||||||
system: enSystem,
|
system: enSystem,
|
||||||
|
tasks: enTasks,
|
||||||
users: enUsers,
|
users: enUsers,
|
||||||
watches: enWatches,
|
watches: enWatches,
|
||||||
},
|
},
|
||||||
@ -118,6 +121,7 @@ void i18n
|
|||||||
setup: deSetup,
|
setup: deSetup,
|
||||||
apiTokens: deApiTokens,
|
apiTokens: deApiTokens,
|
||||||
system: deSystem,
|
system: deSystem,
|
||||||
|
tasks: deTasks,
|
||||||
users: deUsers,
|
users: deUsers,
|
||||||
watches: deWatches,
|
watches: deWatches,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -3843,3 +3843,32 @@ ul[data-type='task_list'] li p:last-of-type {
|
|||||||
.dt-date--overdue {
|
.dt-date--overdue {
|
||||||
color: #b91c1c;
|
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
|
unter `/public/<teich>/<seite>` lesbar — mit der Typografie des Teichs
|
||||||
und einem Link auf die Rechtsseiten der Instanz.
|
und einem Link auf die Rechtsseiten der Instanz.
|
||||||
|
|
||||||
|
## Aufgaben über Seiten hinweg
|
||||||
|
|
||||||
|
Aufgabenzeilen können strukturierte Extras tragen: `@nutzername`
|
||||||
|
erwähnt einen Nutzer (instanzweites Autocomplete; neu Erwähnte mit
|
||||||
|
Leserecht bekommen eine Benachrichtigung), `>>2026-12-31` setzt ein
|
||||||
|
Zieldatum, `<<2026-07-01` ein Startdatum (auch `>>31.12.2026` geht —
|
||||||
|
gespeichert wird kanonisch, angezeigt in deiner Sprache). Der Block
|
||||||
|
**Aufgabenübersicht** (über die Block-Auswahl einfügen) sammelt alle
|
||||||
|
Aufgabenzeilen der aktuellen Seite und ihrer Unterseiten in einer
|
||||||
|
Tabelle — Aufgabe, Wer, Daten, Quellseite — und ein Haken dort ändert
|
||||||
|
die Quellseite für alle, live.
|
||||||
|
|
||||||
## Feeds (Atom)
|
## Feeds (Atom)
|
||||||
|
|
||||||
Jeder Teich hat einen Atom-Feed seiner zuletzt angelegten und geänderten
|
Jeder Teich hat einen Atom-Feed seiner zuletzt angelegten und geänderten
|
||||||
|
|||||||
@ -158,6 +158,17 @@ If a pond admin has published a page for the public, it is readable
|
|||||||
without an account at `/public/<pond>/<page>` — with the pond's
|
without an account at `/public/<pond>/<page>` — with the pond's
|
||||||
typography and a link to the instance's legal pages.
|
typography and a link to the instance's legal pages.
|
||||||
|
|
||||||
|
## Tasks across pages
|
||||||
|
|
||||||
|
Task lines can carry structured extras: `@username` mentions a user
|
||||||
|
(instance-wide autocomplete; newly mentioned people with read access get
|
||||||
|
a notification), `>>2026-12-31` sets a due date and `<<2026-07-01` a
|
||||||
|
start date (type `>>31.12.2026` if you prefer — stored canonically,
|
||||||
|
shown in your language). The **task overview** block (insert via the
|
||||||
|
block picker) collects every task line of the current page and its
|
||||||
|
subpages into a table — task, people, dates, source page — and checking
|
||||||
|
a box there updates the source page for everyone, live.
|
||||||
|
|
||||||
## Feeds (Atom)
|
## Feeds (Atom)
|
||||||
|
|
||||||
Every pond has an Atom feed of its recently created and updated pages at
|
Every pond has an Atom feed of its recently created and updated pages at
|
||||||
|
|||||||
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>`
|
` data-plugin-data="${data}">[${pluginId}/${blockType}]</div>`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
case 'task_overview':
|
||||||
|
// The task overview (issue #154): a placeholder the permission-aware
|
||||||
|
// renderers (public view, exports) replace with the static table.
|
||||||
|
return '<div class="dt-task-overview" data-task-overview="1">[tasks]</div>';
|
||||||
case 'transclusion': {
|
case 'transclusion': {
|
||||||
// A page embed (#135). The static HTML is a placeholder carrying the
|
// A page embed (#135). The static HTML is a placeholder carrying the
|
||||||
// target slug; the read view / public renderer expands it server-side to
|
// target slug; the read view / public renderer expands it server-side to
|
||||||
|
|||||||
@ -6,3 +6,4 @@ export * from './plain-text';
|
|||||||
export * from './outline';
|
export * from './outline';
|
||||||
export * from './wikilinks';
|
export * from './wikilinks';
|
||||||
export * from './mentions';
|
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') {
|
if (tok.type === 'fence') {
|
||||||
const info = PLUGIN_BLOCK_INFO.exec(tok.info.trim());
|
const info = PLUGIN_BLOCK_INFO.exec(tok.info.trim());
|
||||||
if (info) {
|
if (info) {
|
||||||
@ -415,6 +422,7 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
|
|||||||
data: pluginBlockData(tok.content.trim()),
|
data: pluginBlockData(tok.content.trim()),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
task_overview: { node: 'task_overview' },
|
||||||
paragraph: { block: 'paragraph' },
|
paragraph: { block: 'paragraph' },
|
||||||
list_item: { block: 'list_item' },
|
list_item: { block: 'list_item' },
|
||||||
task_item: {
|
task_item: {
|
||||||
@ -557,6 +565,11 @@ const markdownSerializer = new MarkdownSerializer(
|
|||||||
state.write(fence);
|
state.write(fence);
|
||||||
state.closeBlock(node);
|
state.closeBlock(node);
|
||||||
},
|
},
|
||||||
|
task_overview(state, node) {
|
||||||
|
// An empty marker fence (issue #154) — the overview has no payload.
|
||||||
|
state.write('```dorfteich-tasks\n```');
|
||||||
|
state.closeBlock(node);
|
||||||
|
},
|
||||||
heading(state, node) {
|
heading(state, node) {
|
||||||
state.write(`${state.repeat('#', node.attrs.level as number)} `);
|
state.write(`${state.repeat('#', node.attrs.level as number)} `);
|
||||||
state.renderInline(node, false);
|
state.renderInline(node, false);
|
||||||
|
|||||||
@ -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
|
// `>>2026-12-31` / `<<2026-07-01` date marker (issue #152). An inline
|
||||||
// atom carrying a kind ('due' for `>>`, 'start' for `<<`) and the date as
|
// atom carrying a kind ('due' for `>>`, 'start' for `<<`) and the date as
|
||||||
// a canonical ISO `YYYY-MM-DD` string; locale-aware formatting happens in
|
// 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