QA: page-tree and graph e2e packs in CI, manuals updated (#114)
All checks were successful
CD / Build and push images (push) Successful in 1m9s
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 4m18s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 5m59s
CI / Import/export fidelity gate (push) Successful in 47s

Two new CI-wired Playwright packs, each in its own shared pond so the
fixture ponds stay untouched:

- page-tree.spec.ts — create-as-child with the form hint, collapsible
  folder view (collapse state survives reload), label view grouping,
  the local view override vs the owner-set pond default (fresh context
  without localStorage sees the new default), the Move-to dialog with
  the own subtree disabled, promote vs subtree delete, and a restored
  orphan re-attaching at the root.
- graph.spec.ts — pond graph nodes/edges/legend, node click-through,
  the phantom-create flow (dashed node turns solid), the local panel
  with hop toggle and highlight ring, and the permission slice: a
  label-denied reader sees neither the hidden node nor its edge.

Both packs 3× flake-free locally. Manuals: user guide (page tree,
moving/deleting with subpages, knowledge graph + local graph), pond
admin guide (sidebar view default), features.md (knowledge graph
bullet) — with the docs/de mirrors updated (English authoritative).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-07-14 11:10:14 +02:00
parent a72cb1b1c5
commit 14711a18c2
11 changed files with 480 additions and 18 deletions

View File

@ -366,6 +366,26 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/backlinks.spec.ts 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 - name: Reset login rate limit before search pack
run: | run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \

189
apps/web/e2e/graph.spec.ts Normal file
View File

@ -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<T>(
context: BrowserContext,
method: 'post' | 'get',
url: string,
data?: unknown,
): Promise<T> {
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<T>;
}
interface PageRef {
id: string;
slug: string;
}
async function createPage(
context: BrowserContext,
pondId: string,
title: string,
): Promise<PageRef> {
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<void> {
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();
});

View File

@ -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<T>(
context: BrowserContext,
method: 'post' | 'patch',
url: string,
data: unknown,
): Promise<T> {
const response = await context.request[method](url, { data });
if (!response.ok()) throw new Error(`${method} ${url}${response.status()}`);
return response.json() as Promise<T>;
}
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<PageRef> {
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();
});

View File

@ -45,6 +45,14 @@ Familien oder Projekte.
## Organisieren, wie du denkst ## 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 - **Labels**, auf Wunsch hierarchisch, um einen Teich beliebig zu
gliedern — und um Zugriffsregeln zu begrenzen (siehe unten). gliedern — und um Zugriffsregeln zu begrenzen (siehe unten).
- **Schnelle Volltextsuche** über alles, was du lesen darfst — - **Schnelle Volltextsuche** über alles, was du lesen darfst —

View File

@ -49,7 +49,7 @@ Token-Scope und eine etwaige Teich-Beschränkung.
# Die Teiche, die dieses Token erreicht # Die Teiche, die dieses Token erreicht
curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds 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 curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds/team/pages
# Eine Seite — Markdown-Quelle UND gerendertes, bereinigtes HTML # Eine Seite — Markdown-Quelle UND gerendertes, bereinigtes HTML
@ -69,12 +69,13 @@ und Seiten-Slugs für Folgeaufrufe.
## Schreiben (braucht den `write`-Scope) ## Schreiben (braucht den `write`-Scope)
```sh ```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' \ curl -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"title": "Meeting notes", "markdown": "# Agenda\n\n- Enten\n"}' \ -d '{"title": "Meeting notes", "markdown": "# Agenda\n\n- Enten\n"}' \
https://wiki.example.com/api/public/v1/ponds/team/pages 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": <slug> ordnet die Seite unter, "parent": null holt sie auf die oberste Ebene)
curl -X PATCH -H "$AUTH" -H 'Content-Type: application/json' \ curl -X PATCH -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"markdown": "Neuer Inhalt."}' \ -d '{"markdown": "Neuer Inhalt."}' \
https://wiki.example.com/api/public/v1/ponds/team/pages/meeting-notes https://wiki.example.com/api/public/v1/ponds/team/pages/meeting-notes

View File

@ -63,13 +63,13 @@ Stdio-Clients überbrücken mit `mcp-remote`:
## Was der Assistent kann ## Was der Assistent kann
| Tool | Tut | | Tool | Tut |
| -------------------------------------------- | --------------------------------------------- | | ----------------------------------------------------- | -------------------------------------------------- |
| `list_ponds` | die Teiche, die dieses Token erreicht | | `list_ponds` | die Teiche, die dieses Token erreicht |
| `list_pages(pond)` | Seiten mit Slug, Titel, Labels, Zeitstempeln | | `list_pages(pond)` | Seiten mit Slug, Titel, Parent, Labels |
| `read_page(pond, page)` | eine Seite als Markdown plus Metadaten | | `read_page(pond, page)` | eine Seite als Markdown plus Metadaten |
| `search(query, pond?, label?)` | Volltextsuche mit Snippets | | `search(query, pond?, label?)` | Volltextsuche mit Snippets |
| `create_page(pond, title, markdown)` | neue Seite aus Markdown _(write)_ | | `create_page(pond, title, markdown, parent?)` | neue Seite aus Markdown _(write)_ |
| `update_page(pond, page, markdown?, title?)` | umbenennen und/oder Inhalt ersetzen _(write)_ | | `update_page(pond, page, markdown?, title?, parent?)` | umbenennen, Inhalt ersetzen, verschieben _(write)_ |
| `add_comment(pond, page, text)` | eine Seite kommentieren _(write)_ | | `add_comment(pond, page, text)` | eine Seite kommentieren _(write)_ |
| `list_labels(pond)` | der Label-Baum des Teichs | | `list_labels(pond)` | der Label-Baum des Teichs |
| `set_page_labels(pond, page, labelIds)` | die Labels einer Seite ersetzen _(write)_ | | `set_page_labels(pond, page, labelIds)` | die Labels einer Seite ersetzen _(write)_ |

View File

@ -26,6 +26,10 @@ Wer den Teich anlegt, wird sein Teich-Admin.
PDF-Exporte. PDF-Exporte.
- **Seitenleisten-Sortierung** für alle: AZ, Erstellungsdatum oder - **Seitenleisten-Sortierung** für alle: AZ, Erstellungsdatum oder
manuelle Reihenfolge. 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 ## Mitglieder und Rollen

View File

@ -31,9 +31,33 @@ Instanz das [Site-Admin-Handbuch](site-admin-guide.md).
Sortiermodus ist eine Teich-Einstellung). Sortiermodus ist eine Teich-Einstellung).
- **+ Neue Seite** am unteren Ende der Seitenleiste legt eine Seite an. - **+ Neue Seite** am unteren Ende der Seitenleiste legt eine Seite an.
Seiten-Adressen sind lesbar: `/p/<teich>/<seite>`. Seiten-Adressen sind lesbar: `/p/<teich>/<seite>`.
- **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: - Der **Papierkorb**-Link sitzt ganz unten in der Seitenleiste:
Gelöschte Seiten lassen sich dort wiederherstellen, bis die 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 ## 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), für diese Seite), **Kommentare** (mit Zähler für Ungelesenes),
**Anhänge**, **Seiten-Werkzeuge** (Inhaltsverzeichnis, Seitenindex — **Anhänge**, **Seiten-Werkzeuge** (Inhaltsverzeichnis, Seitenindex —
sofern aktiviert), **Labels**, **Verlauf** und das **…**-Menü (Markdown 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 ## Labels

View File

@ -42,6 +42,9 @@ projects.
- **Page tree**: nest pages under pages (up to 6 levels) — slugs and links - **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 stay flat, so moving a page never breaks anything; the ZIP export also
stays flat (the hierarchy is organizational only) 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 - **Labels**, hierarchical if you like, to slice a pond any way you want
— and to scope access rules (see below). — and to scope access rules (see below).
- **Fast full-text search** across everything you may read — accent- and - **Fast full-text search** across everything you may read — accent- and

View File

@ -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 self-hosted catalog (browse it at `/fonts`) — they apply to the app
view, public pages, and PDF exports. view, public pages, and PDF exports.
- **Sidebar sort** for everyone: AZ, creation date, or manual order. - **Sidebar sort** for everyone: AZ, 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 ## Members and roles

View File

@ -27,8 +27,28 @@ instance administration the [site-admin guide](site-admin-guide.md).
mode is a pond setting). mode is a pond setting).
- **+ New page** at the bottom of the sidebar creates a page. Page - **+ New page** at the bottom of the sidebar creates a page. Page
addresses are readable: `/p/<pond>/<page>`. addresses are readable: `/p/<pond>/<page>`.
- **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 - The **trash** link sits at the very bottom of the sidebar: deleted
pages can be restored from there until the retention period ends. 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 ## 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**, this page), **comments** (with unread count), **attachments**,
**plugin tools** (table of contents, page index — when enabled), **plugin tools** (table of contents, page index — when enabled),
**labels**, **history**, and the **…** overflow menu (copy/download **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 ## Labels