@ -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%';" | \
|
||||||
|
|||||||
@ -0,0 +1,16 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "page_mentions" (
|
||||||
|
"page_id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "page_mentions_pkey" PRIMARY KEY ("page_id","user_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "page_mentions_user_id_idx" ON "page_mentions"("user_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "page_mentions" ADD CONSTRAINT "page_mentions_page_id_fkey" FOREIGN KEY ("page_id") REFERENCES "pages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "page_mentions" ADD CONSTRAINT "page_mentions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -52,6 +52,7 @@ model User {
|
|||||||
authTokens AuthToken[]
|
authTokens AuthToken[]
|
||||||
apiTokens ApiToken[]
|
apiTokens ApiToken[]
|
||||||
feedTokens FeedToken[]
|
feedTokens FeedToken[]
|
||||||
|
mentionRows PageMention[]
|
||||||
ponds Pond[]
|
ponds Pond[]
|
||||||
pages Page[]
|
pages Page[]
|
||||||
attachments Attachment[]
|
attachments Attachment[]
|
||||||
@ -282,6 +283,7 @@ model Page {
|
|||||||
attachments Attachment[]
|
attachments Attachment[]
|
||||||
versions PageVersion[]
|
versions PageVersion[]
|
||||||
pendingContributors PagePendingContributor[]
|
pendingContributors PagePendingContributor[]
|
||||||
|
mentionRows PageMention[]
|
||||||
labels PageLabel[]
|
labels PageLabel[]
|
||||||
outgoingLinks PageLink[] @relation("outgoingLinks")
|
outgoingLinks PageLink[] @relation("outgoingLinks")
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
@ -349,6 +351,21 @@ model PageVersion {
|
|||||||
/// Collab flushes the current session's contributors here (deduplicated by the
|
/// Collab flushes the current session's contributors here (deduplicated by the
|
||||||
/// composite key); version creation on either side reads and clears it in the
|
/// composite key); version creation on either side reads and clears it in the
|
||||||
/// same transaction as writing the snapshot. Cascades on page purge (ADR 0013).
|
/// same transaction as writing the snapshot. Cascades on page purge (ADR 0013).
|
||||||
|
/// Derived mention index (issue #151): one row per user currently
|
||||||
|
/// mentioned in the page's document. Rewritten on every collab persist;
|
||||||
|
/// the diff against the previous rows drives the `mentioned` notifications.
|
||||||
|
model PageMention {
|
||||||
|
pageId String @map("page_id")
|
||||||
|
userId String @map("user_id")
|
||||||
|
|
||||||
|
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([pageId, userId])
|
||||||
|
@@index([userId])
|
||||||
|
@@map("page_mentions")
|
||||||
|
}
|
||||||
|
|
||||||
model PagePendingContributor {
|
model PagePendingContributor {
|
||||||
pageId String @map("page_id")
|
pageId String @map("page_id")
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
|
|||||||
@ -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'],
|
||||||
|
|||||||
111
apps/api/src/notifications/mentions.e2e.db.test.ts
Normal file
111
apps/api/src/notifications/mentions.e2e.db.test.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createTestApp } from '../testing/test-app';
|
||||||
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mention notifications (issue #151): newly mentioned users get a
|
||||||
|
* `mentioned` notification — but only with read access (no leak), and the
|
||||||
|
* mention's author (a pending contributor) never notifies themselves.
|
||||||
|
*/
|
||||||
|
describe.skipIf(!hasTestDb)('mention notifications (e2e, issue #151)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
|
||||||
|
let authorId: string;
|
||||||
|
let readerId: string;
|
||||||
|
let outsiderId: string;
|
||||||
|
let pageId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
app = await createTestApp();
|
||||||
|
|
||||||
|
const mkUser = async (handle: string) =>
|
||||||
|
(
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: `mention-${handle}-${suffix}`,
|
||||||
|
email: `mention-${handle}-${suffix}@example.test`,
|
||||||
|
displayName: `Mention ${handle}`,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
authorId = await mkUser('author');
|
||||||
|
readerId = await mkUser('reader');
|
||||||
|
outsiderId = await mkUser('outsider');
|
||||||
|
|
||||||
|
const pond = await prisma.pond.create({
|
||||||
|
data: {
|
||||||
|
slug: `mention-pond-${suffix}`,
|
||||||
|
name: 'Mention Pond',
|
||||||
|
type: 'SHARED',
|
||||||
|
ownerId: authorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const page = await prisma.page.create({
|
||||||
|
data: {
|
||||||
|
pondId: pond.id,
|
||||||
|
slug: `notes-${suffix}`,
|
||||||
|
title: 'Notes',
|
||||||
|
createdBy: authorId,
|
||||||
|
sortKey: 'a0',
|
||||||
|
ydocState: new Uint8Array(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
pageId = page.id;
|
||||||
|
for (const [userId, role] of [
|
||||||
|
[authorId, 'EDITOR'],
|
||||||
|
[readerId, 'READER'],
|
||||||
|
] as const) {
|
||||||
|
await prisma.roleGrant.create({
|
||||||
|
data: {
|
||||||
|
pondId: pond.id,
|
||||||
|
subjectType: 'USER',
|
||||||
|
subjectId: userId,
|
||||||
|
role,
|
||||||
|
scopeType: 'POND',
|
||||||
|
scopeId: null,
|
||||||
|
effect: 'ALLOW',
|
||||||
|
createdBy: authorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// The author edited last — pending contributor, i.e. the acting user.
|
||||||
|
await prisma.pagePendingContributor.create({ data: { pageId, userId: authorId } });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.notification.deleteMany({
|
||||||
|
where: { userId: { in: [authorId, readerId, outsiderId] } },
|
||||||
|
});
|
||||||
|
await prisma.pagePendingContributor.deleteMany({ where: { pageId } });
|
||||||
|
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: authorId } } });
|
||||||
|
await prisma.page.deleteMany({ where: { pond: { ownerId: authorId } } });
|
||||||
|
await prisma.pond.deleteMany({ where: { ownerId: authorId } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: [authorId, readerId, outsiderId] } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies mentioned readers, skips outsiders and the author', async () => {
|
||||||
|
await app.get(NotificationsService).fanoutMentions(pageId, [readerId, outsiderId, authorId]);
|
||||||
|
|
||||||
|
const readerRows = await prisma.notification.findMany({ where: { userId: readerId } });
|
||||||
|
expect(readerRows).toHaveLength(1);
|
||||||
|
expect(readerRows[0]!.type).toBe('mentioned');
|
||||||
|
expect(readerRows[0]!.payload).toMatchObject({
|
||||||
|
pageTitle: 'Notes',
|
||||||
|
actorNames: ['Mention author'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// No read access → nothing; the author never notifies themselves.
|
||||||
|
expect(await prisma.notification.count({ where: { userId: outsiderId } })).toBe(0);
|
||||||
|
expect(await prisma.notification.count({ where: { userId: authorId } })).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -94,6 +94,64 @@ export class NotificationsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies newly mentioned users (issue #151). Independent of the watch
|
||||||
|
* table — a mention addresses the person directly — but with the same
|
||||||
|
* delivery-time read-permission re-check: whoever may not read the page
|
||||||
|
* gets nothing (no leak). The mention's authors (the page's pending
|
||||||
|
* contributors at persist time) never notify themselves.
|
||||||
|
*/
|
||||||
|
async fanoutMentions(pageId: string, mentionedUserIds: string[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
const page = await this.prisma.page.findFirst({
|
||||||
|
where: { id: pageId, deletedAt: null },
|
||||||
|
select: { id: true, pondId: true, title: true, slug: true },
|
||||||
|
});
|
||||||
|
if (!page) return;
|
||||||
|
const pond = await this.prisma.pond.findFirst({
|
||||||
|
where: { id: page.pondId, deletedAt: null },
|
||||||
|
select: { name: true, slug: true },
|
||||||
|
});
|
||||||
|
if (!pond) return;
|
||||||
|
|
||||||
|
const contributors = await this.prisma.pagePendingContributor.findMany({
|
||||||
|
where: { pageId },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
const actorIds = contributors.map((row) => row.userId);
|
||||||
|
const actors = actorIds.length
|
||||||
|
? await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: actorIds } },
|
||||||
|
select: { displayName: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const payload: NotificationPayload = {
|
||||||
|
pageId: page.id,
|
||||||
|
pageTitle: page.title,
|
||||||
|
pageSlug: page.slug,
|
||||||
|
pondSlug: pond.slug,
|
||||||
|
pondName: pond.name,
|
||||||
|
actorNames: actors.slice(0, 3).map((actor) => actor.displayName),
|
||||||
|
};
|
||||||
|
|
||||||
|
const targets = await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: mentionedUserIds.filter((id) => !actorIds.includes(id)) } },
|
||||||
|
});
|
||||||
|
for (const target of targets) {
|
||||||
|
if (!(await this.permissions.canAccessPage(target, page, 'read'))) continue;
|
||||||
|
await this.prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
userId: target.id,
|
||||||
|
type: 'mentioned',
|
||||||
|
payload: payload as unknown as Prisma.InputJsonObject,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ pageId, err: error }, 'mention fan-out failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async list(user: User, page: number): Promise<NotificationListView> {
|
async list(user: User, page: number): Promise<NotificationListView> {
|
||||||
const where = { userId: user.id };
|
const where = { userId: user.id };
|
||||||
const [total, unreadCount] = [
|
const [total, unreadCount] = [
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
import { PAGE_VERSION_CREATED_CHANNEL, type PageVersionCreatedEvent } from '@dorfteich/shared';
|
import {
|
||||||
|
PAGE_MENTIONS_CHANGED_CHANNEL,
|
||||||
|
PAGE_VERSION_CREATED_CHANNEL,
|
||||||
|
type PageMentionsChangedEvent,
|
||||||
|
type PageVersionCreatedEvent,
|
||||||
|
} from '@dorfteich/shared';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
import { Client } from 'pg';
|
import { Client } from 'pg';
|
||||||
|
|
||||||
@ -49,12 +54,17 @@ export class VersionEventListener implements OnModuleInit, OnModuleDestroy {
|
|||||||
client.on('error', () => this.scheduleReconnect());
|
client.on('error', () => this.scheduleReconnect());
|
||||||
client.on('end', () => this.scheduleReconnect());
|
client.on('end', () => this.scheduleReconnect());
|
||||||
client.on('notification', (message) => {
|
client.on('notification', (message) => {
|
||||||
if (message.channel !== PAGE_VERSION_CREATED_CHANNEL || !message.payload) return;
|
if (!message.payload) return;
|
||||||
void this.handle(message.payload);
|
if (message.channel === PAGE_VERSION_CREATED_CHANNEL) void this.handle(message.payload);
|
||||||
|
// Newly added mentions from a collab persist (issue #151).
|
||||||
|
if (message.channel === PAGE_MENTIONS_CHANGED_CHANNEL) {
|
||||||
|
void this.handleMentions(message.payload);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
await client.query(`LISTEN ${PAGE_VERSION_CREATED_CHANNEL}`);
|
await client.query(`LISTEN ${PAGE_VERSION_CREATED_CHANNEL}`);
|
||||||
|
await client.query(`LISTEN ${PAGE_MENTIONS_CHANGED_CHANNEL}`);
|
||||||
this.logger.info({}, 'listening for collab version events');
|
this.logger.info({}, 'listening for collab version events');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn({ err: error }, 'version-event listener could not connect');
|
this.logger.warn({ err: error }, 'version-event listener could not connect');
|
||||||
@ -79,4 +89,16 @@ export class VersionEventListener implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.logger.warn({ err: error }, 'ignoring malformed version event');
|
this.logger.warn({ err: error }, 'ignoring malformed version event');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleMentions(payload: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(payload) as PageMentionsChangedEvent;
|
||||||
|
if (!event.pageId || !Array.isArray(event.addedUserIds) || event.addedUserIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.notifications.fanoutMentions(event.pageId, event.addedUserIds);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error }, 'ignoring malformed mentions event');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +26,9 @@ import {
|
|||||||
pageDeleteQuerySchema,
|
pageDeleteQuerySchema,
|
||||||
pageListQuerySchema,
|
pageListQuerySchema,
|
||||||
repositionPageInputSchema,
|
repositionPageInputSchema,
|
||||||
|
toggleTaskInputSchema,
|
||||||
updatePageInputSchema,
|
updatePageInputSchema,
|
||||||
|
type ToggleTaskInput,
|
||||||
} from '@dorfteich/shared';
|
} from '@dorfteich/shared';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
|
||||||
@ -76,6 +78,21 @@ export class PagesController {
|
|||||||
return this.pages.getState(request.user!, id);
|
return this.pages.getState(request.user!, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggles one task-list checkbox (issue #153). Applied asynchronously
|
||||||
|
* through the collab server, so open editors converge — callers toggle
|
||||||
|
* optimistically and refetch. */
|
||||||
|
@Post('pages/:id/tasks/:taskId')
|
||||||
|
@RequiresPagePermission('write', { idParam: 'id' })
|
||||||
|
@HttpCode(202)
|
||||||
|
async toggleTask(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('taskId') taskId: string,
|
||||||
|
@Body(new ZodValidationPipe(toggleTaskInputSchema)) input: ToggleTaskInput,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.pages.toggleTask(request.user!, id, taskId, input.checked);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Short-lived collaboration token for the collab server (issue #34).
|
* Short-lived collaboration token for the collab server (issue #34).
|
||||||
* `@Public()` so an anonymous visitor to a public page can obtain a token
|
* `@Public()` so an anonymous visitor to a public page can obtain a token
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
|||||||
@ -17,6 +17,8 @@ import {
|
|||||||
PluginPageSummary,
|
PluginPageSummary,
|
||||||
RepositionPageInput,
|
RepositionPageInput,
|
||||||
SidebarSortMode,
|
SidebarSortMode,
|
||||||
|
TASK_TOGGLE_CHANNEL,
|
||||||
|
TaskToggleRequest,
|
||||||
TreeItem,
|
TreeItem,
|
||||||
UpdatePageInput,
|
UpdatePageInput,
|
||||||
collectSubtreeIds,
|
collectSubtreeIds,
|
||||||
@ -110,6 +112,24 @@ export class PagesService {
|
|||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles one task-list checkbox (issue #153). The collab server owns the
|
||||||
|
* live document, so this only records the toggler as a pending contributor
|
||||||
|
* (version attribution) and emits the NOTIFY — the listener applies the
|
||||||
|
* attribute change as a normal edit and every open client converges.
|
||||||
|
*/
|
||||||
|
async toggleTask(user: User, pageId: string, taskId: string, checked: boolean): Promise<void> {
|
||||||
|
await this.findLivePage(pageId);
|
||||||
|
await this.prisma.pagePendingContributor.upsert({
|
||||||
|
where: { pageId_userId: { pageId, userId: user.id } },
|
||||||
|
update: {},
|
||||||
|
create: { pageId, userId: user.id },
|
||||||
|
});
|
||||||
|
const payload: TaskToggleRequest = { pageId, taskId, checked, userId: user.id };
|
||||||
|
await this.prisma
|
||||||
|
.$executeRaw`SELECT pg_notify(${TASK_TOGGLE_CHANNEL}, ${JSON.stringify(payload)})`;
|
||||||
|
}
|
||||||
|
|
||||||
/** The live `{id, parentId}` skeleton of a pond — input to the tree walks
|
/** The live `{id, parentId}` skeleton of a pond — input to the tree walks
|
||||||
* (issue #106). Trashed pages keep their `parentId` but never count here. */
|
* (issue #106). Trashed pages keep their `parentId` but never count here. */
|
||||||
private async livePageTree(pondId: string): Promise<TreeItem[]> {
|
private async livePageTree(pondId: string): Promise<TreeItem[]> {
|
||||||
|
|||||||
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
154
apps/api/src/pages/task-toggle.e2e.db.test.ts
Normal file
154
apps/api/src/pages/task-toggle.e2e.db.test.ts
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { TASK_TOGGLE_CHANNEL, TaskToggleRequest } from '@dorfteich/shared';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { Client } from 'pg';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task-toggle request path (issue #153): the api checks write permission,
|
||||||
|
* records the toggler as a pending contributor, and emits the NOTIFY the
|
||||||
|
* collab server consumes. The Yjs application itself lives in the collab
|
||||||
|
* listener (verified through the collab e2e stack).
|
||||||
|
*/
|
||||||
|
describe.skipIf(!hasTestDb)('task toggle endpoint (e2e, issue #153)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
const password = 'aufgaben sind erledigt 1';
|
||||||
|
|
||||||
|
let editorId: string;
|
||||||
|
let editorCookie: string;
|
||||||
|
let readerCookie: string;
|
||||||
|
let pageId: string;
|
||||||
|
|
||||||
|
const notifies: TaskToggleRequest[] = [];
|
||||||
|
let listenClient: Client;
|
||||||
|
|
||||||
|
const api = () => request(app.getHttpServer());
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
app = await createTestApp();
|
||||||
|
const users = app.get(UsersService);
|
||||||
|
|
||||||
|
const mkUser = async (handle: string) => {
|
||||||
|
const username = `task-${handle}-${suffix}`;
|
||||||
|
const user = await users.createUser({
|
||||||
|
username,
|
||||||
|
email: `${username}@example.test`,
|
||||||
|
displayName: `Task ${handle}`,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
await users.markEmailVerified(user.id);
|
||||||
|
const cookie = sessionCookieOf(
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: username, password })
|
||||||
|
.expect(200),
|
||||||
|
);
|
||||||
|
return { id: user.id, cookie };
|
||||||
|
};
|
||||||
|
const editor = await mkUser('editor');
|
||||||
|
editorId = editor.id;
|
||||||
|
editorCookie = editor.cookie;
|
||||||
|
const reader = await mkUser('reader');
|
||||||
|
readerCookie = reader.cookie;
|
||||||
|
|
||||||
|
const pond = await prisma.pond.create({
|
||||||
|
data: { slug: `task-pond-${suffix}`, name: 'Task Pond', type: 'SHARED', ownerId: editorId },
|
||||||
|
});
|
||||||
|
const page = await prisma.page.create({
|
||||||
|
data: {
|
||||||
|
pondId: pond.id,
|
||||||
|
slug: `tasks-${suffix}`,
|
||||||
|
title: 'Tasks',
|
||||||
|
createdBy: editorId,
|
||||||
|
sortKey: 'a0',
|
||||||
|
ydocState: new Uint8Array(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
pageId = page.id;
|
||||||
|
for (const [userId, role] of [
|
||||||
|
[editorId, 'EDITOR'],
|
||||||
|
[reader.id, 'READER'],
|
||||||
|
] as const) {
|
||||||
|
await prisma.roleGrant.create({
|
||||||
|
data: {
|
||||||
|
pondId: pond.id,
|
||||||
|
subjectType: 'USER',
|
||||||
|
subjectId: userId,
|
||||||
|
role,
|
||||||
|
scopeType: 'POND',
|
||||||
|
scopeId: null,
|
||||||
|
effect: 'ALLOW',
|
||||||
|
createdBy: editorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
listenClient = new Client({ connectionString: process.env.TEST_DATABASE_URL });
|
||||||
|
await listenClient.connect();
|
||||||
|
listenClient.on('notification', (message) => {
|
||||||
|
if (message.channel === TASK_TOGGLE_CHANNEL && message.payload) {
|
||||||
|
notifies.push(JSON.parse(message.payload) as TaskToggleRequest);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await listenClient.query(`LISTEN ${TASK_TOGGLE_CHANNEL}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await listenClient.end().catch(() => undefined);
|
||||||
|
await prisma.pagePendingContributor.deleteMany({ where: { pageId } });
|
||||||
|
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: editorId } } });
|
||||||
|
await prisma.page.deleteMany({ where: { pond: { ownerId: editorId } } });
|
||||||
|
await prisma.pond.deleteMany({ where: { ownerId: editorId } });
|
||||||
|
await prisma.session.deleteMany({ where: { user: { username: { contains: suffix } } } });
|
||||||
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits the toggle NOTIFY and records the pending contributor', async () => {
|
||||||
|
await api()
|
||||||
|
.post(`/api/v1/pages/${pageId}/tasks/abc123defg`)
|
||||||
|
.set('Cookie', editorCookie)
|
||||||
|
.send({ checked: true })
|
||||||
|
.expect(202);
|
||||||
|
|
||||||
|
await expect.poll(() => notifies.length, { timeout: 5000 }).toBeGreaterThan(0);
|
||||||
|
expect(notifies[0]).toEqual({
|
||||||
|
pageId,
|
||||||
|
taskId: 'abc123defg',
|
||||||
|
checked: true,
|
||||||
|
userId: editorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pending = await prisma.pagePendingContributor.findMany({ where: { pageId } });
|
||||||
|
expect(pending.map((row) => row.userId)).toContain(editorId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses read-only users and hides unknown pages', async () => {
|
||||||
|
// A reader may see the page, so the write refusal is a 403 (#60).
|
||||||
|
await api()
|
||||||
|
.post(`/api/v1/pages/${pageId}/tasks/abc123defg`)
|
||||||
|
.set('Cookie', readerCookie)
|
||||||
|
.send({ checked: true })
|
||||||
|
.expect(403);
|
||||||
|
await api()
|
||||||
|
.post(`/api/v1/pages/00000000-0000-4000-8000-000000000000/tasks/x`)
|
||||||
|
.set('Cookie', editorCookie)
|
||||||
|
.send({ checked: true })
|
||||||
|
.expect(404);
|
||||||
|
await api()
|
||||||
|
.post(`/api/v1/pages/${pageId}/tasks/abc123defg`)
|
||||||
|
.set('Cookie', editorCookie)
|
||||||
|
.send({ checked: 'yes' })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
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>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import {
|
|||||||
editorSchema,
|
editorSchema,
|
||||||
extractOutline,
|
extractOutline,
|
||||||
OutlineEntry,
|
OutlineEntry,
|
||||||
|
extractMentionUserIds,
|
||||||
extractWikilinkSlugs,
|
extractWikilinkSlugs,
|
||||||
} from '@dorfteich/shared';
|
} from '@dorfteich/shared';
|
||||||
import { Node } from 'prosemirror-model';
|
import { Node } from 'prosemirror-model';
|
||||||
@ -21,7 +22,27 @@ const FRAGMENT_NAME = 'default';
|
|||||||
/** Thrown for state bytes that are not a well-formed Yjs update for this schema. */
|
/** Thrown for state bytes that are not a well-formed Yjs update for this schema. */
|
||||||
export class InvalidPageStateError extends Error {}
|
export class InvalidPageStateError extends Error {}
|
||||||
|
|
||||||
function docFromState(state: Uint8Array): Node {
|
/**
|
||||||
|
* Decode a page's full current document from its base state plus the
|
||||||
|
* `page_updates` log (issue #154) — the persisted base alone lags behind
|
||||||
|
* the live document until the next compaction merges the log back.
|
||||||
|
*/
|
||||||
|
export function docFromStateAndUpdates(state: Uint8Array, updates: Uint8Array[]): Node {
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
|
try {
|
||||||
|
Y.applyUpdate(ydoc, state);
|
||||||
|
for (const update of updates) Y.applyUpdate(ydoc, update);
|
||||||
|
return yXmlFragmentToProseMirrorRootNode(ydoc.getXmlFragment(FRAGMENT_NAME), editorSchema);
|
||||||
|
} catch (error) {
|
||||||
|
throw new InvalidPageStateError(error instanceof Error ? error.message : 'invalid Yjs state');
|
||||||
|
} finally {
|
||||||
|
ydoc.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode a page's stored Yjs state back into its ProseMirror document —
|
||||||
|
* also the entry point for read-time task extraction (issue #154). */
|
||||||
|
export function docFromState(state: Uint8Array): Node {
|
||||||
const ydoc = new Y.Doc();
|
const ydoc = new Y.Doc();
|
||||||
try {
|
try {
|
||||||
Y.applyUpdate(ydoc, state);
|
Y.applyUpdate(ydoc, state);
|
||||||
@ -73,6 +94,9 @@ export interface DerivedPageContent {
|
|||||||
* api (imports, phantom-create) seed their `page_links` rows from this —
|
* api (imports, phantom-create) seed their `page_links` rows from this —
|
||||||
* collab, the content writer, rewrites them on every later save. */
|
* collab, the content writer, rewrites them on every later save. */
|
||||||
wikilinkSlugs: string[];
|
wikilinkSlugs: string[];
|
||||||
|
/** Resolved user ids of every `@mention` (issue #151) — api-created pages
|
||||||
|
* seed their `page_mentions` rows from this; collab rewrites on save. */
|
||||||
|
mentionUserIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageFileIdsOf(doc: Node): string[] {
|
function imageFileIdsOf(doc: Node): string[] {
|
||||||
@ -99,5 +123,6 @@ export function deriveContent(state: Uint8Array): DerivedPageContent {
|
|||||||
outline: extractOutline(doc),
|
outline: extractOutline(doc),
|
||||||
imageFileIds: imageFileIdsOf(doc),
|
imageFileIds: imageFileIdsOf(doc),
|
||||||
wikilinkSlugs: extractWikilinkSlugs(doc),
|
wikilinkSlugs: extractWikilinkSlugs(doc),
|
||||||
|
mentionUserIds: extractMentionUserIds(doc),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
57
apps/api/src/users/user-search.controller.ts
Normal file
57
apps/api/src/users/user-search.controller.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import type { UserBriefView } from '@dorfteich/shared';
|
||||||
|
|
||||||
|
import { AuthenticatedOnly } from '../permissions/permission.decorators';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { RateLimit } from '../rate-limit/rate-limit.guard';
|
||||||
|
|
||||||
|
/** Cap for the batch `brief` lookup — a page mentions a handful of people. */
|
||||||
|
const BRIEF_MAX_IDS = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instance-wide user lookup for `@` mentions (issue #150). Deliberately
|
||||||
|
* minimal: only id/username/displayName, only active accounts, only for
|
||||||
|
* signed-in users, rate-limited, and never enumerable without a query — a
|
||||||
|
* documented consequence of instance-wide mentions is that logged-in users
|
||||||
|
* can discover usernames this way.
|
||||||
|
*/
|
||||||
|
@Controller('users')
|
||||||
|
@AuthenticatedOnly()
|
||||||
|
export class UserSearchController {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
@Get('search')
|
||||||
|
@RateLimit({ scope: 'user-search', limit: 60, windowSeconds: 60 })
|
||||||
|
async search(@Query('q') q: string | undefined): Promise<UserBriefView[]> {
|
||||||
|
const query = (q ?? '').trim();
|
||||||
|
if (query.length < 2) return [];
|
||||||
|
return this.prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'ACTIVE',
|
||||||
|
OR: [
|
||||||
|
{ username: { contains: query, mode: 'insensitive' } },
|
||||||
|
{ displayName: { contains: query, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true, username: true, displayName: true },
|
||||||
|
orderBy: { username: 'asc' },
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Batch resolution of mentioned users for live display names; unknown or
|
||||||
|
* disabled ids are simply absent (the mention renders as a dead chip). */
|
||||||
|
@Get('brief')
|
||||||
|
async brief(@Query('ids') ids: string | undefined): Promise<UserBriefView[]> {
|
||||||
|
const wanted = (ids ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, BRIEF_MAX_IDS);
|
||||||
|
if (wanted.length === 0) return [];
|
||||||
|
return this.prisma.user.findMany({
|
||||||
|
where: { id: { in: wanted }, status: 'ACTIVE' },
|
||||||
|
select: { id: true, username: true, displayName: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { SessionsModule } from '../auth/sessions.module';
|
import { SessionsModule } from '../auth/sessions.module';
|
||||||
|
import { UserSearchController } from './user-search.controller';
|
||||||
import { UsersController } from './users.controller';
|
import { UsersController } from './users.controller';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [SessionsModule],
|
imports: [SessionsModule],
|
||||||
controllers: [UsersController],
|
controllers: [UsersController, UserSearchController],
|
||||||
providers: [UsersService],
|
providers: [UsersService],
|
||||||
exports: [UsersService],
|
exports: [UsersService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { createLogger } from './logger.js';
|
|||||||
import { createMaintenanceListener, type MaintenanceListener } from './maintenance-listener.js';
|
import { createMaintenanceListener, type MaintenanceListener } from './maintenance-listener.js';
|
||||||
import { PostgresPagePersistence } from './persistence.js';
|
import { PostgresPagePersistence } from './persistence.js';
|
||||||
import { createRestoreListener } from './restore-listener.js';
|
import { createRestoreListener } from './restore-listener.js';
|
||||||
|
import { createTaskToggleListener } from './task-toggle-listener.js';
|
||||||
import { closeDocumentConnections, createCollabServer } from './server.js';
|
import { closeDocumentConnections, createCollabServer } from './server.js';
|
||||||
import { PostgresSessionRegistry } from './session-registry.js';
|
import { PostgresSessionRegistry } from './session-registry.js';
|
||||||
import { PostgresVersionStore } from './version-store.js';
|
import { PostgresVersionStore } from './version-store.js';
|
||||||
@ -75,9 +76,18 @@ async function bootstrap(): Promise<void> {
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Applies api-requested task-checkbox toggles (issue #153).
|
||||||
|
const taskToggleListener = createTaskToggleListener({
|
||||||
|
createClient: () => new Client({ connectionString: env.DATABASE_URL }),
|
||||||
|
openDirectConnection: (documentName) =>
|
||||||
|
server.hocuspocus.openDirectConnection(documentName, { userId: 'task-toggle', mode: 'rw' }),
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
await server.listen(env.PORT);
|
await server.listen(env.PORT);
|
||||||
await accessListener.start();
|
await accessListener.start();
|
||||||
await restoreListener.start();
|
await restoreListener.start();
|
||||||
|
await taskToggleListener.start();
|
||||||
await maintenanceListener.start();
|
await maintenanceListener.start();
|
||||||
sessionRegistry.start(() => [...server.hocuspocus.documents.keys()]);
|
sessionRegistry.start(() => [...server.hocuspocus.documents.keys()]);
|
||||||
logger.info({ event: 'listen', port: env.PORT }, 'collaboration server listening');
|
logger.info({ event: 'listen', port: env.PORT }, 'collaboration server listening');
|
||||||
@ -88,6 +98,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
void Promise.allSettled([
|
void Promise.allSettled([
|
||||||
accessListener.stop(),
|
accessListener.stop(),
|
||||||
restoreListener.stop(),
|
restoreListener.stop(),
|
||||||
|
taskToggleListener.stop(),
|
||||||
maintenanceListener.stop(),
|
maintenanceListener.stop(),
|
||||||
server.destroy(),
|
server.destroy(),
|
||||||
pool.end(),
|
pool.end(),
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import { MAX_PAGE_DOCUMENT_BYTES, normalizeForSearch } from '@dorfteich/shared';
|
import {
|
||||||
|
MAX_PAGE_DOCUMENT_BYTES,
|
||||||
|
PAGE_MENTIONS_CHANGED_CHANNEL,
|
||||||
|
normalizeForSearch,
|
||||||
|
} from '@dorfteich/shared';
|
||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
@ -203,6 +207,31 @@ export class PostgresPagePersistence implements PagePersistence {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rewrite the mention index (issue #151); newly added user ids become a
|
||||||
|
// NOTIFY the api turns into `mentioned` notifications. Inside the
|
||||||
|
// transaction on purpose — pg_notify only fires on COMMIT.
|
||||||
|
const previousMentions = await client.query<{ user_id: string }>(
|
||||||
|
'SELECT user_id FROM page_mentions WHERE page_id = $1',
|
||||||
|
[pageId],
|
||||||
|
);
|
||||||
|
await client.query('DELETE FROM page_mentions WHERE page_id = $1', [pageId]);
|
||||||
|
if (derived.mentionUserIds.length > 0) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO page_mentions (page_id, user_id)
|
||||||
|
SELECT $1, u.id FROM unnest($2::text[]) AS m(user_id)
|
||||||
|
JOIN users u ON u.id = m.user_id`,
|
||||||
|
[pageId, derived.mentionUserIds],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const known = new Set(previousMentions.rows.map((row) => row.user_id));
|
||||||
|
const added = derived.mentionUserIds.filter((id) => !known.has(id));
|
||||||
|
if (added.length > 0) {
|
||||||
|
await client.query('SELECT pg_notify($1, $2)', [
|
||||||
|
PAGE_MENTIONS_CHANGED_CHANNEL,
|
||||||
|
JSON.stringify({ pageId, addedUserIds: added }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
this.lastStoredVector.set(pageId, nextVector);
|
this.lastStoredVector.set(pageId, nextVector);
|
||||||
return { outcome: 'stored', bytes: full.byteLength, durationMs: durationOf(), merged };
|
return { outcome: 'stored', bytes: full.byteLength, durationMs: durationOf(), merged };
|
||||||
|
|||||||
156
apps/collab/src/task-toggle-listener.ts
Normal file
156
apps/collab/src/task-toggle-listener.ts
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import { TASK_TOGGLE_CHANNEL, TaskToggleRequest } from '@dorfteich/shared';
|
||||||
|
import type { Client } from 'pg';
|
||||||
|
import type { Logger } from 'pino';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
|
import type { DirectDocumentConnection } from './restore-listener.js';
|
||||||
|
|
||||||
|
export interface TaskToggleListenerDeps {
|
||||||
|
/** Dedicated `LISTEN` connection factory (connection-bound, not pooled). */
|
||||||
|
createClient: () => Client;
|
||||||
|
/** Opens a server-side connection to a document so edits broadcast + persist. */
|
||||||
|
openDirectConnection: (documentName: string) => Promise<DirectDocumentConnection>;
|
||||||
|
logger: Logger;
|
||||||
|
reconnectDelayMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskToggleListener {
|
||||||
|
start(): Promise<void>;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_RECONNECT_DELAY_MS = 1000;
|
||||||
|
const FRAGMENT_NAME = 'default';
|
||||||
|
|
||||||
|
/** Depth-first search for the task item carrying the wanted stable id. */
|
||||||
|
function findTaskItem(fragment: Y.XmlFragment, taskId: string): Y.XmlElement | null {
|
||||||
|
let found: Y.XmlElement | null = null;
|
||||||
|
const walk = (element: Y.XmlElement | Y.XmlFragment): void => {
|
||||||
|
for (const child of element.toArray()) {
|
||||||
|
if (found) return;
|
||||||
|
if (child instanceof Y.XmlElement) {
|
||||||
|
if (child.nodeName === 'task_item' && child.getAttribute('id') === taskId) {
|
||||||
|
found = child;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
walk(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(fragment);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies task-checkbox toggles requested by the api (issue #153). The api
|
||||||
|
* checks write permission and emits a {@link TASK_TOGGLE_CHANNEL}
|
||||||
|
* notification; this listener owns the live document, opens a direct
|
||||||
|
* connection (loading the document if nobody has it open) and flips the
|
||||||
|
* `checked` attribute in a transaction — a normal edit that Hocuspocus
|
||||||
|
* broadcasts to all clients and persists. An unknown task id is a warn-level
|
||||||
|
* no-op (the source line may have been deleted meanwhile).
|
||||||
|
*/
|
||||||
|
export function createTaskToggleListener(deps: TaskToggleListenerDeps): TaskToggleListener {
|
||||||
|
const reconnectDelayMs = deps.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS;
|
||||||
|
let client: Client | null = null;
|
||||||
|
let stopped = false;
|
||||||
|
let reconnectTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
async function toggle(request: TaskToggleRequest): Promise<void> {
|
||||||
|
const { pageId, taskId, checked, userId } = request;
|
||||||
|
const connection = await deps.openDirectConnection(pageId);
|
||||||
|
try {
|
||||||
|
let applied = false;
|
||||||
|
await connection.transact((doc) => {
|
||||||
|
const item = findTaskItem(doc.getXmlFragment(FRAGMENT_NAME), taskId);
|
||||||
|
if (!item) return;
|
||||||
|
item.setAttribute('checked', checked as unknown as string);
|
||||||
|
applied = true;
|
||||||
|
});
|
||||||
|
if (applied) {
|
||||||
|
deps.logger.info(
|
||||||
|
{ event: 'task_toggle.applied', pageId, taskId, checked, userId },
|
||||||
|
'toggled task item',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
deps.logger.warn(
|
||||||
|
{ event: 'task_toggle.target_missing', pageId, taskId },
|
||||||
|
'task item not found; toggle skipped',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await connection.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect(): void {
|
||||||
|
if (stopped || reconnectTimer) return;
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectTimer = null;
|
||||||
|
void connect();
|
||||||
|
}, reconnectDelayMs);
|
||||||
|
reconnectTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect(): Promise<void> {
|
||||||
|
if (stopped) return;
|
||||||
|
const next = deps.createClient();
|
||||||
|
next.on('error', (error) => {
|
||||||
|
deps.logger.warn(
|
||||||
|
{ event: 'task_toggle.listen.error', err: error.message },
|
||||||
|
'task toggle listener connection error; will reconnect',
|
||||||
|
);
|
||||||
|
if (client === next) client = null;
|
||||||
|
scheduleReconnect();
|
||||||
|
});
|
||||||
|
next.on('notification', (message) => {
|
||||||
|
if (message.channel !== TASK_TOGGLE_CHANNEL || !message.payload) return;
|
||||||
|
let request: TaskToggleRequest;
|
||||||
|
try {
|
||||||
|
request = JSON.parse(message.payload) as TaskToggleRequest;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void toggle(request).catch((error: unknown) => {
|
||||||
|
deps.logger.error(
|
||||||
|
{ event: 'task_toggle.failed', err: (error as Error).message },
|
||||||
|
'failed to apply task toggle',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await next.connect();
|
||||||
|
await next.query(`LISTEN ${TASK_TOGGLE_CHANNEL}`);
|
||||||
|
client = next;
|
||||||
|
deps.logger.info(
|
||||||
|
{ event: 'task_toggle.listen.ready', channel: TASK_TOGGLE_CHANNEL },
|
||||||
|
'listening for task toggle requests',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
deps.logger.warn(
|
||||||
|
{ event: 'task_toggle.listen.connect_failed', err: (error as Error).message },
|
||||||
|
'could not start task toggle listener; will retry',
|
||||||
|
);
|
||||||
|
await next.end().catch(() => undefined);
|
||||||
|
scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async start(): Promise<void> {
|
||||||
|
stopped = false;
|
||||||
|
await connect();
|
||||||
|
},
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
stopped = true;
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
const current = client;
|
||||||
|
client = null;
|
||||||
|
if (current) await current.end().catch(() => undefined);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import {
|
|||||||
docToPlainText,
|
docToPlainText,
|
||||||
editorSchema,
|
editorSchema,
|
||||||
extractOutline,
|
extractOutline,
|
||||||
|
extractMentionUserIds,
|
||||||
extractWikilinkSlugs,
|
extractWikilinkSlugs,
|
||||||
type OutlineEntry,
|
type OutlineEntry,
|
||||||
} from '@dorfteich/shared';
|
} from '@dorfteich/shared';
|
||||||
@ -44,6 +45,9 @@ export interface DerivedPageContent {
|
|||||||
/** Distinct target slugs of every `[[wikilink]]`, for the `page_links`
|
/** Distinct target slugs of every `[[wikilink]]`, for the `page_links`
|
||||||
* index (issue #47). */
|
* index (issue #47). */
|
||||||
wikilinkSlugs: string[];
|
wikilinkSlugs: string[];
|
||||||
|
/** Distinct resolved user ids of every `@mention`, for the
|
||||||
|
* `page_mentions` index and the mention notifications (issue #151). */
|
||||||
|
mentionUserIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageFileIdsOf(doc: Node): string[] {
|
function imageFileIdsOf(doc: Node): string[] {
|
||||||
@ -70,5 +74,6 @@ export function deriveContentFromDoc(ydoc: Y.Doc): DerivedPageContent {
|
|||||||
outline: extractOutline(doc),
|
outline: extractOutline(doc),
|
||||||
imageFileIds: imageFileIdsOf(doc),
|
imageFileIds: imageFileIdsOf(doc),
|
||||||
wikilinkSlugs: extractWikilinkSlugs(doc),
|
wikilinkSlugs: extractWikilinkSlugs(doc),
|
||||||
|
mentionUserIds: extractMentionUserIds(doc),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -83,7 +83,9 @@ test('wrap, restyle in read mode, unwrap, and neutral fallback when disabled', a
|
|||||||
await page.keyboard.type('Boxed content');
|
await page.keyboard.type('Boxed content');
|
||||||
|
|
||||||
// Wrap the paragraph in the plugin's style via the toolbar picker.
|
// Wrap the paragraph in the plugin's style via the toolbar picker.
|
||||||
const picker = page.locator('.editor-toolbar__section-select');
|
// Not the block picker: it shares the styling class and is always
|
||||||
|
// visible since the built-in task overview entry (#154).
|
||||||
|
const picker = page.locator('.editor-toolbar__section-select:not(.editor-toolbar__block-select)');
|
||||||
await picker.selectOption(`${PLUGIN_ID}/boxed`);
|
await picker.selectOption(`${PLUGIN_ID}/boxed`);
|
||||||
const section = content.locator(`.dt-section.dt-style-${PLUGIN_ID}-boxed`);
|
const section = content.locator(`.dt-section.dt-style-${PLUGIN_ID}-boxed`);
|
||||||
await expect(section).toContainText('Boxed content');
|
await expect(section).toContainText('Boxed content');
|
||||||
|
|||||||
@ -20,6 +20,9 @@ test('user settings show the jump nav and clicking scrolls + activates', async (
|
|||||||
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto('/settings');
|
await page.goto('/settings');
|
||||||
|
// Let the async section content (sessions, tokens, …) settle first —
|
||||||
|
// sections growing above the target would push it out of view again.
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
const nav = page.locator('.settings-nav');
|
const nav = page.locator('.settings-nav');
|
||||||
await expect(nav).toBeVisible();
|
await expect(nav).toBeVisible();
|
||||||
@ -31,7 +34,8 @@ test('user settings show the jump nav and clicking scrolls + activates', async (
|
|||||||
const last = links.last();
|
const last = links.last();
|
||||||
await last.click();
|
await last.click();
|
||||||
const lastSection = page.locator('.settings-layout section[id]').last();
|
const lastSection = page.locator('.settings-layout section[id]').last();
|
||||||
await expect(lastSection).toBeInViewport();
|
// Smooth scrolling needs a moment on a loaded CI runner.
|
||||||
|
await expect(lastSection).toBeInViewport({ timeout: 10_000 });
|
||||||
await expect(last).toHaveClass(/settings-nav__link--active/);
|
await expect(last).toHaveClass(/settings-nav__link--active/);
|
||||||
|
|
||||||
await context.close();
|
await context.close();
|
||||||
@ -45,6 +49,7 @@ test('pond settings derive the nav from their sections', async ({ browser }) =>
|
|||||||
|
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto(`/p/${pond.slug}/settings`);
|
await page.goto(`/p/${pond.slug}/settings`);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
const links = page.locator('.settings-nav .settings-nav__link');
|
const links = page.locator('.settings-nav .settings-nav__link');
|
||||||
// The pond owner sees the full section stack — at least members, labels,
|
// The pond owner sees the full section stack — at least members, labels,
|
||||||
@ -54,7 +59,9 @@ test('pond settings derive the nav from their sections', async ({ browser }) =>
|
|||||||
expect(await links.count()).toBeGreaterThanOrEqual(8);
|
expect(await links.count()).toBeGreaterThanOrEqual(8);
|
||||||
|
|
||||||
await links.last().click();
|
await links.last().click();
|
||||||
await expect(page.locator('.settings-layout section[id]').last()).toBeInViewport();
|
await expect(page.locator('.settings-layout section[id]').last()).toBeInViewport({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
await context.close();
|
await context.close();
|
||||||
});
|
});
|
||||||
|
|||||||
119
apps/web/e2e/tasks.spec.ts
Normal file
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();
|
||||||
|
});
|
||||||
@ -102,7 +102,9 @@ export function SettingsLayout({ children }: { children: React.ReactNode }): Rea
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const jump = (id: string): void => {
|
const jump = (id: string): void => {
|
||||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
// Instant, not smooth: async section content (queries) can still grow
|
||||||
|
// during an animation, leaving it at a stale target position.
|
||||||
|
document.getElementById(id)?.scrollIntoView({ block: 'start' });
|
||||||
setActive(id);
|
setActive(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
143
apps/web/src/editor/MentionAutocomplete.tsx
Normal file
143
apps/web/src/editor/MentionAutocomplete.tsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import type { UserBriefView } from '@dorfteich/shared';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import type { Editor } from '@tiptap/react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { apiGet } from '../lib/api';
|
||||||
|
|
||||||
|
/** An open `@` context: the query typed so far and where the `@` began. */
|
||||||
|
interface QueryState {
|
||||||
|
query: string;
|
||||||
|
from: number;
|
||||||
|
coords: { left: number; bottom: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detects `@query` immediately before a collapsed cursor (issue #150) —
|
||||||
|
* only at a word boundary, so typing an e-mail address never opens it. */
|
||||||
|
function detectQuery(editor: Editor): { query: string; from: number } | null {
|
||||||
|
const { selection } = editor.state;
|
||||||
|
if (!selection.empty) return null;
|
||||||
|
const $from = selection.$from;
|
||||||
|
if (!$from.parent.isTextblock) return null;
|
||||||
|
const start = Math.max(0, $from.parentOffset - 40);
|
||||||
|
const before = $from.parent.textBetween(start, $from.parentOffset, undefined, '');
|
||||||
|
const match = /(^|[^\w@.-])@([a-zA-Z0-9][\w-]*)$/.exec(before);
|
||||||
|
if (!match) return null;
|
||||||
|
const query = match[2] ?? '';
|
||||||
|
return { query, from: selection.from - query.length - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autocomplete popup for `@` mentions (issue #150): instance-wide user
|
||||||
|
* search (min. two characters), Enter/click inserts the mention node with
|
||||||
|
* the stable user id. Keyboard handling mirrors the wikilink popup.
|
||||||
|
*/
|
||||||
|
export function MentionAutocomplete({ editor }: { editor: Editor }): React.JSX.Element | null {
|
||||||
|
const { t } = useTranslation('editor');
|
||||||
|
const [state, setState] = useState<QueryState | null>(null);
|
||||||
|
const [selected, setSelected] = useState(0);
|
||||||
|
|
||||||
|
const search = useQuery({
|
||||||
|
queryKey: ['user-search', state?.query ?? ''],
|
||||||
|
queryFn: () => apiGet<UserBriefView[]>(`/users/search?q=${encodeURIComponent(state!.query)}`),
|
||||||
|
enabled: Boolean(state && state.query.length >= 2),
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
});
|
||||||
|
const suggestions = state && state.query.length >= 2 ? (search.data ?? []) : [];
|
||||||
|
|
||||||
|
const live = useRef({ state, suggestions, selected });
|
||||||
|
live.current = { state, suggestions, selected };
|
||||||
|
|
||||||
|
function close(): void {
|
||||||
|
setState(null);
|
||||||
|
setSelected(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function choose(item: UserBriefView | undefined): void {
|
||||||
|
const current = live.current.state;
|
||||||
|
if (!item || !current) return;
|
||||||
|
const range = { from: current.from, to: editor.state.selection.from };
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContentAt(range, [
|
||||||
|
{ type: 'mention', attrs: { userId: item.id, username: item.username } },
|
||||||
|
{ type: 'text', text: ' ' },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recompute the open query on every doc/selection change.
|
||||||
|
useEffect(() => {
|
||||||
|
const update = (): void => {
|
||||||
|
const found = detectQuery(editor);
|
||||||
|
if (!found) {
|
||||||
|
setState(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const coords = editor.view.coordsAtPos(editor.state.selection.from);
|
||||||
|
setState({ ...found, coords: { left: coords.left, bottom: coords.bottom } });
|
||||||
|
setSelected(0);
|
||||||
|
};
|
||||||
|
editor.on('transaction', update);
|
||||||
|
return () => {
|
||||||
|
editor.off('transaction', update);
|
||||||
|
};
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
// Keyboard navigation, intercepted before ProseMirror (capture phase).
|
||||||
|
useEffect(() => {
|
||||||
|
const dom = editor.view.dom;
|
||||||
|
const onKeyDown = (event: KeyboardEvent): void => {
|
||||||
|
const { state: s, suggestions: items, selected: sel } = live.current;
|
||||||
|
if (!s || items.length === 0) return;
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
setSelected((i) => (i + 1) % items.length);
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
setSelected((i) => (i - 1 + items.length) % items.length);
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
choose(items[sel]);
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
dom.addEventListener('keydown', onKeyDown, true);
|
||||||
|
return () => dom.removeEventListener('keydown', onKeyDown, true);
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
if (!state || suggestions.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
className="wikilink-suggest"
|
||||||
|
role="listbox"
|
||||||
|
aria-label={t('mention.suggestLabel')}
|
||||||
|
style={{ position: 'fixed', left: state.coords.left, top: state.coords.bottom + 4 }}
|
||||||
|
>
|
||||||
|
{suggestions.map((user, index) => (
|
||||||
|
<li key={user.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={index === selected}
|
||||||
|
className={
|
||||||
|
index === selected ? 'wikilink-suggest__item is-active' : 'wikilink-suggest__item'
|
||||||
|
}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
choose(user);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="dt-mention">@{user.username}</span> {user.displayName}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
|
|||||||
@ -7,6 +7,10 @@ import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
|
|||||||
import { PluginBlock } from './nodes/plugin-block';
|
import { PluginBlock } from './nodes/plugin-block';
|
||||||
import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
|
import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
|
||||||
import { TaskItem } from './nodes/task-item';
|
import { TaskItem } from './nodes/task-item';
|
||||||
|
import { DateMarker } from './nodes/date-marker';
|
||||||
|
import { TaskOverview } from './nodes/task-overview';
|
||||||
|
import { TaskItemIds } from './task-item-ids';
|
||||||
|
import { Mention } from './nodes/mention';
|
||||||
import { Transclusion } from './nodes/transclusion';
|
import { Transclusion } from './nodes/transclusion';
|
||||||
import { Wikilink } from './nodes/wikilink';
|
import { Wikilink } from './nodes/wikilink';
|
||||||
import {
|
import {
|
||||||
@ -44,6 +48,10 @@ export const documentExtensions: AnyExtension[] = [
|
|||||||
Image,
|
Image,
|
||||||
PluginBlock,
|
PluginBlock,
|
||||||
Wikilink,
|
Wikilink,
|
||||||
|
Mention,
|
||||||
|
DateMarker,
|
||||||
|
TaskOverview,
|
||||||
|
TaskItemIds,
|
||||||
Transclusion,
|
Transclusion,
|
||||||
Table,
|
Table,
|
||||||
TableRow,
|
TableRow,
|
||||||
|
|||||||
91
apps/web/src/editor/nodes/date-marker.tsx
Normal file
91
apps/web/src/editor/nodes/date-marker.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { InputRule, Node } from '@tiptap/core';
|
||||||
|
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
|
import type { NodeViewProps } from '@tiptap/react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { attributesFromSpec, nodeSpec } from '../spec-utils';
|
||||||
|
|
||||||
|
/** `>>`/`<<` + ISO or `dd.mm.yyyy`, completed by a space (the trigger). */
|
||||||
|
const INPUT_PATTERN =
|
||||||
|
/(?:^|\s)([<>])\1\s?(?:(\d{4})-(\d{2})-(\d{2})|(\d{1,2})\.(\d{1,2})\.(\d{4}))\s$/;
|
||||||
|
|
||||||
|
function isoDateOf(match: RegExpMatchArray): string | null {
|
||||||
|
const [year, month, day] = match[2]
|
||||||
|
? [Number(match[2]), Number(match[3]), Number(match[4])]
|
||||||
|
: [Number(match[7]), Number(match[6]), Number(match[5])];
|
||||||
|
const date = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
const valid =
|
||||||
|
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
||||||
|
if (!valid) return null;
|
||||||
|
const pad = (value: number): string => String(value).padStart(2, '0');
|
||||||
|
return `${year}-${pad(month)}-${pad(day)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a date marker (issue #152): `»` = due date (`>>`), `«` = start
|
||||||
|
* date (`<<`), formatted per the viewer's language. A due date in the past
|
||||||
|
* gets an "overdue" tint. The stored attr is always canonical ISO.
|
||||||
|
*/
|
||||||
|
function DateMarkerView({ node }: NodeViewProps): React.JSX.Element {
|
||||||
|
const { t, i18n } = useTranslation('editor');
|
||||||
|
const kind = node.attrs.kind as 'due' | 'start';
|
||||||
|
const iso = node.attrs.date as string;
|
||||||
|
|
||||||
|
const formatted = new Intl.DateTimeFormat(i18n.language, { dateStyle: 'medium' }).format(
|
||||||
|
new Date(`${iso}T00:00:00`),
|
||||||
|
);
|
||||||
|
const overdue = kind === 'due' && iso < new Date().toISOString().slice(0, 10);
|
||||||
|
const classes = ['dt-date', `dt-date--${kind}`];
|
||||||
|
if (overdue) classes.push('dt-date--overdue');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper as="span" className="dt-date-nodeview">
|
||||||
|
<span
|
||||||
|
className={classes.join(' ')}
|
||||||
|
title={t(kind === 'due' ? 'dateMarker.due' : 'dateMarker.start')}
|
||||||
|
contentEditable={false}
|
||||||
|
>
|
||||||
|
{kind === 'due' ? '»' : '«'} {formatted}
|
||||||
|
</span>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateMarkerSpec = nodeSpec('date_marker');
|
||||||
|
export const DateMarker = Node.create({
|
||||||
|
name: 'date_marker',
|
||||||
|
group: dateMarkerSpec.group,
|
||||||
|
inline: dateMarkerSpec.inline,
|
||||||
|
atom: dateMarkerSpec.atom,
|
||||||
|
addAttributes() {
|
||||||
|
return attributesFromSpec(dateMarkerSpec);
|
||||||
|
},
|
||||||
|
parseHTML: () => dateMarkerSpec.parseDOM,
|
||||||
|
renderHTML: ({ node }) => dateMarkerSpec.toDOM!(node),
|
||||||
|
addInputRules() {
|
||||||
|
return [
|
||||||
|
new InputRule({
|
||||||
|
find: INPUT_PATTERN,
|
||||||
|
handler: ({ range, match, chain }) => {
|
||||||
|
const iso = isoDateOf(match);
|
||||||
|
if (!iso) return;
|
||||||
|
// Keep a leading boundary character (space/line start) intact.
|
||||||
|
const full = match[0];
|
||||||
|
const markerStart = range.from + (full.length - full.trimStart().length);
|
||||||
|
chain()
|
||||||
|
.insertContentAt({ from: markerStart, to: range.to }, [
|
||||||
|
{
|
||||||
|
type: 'date_marker',
|
||||||
|
attrs: { kind: match[1] === '>' ? 'due' : 'start', date: iso },
|
||||||
|
},
|
||||||
|
{ type: 'text', text: ' ' },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(DateMarkerView);
|
||||||
|
},
|
||||||
|
});
|
||||||
61
apps/web/src/editor/nodes/mention.tsx
Normal file
61
apps/web/src/editor/nodes/mention.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import type { UserBriefView } from '@dorfteich/shared';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Node } from '@tiptap/core';
|
||||||
|
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
|
import type { NodeViewProps } from '@tiptap/react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { apiGet } from '../../lib/api';
|
||||||
|
import { attributesFromSpec, nodeSpec } from '../spec-utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders an `@username` mention (issue #150). The stable reference is the
|
||||||
|
* user id; the shown text is the user's *current* display name (a rename
|
||||||
|
* shows everywhere immediately), falling back to `@username`. A mention whose
|
||||||
|
* user no longer resolves — deleted account, or an unresolved Markdown
|
||||||
|
* import — renders as a muted "dead" chip and never notifies anyone.
|
||||||
|
*/
|
||||||
|
function MentionView({ node }: NodeViewProps): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('editor');
|
||||||
|
const userId = node.attrs.userId as string;
|
||||||
|
const username = node.attrs.username as string;
|
||||||
|
|
||||||
|
const brief = useQuery({
|
||||||
|
queryKey: ['user-brief', userId],
|
||||||
|
queryFn: () => apiGet<UserBriefView[]>(`/users/brief?ids=${encodeURIComponent(userId)}`),
|
||||||
|
enabled: Boolean(userId),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = brief.data?.find((user) => user.id === userId);
|
||||||
|
const dead = Boolean(userId) && brief.isSuccess && !resolved;
|
||||||
|
const label = resolved ? `@${resolved.displayName}` : `@${username}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper as="span" className="dt-mention-nodeview">
|
||||||
|
<span
|
||||||
|
className={dead || !userId ? 'dt-mention dt-mention--dead' : 'dt-mention'}
|
||||||
|
title={dead || !userId ? t('mention.unresolved') : `@${username}`}
|
||||||
|
contentEditable={false}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionSpec = nodeSpec('mention');
|
||||||
|
export const Mention = Node.create({
|
||||||
|
name: 'mention',
|
||||||
|
group: mentionSpec.group,
|
||||||
|
inline: mentionSpec.inline,
|
||||||
|
atom: mentionSpec.atom,
|
||||||
|
addAttributes() {
|
||||||
|
return attributesFromSpec(mentionSpec);
|
||||||
|
},
|
||||||
|
parseHTML: () => mentionSpec.parseDOM,
|
||||||
|
renderHTML: ({ node }) => mentionSpec.toDOM!(node),
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(MentionView);
|
||||||
|
},
|
||||||
|
});
|
||||||
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
48
apps/web/src/editor/task-item-ids.ts
Normal file
48
apps/web/src/editor/task-item-ids.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { Extension } from '@tiptap/core';
|
||||||
|
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||||
|
|
||||||
|
/** ~10 URL-safe random chars — plenty for per-document uniqueness. */
|
||||||
|
function freshTaskId(): string {
|
||||||
|
const bytes = new Uint8Array(8);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes, (byte) => (byte % 36).toString(36)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily assigns stable ids to task items (issue #153): every `task_item`
|
||||||
|
* without an id — and every duplicate created by copy/paste — gets a fresh
|
||||||
|
* one in an appended transaction. Runs through the normal editing pipeline,
|
||||||
|
* so ids replicate via the collaboration document like any other change and
|
||||||
|
* never conflict. Existing documents pick up ids the next time they are
|
||||||
|
* opened for editing.
|
||||||
|
*/
|
||||||
|
export const TaskItemIds = Extension.create({
|
||||||
|
name: 'taskItemIds',
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: new PluginKey('taskItemIds'),
|
||||||
|
appendTransaction: (transactions, _oldState, newState) => {
|
||||||
|
if (!transactions.some((tr) => tr.docChanged)) return null;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let tr = null as ReturnType<typeof newState.tr.setNodeMarkup> | null;
|
||||||
|
newState.doc.descendants((node, pos) => {
|
||||||
|
if (node.type.name !== 'task_item') return;
|
||||||
|
const id = node.attrs.id as string | null;
|
||||||
|
if (id && !seen.has(id)) {
|
||||||
|
seen.add(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = freshTaskId();
|
||||||
|
seen.add(next);
|
||||||
|
tr = (tr ?? newState.tr).setNodeMarkup(pos, undefined, {
|
||||||
|
...node.attrs,
|
||||||
|
id: next,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return tr;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import { ImageUpload } from '../editor/image-upload';
|
|||||||
import { PresenceStrip } from '../editor/PresenceStrip';
|
import { PresenceStrip } from '../editor/PresenceStrip';
|
||||||
import { Toolbar } from '../editor/Toolbar';
|
import { Toolbar } from '../editor/Toolbar';
|
||||||
import { useCollabProvider } from '../editor/use-collab-provider';
|
import { useCollabProvider } from '../editor/use-collab-provider';
|
||||||
|
import { MentionAutocomplete } from '../editor/MentionAutocomplete';
|
||||||
import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete';
|
import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete';
|
||||||
import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context';
|
import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context';
|
||||||
import { usePageActionsSlot } from '../layout/page-actions';
|
import { usePageActionsSlot } from '../layout/page-actions';
|
||||||
@ -337,6 +338,7 @@ function PageEditor({
|
|||||||
)}
|
)}
|
||||||
<EditorContent editor={editor} className="editor-content" />
|
<EditorContent editor={editor} className="editor-content" />
|
||||||
{canEdit && <WikilinkAutocomplete editor={editor} />}
|
{canEdit && <WikilinkAutocomplete editor={editor} />}
|
||||||
|
{canEdit && <MentionAutocomplete editor={editor} />}
|
||||||
</div>
|
</div>
|
||||||
</PluginBlockContext.Provider>
|
</PluginBlockContext.Provider>
|
||||||
</WikilinkContext.Provider>
|
</WikilinkContext.Provider>
|
||||||
|
|||||||
@ -3808,3 +3808,67 @@ ul[data-type='task_list'] li p:last-of-type {
|
|||||||
border-bottom-color: var(--color-accent);
|
border-bottom-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* `@username` mention chip (issue #150). */
|
||||||
|
.dt-mention {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 0.35em;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-mention--dead {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Date marker chip (issue #152): » = due date, « = start date. */
|
||||||
|
.dt-date {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 0.35em;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-date--due {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-date--overdue {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task overview block (issue #154). */
|
||||||
|
.dt-task-overview-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: var(--space-3) 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-task-overview-table th,
|
||||||
|
.dt-task-overview-table td {
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-task-overview-table th {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: var(--font-weight-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-task-overview-empty {
|
||||||
|
margin: var(--space-3) 0;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -181,5 +181,13 @@
|
|||||||
"notFound": {
|
"notFound": {
|
||||||
"hint": "Du kannst sie direkt hier anlegen — alle Wikilinks auf diese Adresse zeigen dann auf die neue Seite.",
|
"hint": "Du kannst sie direkt hier anlegen — alle Wikilinks auf diese Adresse zeigen dann auf die neue Seite.",
|
||||||
"create": "Seite „{{slug}}“ anlegen"
|
"create": "Seite „{{slug}}“ anlegen"
|
||||||
|
},
|
||||||
|
"mention": {
|
||||||
|
"unresolved": "Unbekannter Nutzer",
|
||||||
|
"suggestLabel": "Nutzer-Vorschläge"
|
||||||
|
},
|
||||||
|
"dateMarker": {
|
||||||
|
"due": "Zieldatum",
|
||||||
|
"start": "Startdatum"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,8 @@
|
|||||||
"someone": "Jemand",
|
"someone": "Jemand",
|
||||||
"types": {
|
"types": {
|
||||||
"page_changed": "{{actor}} hat „{{page}}“ in {{pond}} geändert",
|
"page_changed": "{{actor}} hat „{{page}}“ in {{pond}} geändert",
|
||||||
"comment_added": "{{actor}} hat „{{page}}“ in {{pond}} kommentiert"
|
"comment_added": "{{actor}} hat „{{page}}“ in {{pond}} kommentiert",
|
||||||
|
"mentioned": "{{actor}} hat dich auf „{{page}}“ in {{pond}} erwähnt"
|
||||||
},
|
},
|
||||||
"digest": {
|
"digest": {
|
||||||
"label": "E-Mail-Digest",
|
"label": "E-Mail-Digest",
|
||||||
|
|||||||
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."
|
||||||
|
}
|
||||||
@ -181,5 +181,13 @@
|
|||||||
"notFound": {
|
"notFound": {
|
||||||
"hint": "You can create it right here — every wikilink pointing at this address will resolve to the new page.",
|
"hint": "You can create it right here — every wikilink pointing at this address will resolve to the new page.",
|
||||||
"create": "Create the page “{{slug}}”"
|
"create": "Create the page “{{slug}}”"
|
||||||
|
},
|
||||||
|
"mention": {
|
||||||
|
"unresolved": "Unknown user",
|
||||||
|
"suggestLabel": "User suggestions"
|
||||||
|
},
|
||||||
|
"dateMarker": {
|
||||||
|
"due": "Due date",
|
||||||
|
"start": "Start date"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,8 @@
|
|||||||
"someone": "Someone",
|
"someone": "Someone",
|
||||||
"types": {
|
"types": {
|
||||||
"page_changed": "{{actor}} changed “{{page}}” in {{pond}}",
|
"page_changed": "{{actor}} changed “{{page}}” in {{pond}}",
|
||||||
"comment_added": "{{actor}} commented on “{{page}}” in {{pond}}"
|
"comment_added": "{{actor}} commented on “{{page}}” in {{pond}}",
|
||||||
|
"mentioned": "{{actor}} mentioned you on “{{page}}” in {{pond}}"
|
||||||
},
|
},
|
||||||
"digest": {
|
"digest": {
|
||||||
"label": "E-mail digest",
|
"label": "E-mail digest",
|
||||||
|
|||||||
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."
|
||||||
|
}
|
||||||
@ -47,6 +47,40 @@ export interface PageVersionCreatedEvent {
|
|||||||
contributorIds: string[];
|
contributorIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostgreSQL `NOTIFY` channel over which the api asks the collab server to
|
||||||
|
* toggle a single task-list checkbox (issue #153). The api has already
|
||||||
|
* checked write permission; the collab server owns the live document and
|
||||||
|
* applies the attribute change as a normal edit, so every open client
|
||||||
|
* converges. Payload is a JSON {@link TaskToggleRequest}.
|
||||||
|
*/
|
||||||
|
export const TASK_TOGGLE_CHANNEL = 'task_toggle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostgreSQL `NOTIFY` channel over which the collab server announces that a
|
||||||
|
* persist added new user mentions to a page (issue #151). The api listens
|
||||||
|
* and creates the `mentioned` notifications — permission-checked there.
|
||||||
|
* Payload is a JSON {@link PageMentionsChangedEvent}.
|
||||||
|
*/
|
||||||
|
export const PAGE_MENTIONS_CHANGED_CHANNEL = 'page_mentions_changed';
|
||||||
|
|
||||||
|
/** JSON payload carried on {@link PAGE_MENTIONS_CHANGED_CHANNEL}. */
|
||||||
|
export interface PageMentionsChangedEvent {
|
||||||
|
pageId: string;
|
||||||
|
/** Users newly mentioned by this persist (diff against the stored rows). */
|
||||||
|
addedUserIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON payload carried on {@link TASK_TOGGLE_CHANNEL}. */
|
||||||
|
export interface TaskToggleRequest {
|
||||||
|
pageId: string;
|
||||||
|
/** The task item's stable `id` attribute (issue #153). */
|
||||||
|
taskId: string;
|
||||||
|
checked: boolean;
|
||||||
|
/** The user who toggled — recorded as a pending contributor. */
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** JSON payload carried on {@link PAGE_RESTORE_CHANNEL}. */
|
/** JSON payload carried on {@link PAGE_RESTORE_CHANNEL}. */
|
||||||
export interface PageRestoreRequest {
|
export interface PageRestoreRequest {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
|||||||
73
packages/shared/src/editor-schema/date-marker.test.ts
Normal file
73
packages/shared/src/editor-schema/date-marker.test.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { Node } from 'prosemirror-model';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { docToHtml } from './html';
|
||||||
|
import { docToMarkdown, markdownToDoc } from './markdown';
|
||||||
|
import { docToPlainText } from './plain-text';
|
||||||
|
|
||||||
|
function markers(doc: Node): { kind: string; date: string }[] {
|
||||||
|
const found: { kind: string; date: string }[] = [];
|
||||||
|
doc.descendants((node) => {
|
||||||
|
if (node.type.name === 'date_marker') {
|
||||||
|
found.push({ kind: node.attrs.kind as string, date: node.attrs.date as string });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('date markers >>/<< (issue #152)', () => {
|
||||||
|
it('parses ISO due and start dates and round-trips canonically', () => {
|
||||||
|
const doc = markdownToDoc('Projekt <<2026-07-01 bis >>2026-12-31 fertig.');
|
||||||
|
expect(markers(doc)).toEqual([
|
||||||
|
{ kind: 'start', date: '2026-07-01' },
|
||||||
|
{ kind: 'due', date: '2026-12-31' },
|
||||||
|
]);
|
||||||
|
const markdown = docToMarkdown(doc);
|
||||||
|
expect(markdown).toContain('<<2026-07-01');
|
||||||
|
expect(markdown).toContain('>>2026-12-31');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts dd.mm.yyyy as input lenience but serializes ISO', () => {
|
||||||
|
const doc = markdownToDoc('Zieltermin >>31.12.2026 bitte.');
|
||||||
|
expect(markers(doc)).toEqual([{ kind: 'due', date: '2026-12-31' }]);
|
||||||
|
expect(docToMarkdown(doc)).toContain('>>2026-12-31');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a line-leading >>date out of blockquote parsing', () => {
|
||||||
|
const doc = markdownToDoc('>>2026-12-31 ist die Deadline.');
|
||||||
|
expect(markers(doc)).toEqual([{ kind: 'due', date: '2026-12-31' }]);
|
||||||
|
let quotes = 0;
|
||||||
|
doc.descendants((node) => {
|
||||||
|
if (node.type.name === 'blockquote') quotes += 1;
|
||||||
|
});
|
||||||
|
expect(quotes).toBe(0);
|
||||||
|
// A real blockquote still works.
|
||||||
|
const quote = markdownToDoc('> ein Zitat');
|
||||||
|
let realQuotes = 0;
|
||||||
|
quote.descendants((node) => {
|
||||||
|
if (node.type.name === 'blockquote') realQuotes += 1;
|
||||||
|
});
|
||||||
|
expect(realQuotes).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves invalid calendar dates as plain text', () => {
|
||||||
|
const doc = markdownToDoc('Kaputt: >>31.02.2026 bleibt Text.');
|
||||||
|
expect(markers(doc)).toEqual([]);
|
||||||
|
expect(docToMarkdown(doc)).toContain('31.02.2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders HTML with kind, ISO date, and shows up in plain text', () => {
|
||||||
|
const doc = markdownToDoc('Bis >>2026-12-31.');
|
||||||
|
const html = docToHtml(doc);
|
||||||
|
expect(html).toContain('data-date-marker="due"');
|
||||||
|
expect(html).toContain('data-date="2026-12-31"');
|
||||||
|
expect(docToPlainText(doc)).toContain('2026-12-31');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works inside task list lines', () => {
|
||||||
|
const doc = markdownToDoc('- [ ] Bühne buchen >>2026-08-01 @nadia');
|
||||||
|
expect(markers(doc)).toEqual([{ kind: 'due', date: '2026-08-01' }]);
|
||||||
|
const markdown = docToMarkdown(doc);
|
||||||
|
expect(markdown).toContain('- [ ] Bühne buchen >>2026-08-01 @nadia');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -69,6 +69,21 @@ function renderInline(node: Node): string {
|
|||||||
const text = escapeHtml(display ?? (child.attrs.targetSlug as string));
|
const text = escapeHtml(display ?? (child.attrs.targetSlug as string));
|
||||||
const displayAttr = display ? ` data-display="${escapeHtml(display)}"` : '';
|
const displayAttr = display ? ` data-display="${escapeHtml(display)}"` : '';
|
||||||
out += `<a class="wikilink" href="${slug}" data-wikilink="${slug}"${displayAttr}>${text}</a>`;
|
out += `<a class="wikilink" href="${slug}" data-wikilink="${slug}"${displayAttr}>${text}</a>`;
|
||||||
|
} else if (child.type.name === 'date_marker') {
|
||||||
|
// A date marker (issue #152): static HTML shows the unambiguous ISO
|
||||||
|
// date; locale-aware formatting happens where the viewer is known.
|
||||||
|
const kind = escapeHtml(child.attrs.kind as string);
|
||||||
|
const date = escapeHtml(child.attrs.date as string);
|
||||||
|
out +=
|
||||||
|
`<span class="dt-date dt-date--${kind}" data-date-marker="${kind}"` +
|
||||||
|
` data-date="${date}">${kind === 'due' ? '»' : '«'} ${date}</span>`;
|
||||||
|
} else if (child.type.name === 'mention') {
|
||||||
|
// A user mention (issue #150): static HTML shows the @username; the
|
||||||
|
// stable user id rides along for consumers that can resolve it.
|
||||||
|
const username = escapeHtml(child.attrs.username as string);
|
||||||
|
const userId = child.attrs.userId ? escapeHtml(child.attrs.userId as string) : '';
|
||||||
|
const idAttr = userId ? ` data-mention-user-id="${userId}"` : '';
|
||||||
|
out += `<span class="dt-mention" data-mention="${username}"${idAttr}>@${username}</span>`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return out;
|
return out;
|
||||||
@ -79,7 +94,8 @@ function renderListItems(node: Node): string {
|
|||||||
node.forEach((item) => {
|
node.forEach((item) => {
|
||||||
if (item.type.name === 'task_item') {
|
if (item.type.name === 'task_item') {
|
||||||
const checked = item.attrs.checked === true;
|
const checked = item.attrs.checked === true;
|
||||||
out += `<li data-type="task_item" data-checked="${checked}"><input type="checkbox" disabled${checked ? ' checked' : ''}>${renderBlocks(item)}</li>`;
|
const id = item.attrs.id ? ` data-task-id="${escapeHtml(item.attrs.id as string)}"` : '';
|
||||||
|
out += `<li data-type="task_item" data-checked="${checked}"${id}><input type="checkbox" disabled${checked ? ' checked' : ''}>${renderBlocks(item)}</li>`;
|
||||||
} else {
|
} else {
|
||||||
out += `<li>${renderBlocks(item)}</li>`;
|
out += `<li>${renderBlocks(item)}</li>`;
|
||||||
}
|
}
|
||||||
@ -132,6 +148,10 @@ function renderBlock(node: Node): string {
|
|||||||
` data-plugin-data="${data}">[${pluginId}/${blockType}]</div>`
|
` data-plugin-data="${data}">[${pluginId}/${blockType}]</div>`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
case 'task_overview':
|
||||||
|
// The task overview (issue #154): a placeholder the permission-aware
|
||||||
|
// renderers (public view, exports) replace with the static table.
|
||||||
|
return '<div class="dt-task-overview" data-task-overview="1">[tasks]</div>';
|
||||||
case 'transclusion': {
|
case 'transclusion': {
|
||||||
// A page embed (#135). The static HTML is a placeholder carrying the
|
// A page embed (#135). The static HTML is a placeholder carrying the
|
||||||
// target slug; the read view / public renderer expands it server-side to
|
// target slug; the read view / public renderer expands it server-side to
|
||||||
|
|||||||
@ -5,3 +5,5 @@ export * from './html';
|
|||||||
export * from './plain-text';
|
export * from './plain-text';
|
||||||
export * from './outline';
|
export * from './outline';
|
||||||
export * from './wikilinks';
|
export * from './wikilinks';
|
||||||
|
export * from './mentions';
|
||||||
|
export * from './tasks';
|
||||||
|
|||||||
@ -128,6 +128,13 @@ function transformTokens(tokens: Token[]): Token[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tok.type === 'fence' && tok.info.trim() === 'dorfteich-tasks') {
|
||||||
|
// The task overview block (issue #154) — an empty fence marker.
|
||||||
|
out.push(retype(tok, 'task_overview'));
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (tok.type === 'fence') {
|
if (tok.type === 'fence') {
|
||||||
const info = PLUGIN_BLOCK_INFO.exec(tok.info.trim());
|
const info = PLUGIN_BLOCK_INFO.exec(tok.info.trim());
|
||||||
if (info) {
|
if (info) {
|
||||||
@ -192,6 +199,108 @@ function wikilinkRule(state: StateInline, silent: boolean): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `>>`/`<<` + ISO or `dd.mm.yyyy` date (issue #152). */
|
||||||
|
const DATE_MARKER = /^([<>])\1\s?(?:(\d{4})-(\d{2})-(\d{2})|(\d{1,2})\.(\d{1,2})\.(\d{4}))/;
|
||||||
|
|
||||||
|
/** Canonical `YYYY-MM-DD` from the regex groups, or null for a non-date. */
|
||||||
|
function isoDateOf(match: RegExpExecArray): string | null {
|
||||||
|
const [year, month, day] = match[2]
|
||||||
|
? [Number(match[2]), Number(match[3]), Number(match[4])]
|
||||||
|
: [Number(match[7]), Number(match[6]), Number(match[5])];
|
||||||
|
const date = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
const valid =
|
||||||
|
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
||||||
|
if (!valid) return null;
|
||||||
|
const pad = (value: number): string => String(value).padStart(2, '0');
|
||||||
|
return `${year}-${pad(month)}-${pad(day)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** markdown-it inline rule for date markers (issue #152): `>>` = due date,
|
||||||
|
* `<<` = start date; ISO is canonical, `dd.mm.yyyy` is accepted as input
|
||||||
|
* lenience. An invalid calendar date stays plain text. */
|
||||||
|
function dateMarkerRule(state: StateInline, silent: boolean): boolean {
|
||||||
|
const match = DATE_MARKER.exec(state.src.slice(state.pos));
|
||||||
|
if (!match) return false;
|
||||||
|
const iso = isoDateOf(match);
|
||||||
|
if (!iso) return false;
|
||||||
|
if (!silent) {
|
||||||
|
const token = state.push('date_marker', '', 0);
|
||||||
|
token.attrs = [
|
||||||
|
['kind', match[1] === '>' ? 'due' : 'start'],
|
||||||
|
['date', iso],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
state.pos += match[0].length;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Block guard (issue #152): a line starting with `>>2026-…` is a paragraph
|
||||||
|
* with a due-date marker, not a nested blockquote — registered before
|
||||||
|
* `blockquote` so markdown-it never sees the `>` as a quote. */
|
||||||
|
function dateLineRule(
|
||||||
|
state: StateBlock,
|
||||||
|
startLine: number,
|
||||||
|
endLine: number,
|
||||||
|
silent: boolean,
|
||||||
|
): boolean {
|
||||||
|
const start = state.bMarks[startLine]! + state.tShift[startLine]!;
|
||||||
|
const max = state.eMarks[startLine]!;
|
||||||
|
const match = DATE_MARKER.exec(state.src.slice(start, max));
|
||||||
|
if (!match || !isoDateOf(match)) return false;
|
||||||
|
if (silent) return true;
|
||||||
|
// Consume the paragraph like markdown-it's own paragraph rule would.
|
||||||
|
const terminatorRules = state.md.block.ruler.getRules('paragraph');
|
||||||
|
let nextLine = startLine + 1;
|
||||||
|
for (; nextLine < endLine && !state.isEmpty(nextLine); nextLine += 1) {
|
||||||
|
if (state.sCount[nextLine]! - state.blkIndent > 3) continue;
|
||||||
|
if (state.sCount[nextLine]! < 0) continue;
|
||||||
|
let terminate = false;
|
||||||
|
for (const rule of terminatorRules) {
|
||||||
|
if (rule(state, nextLine, endLine, true)) {
|
||||||
|
terminate = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (terminate) break;
|
||||||
|
}
|
||||||
|
const content = state.getLines(startLine, nextLine, state.blkIndent, false).trim();
|
||||||
|
const open = state.push('paragraph_open', 'p', 1);
|
||||||
|
open.map = [startLine, nextLine];
|
||||||
|
const inline = state.push('inline', '', 0);
|
||||||
|
inline.content = content;
|
||||||
|
inline.map = [startLine, nextLine];
|
||||||
|
inline.children = [];
|
||||||
|
state.push('paragraph_close', 'p', -1);
|
||||||
|
state.line = nextLine;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Username shape after the `@` (mirrors the signup `usernameSchema`). */
|
||||||
|
const MENTION_NAME = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/i;
|
||||||
|
|
||||||
|
/** markdown-it inline rule for `@username` mentions (issue #150). Only fires
|
||||||
|
* at a word boundary — `stefan@example.org` stays plain text. The parser is
|
||||||
|
* database-less, so the user id stays empty (purely visual) until the editor
|
||||||
|
* or a resolver fills it in. */
|
||||||
|
function mentionRule(state: StateInline, silent: boolean): boolean {
|
||||||
|
const start = state.pos;
|
||||||
|
if (state.src.charCodeAt(start) !== 0x40 /* @ */) return false;
|
||||||
|
const before = start > 0 ? state.src[start - 1]! : '';
|
||||||
|
if (/[\w@.-]/.test(before)) return false;
|
||||||
|
const match = MENTION_NAME.exec(state.src.slice(start + 1));
|
||||||
|
if (!match || match[0].length < 3) return false;
|
||||||
|
const username = match[0];
|
||||||
|
// Trailing user-ish characters would make this an address, not a mention.
|
||||||
|
const after = state.src[start + 1 + username.length] ?? '';
|
||||||
|
if (after === '@') return false;
|
||||||
|
if (!silent) {
|
||||||
|
const token = state.push('mention', '', 0);
|
||||||
|
token.attrs = [['username', username]];
|
||||||
|
}
|
||||||
|
state.pos = start + 1 + username.length;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** A whole line that is only `![[slug]]` / `![[slug|display]]` embeds a page
|
/** A whole line that is only `![[slug]]` / `![[slug|display]]` embeds a page
|
||||||
* (#135); the `$[[slug]]` prefix embeds without frame or title (#146). */
|
* (#135); the `$[[slug]]` prefix embeds without frame or title (#146). */
|
||||||
const TRANSCLUSION_LINE = /^([!$])\[\[([^[\]\n|]+)(?:\|([^[\]\n]+))?\]\]\s*$/;
|
const TRANSCLUSION_LINE = /^([!$])\[\[([^[\]\n|]+)(?:\|([^[\]\n]+))?\]\]\s*$/;
|
||||||
@ -280,6 +389,12 @@ function createTokenizer(): MarkdownIt {
|
|||||||
const md = new MarkdownIt('default', { html: false });
|
const md = new MarkdownIt('default', { html: false });
|
||||||
// Run before `link` so `[[…]]` is not first eaten as two nested `[…]` links.
|
// Run before `link` so `[[…]]` is not first eaten as two nested `[…]` links.
|
||||||
md.inline.ruler.before('link', 'wikilink', wikilinkRule);
|
md.inline.ruler.before('link', 'wikilink', wikilinkRule);
|
||||||
|
// `@username` mentions (issue #150).
|
||||||
|
md.inline.ruler.before('link', 'mention', mentionRule);
|
||||||
|
// `>>`/`<<` date markers (issue #152) …
|
||||||
|
md.inline.ruler.before('link', 'date_marker', dateMarkerRule);
|
||||||
|
// … and the guard that keeps a line-leading `>>date` out of blockquote.
|
||||||
|
md.block.ruler.before('blockquote', 'date_line', dateLineRule);
|
||||||
// Run before `paragraph` so a lone `![[slug]]` line embeds rather than reads
|
// Run before `paragraph` so a lone `![[slug]]` line embeds rather than reads
|
||||||
// as plain text (issue #135).
|
// as plain text (issue #135).
|
||||||
md.block.ruler.before('paragraph', 'transclusion', transclusionRule);
|
md.block.ruler.before('paragraph', 'transclusion', transclusionRule);
|
||||||
@ -307,6 +422,7 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
|
|||||||
data: pluginBlockData(tok.content.trim()),
|
data: pluginBlockData(tok.content.trim()),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
task_overview: { node: 'task_overview' },
|
||||||
paragraph: { block: 'paragraph' },
|
paragraph: { block: 'paragraph' },
|
||||||
list_item: { block: 'list_item' },
|
list_item: { block: 'list_item' },
|
||||||
task_item: {
|
task_item: {
|
||||||
@ -347,6 +463,21 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
|
|||||||
displayText: tok.attrGet('display') || null,
|
displayText: tok.attrGet('display') || null,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
mention: {
|
||||||
|
node: 'mention',
|
||||||
|
getAttrs: (tok) => ({
|
||||||
|
username: tok.attrGet('username') ?? '',
|
||||||
|
// A Markdown import cannot resolve users — the mention stays visual.
|
||||||
|
userId: tok.attrGet('userId') ?? '',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
date_marker: {
|
||||||
|
node: 'date_marker',
|
||||||
|
getAttrs: (tok) => ({
|
||||||
|
kind: tok.attrGet('kind') ?? 'due',
|
||||||
|
date: tok.attrGet('date') ?? '',
|
||||||
|
}),
|
||||||
|
},
|
||||||
transclusion: {
|
transclusion: {
|
||||||
node: 'transclusion',
|
node: 'transclusion',
|
||||||
getAttrs: (tok) => ({
|
getAttrs: (tok) => ({
|
||||||
@ -434,6 +565,11 @@ const markdownSerializer = new MarkdownSerializer(
|
|||||||
state.write(fence);
|
state.write(fence);
|
||||||
state.closeBlock(node);
|
state.closeBlock(node);
|
||||||
},
|
},
|
||||||
|
task_overview(state, node) {
|
||||||
|
// An empty marker fence (issue #154) — the overview has no payload.
|
||||||
|
state.write('```dorfteich-tasks\n```');
|
||||||
|
state.closeBlock(node);
|
||||||
|
},
|
||||||
heading(state, node) {
|
heading(state, node) {
|
||||||
state.write(`${state.repeat('#', node.attrs.level as number)} `);
|
state.write(`${state.repeat('#', node.attrs.level as number)} `);
|
||||||
state.renderInline(node, false);
|
state.renderInline(node, false);
|
||||||
@ -478,6 +614,13 @@ const markdownSerializer = new MarkdownSerializer(
|
|||||||
const display = node.attrs.displayText as string | null;
|
const display = node.attrs.displayText as string | null;
|
||||||
state.write(display ? `[[${slug}|${display}]]` : `[[${slug}]]`);
|
state.write(display ? `[[${slug}|${display}]]` : `[[${slug}]]`);
|
||||||
},
|
},
|
||||||
|
mention(state, node) {
|
||||||
|
state.write(`@${node.attrs.username as string}`);
|
||||||
|
},
|
||||||
|
date_marker(state, node) {
|
||||||
|
const prefix = node.attrs.kind === 'due' ? '>>' : '<<';
|
||||||
|
state.write(`${prefix}${node.attrs.date as string}`);
|
||||||
|
},
|
||||||
transclusion(state, node) {
|
transclusion(state, node) {
|
||||||
const slug = node.attrs.targetSlug as string;
|
const slug = node.attrs.targetSlug as string;
|
||||||
const display = node.attrs.displayText as string | null;
|
const display = node.attrs.displayText as string | null;
|
||||||
|
|||||||
62
packages/shared/src/editor-schema/mention.test.ts
Normal file
62
packages/shared/src/editor-schema/mention.test.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { Node } from 'prosemirror-model';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { docToHtml } from './html';
|
||||||
|
import { docToMarkdown, markdownToDoc } from './markdown';
|
||||||
|
import { extractMentionUserIds } from './mentions';
|
||||||
|
import { docToPlainText } from './plain-text';
|
||||||
|
import { editorSchema } from './schema';
|
||||||
|
|
||||||
|
function firstMention(doc: Node): Node | null {
|
||||||
|
let found: Node | null = null;
|
||||||
|
doc.descendants((node) => {
|
||||||
|
if (!found && node.type.name === 'mention') found = node;
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('@username mention (issue #150)', () => {
|
||||||
|
it('parses @username to a mention node and round-trips', () => {
|
||||||
|
const doc = markdownToDoc('Hallo @nadia, schau mal.');
|
||||||
|
const node = firstMention(doc);
|
||||||
|
expect(node).not.toBeNull();
|
||||||
|
expect(node!.attrs.username).toBe('nadia');
|
||||||
|
expect(node!.attrs.userId).toBe('');
|
||||||
|
expect(docToMarkdown(doc)).toContain('@nadia');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves e-mail addresses untouched', () => {
|
||||||
|
const doc = markdownToDoc('Schreib an stefan@example.org bitte.');
|
||||||
|
expect(firstMention(doc)).toBeNull();
|
||||||
|
expect(docToMarkdown(doc)).toContain('stefan@example.org');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders HTML with the username and optional user id', () => {
|
||||||
|
const doc = markdownToDoc('Ping @zoraya-shahin!');
|
||||||
|
expect(docToHtml(doc)).toContain('class="dt-mention" data-mention="zoraya-shahin"');
|
||||||
|
expect(docToHtml(doc)).toContain('@zoraya-shahin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appears in the plain text for search', () => {
|
||||||
|
expect(docToPlainText(markdownToDoc('Frag @nadia.'))).toContain('@nadia');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts only resolved user ids', () => {
|
||||||
|
const doc = Node.fromJSON(editorSchema, {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{ type: 'mention', attrs: { userId: 'u-1', username: 'nadia' } },
|
||||||
|
{ type: 'text', text: ' und ' },
|
||||||
|
{ type: 'mention', attrs: { userId: '', username: 'import-only' } },
|
||||||
|
{ type: 'text', text: ' und nochmal ' },
|
||||||
|
{ type: 'mention', attrs: { userId: 'u-1', username: 'nadia' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(extractMentionUserIds(doc)).toEqual(['u-1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
18
packages/shared/src/editor-schema/mentions.ts
Normal file
18
packages/shared/src/editor-schema/mentions.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Node } from 'prosemirror-model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The distinct user ids mentioned in a document (issue #150) — the input for
|
||||||
|
* the derived `page_mentions` rows and the mention notifications (#151).
|
||||||
|
* Mentions without a resolved user id (e.g. from a Markdown import) are
|
||||||
|
* purely visual and excluded.
|
||||||
|
*/
|
||||||
|
export function extractMentionUserIds(doc: Node): string[] {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
doc.descendants((node) => {
|
||||||
|
if (node.type.name === 'mention') {
|
||||||
|
const userId = node.attrs.userId as string;
|
||||||
|
if (userId) ids.add(userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
@ -9,6 +9,10 @@ export function docToPlainText(doc: Node): string {
|
|||||||
if (leaf.type.name === 'wikilink') {
|
if (leaf.type.name === 'wikilink') {
|
||||||
return (leaf.attrs.displayText as string | null) ?? (leaf.attrs.targetSlug as string);
|
return (leaf.attrs.displayText as string | null) ?? (leaf.attrs.targetSlug as string);
|
||||||
}
|
}
|
||||||
|
// A mention reads as `@username` (issue #150), so search finds it.
|
||||||
|
if (leaf.type.name === 'mention') return `@${leaf.attrs.username as string}`;
|
||||||
|
// A date marker contributes its ISO date (issue #152).
|
||||||
|
if (leaf.type.name === 'date_marker') return leaf.attrs.date as string;
|
||||||
return '';
|
return '';
|
||||||
});
|
});
|
||||||
return text.replace(/\n{3,}/g, '\n\n').trim();
|
return text.replace(/\n{3,}/g, '\n\n').trim();
|
||||||
|
|||||||
@ -167,13 +167,31 @@ export const editorSchema = new Schema({
|
|||||||
|
|
||||||
task_item: {
|
task_item: {
|
||||||
content: 'paragraph block*',
|
content: 'paragraph block*',
|
||||||
attrs: { checked: { default: false, validate: 'boolean' } },
|
// `id` (issue #153): a stable per-line id (assigned lazily in the
|
||||||
parseDOM: [{ tag: 'li[data-type="task_item"]' }],
|
// editor) so the task overview (#154) can address a single checkbox for
|
||||||
toDOM: (node) => [
|
// display and server-side toggling. `default: null` keeps every
|
||||||
'li',
|
// existing document valid; Markdown stays id-less by design.
|
||||||
{ 'data-type': 'task_item', 'data-checked': String(node.attrs.checked) },
|
attrs: {
|
||||||
0,
|
checked: { default: false, validate: 'boolean' },
|
||||||
|
id: { default: null },
|
||||||
|
},
|
||||||
|
parseDOM: [
|
||||||
|
{
|
||||||
|
tag: 'li[data-type="task_item"]',
|
||||||
|
getAttrs: (dom) => ({
|
||||||
|
checked: dom.getAttribute('data-checked') === 'true',
|
||||||
|
id: dom.getAttribute('data-task-id') || null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
|
toDOM: (node) => {
|
||||||
|
const attrs: Record<string, string> = {
|
||||||
|
'data-type': 'task_item',
|
||||||
|
'data-checked': String(node.attrs.checked),
|
||||||
|
};
|
||||||
|
if (node.attrs.id) attrs['data-task-id'] = node.attrs.id as string;
|
||||||
|
return ['li', attrs, 0];
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
text: { group: 'inline' },
|
text: { group: 'inline' },
|
||||||
@ -256,6 +274,82 @@ export const editorSchema = new Schema({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Task overview block (issue #154): collects the task-list lines of the
|
||||||
|
// current page and its subtree into a table (text, mentioned users,
|
||||||
|
// start/due dates, source page) with write-back checkboxes. A block atom
|
||||||
|
// without payload — the collection is computed where permissions are
|
||||||
|
// known (server-side for the public view, the /read tasks endpoint for
|
||||||
|
// the editor's read mode).
|
||||||
|
task_overview: {
|
||||||
|
group: 'block',
|
||||||
|
atom: true,
|
||||||
|
parseDOM: [{ tag: 'div[data-task-overview]' }],
|
||||||
|
toDOM: () => ['div', { 'data-task-overview': '1', class: 'dt-task-overview' }, 'Tasks'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// `>>2026-12-31` / `<<2026-07-01` date marker (issue #152). An inline
|
||||||
|
// atom carrying a kind ('due' for `>>`, 'start' for `<<`) and the date as
|
||||||
|
// a canonical ISO `YYYY-MM-DD` string; locale-aware formatting happens in
|
||||||
|
// the editor, where the viewer's language is known.
|
||||||
|
date_marker: {
|
||||||
|
group: 'inline',
|
||||||
|
inline: true,
|
||||||
|
atom: true,
|
||||||
|
attrs: {
|
||||||
|
kind: { validate: 'string' },
|
||||||
|
date: { validate: 'string' },
|
||||||
|
},
|
||||||
|
parseDOM: [
|
||||||
|
{
|
||||||
|
tag: 'span[data-date-marker]',
|
||||||
|
getAttrs: (dom) => ({
|
||||||
|
kind: dom.getAttribute('data-date-marker'),
|
||||||
|
date: dom.getAttribute('data-date'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
toDOM: (node) => {
|
||||||
|
const kind = node.attrs.kind as string;
|
||||||
|
const date = node.attrs.date as string;
|
||||||
|
return [
|
||||||
|
'span',
|
||||||
|
{ 'data-date-marker': kind, 'data-date': date, class: `dt-date dt-date--${kind}` },
|
||||||
|
`${kind === 'due' ? '»' : '«'} ${date}`,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// `@username` user mention (issue #150). An inline atom carrying the
|
||||||
|
// mentioned user's stable id plus the username as serialization/display
|
||||||
|
// fallback. Live display-name resolution happens in the editor where a
|
||||||
|
// user lookup is available; a Markdown import cannot resolve users, so it
|
||||||
|
// leaves `userId` empty — such a mention is purely visual.
|
||||||
|
mention: {
|
||||||
|
group: 'inline',
|
||||||
|
inline: true,
|
||||||
|
atom: true,
|
||||||
|
attrs: {
|
||||||
|
userId: { default: '' },
|
||||||
|
username: { validate: 'string' },
|
||||||
|
},
|
||||||
|
parseDOM: [
|
||||||
|
{
|
||||||
|
tag: 'span[data-mention]',
|
||||||
|
getAttrs: (dom) => ({
|
||||||
|
username: dom.getAttribute('data-mention'),
|
||||||
|
userId: dom.getAttribute('data-mention-user-id') || '',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
toDOM: (node) => {
|
||||||
|
const username = node.attrs.username as string;
|
||||||
|
const userId = node.attrs.userId as string;
|
||||||
|
const attrs: Record<string, string> = { 'data-mention': username, class: 'dt-mention' };
|
||||||
|
if (userId) attrs['data-mention-user-id'] = userId;
|
||||||
|
return ['span', attrs, `@${username}`];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Obsidian-style page embed `![[slug]]` (issue #135). A block atom that
|
// Obsidian-style page embed `![[slug]]` (issue #135). A block atom that
|
||||||
// references another page by slug; the read view / public renderer expands
|
// references another page by slug; the read view / public renderer expands
|
||||||
// it to the target page's rendered HTML (permission-checked, recursion
|
// it to the target page's rendered HTML (permission-checked, recursion
|
||||||
|
|||||||
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[];
|
||||||
|
}
|
||||||
@ -20,6 +20,7 @@ export * from './labels';
|
|||||||
export * from './legal';
|
export * from './legal';
|
||||||
export * from './links';
|
export * from './links';
|
||||||
export * from './members';
|
export * from './members';
|
||||||
|
export * from './users';
|
||||||
export * from './notifications';
|
export * from './notifications';
|
||||||
export * from './pages';
|
export * from './pages';
|
||||||
export * from './permissions';
|
export * from './permissions';
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { z } from 'zod';
|
|||||||
* permission at delivery time.
|
* permission at delivery time.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const NOTIFICATION_TYPES = ['page_changed', 'comment_added'] as const;
|
export const NOTIFICATION_TYPES = ['page_changed', 'comment_added', 'mentioned'] as const;
|
||||||
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
|
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
|
||||||
|
|
||||||
export interface NotificationPayload {
|
export interface NotificationPayload {
|
||||||
|
|||||||
@ -66,6 +66,10 @@ export type RepositionPageInput = z.infer<typeof repositionPageInputSchema>;
|
|||||||
* nothing disappears but the page itself); `subtree` trashes every live
|
* nothing disappears but the page itself); `subtree` trashes every live
|
||||||
* descendant along with it, which requires write permission on all of them.
|
* descendant along with it, which requires write permission on all of them.
|
||||||
*/
|
*/
|
||||||
|
/** Body of `POST /pages/:id/tasks/:taskId` (issue #153). */
|
||||||
|
export const toggleTaskInputSchema = z.object({ checked: z.boolean() });
|
||||||
|
export type ToggleTaskInput = z.infer<typeof toggleTaskInputSchema>;
|
||||||
|
|
||||||
export const PAGE_DELETE_MODES = ['promote', 'subtree'] as const;
|
export const PAGE_DELETE_MODES = ['promote', 'subtree'] as const;
|
||||||
export type PageDeleteMode = (typeof PAGE_DELETE_MODES)[number];
|
export type PageDeleteMode = (typeof PAGE_DELETE_MODES)[number];
|
||||||
|
|
||||||
|
|||||||
10
packages/shared/src/users.ts
Normal file
10
packages/shared/src/users.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Minimal public identity of a user (issue #150): what the instance-wide
|
||||||
|
* mention search and the mention rendering expose — never more than id,
|
||||||
|
* username, and display name.
|
||||||
|
*/
|
||||||
|
export interface UserBriefView {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user