Page hierarchy: parentId, create-under-parent, reparent (#106)
Some checks failed
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Successful in 3m53s
CD / Deploy to Test (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 4m16s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 14s
CI / Auth e2e pack (push) Has been cancelled
Some checks failed
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Successful in 3m53s
CD / Deploy to Test (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 4m16s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 14s
CI / Auth e2e pack (push) Has been cancelled
Pages form a tree via a nullable parent_id self-relation (SetNull backstop; the real trash/purge semantics follow with #107). Slugs and URLs stay flat and pond-unique, so moving a page never breaks links. - Shared: generic parent-id tree helpers in tree.ts (labels re-export them; buildLabelTree keeps its name-sorted behavior), MAX_PAGE_DEPTH=6, parentId on PageView, createPageInputSchema.parentId (nullish), repositionPageInputSchema.parentId (optional; absent = keep parent). - API: create validates the parent (same pond, live, depth); PATCH /pages/:id/position reparents atomically with the placement, rejecting cycles (page_cycle) and depth violations (page_depth_exceeded); GET /ponds/:id/pages nulls parentId when the caller may not read the parent, so hidden page ids never leak. - New error codes translated de+en; hierarchy.db.test.ts covers create, 404s, depth, cycle, atomic reparent, and the permission nulling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
a33b37f1a4
commit
eb6b0d5d02
@ -0,0 +1,8 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "pages" ADD COLUMN "parent_id" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "pages_parent_id_idx" ON "pages"("parent_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "pages" ADD CONSTRAINT "pages_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "pages"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@ -251,9 +251,16 @@ model RoleGrant {
|
|||||||
/// the merged state Y.Doc, decoded by the API to derive `PageContentCache`
|
/// the merged state Y.Doc, decoded by the API to derive `PageContentCache`
|
||||||
/// on every save (issue #23). `sortKey` uses fractional indexing so pages
|
/// on every save (issue #23). `sortKey` uses fractional indexing so pages
|
||||||
/// can be reordered without rewriting siblings (sidebar reorder is #26).
|
/// can be reordered without rewriting siblings (sidebar reorder is #26).
|
||||||
|
/// `parentId` nests pages into a tree (issue #106), mirroring the label
|
||||||
|
/// hierarchy (max 6 levels, enforced in the service; cycles rejected at write
|
||||||
|
/// time). Purely organizational: slugs stay flat and pond-unique, so moving a
|
||||||
|
/// page never changes its URL or breaks wikilinks. Trashed pages keep their
|
||||||
|
/// `parentId` (restore re-attaches to the nearest live ancestor, issue #107);
|
||||||
|
/// `SetNull` is only the FK backstop — purge promotes children explicitly.
|
||||||
model Page {
|
model Page {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
pondId String @map("pond_id")
|
pondId String @map("pond_id")
|
||||||
|
parentId String? @map("parent_id")
|
||||||
title String
|
title String
|
||||||
slug String
|
slug String
|
||||||
ydocState Bytes @map("ydoc_state")
|
ydocState Bytes @map("ydoc_state")
|
||||||
@ -265,6 +272,8 @@ model Page {
|
|||||||
deletedBy String? @map("deleted_by")
|
deletedBy String? @map("deleted_by")
|
||||||
|
|
||||||
pond Pond @relation(fields: [pondId], references: [id])
|
pond Pond @relation(fields: [pondId], references: [id])
|
||||||
|
parent Page? @relation("PageHierarchy", fields: [parentId], references: [id], onDelete: SetNull)
|
||||||
|
children Page[] @relation("PageHierarchy")
|
||||||
creator User @relation(fields: [createdBy], references: [id])
|
creator User @relation(fields: [createdBy], references: [id])
|
||||||
updates PageUpdate[]
|
updates PageUpdate[]
|
||||||
contentCache PageContentCache?
|
contentCache PageContentCache?
|
||||||
@ -279,6 +288,7 @@ model Page {
|
|||||||
|
|
||||||
@@unique([pondId, slug])
|
@@unique([pondId, slug])
|
||||||
@@index([pondId])
|
@@index([pondId])
|
||||||
|
@@index([parentId])
|
||||||
@@map("pages")
|
@@map("pages")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
233
apps/api/src/pages/hierarchy.db.test.ts
Normal file
233
apps/api/src/pages/hierarchy.db.test.ts
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
import { ConflictException, INestApplication, NotFoundException } from '@nestjs/common';
|
||||||
|
import { MAX_PAGE_DEPTH } from '@dorfteich/shared';
|
||||||
|
import { PrismaClient, User } from '@prisma/client';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createTestApp } from '../testing/test-app';
|
||||||
|
import { createTestPrisma, grantOwnerAdmin, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
import { PagesService } from './pages.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page hierarchy (issue #106): create-under-parent, reparent via reposition,
|
||||||
|
* cycle/depth rejection, and the permission rule that a child of an unreadable
|
||||||
|
* parent lists with `parentId: null` (no hidden-page id ever leaks).
|
||||||
|
*/
|
||||||
|
describe.skipIf(!hasTestDb)('Page hierarchy (db, issue #106)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let pages: PagesService;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
let owner: User;
|
||||||
|
let reader: User;
|
||||||
|
let pondId: string;
|
||||||
|
let secretLabelId: string;
|
||||||
|
|
||||||
|
async function createChain(titles: string[]): Promise<string[]> {
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const [index, title] of titles.entries()) {
|
||||||
|
const page = await pages.create(owner, pondId, {
|
||||||
|
title,
|
||||||
|
parentId: index === 0 ? null : ids[index - 1],
|
||||||
|
});
|
||||||
|
ids.push(page.id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
app = await createTestApp();
|
||||||
|
pages = app.get(PagesService);
|
||||||
|
|
||||||
|
owner = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: `tree-owner-${suffix}`,
|
||||||
|
email: `tree-owner-${suffix}@example.test`,
|
||||||
|
displayName: 'Tree Owner',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
reader = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: `tree-reader-${suffix}`,
|
||||||
|
email: `tree-reader-${suffix}@example.test`,
|
||||||
|
displayName: 'Tree Reader',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const pond = await prisma.pond.create({
|
||||||
|
data: {
|
||||||
|
slug: `tree-pond-${suffix}`,
|
||||||
|
name: 'Tree Pond',
|
||||||
|
type: 'SHARED',
|
||||||
|
ownerId: owner.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
pondId = pond.id;
|
||||||
|
await grantOwnerAdmin(prisma, pondId, owner.id);
|
||||||
|
|
||||||
|
// Reader grants BEFORE any permission resolution touches the pond — the
|
||||||
|
// PondPermissionCache would otherwise serve the pre-grant state.
|
||||||
|
const secret = await prisma.label.create({
|
||||||
|
data: { pondId, name: 'secret', color: '#334455' },
|
||||||
|
});
|
||||||
|
secretLabelId = secret.id;
|
||||||
|
await prisma.roleGrant.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
pondId,
|
||||||
|
subjectType: 'USER',
|
||||||
|
subjectId: reader.id,
|
||||||
|
role: 'READER',
|
||||||
|
scopeType: 'POND',
|
||||||
|
effect: 'ALLOW',
|
||||||
|
createdBy: owner.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pondId,
|
||||||
|
subjectType: 'USER',
|
||||||
|
subjectId: reader.id,
|
||||||
|
role: 'READER',
|
||||||
|
scopeType: 'LABEL',
|
||||||
|
scopeId: secret.id,
|
||||||
|
effect: 'DENY',
|
||||||
|
createdBy: owner.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.roleGrant.deleteMany({ where: { pondId } });
|
||||||
|
await prisma.label.deleteMany({ where: { pondId } });
|
||||||
|
await prisma.page.deleteMany({ where: { pondId } });
|
||||||
|
await prisma.pond.deleteMany({ where: { id: pondId } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: [owner.id, reader.id] } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a page under a parent and at the root', async () => {
|
||||||
|
const root = await pages.create(owner, pondId, { title: 'Root' });
|
||||||
|
expect(root.parentId).toBeNull();
|
||||||
|
|
||||||
|
const child = await pages.create(owner, pondId, { title: 'Child', parentId: root.id });
|
||||||
|
expect(child.parentId).toBe(root.id);
|
||||||
|
|
||||||
|
const list = await pages.list(owner, pondId);
|
||||||
|
expect(list.find((p) => p.id === child.id)?.parentId).toBe(root.id);
|
||||||
|
expect(list.find((p) => p.id === root.id)?.parentId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown, foreign, or trashed parent as 404', async () => {
|
||||||
|
await expect(
|
||||||
|
pages.create(owner, pondId, { title: 'Orphan', parentId: 'no-such-page' }),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
|
||||||
|
const foreignPond = await prisma.pond.create({
|
||||||
|
data: {
|
||||||
|
slug: `tree-foreign-${suffix}`,
|
||||||
|
name: 'Foreign',
|
||||||
|
type: 'PERSONAL',
|
||||||
|
ownerId: owner.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await grantOwnerAdmin(prisma, foreignPond.id, owner.id);
|
||||||
|
const foreignPage = await pages.create(owner, foreignPond.id, { title: 'Elsewhere' });
|
||||||
|
await expect(
|
||||||
|
pages.create(owner, pondId, { title: 'Crossing', parentId: foreignPage.id }),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
|
||||||
|
const doomed = await pages.create(owner, pondId, { title: 'Doomed' });
|
||||||
|
await pages.softDelete(owner, doomed.id);
|
||||||
|
await expect(
|
||||||
|
pages.create(owner, pondId, { title: 'Under trash', parentId: doomed.id }),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
|
||||||
|
await prisma.page.deleteMany({ where: { pondId: foreignPond.id } });
|
||||||
|
await prisma.roleGrant.deleteMany({ where: { pondId: foreignPond.id } });
|
||||||
|
await prisma.pond.delete({ where: { id: foreignPond.id } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`rejects nesting beyond ${MAX_PAGE_DEPTH} levels on create`, async () => {
|
||||||
|
const chain = await createChain(
|
||||||
|
Array.from({ length: MAX_PAGE_DEPTH }, (_, i) => `Deep ${i + 1}`),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
pages.create(owner, pondId, { title: 'Too deep', parentId: chain[MAX_PAGE_DEPTH - 1] }),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reparents atomically through the position endpoint', async () => {
|
||||||
|
const a = await pages.create(owner, pondId, { title: 'Move A' });
|
||||||
|
const b = await pages.create(owner, pondId, { title: 'Move B' });
|
||||||
|
const child = await pages.create(owner, pondId, { title: 'Move child', parentId: a.id });
|
||||||
|
|
||||||
|
// Drag onto B: reparent + append, one call.
|
||||||
|
const moved = await pages.reposition(owner, child.id, {
|
||||||
|
afterId: b.id,
|
||||||
|
beforeId: null,
|
||||||
|
parentId: b.id,
|
||||||
|
});
|
||||||
|
expect(moved.parentId).toBe(b.id);
|
||||||
|
|
||||||
|
// Back to the root with `parentId: null`.
|
||||||
|
const rooted = await pages.reposition(owner, child.id, {
|
||||||
|
afterId: null,
|
||||||
|
beforeId: a.id,
|
||||||
|
parentId: null,
|
||||||
|
});
|
||||||
|
expect(rooted.parentId).toBeNull();
|
||||||
|
|
||||||
|
// An absent `parentId` keeps the current parent (plain reorder).
|
||||||
|
const reordered = await pages.reposition(owner, child.id, { afterId: b.id, beforeId: null });
|
||||||
|
expect(reordered.parentId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a move into the page own subtree as page_cycle', async () => {
|
||||||
|
const [top, , grandchild] = await createChain(['Cycle 1', 'Cycle 2', 'Cycle 3']);
|
||||||
|
let caught: unknown;
|
||||||
|
await pages
|
||||||
|
.reposition(owner, top!, { afterId: null, beforeId: null, parentId: grandchild! })
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
caught = error;
|
||||||
|
});
|
||||||
|
expect(caught).toBeInstanceOf(ConflictException);
|
||||||
|
expect((caught as ConflictException).getResponse()).toMatchObject({ code: 'page_cycle' });
|
||||||
|
|
||||||
|
// Self-parenting is the trivial cycle.
|
||||||
|
await expect(
|
||||||
|
pages.reposition(owner, top!, { afterId: null, beforeId: null, parentId: top! }),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a move that pushes the subtree past the depth limit', async () => {
|
||||||
|
const deep = await createChain(['Limit 1', 'Limit 2', 'Limit 3', 'Limit 4']);
|
||||||
|
const [subtreeTop] = await createChain(['Tall 1', 'Tall 2', 'Tall 3']);
|
||||||
|
|
||||||
|
let caught: unknown;
|
||||||
|
await pages
|
||||||
|
.reposition(owner, subtreeTop!, { afterId: null, beforeId: null, parentId: deep[3]! })
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
caught = error;
|
||||||
|
});
|
||||||
|
expect(caught).toBeInstanceOf(ConflictException);
|
||||||
|
expect((caught as ConflictException).getResponse()).toMatchObject({
|
||||||
|
code: 'page_depth_exceeded',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nulls the parentId of a child whose parent the caller may not read', async () => {
|
||||||
|
const hidden = await pages.create(owner, pondId, { title: 'Hidden parent' });
|
||||||
|
await prisma.pageLabel.create({ data: { pageId: hidden.id, labelId: secretLabelId } });
|
||||||
|
const child = await pages.create(owner, pondId, {
|
||||||
|
title: 'Visible child',
|
||||||
|
parentId: hidden.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerList = await pages.list(owner, pondId);
|
||||||
|
expect(ownerList.find((p) => p.id === child.id)?.parentId).toBe(hidden.id);
|
||||||
|
|
||||||
|
const readerList = await pages.list(reader, pondId);
|
||||||
|
expect(readerList.some((p) => p.id === hidden.id)).toBe(false);
|
||||||
|
expect(readerList.find((p) => p.id === child.id)?.parentId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -2,6 +2,7 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common
|
|||||||
import {
|
import {
|
||||||
CollabTokenResponse,
|
CollabTokenResponse,
|
||||||
CreatePageInput,
|
CreatePageInput,
|
||||||
|
MAX_PAGE_DEPTH,
|
||||||
OutlineEntry,
|
OutlineEntry,
|
||||||
PageListItemView,
|
PageListItemView,
|
||||||
PageStateView,
|
PageStateView,
|
||||||
@ -9,9 +10,13 @@ import {
|
|||||||
PluginPageSummary,
|
PluginPageSummary,
|
||||||
RepositionPageInput,
|
RepositionPageInput,
|
||||||
SidebarSortMode,
|
SidebarSortMode,
|
||||||
|
TreeItem,
|
||||||
UpdatePageInput,
|
UpdatePageInput,
|
||||||
|
collectSubtreeIds,
|
||||||
|
nodeDepth,
|
||||||
pondSettingsSchema,
|
pondSettingsSchema,
|
||||||
slugify,
|
slugify,
|
||||||
|
subtreeHeight,
|
||||||
} from '@dorfteich/shared';
|
} from '@dorfteich/shared';
|
||||||
import { signCollabToken } from '@dorfteich/shared/token-crypto';
|
import { signCollabToken } from '@dorfteich/shared/token-crypto';
|
||||||
import { Page, Prisma, User } from '@prisma/client';
|
import { Page, Prisma, User } from '@prisma/client';
|
||||||
@ -59,6 +64,7 @@ export class PagesService {
|
|||||||
return {
|
return {
|
||||||
id: page.id,
|
id: page.id,
|
||||||
pondId: page.pondId,
|
pondId: page.pondId,
|
||||||
|
parentId: page.parentId,
|
||||||
title: page.title,
|
title: page.title,
|
||||||
slug: page.slug,
|
slug: page.slug,
|
||||||
sortKey: page.sortKey,
|
sortKey: page.sortKey,
|
||||||
@ -97,6 +103,36 @@ export class PagesService {
|
|||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The live `{id, parentId}` skeleton of a pond — input to the tree walks
|
||||||
|
* (issue #106). Trashed pages keep their `parentId` but never count here. */
|
||||||
|
private async livePageTree(pondId: string): Promise<TreeItem[]> {
|
||||||
|
return this.prisma.page.findMany({
|
||||||
|
where: { pondId, deletedAt: null },
|
||||||
|
select: { id: true, parentId: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a page's new parent (issue #106, mirroring `LabelsService`): it
|
||||||
|
* must be a live page of the pond (unknown/foreign ids read as 404), may not
|
||||||
|
* sit inside the moved page's own subtree (`page_cycle`), and the moved
|
||||||
|
* subtree must stay within {@link MAX_PAGE_DEPTH} (`page_depth_exceeded`).
|
||||||
|
* `movedId` is null when creating — the new page is a leaf of height 1.
|
||||||
|
*/
|
||||||
|
private assertValidParent(tree: TreeItem[], parentId: string, movedId: string | null): void {
|
||||||
|
if (!tree.some((p) => p.id === parentId)) throw new NotFoundException();
|
||||||
|
if (movedId && collectSubtreeIds(tree, movedId).has(parentId)) {
|
||||||
|
throw new ConflictException({ code: 'page_cycle' });
|
||||||
|
}
|
||||||
|
const height = movedId ? subtreeHeight(tree, movedId) : 1;
|
||||||
|
if (nodeDepth(tree, parentId) + height > MAX_PAGE_DEPTH) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'page_depth_exceeded',
|
||||||
|
details: { max: [String(MAX_PAGE_DEPTH)] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static readonly SORT_ORDER: Record<SidebarSortMode, Prisma.PageOrderByWithRelationInput> =
|
private static readonly SORT_ORDER: Record<SidebarSortMode, Prisma.PageOrderByWithRelationInput> =
|
||||||
{
|
{
|
||||||
alpha: { title: 'asc' },
|
alpha: { title: 'asc' },
|
||||||
@ -129,6 +165,9 @@ export class PagesService {
|
|||||||
.filter((page) => readable.has(page.id))
|
.filter((page) => readable.has(page.id))
|
||||||
.map((page) => ({
|
.map((page) => ({
|
||||||
...this.viewOf(page),
|
...this.viewOf(page),
|
||||||
|
// A parent the caller may not read is nulled (issue #106): the child
|
||||||
|
// shows at the root and the hidden page's id never leaks.
|
||||||
|
parentId: page.parentId && readable.has(page.parentId) ? page.parentId : null,
|
||||||
labelIds: page.labels.map((l) => l.labelId),
|
labelIds: page.labels.map((l) => l.labelId),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@ -154,7 +193,13 @@ export class PagesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(user: User, pondId: string, input: CreatePageInput): Promise<PageView> {
|
async create(user: User, pondId: string, input: CreatePageInput): Promise<PageView> {
|
||||||
const page = await this.insertPage(user, pondId, input.title, emptyPageState());
|
const page = await this.insertPage(
|
||||||
|
user,
|
||||||
|
pondId,
|
||||||
|
input.title,
|
||||||
|
emptyPageState(),
|
||||||
|
input.parentId ?? null,
|
||||||
|
);
|
||||||
// Auto-watch own pages (issue #93) — preference-gated, never fatal.
|
// Auto-watch own pages (issue #93) — preference-gated, never fatal.
|
||||||
await this.watches.autoWatchPage(user, page.id, 'ownPage').catch(() => {});
|
await this.watches.autoWatchPage(user, page.id, 'ownPage').catch(() => {});
|
||||||
return this.viewOf(page);
|
return this.viewOf(page);
|
||||||
@ -182,9 +227,11 @@ export class PagesService {
|
|||||||
pondId: string,
|
pondId: string,
|
||||||
title: string,
|
title: string,
|
||||||
state: Uint8Array<ArrayBuffer>,
|
state: Uint8Array<ArrayBuffer>,
|
||||||
|
parentId: string | null = null,
|
||||||
): Promise<Page> {
|
): Promise<Page> {
|
||||||
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
|
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
|
||||||
if (!pond) throw new NotFoundException();
|
if (!pond) throw new NotFoundException();
|
||||||
|
if (parentId) this.assertValidParent(await this.livePageTree(pond.id), parentId, null);
|
||||||
|
|
||||||
const slug = await this.generateUniqueSlugInPond(pond.id, title);
|
const slug = await this.generateUniqueSlugInPond(pond.id, title);
|
||||||
const last = await this.prisma.page.findFirst({
|
const last = await this.prisma.page.findFirst({
|
||||||
@ -198,6 +245,7 @@ export class PagesService {
|
|||||||
const page = await this.prisma.page.create({
|
const page = await this.prisma.page.create({
|
||||||
data: {
|
data: {
|
||||||
pondId: pond.id,
|
pondId: pond.id,
|
||||||
|
parentId,
|
||||||
title,
|
title,
|
||||||
slug,
|
slug,
|
||||||
sortKey,
|
sortKey,
|
||||||
@ -296,13 +344,16 @@ export class PagesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reposition a page in the manual sidebar order (issue #45). Recomputes only
|
* Reposition a page in the manual sidebar order (issue #45) and/or move it to
|
||||||
* the moved page's `sort_key` to a value between its two new neighbours; when
|
* a new parent in the page tree (issue #106). Recomputes only the moved
|
||||||
* that key would grow too long (or the client's neighbours are stale) the
|
* page's `sort_key` to a value between its two new neighbours; when that key
|
||||||
* whole pond is rebalanced to evenly-spaced keys with the page dropped at the
|
* would grow too long (or the client's neighbours are stale) the whole pond
|
||||||
* target slot. The order is server-authoritative, so every viewer sees the
|
* is rebalanced to evenly-spaced keys with the page dropped at the target
|
||||||
* same sequence. Requires write access; the sort mode does not have to be
|
* slot. Per-sibling-group order needs no extra machinery: a key between two
|
||||||
* `manual` (the key is stored regardless, just not applied in other modes).
|
* siblings keeps the group's relative order under the pond-wide sequence.
|
||||||
|
* The order is server-authoritative, so every viewer sees the same sequence.
|
||||||
|
* Requires write access; the sort mode does not have to be `manual` (the key
|
||||||
|
* is stored regardless, just not applied in other modes).
|
||||||
*/
|
*/
|
||||||
async reposition(_user: User, id: string, input: RepositionPageInput): Promise<PageView> {
|
async reposition(_user: User, id: string, input: RepositionPageInput): Promise<PageView> {
|
||||||
const page = await this.findLivePage(id);
|
const page = await this.findLivePage(id);
|
||||||
@ -311,6 +362,13 @@ export class PagesService {
|
|||||||
throw new ConflictException({ code: 'bad_request' });
|
throw new ConflictException({ code: 'bad_request' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An absent `parentId` leaves the parent untouched; a present one (page id
|
||||||
|
// or null-for-root) reparents atomically with the placement (issue #106).
|
||||||
|
const parentId = input.parentId === page.parentId ? undefined : input.parentId;
|
||||||
|
if (parentId != null) {
|
||||||
|
this.assertValidParent(await this.livePageTree(page.pondId), parentId, id);
|
||||||
|
}
|
||||||
|
|
||||||
const [afterPage, beforePage] = await Promise.all([
|
const [afterPage, beforePage] = await Promise.all([
|
||||||
afterId
|
afterId
|
||||||
? this.prisma.page.findFirst({
|
? this.prisma.page.findFirst({
|
||||||
@ -330,10 +388,13 @@ export class PagesService {
|
|||||||
|
|
||||||
const key = nextKeyOrRebalance(afterPage?.sortKey ?? null, beforePage?.sortKey ?? null);
|
const key = nextKeyOrRebalance(afterPage?.sortKey ?? null, beforePage?.sortKey ?? null);
|
||||||
if (key !== null) {
|
if (key !== null) {
|
||||||
const updated = await this.prisma.page.update({ where: { id }, data: { sortKey: key } });
|
const updated = await this.prisma.page.update({
|
||||||
|
where: { id },
|
||||||
|
data: { sortKey: key, parentId },
|
||||||
|
});
|
||||||
return this.viewOf(updated);
|
return this.viewOf(updated);
|
||||||
}
|
}
|
||||||
return this.rebalanceAndPlace(page.pondId, id, afterId, beforeId);
|
return this.rebalanceAndPlace(page.pondId, id, afterId, beforeId, parentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -346,6 +407,7 @@ export class PagesService {
|
|||||||
movedId: string,
|
movedId: string,
|
||||||
afterId: string | null,
|
afterId: string | null,
|
||||||
beforeId: string | null,
|
beforeId: string | null,
|
||||||
|
parentId?: string | null,
|
||||||
): Promise<PageView> {
|
): Promise<PageView> {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const pages = await tx.page.findMany({
|
const pages = await tx.page.findMany({
|
||||||
@ -361,7 +423,12 @@ export class PagesService {
|
|||||||
|
|
||||||
const keys = evenlySpacedKeys(order.length);
|
const keys = evenlySpacedKeys(order.length);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
order.map((pid, i) => tx.page.update({ where: { id: pid }, data: { sortKey: keys[i]! } })),
|
order.map((pid, i) =>
|
||||||
|
tx.page.update({
|
||||||
|
where: { id: pid },
|
||||||
|
data: { sortKey: keys[i]!, ...(pid === movedId ? { parentId } : {}) },
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
this.logger.info({ pondId, movedId, pages: order.length }, 'audit: sort keys rebalanced');
|
this.logger.info({ pondId, movedId, pages: order.length }, 'audit: sort keys rebalanced');
|
||||||
return this.viewOf(await tx.page.findUniqueOrThrow({ where: { id: movedId } }));
|
return this.viewOf(await tx.page.findUniqueOrThrow({ where: { id: movedId } }));
|
||||||
|
|||||||
@ -23,6 +23,8 @@
|
|||||||
"page_document_too_large": "Die Seite ist zu groß (Limit: {{limitBytes}} Bytes).",
|
"page_document_too_large": "Die Seite ist zu groß (Limit: {{limitBytes}} Bytes).",
|
||||||
"invalid_page_state": "Der übermittelte Seiteninhalt ist ungültig.",
|
"invalid_page_state": "Der übermittelte Seiteninhalt ist ungültig.",
|
||||||
"page_trashed": "Diese Seite wurde in den Papierkorb verschoben.",
|
"page_trashed": "Diese Seite wurde in den Papierkorb verschoben.",
|
||||||
|
"page_cycle": "Eine Seite kann nicht in ihren eigenen Teilbaum verschoben werden.",
|
||||||
|
"page_depth_exceeded": "Seiten lassen sich höchstens {{max}} Ebenen tief verschachteln.",
|
||||||
"label_name_taken": "Ein Label mit diesem Namen existiert auf dieser Ebene bereits.",
|
"label_name_taken": "Ein Label mit diesem Namen existiert auf dieser Ebene bereits.",
|
||||||
"label_cycle": "Ein Label kann nicht in seinen eigenen Teilbaum verschoben werden.",
|
"label_cycle": "Ein Label kann nicht in seinen eigenen Teilbaum verschoben werden.",
|
||||||
"label_depth_exceeded": "Labels lassen sich höchstens {{max}} Ebenen tief verschachteln.",
|
"label_depth_exceeded": "Labels lassen sich höchstens {{max}} Ebenen tief verschachteln.",
|
||||||
|
|||||||
@ -23,6 +23,8 @@
|
|||||||
"page_document_too_large": "The page is too large (limit: {{limitBytes}} bytes).",
|
"page_document_too_large": "The page is too large (limit: {{limitBytes}} bytes).",
|
||||||
"invalid_page_state": "The submitted page content is invalid.",
|
"invalid_page_state": "The submitted page content is invalid.",
|
||||||
"page_trashed": "This page has been moved to the trash.",
|
"page_trashed": "This page has been moved to the trash.",
|
||||||
|
"page_cycle": "A page cannot be moved into its own subtree.",
|
||||||
|
"page_depth_exceeded": "Pages can be nested at most {{max}} levels deep.",
|
||||||
"label_name_taken": "A label with this name already exists at this level.",
|
"label_name_taken": "A label with this name already exists at this level.",
|
||||||
"label_cycle": "A label cannot be moved into its own subtree.",
|
"label_cycle": "A label cannot be moved into its own subtree.",
|
||||||
"label_depth_exceeded": "Labels can be nested at most {{max}} levels deep.",
|
"label_depth_exceeded": "Labels can be nested at most {{max}} levels deep.",
|
||||||
|
|||||||
@ -30,4 +30,5 @@ export * from './ponds';
|
|||||||
export * from './public-api';
|
export * from './public-api';
|
||||||
export * from './quotas';
|
export * from './quotas';
|
||||||
export * from './text-diff';
|
export * from './text-diff';
|
||||||
|
export * from './tree';
|
||||||
export * from './watches';
|
export * from './watches';
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { buildTree, nodeDepth } from './tree';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Label schemas, views, and hierarchy helpers shared between api and web
|
* Label schemas, views, and hierarchy helpers shared between api and web
|
||||||
* (issue #43). Labels organize pages within a pond and form a tree via
|
* (issue #43). Labels organize pages within a pond and form a tree via
|
||||||
@ -90,19 +92,7 @@ function byName(a: LabelView, b: LabelView): number {
|
|||||||
* resolver, which all need the same hierarchy.
|
* resolver, which all need the same hierarchy.
|
||||||
*/
|
*/
|
||||||
export function buildLabelTree(labels: LabelView[]): LabelTreeNode[] {
|
export function buildLabelTree(labels: LabelView[]): LabelTreeNode[] {
|
||||||
const nodes = new Map<string, LabelTreeNode>();
|
return buildTree(labels, byName);
|
||||||
for (const label of labels) nodes.set(label.id, { ...label, children: [] });
|
|
||||||
|
|
||||||
const roots: LabelTreeNode[] = [];
|
|
||||||
for (const node of nodes.values()) {
|
|
||||||
const parent = node.parentId ? nodes.get(node.parentId) : undefined;
|
|
||||||
if (parent) parent.children.push(node);
|
|
||||||
else roots.push(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const node of nodes.values()) node.children.sort(byName);
|
|
||||||
roots.sort(byName);
|
|
||||||
return roots;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -122,87 +112,18 @@ export function flattenLabelTree(nodes: LabelTreeNode[]): LabelView[] {
|
|||||||
return flat;
|
return flat;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Indexes labels by id → parentId for the ancestor/descendant walks below. */
|
|
||||||
function parentIndex(labels: LabelView[]): Map<string, string | null> {
|
|
||||||
const index = new Map<string, string | null>();
|
|
||||||
for (const label of labels) index.set(label.id, label.parentId);
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ids of a label and all its descendants (its whole subtree). Used to reject a
|
* The subtree/ancestor/depth walks moved to the generic tree helpers (issue
|
||||||
* move that would create a cycle (the new parent may not be inside the subtree)
|
* #106) — labels and pages share one implementation. Re-exported here so the
|
||||||
* and to gather the labels a delete removes. Robust against malformed cycles in
|
* label API keeps its established import surface; `labelDepth` keeps its name
|
||||||
* the input: every id is visited at most once.
|
* because permission resolution documents it (permissions.md §label scope).
|
||||||
*/
|
*/
|
||||||
export function collectSubtreeIds(labels: LabelView[], rootId: string): Set<string> {
|
export { collectAncestorIds, collectSubtreeIds, subtreeHeight } from './tree';
|
||||||
const childrenOf = new Map<string, string[]>();
|
|
||||||
for (const label of labels) {
|
|
||||||
if (!label.parentId) continue;
|
|
||||||
const siblings = childrenOf.get(label.parentId) ?? [];
|
|
||||||
siblings.push(label.id);
|
|
||||||
childrenOf.set(label.parentId, siblings);
|
|
||||||
}
|
|
||||||
|
|
||||||
const subtree = new Set<string>();
|
|
||||||
const stack = [rootId];
|
|
||||||
while (stack.length > 0) {
|
|
||||||
const id = stack.pop()!;
|
|
||||||
if (subtree.has(id)) continue;
|
|
||||||
subtree.add(id);
|
|
||||||
for (const child of childrenOf.get(id) ?? []) stack.push(child);
|
|
||||||
}
|
|
||||||
return subtree;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ancestor ids of a label, nearest first (its parent, grandparent, …). This is
|
|
||||||
* what permission resolution needs: a grant on any ancestor of a page's label
|
|
||||||
* applies to the label too (permissions.md §label scope). Stops on a missing
|
|
||||||
* parent or a cycle, so it always terminates.
|
|
||||||
*/
|
|
||||||
export function collectAncestorIds(labels: LabelView[], labelId: string): string[] {
|
|
||||||
const parents = parentIndex(labels);
|
|
||||||
const ancestors: string[] = [];
|
|
||||||
const seen = new Set<string>([labelId]);
|
|
||||||
let current = parents.get(labelId) ?? null;
|
|
||||||
while (current && !seen.has(current)) {
|
|
||||||
ancestors.push(current);
|
|
||||||
seen.add(current);
|
|
||||||
current = parents.get(current) ?? null;
|
|
||||||
}
|
|
||||||
return ancestors;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Depth of a label as a 1-based level (a root label is 1). Derived from the
|
* Depth of a label as a 1-based level (a root label is 1). Derived from the
|
||||||
* ancestor chain, so it is bounded even if the input is malformed.
|
* ancestor chain, so it is bounded even if the input is malformed.
|
||||||
*/
|
*/
|
||||||
export function labelDepth(labels: LabelView[], labelId: string): number {
|
export function labelDepth(labels: LabelView[], labelId: string): number {
|
||||||
return collectAncestorIds(labels, labelId).length + 1;
|
return nodeDepth(labels, labelId);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Height of a label's subtree in levels (a leaf is 1, a label with children 2,
|
|
||||||
* …). Combined with a target parent's depth it tells us whether a move keeps
|
|
||||||
* the whole subtree within {@link MAX_LABEL_DEPTH}.
|
|
||||||
*/
|
|
||||||
export function subtreeHeight(labels: LabelView[], rootId: string): number {
|
|
||||||
const childrenOf = new Map<string, LabelView[]>();
|
|
||||||
for (const label of labels) {
|
|
||||||
if (!label.parentId) continue;
|
|
||||||
const siblings = childrenOf.get(label.parentId) ?? [];
|
|
||||||
siblings.push(label);
|
|
||||||
childrenOf.set(label.parentId, siblings);
|
|
||||||
}
|
|
||||||
|
|
||||||
const heightFrom = (id: string, seen: Set<string>): number => {
|
|
||||||
if (seen.has(id)) return 0;
|
|
||||||
seen.add(id);
|
|
||||||
const children = childrenOf.get(id) ?? [];
|
|
||||||
let max = 0;
|
|
||||||
for (const child of children) max = Math.max(max, heightFrom(child.id, seen));
|
|
||||||
return max + 1;
|
|
||||||
};
|
|
||||||
return heightFrom(rootId, new Set());
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,8 +18,18 @@ export const pageSlugSchema = z
|
|||||||
.min(1, 'validation.required')
|
.min(1, 'validation.required')
|
||||||
.max(60, 'validation.tooLong');
|
.max(60, 'validation.tooLong');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum nesting depth of the page tree (issue #106), counted as levels like
|
||||||
|
* {@link MAX_LABEL_DEPTH}: a root page is level 1. Creating or moving a page
|
||||||
|
* whose deepest descendant would exceed this is rejected.
|
||||||
|
*/
|
||||||
|
export const MAX_PAGE_DEPTH = 6;
|
||||||
|
|
||||||
export const createPageInputSchema = z.object({
|
export const createPageInputSchema = z.object({
|
||||||
title: pageTitleSchema,
|
title: pageTitleSchema,
|
||||||
|
/** Parent page id for a nested page (issue #106); omitted/null creates at
|
||||||
|
* the root level. Must be a live page of the same pond. */
|
||||||
|
parentId: z.string().min(1).nullish(),
|
||||||
});
|
});
|
||||||
export type CreatePageInput = z.infer<typeof createPageInputSchema>;
|
export type CreatePageInput = z.infer<typeof createPageInputSchema>;
|
||||||
|
|
||||||
@ -40,6 +50,13 @@ export type UpdatePageInput = z.infer<typeof updatePageInputSchema>;
|
|||||||
export const repositionPageInputSchema = z.object({
|
export const repositionPageInputSchema = z.object({
|
||||||
afterId: z.string().min(1).nullable(),
|
afterId: z.string().min(1).nullable(),
|
||||||
beforeId: z.string().min(1).nullable(),
|
beforeId: z.string().min(1).nullable(),
|
||||||
|
/**
|
||||||
|
* New parent for the page (issue #106): a page id nests it, `null` moves it
|
||||||
|
* to the root level, and an absent field keeps the current parent — so the
|
||||||
|
* one endpoint covers plain reordering, drag-onto-a-page, and the "Move
|
||||||
|
* to…" dialog atomically. Cycles and depth violations are rejected.
|
||||||
|
*/
|
||||||
|
parentId: z.string().min(1).nullable().optional(),
|
||||||
});
|
});
|
||||||
export type RepositionPageInput = z.infer<typeof repositionPageInputSchema>;
|
export type RepositionPageInput = z.infer<typeof repositionPageInputSchema>;
|
||||||
|
|
||||||
@ -56,6 +73,13 @@ export const MAX_PAGE_DOCUMENT_BYTES = 5 * 1024 * 1024;
|
|||||||
export interface PageView {
|
export interface PageView {
|
||||||
id: string;
|
id: string;
|
||||||
pondId: string;
|
pondId: string;
|
||||||
|
/**
|
||||||
|
* Parent page in the tree (issue #106), or `null` at the root. In list
|
||||||
|
* responses the server nulls this when the caller may not read the parent,
|
||||||
|
* so a permission-sliced view never leaks a hidden page's id — the child
|
||||||
|
* then simply renders at the root level.
|
||||||
|
*/
|
||||||
|
parentId: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
sortKey: string;
|
sortKey: string;
|
||||||
|
|||||||
128
packages/shared/src/tree.ts
Normal file
128
packages/shared/src/tree.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Generic parent-id tree helpers (issue #106). Labels (issue #43) and pages
|
||||||
|
* (issue #106) both form a forest via a nullable `parentId`; these helpers are
|
||||||
|
* the single implementation of the subtree/ancestor/depth walks both trees
|
||||||
|
* share. All of them are robust against malformed input (missing parents,
|
||||||
|
* cycles): every node is visited at most once, so they always terminate.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The minimal shape a tree helper needs: an id and a nullable parent id. */
|
||||||
|
export interface TreeItem {
|
||||||
|
id: string;
|
||||||
|
parentId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tree item with its children nested, as {@link buildTree} produces it. */
|
||||||
|
export type TreeNode<T extends TreeItem> = T & { children: TreeNode<T>[] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nests a flat list into a forest of {@link TreeNode}s. An item whose
|
||||||
|
* `parentId` is not present in the list is treated as a root, so no rows are
|
||||||
|
* ever dropped — that is also the deliberate fallback for a child whose parent
|
||||||
|
* the server hid from the caller (issue #106 permission rule). Siblings keep
|
||||||
|
* the input order unless a `compare` function is given; the page sidebar
|
||||||
|
* relies on input order because the list arrives in the pond's sort order.
|
||||||
|
*/
|
||||||
|
export function buildTree<T extends TreeItem>(
|
||||||
|
items: T[],
|
||||||
|
compare?: (a: T, b: T) => number,
|
||||||
|
): TreeNode<T>[] {
|
||||||
|
const nodes = new Map<string, TreeNode<T>>();
|
||||||
|
for (const item of items) nodes.set(item.id, { ...item, children: [] });
|
||||||
|
|
||||||
|
const roots: TreeNode<T>[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const node = nodes.get(item.id)!;
|
||||||
|
const parent = item.parentId ? nodes.get(item.parentId) : undefined;
|
||||||
|
if (parent && parent !== node) parent.children.push(node);
|
||||||
|
else roots.push(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compare) {
|
||||||
|
for (const node of nodes.values()) node.children.sort(compare);
|
||||||
|
roots.sort(compare);
|
||||||
|
}
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Indexes items by id → parentId for the ancestor walks below. */
|
||||||
|
function parentIndex(items: TreeItem[]): Map<string, string | null> {
|
||||||
|
const index = new Map<string, string | null>();
|
||||||
|
for (const item of items) index.set(item.id, item.parentId);
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ids of an item and all its descendants (its whole subtree). Used to reject a
|
||||||
|
* move that would create a cycle (the new parent may not be inside the moved
|
||||||
|
* subtree) and to gather what a subtree-wide operation covers.
|
||||||
|
*/
|
||||||
|
export function collectSubtreeIds(items: TreeItem[], rootId: string): Set<string> {
|
||||||
|
const childrenOf = new Map<string, string[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.parentId) continue;
|
||||||
|
const siblings = childrenOf.get(item.parentId) ?? [];
|
||||||
|
siblings.push(item.id);
|
||||||
|
childrenOf.set(item.parentId, siblings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtree = new Set<string>();
|
||||||
|
const stack = [rootId];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const id = stack.pop()!;
|
||||||
|
if (subtree.has(id)) continue;
|
||||||
|
subtree.add(id);
|
||||||
|
for (const child of childrenOf.get(id) ?? []) stack.push(child);
|
||||||
|
}
|
||||||
|
return subtree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ancestor ids of an item, nearest first (its parent, grandparent, …). Stops
|
||||||
|
* on a missing parent or a cycle.
|
||||||
|
*/
|
||||||
|
export function collectAncestorIds(items: TreeItem[], itemId: string): string[] {
|
||||||
|
const parents = parentIndex(items);
|
||||||
|
const ancestors: string[] = [];
|
||||||
|
const seen = new Set<string>([itemId]);
|
||||||
|
let current = parents.get(itemId) ?? null;
|
||||||
|
while (current && !seen.has(current)) {
|
||||||
|
ancestors.push(current);
|
||||||
|
seen.add(current);
|
||||||
|
current = parents.get(current) ?? null;
|
||||||
|
}
|
||||||
|
return ancestors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Depth of an item as a 1-based level (a root is 1). Derived from the ancestor
|
||||||
|
* chain, so it is bounded even if the input is malformed.
|
||||||
|
*/
|
||||||
|
export function nodeDepth(items: TreeItem[], itemId: string): number {
|
||||||
|
return collectAncestorIds(items, itemId).length + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Height of an item's subtree in levels (a leaf is 1, an item with children 2,
|
||||||
|
* …). Combined with a target parent's depth it tells us whether a move keeps
|
||||||
|
* the whole subtree within the depth limit.
|
||||||
|
*/
|
||||||
|
export function subtreeHeight(items: TreeItem[], rootId: string): number {
|
||||||
|
const childrenOf = new Map<string, TreeItem[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.parentId) continue;
|
||||||
|
const siblings = childrenOf.get(item.parentId) ?? [];
|
||||||
|
siblings.push(item);
|
||||||
|
childrenOf.set(item.parentId, siblings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const heightFrom = (id: string, seen: Set<string>): number => {
|
||||||
|
if (seen.has(id)) return 0;
|
||||||
|
seen.add(id);
|
||||||
|
const children = childrenOf.get(id) ?? [];
|
||||||
|
let max = 0;
|
||||||
|
for (const child of children) max = Math.max(max, heightFrom(child.id, seen));
|
||||||
|
return max + 1;
|
||||||
|
};
|
||||||
|
return heightFrom(rootId, new Set());
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user