diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 0c579ff..ad25315 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -366,6 +366,26 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/backlinks.spec.ts + - name: Reset login rate limit before page-tree 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 page-tree pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/page-tree.spec.ts + + - name: Reset login rate limit before graph 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 graph pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/graph.spec.ts + - name: Reset login rate limit before search pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ diff --git a/apps/web/e2e/graph.spec.ts b/apps/web/e2e/graph.spec.ts new file mode 100644 index 0000000..114a4fa --- /dev/null +++ b/apps/web/e2e/graph.spec.ts @@ -0,0 +1,189 @@ +import { expect, test, type BrowserContext, type Page } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +/** + * Knowledge-graph pack (issues #111–#113): the pond graph view (nodes, + * edges, phantom-create flow, legend), the local neighborhood panel with + * its hop toggle, and the permission slice — a label-denied reader must + * not see the hidden page anywhere in the graph. Wikilinks are typed in + * the editor; a reload flushes the collab store into `page_links` (same + * ordering constraint as the backlinks pack). + */ +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +async function json( + context: BrowserContext, + method: 'post' | 'get', + url: string, + data?: unknown, +): Promise { + const response = await context.request[method](url, data !== undefined ? { data } : {}); + if (!response.ok()) throw new Error(`${method} ${url} → ${response.status()}`); + return response.json() as Promise; +} + +interface PageRef { + id: string; + slug: string; +} + +async function createPage( + context: BrowserContext, + pondId: string, + title: string, +): Promise { + return json(context, 'post', `/api/v1/ponds/${pondId}/pages`, { title }); +} + +/** Types `[[query` in the open editor and accepts the first suggestion. */ +async function insertWikilink(page: Page, query: string): Promise { + const body = page.locator('.editor-content .ProseMirror'); + await body.click(); + await page.keyboard.type(`[[${query}`); + await expect(page.locator('.wikilink-suggest')).toBeVisible(); + await page.keyboard.press('Enter'); +} + +test('pond graph renders the link structure and creates phantom pages', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const ts = Date.now(); + const pond = await json<{ id: string; slug: string }>(context, 'post', '/api/v1/ponds', { + name: `Graph Pack ${ts}`, + }); + const source = await createPage(context, pond.id, `GP Source ${ts}`); + const target = await createPage(context, pond.id, `GP Target ${ts}`); + + const page = await context.newPage(); + await page.goto(`/p/${pond.slug}/${source.slug}`); + await page.locator('.editor-page__mode-toggle').click(); + await insertWikilink(page, `GP Target ${ts}`); + await page.keyboard.type(' '); + // No page matches → the suggestion creates a phantom link. + await insertWikilink(page, `gp-ghost-${ts}`); + await page.reload(); // flush the collab store → page_links rows + + // The sidebar links every member to the graph route. + await page.locator('.sidebar__graph-link').click(); + await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/graph$`)); + await expect(page.getByTestId(`graph-node-${source.slug}`)).toBeVisible(); + await expect(page.getByTestId(`graph-node-${target.slug}`)).toBeVisible(); + await expect(page.getByTestId(`graph-phantom-gp-ghost-${ts}`)).toBeVisible(); + expect(await page.locator('.force-graph__edge').count()).toBeGreaterThanOrEqual(2); + await expect(page.locator('.graph-legend')).toBeVisible(); + + // A node click opens the page. + await page.getByTestId(`graph-node-${target.slug}`).click(); + await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/${target.slug}$`)); + + // A phantom click creates the page (confirm) and resolves the links. + page.on('dialog', (dialog) => void dialog.accept()); + await page.goto(`/p/${pond.slug}/graph`); + await page.getByTestId(`graph-phantom-gp-ghost-${ts}`).click(); + await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/gp-ghost-${ts}$`)); + await page.goto(`/p/${pond.slug}/graph`); + await expect(page.getByTestId(`graph-node-gp-ghost-${ts}`)).toBeVisible(); + await expect(page.getByTestId(`graph-phantom-gp-ghost-${ts}`)).toHaveCount(0); + + await context.close(); +}); + +test('local graph panel shows the neighborhood per hop depth and navigates', async ({ + browser, +}) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const ts = Date.now(); + const pond = await json<{ id: string; slug: string }>(context, 'post', '/api/v1/ponds', { + name: `Local Graph ${ts}`, + }); + const a = await createPage(context, pond.id, `LG A ${ts}`); + const b = await createPage(context, pond.id, `LG B ${ts}`); + const c = await createPage(context, pond.id, `LG C ${ts}`); + const lonely = await createPage(context, pond.id, `LG Lonely ${ts}`); + + // Chain A → B → C so the hop depth matters when viewed from A. + const page = await context.newPage(); + await page.goto(`/p/${pond.slug}/${a.slug}`); + await page.locator('.editor-page__mode-toggle').click(); + await insertWikilink(page, `LG B ${ts}`); + await page.goto(`/p/${pond.slug}/${b.slug}`); + await page.locator('.editor-page__mode-toggle').click(); + await insertWikilink(page, `LG C ${ts}`); + await page.reload(); + + await page.goto(`/p/${pond.slug}/${a.slug}`); + const panel = page.locator('.local-graph'); + await expect(panel).toBeVisible(); + await expect(page.getByTestId(`local-graph-node-${b.slug}`)).toBeVisible(); + await expect(page.getByTestId(`local-graph-node-${c.slug}`)).toHaveCount(0); + // The current page carries the highlight ring. + await expect( + page.getByTestId(`local-graph-node-${a.slug}`).locator('.force-graph__ring'), + ).toHaveCount(1); + + await panel.getByRole('button', { name: /two hops|zwei ebenen/i }).click(); + await expect(page.getByTestId(`local-graph-node-${c.slug}`)).toBeVisible(); + + await page.getByTestId(`local-graph-node-${b.slug}`).click(); + await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/${b.slug}$`)); + + // Pages without links show no panel at all. + await page.goto(`/p/${pond.slug}/${lonely.slug}`); + await expect(page.locator('.editor-content .ProseMirror')).toBeVisible(); + await expect(page.locator('.local-graph')).toHaveCount(0); + + await context.close(); +}); + +test('a label-denied reader sees no trace of the hidden page in the graph', async ({ browser }) => { + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + const viewer = await contextForUser(browser, BASE_URL, 'fixture-viewer'); + const viewerId = (await json<{ id: string }>(viewer, 'get', '/api/v1/auth/me')).id; + const ts = Date.now(); + const pond = await json<{ id: string; slug: string }>(owner, 'post', '/api/v1/ponds', { + name: `Sliced Graph ${ts}`, + }); + // Grants BEFORE content: pond-wide read for the viewer, minus the label. + const label = await json<{ id: string }>(owner, 'post', `/api/v1/ponds/${pond.id}/labels`, { + name: 'secret', + }); + await json(owner, 'post', `/api/v1/ponds/${pond.id}/grants`, { + subjectType: 'user', + subjectId: viewerId, + role: 'reader', + scopeType: 'pond', + effect: 'allow', + }); + await json(owner, 'post', `/api/v1/ponds/${pond.id}/grants`, { + subjectType: 'user', + subjectId: viewerId, + role: 'reader', + scopeType: 'label', + scopeId: label.id, + effect: 'deny', + }); + + const open = await createPage(owner, pond.id, `SG Open ${ts}`); + const secret = await createPage(owner, pond.id, `SG Secret ${ts}`); + await json(owner, 'post', `/api/v1/pages/${secret.id}/labels`, { labelId: label.id }); + + // Owner links the open page to the secret one. + const ownerPage = await owner.newPage(); + await ownerPage.goto(`/p/${pond.slug}/${open.slug}`); + await ownerPage.locator('.editor-page__mode-toggle').click(); + await insertWikilink(ownerPage, `SG Secret ${ts}`); + await ownerPage.reload(); + await ownerPage.goto(`/p/${pond.slug}/graph`); + await expect(ownerPage.getByTestId(`graph-node-${secret.slug}`)).toBeVisible(); + expect(await ownerPage.locator('.force-graph__edge').count()).toBe(1); + + // The viewer gets the sliced graph: no secret node, no edge into it. + const viewerPage = await viewer.newPage(); + await viewerPage.goto(`/p/${pond.slug}/graph`); + await expect(viewerPage.getByTestId(`graph-node-${open.slug}`)).toBeVisible(); + await expect(viewerPage.getByTestId(`graph-node-${secret.slug}`)).toHaveCount(0); + expect(await viewerPage.locator('.force-graph__edge').count()).toBe(0); + + await owner.close(); + await viewer.close(); +}); diff --git a/apps/web/e2e/page-tree.spec.ts b/apps/web/e2e/page-tree.spec.ts new file mode 100644 index 0000000..e09b626 --- /dev/null +++ b/apps/web/e2e/page-tree.spec.ts @@ -0,0 +1,189 @@ +import { expect, test, type BrowserContext, type Page } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +/** + * Page-tree pack (issues #106–#109): create-as-child from the open page, + * folder/label sidebar views with the pond default and the local override, + * the "Move to…" dialog, the per-case delete decision (promote vs subtree), + * and the restore re-attachment to the root once the parent is gone. + * Runs in its own shared pond — fixture ponds stay untouched, and the pond + * default flip cannot leak into other packs. Language-independent selectors + * (CSS classes, testids) plus |-alternation for the two locales elsewhere. + */ +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +async function json( + context: BrowserContext, + method: 'post' | 'patch', + url: string, + data: unknown, +): Promise { + const response = await context.request[method](url, { data }); + if (!response.ok()) throw new Error(`${method} ${url} → ${response.status()}`); + return response.json() as Promise; +} + +interface PageRef { + id: string; + slug: string; +} + +async function provisionPond( + context: BrowserContext, + name: string, +): Promise<{ id: string; slug: string }> { + return json(context, 'post', '/api/v1/ponds', { name }); +} + +async function createPage( + context: BrowserContext, + pondId: string, + title: string, + parentId?: string, +): Promise { + return json(context, 'post', `/api/v1/ponds/${pondId}/pages`, { + title, + ...(parentId ? { parentId } : {}), + }); +} + +function sidebarPage(page: Page, title: string) { + return page.locator(`.sidebar__page:text-is("${title}")`); +} + +test('folder view nests new pages under the open page; views toggle and persist', async ({ + browser, +}) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const ts = Date.now(); + const pond = await provisionPond(context, `Tree Pack ${ts}`); + const parent = await createPage(context, pond.id, `TP Parent ${ts}`); + + const page = await context.newPage(); + // Create from the sidebar while the parent is open → nests under it. + await page.goto(`/p/${pond.slug}/${parent.slug}`); + await page.locator('.sidebar__new-page').click(); + await expect(page.locator('.sidebar__new-page-parent')).toContainText(`TP Parent ${ts}`); + await page.locator('.sidebar__new-page-form input').fill(`TP Child ${ts}`); + await page.locator('.sidebar__new-page-form button[type="submit"]').click(); + await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/tp-child-`)); + + const parentItem = page + .locator('.sidebar__page-item') + .filter({ has: page.locator(`.sidebar__page:text-is("TP Parent ${ts}")`) }) + .first(); + await expect( + parentItem.locator('.sidebar__tree-children .sidebar__page', { hasText: `TP Child ${ts}` }), + ).toBeVisible(); + + // Collapse hides the child and survives a reload (persisted per pond). + await parentItem.locator('.sidebar__caret').first().click(); + await expect(sidebarPage(page, `TP Child ${ts}`)).toHaveCount(0); + await page.reload(); + await expect(sidebarPage(page, `TP Child ${ts}`)).toHaveCount(0); + await page + .locator('.sidebar__page-item') + .filter({ has: page.locator(`.sidebar__page:text-is("TP Parent ${ts}")`) }) + .first() + .locator('.sidebar__caret') + .first() + .click(); + await expect(sidebarPage(page, `TP Child ${ts}`)).toBeVisible(); + + // The label view groups pages; ours land in the "unlabeled" group. + await page.locator('.sidebar__view-btn').nth(1).click(); + await expect( + page.locator('.sidebar__label-group--unlabeled .sidebar__page').first(), + ).toBeVisible(); + // The local choice survives a reload (localStorage override). + await page.reload(); + await expect(page.locator('.sidebar__label-view')).toBeVisible(); + await page.locator('.sidebar__view-btn').first().click(); + await expect(page.locator('.sidebar__pages--tree')).toBeVisible(); + + // The owner-set pond default applies to a context without an override. + await json(context, 'patch', `/api/v1/ponds/${pond.id}`, { sidebarView: 'labels' }); + const fresh = await contextForUser(browser, BASE_URL, 'fixture-user'); + const freshPage = await fresh.newPage(); + await freshPage.goto(`/p/${pond.slug}/${parent.slug}`); + await expect(freshPage.locator('.sidebar__label-view')).toBeVisible(); + await fresh.close(); + + await context.close(); +}); + +test('the Move-to dialog reparents and blocks the own subtree', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const ts = Date.now(); + const pond = await provisionPond(context, `Move Pack ${ts}`); + const alpha = await createPage(context, pond.id, `MP Alpha ${ts}`); + await createPage(context, pond.id, `MP Beta ${ts}`); + await createPage(context, pond.id, `MP Child ${ts}`, alpha.id); + + const page = await context.newPage(); + await page.goto(`/p/${pond.slug}/${alpha.slug}`); + await page.getByRole('button', { name: /more actions|weitere aktionen/i }).click(); + await page.locator('.page-actions__move').click(); + const dialog = page.locator('.move-dialog'); + await expect(dialog).toBeVisible(); + // The page itself and its child are disabled targets (cycle). + await expect(dialog.locator('li', { hasText: `MP Alpha ${ts}` }).locator('input')).toBeDisabled(); + await expect(dialog.locator('li', { hasText: `MP Child ${ts}` }).locator('input')).toBeDisabled(); + await dialog + .locator('li', { hasText: `MP Beta ${ts}` }) + .locator('input') + .check(); + await dialog.getByRole('button', { name: /^(move|verschieben)$/i }).click(); + await expect(dialog).toHaveCount(0); + + const betaItem = page + .locator('.sidebar__page-item') + .filter({ has: page.locator(`.sidebar__page:text-is("MP Beta ${ts}")`) }) + .first(); + await expect( + betaItem.locator('.sidebar__tree-children .sidebar__page', { hasText: `MP Alpha ${ts}` }), + ).toBeVisible(); + + await context.close(); +}); + +test('delete decides per case; a restored orphan lands at the root', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const ts = Date.now(); + const pond = await provisionPond(context, `Del Pack ${ts}`); + const promoteParent = await createPage(context, pond.id, `DP Promote ${ts}`); + await createPage(context, pond.id, `DP Kid ${ts}`, promoteParent.id); + const subtreeParent = await createPage(context, pond.id, `DP Subtree ${ts}`); + await createPage(context, pond.id, `DP Deep ${ts}`, subtreeParent.id); + + const page = await context.newPage(); + // Promote: only the parent goes; the child moves up to the root. + await page.goto(`/p/${pond.slug}/${promoteParent.slug}`); + await page.getByRole('button', { name: /more actions|weitere aktionen/i }).click(); + await page.locator('.page-actions__menu-danger').click(); + const decision = page.locator('.delete-page-dialog'); + await expect(decision).toBeVisible(); + await decision.locator('.delete-page-dialog__promote').click(); + await expect(sidebarPage(page, `DP Kid ${ts}`)).toBeVisible(); + await expect(sidebarPage(page, `DP Promote ${ts}`)).toHaveCount(0); + + // Subtree: parent and descendant go together. + await page.goto(`/p/${pond.slug}/${subtreeParent.slug}`); + await page.getByRole('button', { name: /more actions|weitere aktionen/i }).click(); + await page.locator('.page-actions__menu-danger').click(); + await page.locator('.delete-page-dialog__subtree').click(); + await expect(sidebarPage(page, `DP Subtree ${ts}`)).toHaveCount(0); + await expect(sidebarPage(page, `DP Deep ${ts}`)).toHaveCount(0); + + // Restoring the deep child while its parent stays trashed → root level. + await page.goto(`/p/${pond.slug}/trash`); + const item = page.locator('.trash-page__item').filter({ hasText: `DP Deep ${ts}` }); + await item.getByRole('button', { name: /restore|wiederherstellen/i }).click(); + const rootDeep = page + .locator('.sidebar__pages--tree > .sidebar__page-item') + .filter({ has: page.locator(`.sidebar__page:text-is("DP Deep ${ts}")`) }); + await expect(rootDeep).toHaveCount(1); + + await context.close(); +}); diff --git a/docs/de/features.md b/docs/de/features.md index 1b722b9..7155c06 100644 --- a/docs/de/features.md +++ b/docs/de/features.md @@ -45,6 +45,14 @@ Familien oder Projekte. ## Organisieren, wie du denkst +- **Seitenbaum**: Seiten unter Seiten verschachteln (bis zu 6 Ebenen) — + Slugs und Links bleiben flach, Verschieben bricht also nie etwas; der + ZIP-Export bleibt ebenfalls flach (die Hierarchie ist rein + organisatorisch). +- **Wissensgraph**: eine interaktive Karte jedes Teichs — Seiten als + Knoten, Wikilinks als Kanten, fehlende Ziele als gestrichelte + Phantome, die ein Klick anlegt; jede Seite hat zusätzlich einen + lokalen Nachbarschafts-Graphen. - **Labels**, auf Wunsch hierarchisch, um einen Teich beliebig zu gliedern — und um Zugriffsregeln zu begrenzen (siehe unten). - **Schnelle Volltextsuche** über alles, was du lesen darfst — diff --git a/docs/de/manual/api-guide.md b/docs/de/manual/api-guide.md index 1c11bde..894cf85 100644 --- a/docs/de/manual/api-guide.md +++ b/docs/de/manual/api-guide.md @@ -49,7 +49,7 @@ Token-Scope und eine etwaige Teich-Beschränkung. # Die Teiche, die dieses Token erreicht curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds -# Seiten eines Teichs: Slug, Titel, Labels, Zeitstempel +# Seiten eines Teichs: Slug, Titel, Parent (Seitenbaum-Slug), Labels, Zeitstempel curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds/team/pages # Eine Seite — Markdown-Quelle UND gerendertes, bereinigtes HTML @@ -69,12 +69,13 @@ und Seiten-Slugs für Folgeaufrufe. ## Schreiben (braucht den `write`-Scope) ```sh -# Eine Seite aus Markdown anlegen +# Eine Seite aus Markdown anlegen (optional "parent": ein Seiten-Slug ordnet sie unter) curl -H "$AUTH" -H 'Content-Type: application/json' \ -d '{"title": "Meeting notes", "markdown": "# Agenda\n\n- Enten\n"}' \ https://wiki.example.com/api/public/v1/ponds/team/pages -# Umbenennen und/oder den Inhalt ERSETZEN +# Umbenennen, den Inhalt ERSETZEN und/oder im Seitenbaum verschieben +# ("parent": ordnet die Seite unter, "parent": null holt sie auf die oberste Ebene) curl -X PATCH -H "$AUTH" -H 'Content-Type: application/json' \ -d '{"markdown": "Neuer Inhalt."}' \ https://wiki.example.com/api/public/v1/ponds/team/pages/meeting-notes diff --git a/docs/de/manual/mcp-guide.md b/docs/de/manual/mcp-guide.md index 7de5a72..b792e30 100644 --- a/docs/de/manual/mcp-guide.md +++ b/docs/de/manual/mcp-guide.md @@ -62,18 +62,18 @@ Stdio-Clients überbrücken mit `mcp-remote`: ## Was der Assistent kann -| Tool | Tut | -| -------------------------------------------- | --------------------------------------------- | -| `list_ponds` | die Teiche, die dieses Token erreicht | -| `list_pages(pond)` | Seiten mit Slug, Titel, Labels, Zeitstempeln | -| `read_page(pond, page)` | eine Seite als Markdown plus Metadaten | -| `search(query, pond?, label?)` | Volltextsuche mit Snippets | -| `create_page(pond, title, markdown)` | neue Seite aus Markdown _(write)_ | -| `update_page(pond, page, markdown?, title?)` | umbenennen und/oder Inhalt ersetzen _(write)_ | -| `add_comment(pond, page, text)` | eine Seite kommentieren _(write)_ | -| `list_labels(pond)` | der Label-Baum des Teichs | -| `set_page_labels(pond, page, labelIds)` | die Labels einer Seite ersetzen _(write)_ | -| `export_pond(pond)` | ein Download-Link für den Markdown-ZIP-Export | +| Tool | Tut | +| ----------------------------------------------------- | -------------------------------------------------- | +| `list_ponds` | die Teiche, die dieses Token erreicht | +| `list_pages(pond)` | Seiten mit Slug, Titel, Parent, Labels | +| `read_page(pond, page)` | eine Seite als Markdown plus Metadaten | +| `search(query, pond?, label?)` | Volltextsuche mit Snippets | +| `create_page(pond, title, markdown, parent?)` | neue Seite aus Markdown _(write)_ | +| `update_page(pond, page, markdown?, title?, parent?)` | umbenennen, Inhalt ersetzen, verschieben _(write)_ | +| `add_comment(pond, page, text)` | eine Seite kommentieren _(write)_ | +| `list_labels(pond)` | der Label-Baum des Teichs | +| `set_page_labels(pond, page, labelIds)` | die Labels einer Seite ersetzen _(write)_ | +| `export_pond(pond)` | ein Download-Link für den Markdown-ZIP-Export | Inhalts-Updates nehmen denselben kollaborativen Weg wie menschliche Bearbeitungen: Offene Editoren konvergieren live, und der vorherige diff --git a/docs/de/manual/pond-admin-guide.md b/docs/de/manual/pond-admin-guide.md index e63a732..f36e738 100644 --- a/docs/de/manual/pond-admin-guide.md +++ b/docs/de/manual/pond-admin-guide.md @@ -26,6 +26,10 @@ Wer den Teich anlegt, wird sein Teich-Admin. PDF-Exporte. - **Seitenleisten-Sortierung** für alle: A–Z, Erstellungsdatum oder manuelle Reihenfolge. +- **Seitenleisten-Ansicht** als Standard: der Seitenbaum („Ordner") + oder Seiten gruppiert unter dem Label-Baum. Mitglieder können ihre + eigene Seitenleiste weiterhin lokal umschalten — die Einstellung + bestimmt nur den Ausgangspunkt. ## Mitglieder und Rollen diff --git a/docs/de/manual/user-guide.md b/docs/de/manual/user-guide.md index 0e07222..fc5077f 100644 --- a/docs/de/manual/user-guide.md +++ b/docs/de/manual/user-guide.md @@ -31,9 +31,33 @@ Instanz das [Site-Admin-Handbuch](site-admin-guide.md). Sortiermodus ist eine Teich-Einstellung). - **+ Neue Seite** am unteren Ende der Seitenleiste legt eine Seite an. Seiten-Adressen sind lesbar: `/p//`. +- **Seiten bilden einen Baum.** Eine Seite, die du anlegst, während eine + andere geöffnet ist, wird deren Unterseite (das Formular sagt es an); + Seiten verschachteln bis zu sechs Ebenen tief. Die **Ordner-Ansicht** + der Seitenleiste zeigt den Baum mit einklappbaren Zweigen; die + **Label-Ansicht** gruppiert die Seiten stattdessen unter dem + Label-Baum. Der Umschalter über der Seitenliste gilt nur für dich — + der Teich-Eigentümer legt lediglich den Standard fest. Verschieben + ändert nie die Adresse einer Seite, Links bleiben also intakt. +- **Seiten verschieben:** Ziehe eine Seite auf eine andere, um sie dort + einzuordnen (zwischen Seiten ziehen sortiert innerhalb der Ebene um, + im manuellen Sortiermodus), oder nutze **… → Verschieben nach…** für + eine Zielauswahl, die in jedem Sortiermodus funktioniert. Beim + Löschen einer Seite mit Unterseiten wirst du gefragt, was mit ihnen + passieren soll: eine Ebene hochrücken oder den ganzen Teilbaum + gemeinsam in den Papierkorb. +- Der **Graph**-Link unten in der Seitenleiste öffnet den + **Wissensgraphen** des Teichs: Seiten als Punkte, Wikilinks als + Linien — ein Klick öffnet die Seite, Ziehen ordnet an, Scrollen + zoomt. Gestrichelte Punkte sind Wikilink-Ziele, die es noch nicht + gibt; ein Klick legt die Seite an. Unter jeder Seite (Lesemodus) + zeigt ein **lokaler Graph** ihre Nachbarschaft, umschaltbar zwischen + einer und zwei Ebenen. - Der **Papierkorb**-Link sitzt ganz unten in der Seitenleiste: Gelöschte Seiten lassen sich dort wiederherstellen, bis die - Aufbewahrungsfrist endet. + Aufbewahrungsfrist endet. Eine wiederhergestellte Seite hängt sich an + den nächsten noch vorhandenen Elternknoten, oder an die oberste + Ebene, wenn der ganze Zweig fehlt. ## Der Editor @@ -74,7 +98,8 @@ Bei geöffneter Seite findest du neben dem Stift: **Beobachten** (Glocke für diese Seite), **Kommentare** (mit Zähler für Ungelesenes), **Anhänge**, **Seiten-Werkzeuge** (Inhaltsverzeichnis, Seitenindex — sofern aktiviert), **Labels**, **Verlauf** und das **…**-Menü (Markdown -kopieren/herunterladen, Export nach Word/LibreOffice/PDF, Löschen). +kopieren/herunterladen, Export nach Word/LibreOffice/PDF, Verschieben +nach…, Löschen). ## Labels diff --git a/docs/features.md b/docs/features.md index 976e82d..8626b7b 100644 --- a/docs/features.md +++ b/docs/features.md @@ -42,6 +42,9 @@ projects. - **Page tree**: nest pages under pages (up to 6 levels) — slugs and links stay flat, so moving a page never breaks anything; the ZIP export also stays flat (the hierarchy is organizational only) +- **Knowledge graph**: an interactive map of every pond — pages as nodes, + wikilinks as edges, missing targets as dashed phantoms you can create + with a click; each page also gets a local neighborhood graph - **Labels**, hierarchical if you like, to slice a pond any way you want — and to scope access rules (see below). - **Fast full-text search** across everything you may read — accent- and diff --git a/docs/manual/pond-admin-guide.md b/docs/manual/pond-admin-guide.md index 5346702..1487a14 100644 --- a/docs/manual/pond-admin-guide.md +++ b/docs/manual/pond-admin-guide.md @@ -22,6 +22,9 @@ shared ponds per user", default 0). The creator becomes the pond admin. self-hosted catalog (browse it at `/fonts`) — they apply to the app view, public pages, and PDF exports. - **Sidebar sort** for everyone: A–Z, creation date, or manual order. +- **Sidebar view** default: the page tree ("folders") or pages grouped + under the label tree. Members can still switch their own sidebar + locally — the setting only picks the starting point. ## Members and roles diff --git a/docs/manual/user-guide.md b/docs/manual/user-guide.md index 9d6ede8..5426e74 100644 --- a/docs/manual/user-guide.md +++ b/docs/manual/user-guide.md @@ -27,8 +27,28 @@ instance administration the [site-admin guide](site-admin-guide.md). mode is a pond setting). - **+ New page** at the bottom of the sidebar creates a page. Page addresses are readable: `/p//`. +- **Pages form a tree.** A page created while another page is open + becomes its subpage (the form says so); pages nest up to six levels. + The sidebar's **folder view** shows the tree with collapsible + branches; the **label view** groups pages under the label tree + instead. The toggle above the page list is yours alone — the pond + owner only sets the default. Moving a page never changes its address, + so links keep working. +- **Moving pages:** drag a page onto another one to nest it there + (drag between pages to reorder within the level, in manual sort + mode), or use **… → Move to…** for a picker that works in any sort + mode. Deleting a page that has subpages asks what should happen: + move them up one level, or trash the whole subtree together. +- The **graph** link at the bottom of the sidebar opens the pond's + **knowledge graph**: pages as dots, wikilinks as lines — click a dot + to open the page, drag to rearrange, scroll to zoom. Dashed dots are + wikilink targets that do not exist yet; clicking one creates the + page. Below each page (read mode) a **local graph** shows its own + neighborhood, switchable between one and two hops. - The **trash** link sits at the very bottom of the sidebar: deleted pages can be restored from there until the retention period ends. + A restored page re-attaches to its nearest surviving parent, or to + the top level when the whole branch is gone. ## The editor @@ -65,7 +85,7 @@ When a page is open you find, next to the pencil: **watch** (bell for this page), **comments** (with unread count), **attachments**, **plugin tools** (table of contents, page index — when enabled), **labels**, **history**, and the **…** overflow menu (copy/download -Markdown, export to Word/LibreOffice/PDF, delete). +Markdown, export to Word/LibreOffice/PDF, move to…, delete). ## Labels