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
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:
parent
a72cb1b1c5
commit
14711a18c2
@ -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%';" | \
|
||||
|
||||
189
apps/web/e2e/graph.spec.ts
Normal file
189
apps/web/e2e/graph.spec.ts
Normal 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();
|
||||
});
|
||||
189
apps/web/e2e/page-tree.spec.ts
Normal file
189
apps/web/e2e/page-tree.spec.ts
Normal 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();
|
||||
});
|
||||
@ -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 —
|
||||
|
||||
@ -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": <slug> 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
|
||||
|
||||
@ -63,13 +63,13 @@ 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 |
|
||||
| `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)` | neue Seite aus Markdown _(write)_ |
|
||||
| `update_page(pond, page, markdown?, title?)` | umbenennen und/oder Inhalt ersetzen _(write)_ |
|
||||
| `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)_ |
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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/<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:
|
||||
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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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/<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
|
||||
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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user