Page-tree parity for the public REST API and MCP (#110)
Some checks failed
CD / Build and push images (push) Successful in 3m57s
CD / Deploy to Test (push) Successful in 9s
CI / Lint, typecheck, test (push) Failing after 4m16s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 11s
Some checks failed
CD / Build and push images (push) Successful in 3m57s
CD / Deploy to Test (push) Successful in 9s
CI / Lint, typecheck, test (push) Failing after 4m16s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 11s
The slug-based machine surfaces now see and shape the hierarchy: - REST: page list/detail carry parent (the parent page's slug, nulled when the token's user may not read it — same no-leak rule as the internal list); create accepts parent; PATCH accepts parent (slug nests, null moves to the top level, appended at the end of the new sibling group via the new PagesService.moveToEnd). Cycle/depth refusals keep their regular error codes. OpenAPI updated. - MCP: list_pages returns parent, create_page takes an optional parent slug, update_page moves with parent (slug|null); tool errors carry the api code (page_cycle covered in the e2e pack). - ZIP export deliberately stays flat — noted in features.md; the hierarchy is organizational only. e2e: REST pack covers nested create, list shape, move/root-move, 409 page_cycle, 404 unknown parent; MCP pack covers nested create, list parent, and the cycle tool error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
0308bc712d
commit
ffcc337ed0
@ -25,6 +25,7 @@ export const MCP_TOOL_INPUTS = {
|
||||
pond,
|
||||
title: z.string().min(1).max(200),
|
||||
markdown: z.string().default(''),
|
||||
parent: page.optional(),
|
||||
}),
|
||||
update_page: z
|
||||
.object({
|
||||
@ -32,10 +33,13 @@ export const MCP_TOOL_INPUTS = {
|
||||
page,
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
markdown: z.string().optional(),
|
||||
parent: page.nullable().optional(),
|
||||
})
|
||||
.refine((input) => input.title !== undefined || input.markdown !== undefined, {
|
||||
message: 'title or markdown required',
|
||||
}),
|
||||
.refine(
|
||||
(input) =>
|
||||
input.title !== undefined || input.markdown !== undefined || input.parent !== undefined,
|
||||
{ message: 'title, markdown, or parent required' },
|
||||
),
|
||||
add_comment: z.object({ pond, page, text: z.string().min(1) }),
|
||||
list_labels: z.object({ pond }),
|
||||
set_page_labels: z.object({ pond, page, labelIds: z.array(z.string()) }),
|
||||
@ -62,7 +66,9 @@ export const MCP_TOOL_DEFINITIONS: {
|
||||
},
|
||||
{
|
||||
name: 'list_pages',
|
||||
description: 'List the readable pages of a pond: slug, title, labels, timestamps.',
|
||||
description:
|
||||
'List the readable pages of a pond: slug, title, parent (the page tree), labels, ' +
|
||||
'timestamps.',
|
||||
inputSchema: { type: 'object', properties: { pond: pondProp }, required: ['pond'] },
|
||||
},
|
||||
{
|
||||
@ -100,6 +106,10 @@ export const MCP_TOOL_DEFINITIONS: {
|
||||
pond: pondProp,
|
||||
title: { type: 'string', maxLength: 200 },
|
||||
markdown: { type: 'string', description: 'Initial content as Markdown' },
|
||||
parent: {
|
||||
type: 'string',
|
||||
description: 'Optional parent page slug — nests the new page under it',
|
||||
},
|
||||
},
|
||||
required: ['pond', 'title'],
|
||||
},
|
||||
@ -118,6 +128,11 @@ export const MCP_TOOL_DEFINITIONS: {
|
||||
page: pageProp,
|
||||
title: { type: 'string', maxLength: 200 },
|
||||
markdown: { type: 'string', description: 'Replacement content as Markdown' },
|
||||
parent: {
|
||||
type: ['string', 'null'],
|
||||
description:
|
||||
'Move the page in the tree: a page slug nests it, null moves it to the top level',
|
||||
},
|
||||
},
|
||||
required: ['pond', 'page'],
|
||||
},
|
||||
|
||||
@ -269,6 +269,30 @@ describe.skipIf(!hasTestDb)('mcp endpoint (e2e, issue #105)', () => {
|
||||
});
|
||||
expect(JSON.parse(textOf(link)).url).toContain(`/api/public/v1/ponds/${pondSlug}/export`);
|
||||
|
||||
// Page-tree parity (issue #110): create nested, list carries the parent
|
||||
// slug, moving into the own subtree is a tool error with the api code.
|
||||
const nested = await client.callTool({
|
||||
name: 'create_page',
|
||||
arguments: { pond: pondSlug, title: `MCP Child ${suffix}`, parent: page.slug },
|
||||
});
|
||||
expect(nested.isError).toBeFalsy();
|
||||
const childPage = JSON.parse(textOf(nested)) as { slug: string; parent: string | null };
|
||||
expect(childPage.parent).toBe(page.slug);
|
||||
|
||||
const pages = await client.callTool({
|
||||
name: 'list_pages',
|
||||
arguments: { pond: pondSlug },
|
||||
});
|
||||
const items = JSON.parse(textOf(pages)) as { slug: string; parent: string | null }[];
|
||||
expect(items.find((p) => p.slug === childPage.slug)?.parent).toBe(page.slug);
|
||||
|
||||
const cyclic = await client.callTool({
|
||||
name: 'update_page',
|
||||
arguments: { pond: pondSlug, page: page.slug, parent: childPage.slug },
|
||||
});
|
||||
expect(cyclic.isError).toBe(true);
|
||||
expect(textOf(cyclic)).toContain('page_cycle');
|
||||
|
||||
await client.close();
|
||||
});
|
||||
|
||||
|
||||
@ -89,7 +89,10 @@ export class McpService {
|
||||
name: Name,
|
||||
args: z.infer<(typeof MCP_TOOL_INPUTS)[Name]>,
|
||||
): Promise<ToolResult> {
|
||||
const input = args as Record<string, string> & { labelIds?: string[] };
|
||||
const input = args as Record<string, string> & {
|
||||
labelIds?: string[];
|
||||
parent?: string | null;
|
||||
};
|
||||
switch (name) {
|
||||
case 'list_ponds':
|
||||
return asJson(await this.publicApi.listPonds(user, token, 'mcp'));
|
||||
@ -101,7 +104,7 @@ export class McpService {
|
||||
case 'read_page':
|
||||
await this.assertPondExposed(input.pond!, token);
|
||||
await this.requirePage(user, input.pond!, input.page!, 'read');
|
||||
return asJson(await this.publicApi.getPage(input.pond!, input.page!));
|
||||
return asJson(await this.publicApi.getPage(user, input.pond!, input.page!));
|
||||
|
||||
case 'search': {
|
||||
if (input.pond) await this.assertPondExposed(input.pond, token);
|
||||
@ -123,6 +126,7 @@ export class McpService {
|
||||
await this.publicApi.createPage(user, token, input.pond!, {
|
||||
title: input.title!,
|
||||
markdown: input.markdown ?? '',
|
||||
parent: input.parent ?? undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
@ -134,6 +138,7 @@ export class McpService {
|
||||
await this.publicApi.updatePage(user, token, input.pond!, input.page!, {
|
||||
title: input.title,
|
||||
markdown: input.markdown,
|
||||
parent: input.parent,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@ -224,8 +224,24 @@ export class PagesService {
|
||||
pondId: string,
|
||||
title: string,
|
||||
state: Uint8Array<ArrayBuffer>,
|
||||
parentId: string | null = null,
|
||||
): Promise<Page> {
|
||||
return this.insertPage(user, pondId, title, state);
|
||||
return this.insertPage(user, pondId, title, state, parentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a page to a new parent, appended at the end of the pond order
|
||||
* (issue #110): the placement clients cannot express when they only speak
|
||||
* slugs (public API, MCP). Same validation path as {@link reposition}.
|
||||
*/
|
||||
async moveToEnd(user: User, id: string, parentId: string | null): Promise<PageView> {
|
||||
const page = await this.findLivePage(id);
|
||||
const last = await this.prisma.page.findFirst({
|
||||
where: { pondId: page.pondId, deletedAt: null, id: { not: id } },
|
||||
orderBy: { sortKey: 'desc' },
|
||||
select: { id: true },
|
||||
});
|
||||
return this.reposition(user, id, { afterId: last?.id ?? null, beforeId: null, parentId });
|
||||
}
|
||||
|
||||
private async insertPage(
|
||||
|
||||
@ -101,6 +101,12 @@ export function buildOpenApiDocument(): object {
|
||||
properties: {
|
||||
slug: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
parent: {
|
||||
type: ['string', 'null'],
|
||||
description:
|
||||
'Parent page slug in the page tree; null at the root or when the parent ' +
|
||||
'is not readable for this token.',
|
||||
},
|
||||
labels: { type: 'array', items: { type: 'string' } },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
updatedAt: { type: 'string', format: 'date-time' },
|
||||
@ -112,6 +118,10 @@ export function buildOpenApiDocument(): object {
|
||||
slug: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
pondSlug: { type: 'string' },
|
||||
parent: {
|
||||
type: ['string', 'null'],
|
||||
description: 'Parent page slug; see PageListItem.parent.',
|
||||
},
|
||||
markdown: { type: 'string' },
|
||||
html: { type: 'string', description: 'Server-rendered, sanitized HTML.' },
|
||||
labels: { type: 'array', items: { type: 'string' } },
|
||||
@ -125,6 +135,10 @@ export function buildOpenApiDocument(): object {
|
||||
properties: {
|
||||
title: { type: 'string', maxLength: 200 },
|
||||
markdown: { type: 'string', description: 'Initial content; empty allowed.' },
|
||||
parent: {
|
||||
type: 'string',
|
||||
description: 'Parent page slug — nests the new page under it (max depth 6).',
|
||||
},
|
||||
},
|
||||
},
|
||||
UpdatePage: {
|
||||
@ -138,6 +152,13 @@ export function buildOpenApiDocument(): object {
|
||||
'Replaces the whole content. Applied through the collaborative document, ' +
|
||||
'so open editors converge; the response may briefly lag the change.',
|
||||
},
|
||||
parent: {
|
||||
type: ['string', 'null'],
|
||||
description:
|
||||
'Move the page in the tree: a page slug nests it (appended to the new ' +
|
||||
'sibling group), null moves it to the top level. Moving a page into its ' +
|
||||
'own subtree or past the depth limit is rejected (409).',
|
||||
},
|
||||
},
|
||||
},
|
||||
SearchResult: {
|
||||
|
||||
@ -109,8 +109,9 @@ export class PublicApiController {
|
||||
getPage(
|
||||
@Param('pondSlug') pondSlug: string,
|
||||
@Param('pageSlug') pageSlug: string,
|
||||
@Req() request: PublicApiRequest,
|
||||
): Promise<PublicPageView> {
|
||||
return this.publicApi.getPage(pondSlug, pageSlug);
|
||||
return this.publicApi.getPage(request.user!, pondSlug, pageSlug);
|
||||
}
|
||||
|
||||
@Patch('ponds/:pondSlug/pages/:pageSlug')
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
type ApiTokenCreatedView,
|
||||
type ApiTokenView,
|
||||
type LabelView,
|
||||
type PublicPageListItemView,
|
||||
type PublicPageView,
|
||||
type PublicPondView,
|
||||
type PublicSearchResultView,
|
||||
@ -405,6 +406,60 @@ describe.skipIf(!hasTestDb)('public api v1 (e2e, issue #104)', () => {
|
||||
expect(ops).toEqual(expect.arrayContaining(['page_created', 'page_updated', 'page_trashed']));
|
||||
});
|
||||
|
||||
it('exposes and shapes the page tree through parent slugs (issue #110)', async () => {
|
||||
const parent = await pub()
|
||||
.post(`/api/public/v1/ponds/${pondSlug}/pages`)
|
||||
.set('Authorization', bearer('editor'))
|
||||
.send({ title: `Tree Parent ${suffix}` })
|
||||
.expect(201);
|
||||
const parentSlug = (parent.body as PublicPageView).slug;
|
||||
|
||||
// Create nested; the response and the list carry the parent slug.
|
||||
const child = await pub()
|
||||
.post(`/api/public/v1/ponds/${pondSlug}/pages`)
|
||||
.set('Authorization', bearer('editor'))
|
||||
.send({ title: `Tree Child ${suffix}`, parent: parentSlug })
|
||||
.expect(201);
|
||||
const childView = child.body as PublicPageView;
|
||||
expect(childView.parent).toBe(parentSlug);
|
||||
const listed = await pub()
|
||||
.get(`/api/public/v1/ponds/${pondSlug}/pages`)
|
||||
.set('Authorization', bearer('reader'))
|
||||
.expect(200);
|
||||
const listedChild = listed.body.find(
|
||||
(p: { slug: string }) => p.slug === childView.slug,
|
||||
) as PublicPageListItemView;
|
||||
expect(listedChild.parent).toBe(parentSlug);
|
||||
|
||||
// Move to the root and back via PATCH parent.
|
||||
const rooted = await pub()
|
||||
.patch(`/api/public/v1/ponds/${pondSlug}/pages/${childView.slug}`)
|
||||
.set('Authorization', bearer('editor'))
|
||||
.send({ parent: null })
|
||||
.expect(200);
|
||||
expect((rooted.body as PublicPageView).parent).toBeNull();
|
||||
await pub()
|
||||
.patch(`/api/public/v1/ponds/${pondSlug}/pages/${childView.slug}`)
|
||||
.set('Authorization', bearer('editor'))
|
||||
.send({ parent: parentSlug })
|
||||
.expect(200);
|
||||
|
||||
// A cycle is refused with the regular error code.
|
||||
const cycle = await pub()
|
||||
.patch(`/api/public/v1/ponds/${pondSlug}/pages/${parentSlug}`)
|
||||
.set('Authorization', bearer('editor'))
|
||||
.send({ parent: childView.slug })
|
||||
.expect(409);
|
||||
expect(cycle.body.code).toBe('page_cycle');
|
||||
|
||||
// An unknown parent slug reads as 404 on create.
|
||||
await pub()
|
||||
.post(`/api/public/v1/ponds/${pondSlug}/pages`)
|
||||
.set('Authorization', bearer('editor'))
|
||||
.send({ title: 'Orphan', parent: `missing-${suffix}` })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('manages labels with pond-admin rights and assigns them to pages', async () => {
|
||||
const label = await pub()
|
||||
.post(`/api/public/v1/ponds/${pondSlug}/labels`)
|
||||
|
||||
@ -33,6 +33,7 @@ import { CommentsService } from '../comments/comments.service';
|
||||
import { LabelsService } from '../labels/labels.service';
|
||||
import { docToState } from '../pages/yjs-content';
|
||||
import { PagesService } from '../pages/pages.service';
|
||||
import { PermissionService } from '../permissions/permission.service';
|
||||
import { PondsService } from '../ponds/ponds.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SearchProvider } from '../search/search.provider';
|
||||
@ -59,6 +60,7 @@ export class PublicApiService {
|
||||
private readonly search: SearchProvider,
|
||||
private readonly tokens: ApiTokensService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly permissions: PermissionService,
|
||||
) {}
|
||||
|
||||
async me(user: User, token: ApiToken): Promise<PublicMeView> {
|
||||
@ -97,26 +99,32 @@ export class PublicApiService {
|
||||
this.pages.list(user, pond.id),
|
||||
this.labelNames(pond.id),
|
||||
]);
|
||||
// parentId is already permission-nulled by the list (#106); mapping it
|
||||
// to a slug within the same filtered list keeps that guarantee.
|
||||
const slugById = new Map(items.map((item) => [item.id, item.slug]));
|
||||
return items.map((item) => ({
|
||||
slug: item.slug,
|
||||
title: item.title,
|
||||
parent: (item.parentId && slugById.get(item.parentId)) || null,
|
||||
labels: item.labelIds.map((id) => labelNames.get(id) ?? id).sort(),
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async getPage(pondSlug: string, pageSlug: string): Promise<PublicPageView> {
|
||||
async getPage(user: User, pondSlug: string, pageSlug: string): Promise<PublicPageView> {
|
||||
const page = await this.requirePage(pondSlug, pageSlug);
|
||||
const [cache, pageLabels, labelNames] = await Promise.all([
|
||||
const [cache, pageLabels, labelNames, parent] = await Promise.all([
|
||||
this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }),
|
||||
this.prisma.pageLabel.findMany({ where: { pageId: page.id }, select: { labelId: true } }),
|
||||
this.labelNames(page.pondId),
|
||||
this.readableParentSlug(user, page),
|
||||
]);
|
||||
return {
|
||||
slug: page.slug,
|
||||
title: page.title,
|
||||
pondSlug,
|
||||
parent,
|
||||
markdown: cache?.markdown ?? '',
|
||||
html: cache?.html ?? '',
|
||||
labels: pageLabels.map((row) => labelNames.get(row.labelId) ?? row.labelId).sort(),
|
||||
@ -132,10 +140,11 @@ export class PublicApiService {
|
||||
input: PublicCreatePageInput,
|
||||
): Promise<PublicPageView> {
|
||||
const pond = await this.requirePond(pondSlug);
|
||||
const parentId = input.parent ? (await this.requirePage(pondSlug, input.parent)).id : null;
|
||||
const state = this.stateFromMarkdown(input.markdown);
|
||||
const page = await this.pages.createWithState(user, pond.id, input.title, state);
|
||||
const page = await this.pages.createWithState(user, pond.id, input.title, state, parentId);
|
||||
await this.auditWrite(user, token, 'page_created', page.id);
|
||||
return this.getPage(pondSlug, page.slug);
|
||||
return this.getPage(user, pondSlug, page.slug);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -159,8 +168,15 @@ export class PublicApiService {
|
||||
const state = this.stateFromMarkdown(input.markdown);
|
||||
await this.versions.replaceContent(user, page.id, state, 'API update');
|
||||
}
|
||||
if (input.parent !== undefined) {
|
||||
// Move in the tree (issue #110): a slug nests, null goes to the root;
|
||||
// cycle/depth refusals surface as their regular error codes.
|
||||
const parentId =
|
||||
input.parent === null ? null : (await this.requirePage(pondSlug, input.parent)).id;
|
||||
await this.pages.moveToEnd(user, page.id, parentId);
|
||||
}
|
||||
await this.auditWrite(user, token, 'page_updated', page.id);
|
||||
return this.getPage(pondSlug, page.slug);
|
||||
return this.getPage(user, pondSlug, page.slug);
|
||||
}
|
||||
|
||||
async deletePage(user: User, token: ApiToken, pondSlug: string, pageSlug: string): Promise<void> {
|
||||
@ -359,6 +375,18 @@ export class PublicApiService {
|
||||
return page;
|
||||
}
|
||||
|
||||
/** The parent's slug, or null when there is none or the user may not read
|
||||
* it (the same no-leak rule as the internal list, issue #106). */
|
||||
private async readableParentSlug(user: User, page: Page): Promise<string | null> {
|
||||
if (!page.parentId) return null;
|
||||
const parent = await this.prisma.page.findFirst({
|
||||
where: { id: page.parentId, deletedAt: null },
|
||||
});
|
||||
if (!parent) return null;
|
||||
const readable = await this.permissions.canAccessPage(user, parent, 'read');
|
||||
return readable ? parent.slug : null;
|
||||
}
|
||||
|
||||
private async requireLabelInPond(pondSlug: string, labelId: string): Promise<void> {
|
||||
const label = await this.prisma.label.findFirst({
|
||||
where: { id: labelId, pond: { slug: pondSlug, deletedAt: null } },
|
||||
|
||||
@ -39,6 +39,9 @@ projects.
|
||||
|
||||
## Organize the way you think
|
||||
|
||||
- **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)
|
||||
- **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
|
||||
|
||||
@ -46,7 +46,7 @@ and any pond restriction.
|
||||
# The ponds this token can reach
|
||||
curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds
|
||||
|
||||
# Pages of a pond: slug, title, labels, timestamps
|
||||
# Pages of a pond: slug, title, parent (page-tree slug), labels, timestamps
|
||||
curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds/team/pages
|
||||
|
||||
# One page — Markdown source AND rendered, sanitized HTML
|
||||
@ -66,12 +66,13 @@ slugs for follow-up calls.
|
||||
## Writing (requires the `write` scope)
|
||||
|
||||
```sh
|
||||
# Create a page from Markdown
|
||||
# Create a page from Markdown (optional "parent": a page slug nests it)
|
||||
curl -H "$AUTH" -H 'Content-Type: application/json' \
|
||||
-d '{"title": "Meeting notes", "markdown": "# Agenda\n\n- Ducks\n"}' \
|
||||
https://wiki.example.com/api/public/v1/ponds/team/pages
|
||||
|
||||
# Rename and/or REPLACE the content
|
||||
# Rename, REPLACE the content, and/or move in the page tree
|
||||
# ("parent": <slug> nests the page, "parent": null moves it to the top level)
|
||||
curl -X PATCH -H "$AUTH" -H 'Content-Type: application/json' \
|
||||
-d '{"markdown": "New content."}' \
|
||||
https://wiki.example.com/api/public/v1/ponds/team/pages/meeting-notes
|
||||
|
||||
@ -61,18 +61,18 @@ clients bridge with `mcp-remote`:
|
||||
|
||||
## What the assistant can do
|
||||
|
||||
| Tool | Does |
|
||||
| -------------------------------------------- | ------------------------------------------- |
|
||||
| `list_ponds` | the ponds this token can reach |
|
||||
| `list_pages(pond)` | pages with slug, title, labels, timestamps |
|
||||
| `read_page(pond, page)` | a page as Markdown plus metadata |
|
||||
| `search(query, pond?, label?)` | full-text search with snippets |
|
||||
| `create_page(pond, title, markdown)` | new page from Markdown _(write)_ |
|
||||
| `update_page(pond, page, markdown?, title?)` | rename and/or replace content _(write)_ |
|
||||
| `add_comment(pond, page, text)` | comment on a page _(write)_ |
|
||||
| `list_labels(pond)` | the pond's label tree |
|
||||
| `set_page_labels(pond, page, labelIds)` | replace a page's labels _(write)_ |
|
||||
| `export_pond(pond)` | a download link for the Markdown-ZIP export |
|
||||
| Tool | Does |
|
||||
| ----------------------------------------------------- | ------------------------------------------- |
|
||||
| `list_ponds` | the ponds this token can reach |
|
||||
| `list_pages(pond)` | pages with slug, title, parent, labels |
|
||||
| `read_page(pond, page)` | a page as Markdown plus metadata |
|
||||
| `search(query, pond?, label?)` | full-text search with snippets |
|
||||
| `create_page(pond, title, markdown, parent?)` | new page from Markdown _(write)_ |
|
||||
| `update_page(pond, page, markdown?, title?, parent?)` | rename, replace content, move _(write)_ |
|
||||
| `add_comment(pond, page, text)` | comment on a page _(write)_ |
|
||||
| `list_labels(pond)` | the pond's label tree |
|
||||
| `set_page_labels(pond, page, labelIds)` | replace a page's labels _(write)_ |
|
||||
| `export_pond(pond)` | a download link for the Markdown-ZIP export |
|
||||
|
||||
Content updates travel the same collaborative path as human edits: open
|
||||
editors converge live, and the previous state stays in the version
|
||||
|
||||
@ -29,6 +29,9 @@ export interface PublicPondView {
|
||||
export interface PublicPageListItemView {
|
||||
slug: string;
|
||||
title: string;
|
||||
/** Parent page slug in the tree (issue #110), or null at the root — nulled
|
||||
* as well when the token's user may not read the parent (no existence leak). */
|
||||
parent: string | null;
|
||||
labels: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@ -38,6 +41,8 @@ export interface PublicPageView {
|
||||
slug: string;
|
||||
title: string;
|
||||
pondSlug: string;
|
||||
/** Parent page slug (issue #110); see {@link PublicPageListItemView.parent}. */
|
||||
parent: string | null;
|
||||
markdown: string;
|
||||
html: string;
|
||||
labels: string[];
|
||||
@ -52,6 +57,8 @@ const MARKDOWN_MAX_BYTES = 2 * 1024 * 1024;
|
||||
export const publicCreatePageInputSchema = z.object({
|
||||
title: z.string().trim().min(1, 'validation.required').max(200, 'validation.tooLong'),
|
||||
markdown: z.string().max(MARKDOWN_MAX_BYTES).default(''),
|
||||
/** Parent page slug (issue #110) — nests the new page under it. */
|
||||
parent: z.string().min(1).optional(),
|
||||
});
|
||||
export type PublicCreatePageInput = z.infer<typeof publicCreatePageInputSchema>;
|
||||
|
||||
@ -60,11 +67,16 @@ export const publicUpdatePageInputSchema = z
|
||||
title: z.string().trim().min(1, 'validation.required').max(200, 'validation.tooLong'),
|
||||
/** Replace semantics: the whole content becomes this Markdown. */
|
||||
markdown: z.string().max(MARKDOWN_MAX_BYTES),
|
||||
/** Move in the tree (issue #110): a page slug nests, `null` moves to the
|
||||
* root; the page lands at the end of its new sibling group. */
|
||||
parent: z.string().min(1).nullable(),
|
||||
})
|
||||
.partial()
|
||||
.refine((input) => input.title !== undefined || input.markdown !== undefined, {
|
||||
message: 'validation.required',
|
||||
});
|
||||
.refine(
|
||||
(input) =>
|
||||
input.title !== undefined || input.markdown !== undefined || input.parent !== undefined,
|
||||
{ message: 'validation.required' },
|
||||
);
|
||||
export type PublicUpdatePageInput = z.infer<typeof publicUpdatePageInputSchema>;
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user