From 1ee39033deda3bf8c5354b3267c5ede6644cb478 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:09:33 -0400 Subject: [PATCH 1/2] Add sidebar command work checkpoint --- .../actions/get-document-sidebar-commands.ts | 108 ++++++ .../components/editor/database/sidebar.tsx | 279 +++++++------- .../sidebar/DocumentTreeItem.test.tsx | 11 +- .../components/sidebar/DocumentTreeItem.tsx | 359 +++++++++--------- .../sidebar/SidebarCommandDialog.test.tsx | 191 ++++++++++ .../sidebar/SidebarCommandDialog.tsx | 306 +++++++++++++++ .../app/components/sidebar/SidebarRowMenu.tsx | 256 +++++++++++++ .../sidebar/sidebar-commands.test.ts | 27 ++ .../components/sidebar/sidebar-commands.ts | 42 ++ .../app/components/ui/context-menu.tsx | 1 + templates/content/app/i18n-data.ts | 4 + templates/content/app/i18n/zh-TW.ts | 3 + .../content/app/sidebar-command-messages.ts | 248 ++++++++++++ templates/content/shared/sidebar-commands.ts | 12 + 14 files changed, 1500 insertions(+), 347 deletions(-) create mode 100644 templates/content/actions/get-document-sidebar-commands.ts create mode 100644 templates/content/app/components/sidebar/SidebarCommandDialog.test.tsx create mode 100644 templates/content/app/components/sidebar/SidebarCommandDialog.tsx create mode 100644 templates/content/app/components/sidebar/SidebarRowMenu.tsx create mode 100644 templates/content/app/components/sidebar/sidebar-commands.test.ts create mode 100644 templates/content/app/components/sidebar/sidebar-commands.ts create mode 100644 templates/content/app/components/ui/context-menu.tsx create mode 100644 templates/content/app/sidebar-command-messages.ts create mode 100644 templates/content/shared/sidebar-commands.ts diff --git a/templates/content/actions/get-document-sidebar-commands.ts b/templates/content/actions/get-document-sidebar-commands.ts new file mode 100644 index 00000000000..b94014961db --- /dev/null +++ b/templates/content/actions/get-document-sidebar-commands.ts @@ -0,0 +1,108 @@ +import { defineAction } from "@agent-native/core/action"; +import { accessFilter, assertAccess, roleSatisfies } from "@agent-native/core/sharing"; +import { and, asc, eq, inArray, isNull, notExists, or } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import type { SidebarCommandsResponse } from "../shared/sidebar-commands.js"; + +function nativePageFilters(db: ReturnType) { + const document = schema.documents; + const nativeSource = and( + or(isNull(document.sourceMode), eq(document.sourceMode, "database")), + isNull(document.sourceKind), + isNull(document.sourcePath), + isNull(document.sourceRootPath), + notExists(db.select({ id: schema.documentSyncLinks.documentId }) + .from(schema.documentSyncLinks) + .where(eq(schema.documentSyncLinks.documentId, document.id))), + notExists(db.select({ id: schema.contentDatabaseSourceRows.id }) + .from(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.documentId, document.id))), + ); + const pageType = and( + notExists(db.select({ id: schema.contentDatabases.id }) + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.documentId, document.id))), + notExists(db.select({ id: schema.contentSpaceCatalogItems.id }) + .from(schema.contentSpaceCatalogItems) + .where(eq(schema.contentSpaceCatalogItems.documentId, document.id))), + ); + return { nativeSource, pageType }; +} + +export default defineAction({ + description: "Read authorized native Page rename and move eligibility and optionally complete same-section move destinations.", + agentTool: false, + schema: z.object({ + documentId: z.string().min(1).describe("Page whose sidebar commands are being opened"), + includeDestinations: z.union([z.boolean(), z.enum(["true", "false"])]) + .transform((value) => value === true || value === "true") + .default(false) + .describe("Include the complete list of authorized native Page move destinations"), + }), + http: { method: "GET" }, + readOnly: true, + run: async ({ documentId, includeDestinations }): Promise => { + const access = await assertAccess("document", documentId, "viewer", undefined, { skipResourceBody: true }); + const db = getDb(); + const [document] = await db.select({ + id: schema.documents.id, + title: schema.documents.title, + ownerEmail: schema.documents.ownerEmail, + spaceId: schema.documents.spaceId, + parentId: schema.documents.parentId, + orgId: schema.documents.orgId, + visibility: schema.documents.visibility, + trashedAt: schema.documents.trashedAt, + }).from(schema.documents).where(eq(schema.documents.id, documentId)); + if (!document) throw new Error("Document no longer exists"); + const result: SidebarCommandsResponse = { + documentId, title: document.title, writeReason: null, canMoveToRoot: false, destinations: [], + }; + if (!roleSatisfies(access.role, "editor") || document.trashedAt !== null) { + result.writeReason = "readOnly"; + return result; + } + const { nativeSource, pageType } = nativePageFilters(db); + const [nativeRows, pageRows] = await Promise.all([ + db.select({ id: schema.documents.id }).from(schema.documents).where(and(eq(schema.documents.id, documentId), nativeSource)), + db.select({ id: schema.documents.id }).from(schema.documents).where(and(eq(schema.documents.id, documentId), pageType)), + ]); + if (nativeRows.length === 0 || pageRows.length === 0) { + result.writeReason = nativeRows.length === 0 ? "sourceUnsupported" : "typeUnsupported"; + return result; + } + result.canMoveToRoot = document.parentId !== null; + if (!includeDestinations) return result; + + // Match move-document's descendant walk even across inaccessible intermediate + // pages. Only opaque IDs enter this internal traversal; titles stay scoped. + const descendants = new Set([documentId]); + let frontier = [documentId]; + while (frontier.length > 0) { + const next: string[] = []; + for (let offset = 0; offset < frontier.length; offset += 200) { + const children = await db.select({ id: schema.documents.id }).from(schema.documents) + .where(and(eq(schema.documents.ownerEmail, document.ownerEmail), inArray(schema.documents.parentId, frontier.slice(offset, offset + 200)))); + for (const child of children) { + if (!descendants.has(child.id)) { descendants.add(child.id); next.push(child.id); } + } + } + frontier = next; + } + const candidates = await db.select({ id: schema.documents.id, title: schema.documents.title, parentId: schema.documents.parentId }) + .from(schema.documents) + .where(and( + accessFilter(schema.documents, schema.documentShares, undefined, "editor"), + eq(schema.documents.ownerEmail, document.ownerEmail), + document.spaceId === null ? isNull(schema.documents.spaceId) : eq(schema.documents.spaceId, document.spaceId), + document.orgId === null ? isNull(schema.documents.orgId) : eq(schema.documents.orgId, document.orgId), + eq(schema.documents.visibility, document.visibility), + isNull(schema.documents.trashedAt), nativeSource, pageType, + )) + .orderBy(asc(schema.documents.title), asc(schema.documents.id)); + result.destinations = candidates.filter((candidate) => !descendants.has(candidate.id)); + return result; + }, +}); diff --git a/templates/content/app/components/editor/database/sidebar.tsx b/templates/content/app/components/editor/database/sidebar.tsx index b63ef0044e5..599d8783b88 100644 --- a/templates/content/app/components/editor/database/sidebar.tsx +++ b/templates/content/app/components/editor/database/sidebar.tsx @@ -11,18 +11,16 @@ import { IconChevronDown, IconChevronRight, IconDatabase, - IconDots, IconFileText, IconFolder, IconFolderOpen, IconPlus, - IconPin, - IconTrash, } from "@tabler/icons-react"; import { useEffect, useState, type MouseEvent, type ReactNode } from "react"; import { Link } from "react-router"; import { documentSidebarActionAvailability } from "@/components/sidebar/document-sidebar-actions"; +import { SidebarRowMenu } from "@/components/sidebar/SidebarRowMenu"; import { Button } from "@/components/ui/button"; import { Collapsible, @@ -33,7 +31,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -720,11 +717,13 @@ function DatabaseSidebarRow({ }; }) { const t = useT(); - const { canEdit, canManage, canFavorite, hasMenuActions } = - documentSidebarActionAvailability(item.document, { + const { canEdit, canManage, canFavorite } = documentSidebarActionAvailability( + item.document, + { favoriteAvailable: Boolean(onToggleFavorite), manageAvailable: Boolean(onDeleteItem), - }); + }, + ); const canCreateChild = canEdit && Boolean(onCreateChildPage); function handleClick(event: MouseEvent) { if ( @@ -782,163 +781,139 @@ function DatabaseSidebarRow({ } return ( - <> -
- {hasChildren ? ( - + ) : null} + event.currentTarget.blur()} - onClick={() => onToggleExpanded?.(!expanded)} + aria-current={active ? "page" : undefined} > - - - ) : null} - event.currentTarget.blur()} - aria-current={active ? "page" : undefined} - > - - + - {title} - - + )} + > + {title} + + - {(hasMenuActions || canCreateChild) && ( -
- {hasMenuActions && ( - - - - - - {canFavorite && onToggleFavorite ? ( - onToggleFavorite(item)}> - - {item.document.isFavorite - ? t("sidebar.unpinFromSidebar") - : t("sidebar.pinToSidebar")} - - ) : null} - {canFavorite && - onToggleFavorite && - canManage && - onDeleteItem ? ( - - ) : null} - {canManage && onDeleteItem ? ( - onDeleteItem(item)} - > - - {t("database.delete")} - - ) : null} - - - )} + { +
+ {menuTrigger} - {canCreateChild ? ( - - - - - - - - {t("sidebar.addChild")} - - - onCreateChildPage?.(item)}> - - {t("sidebar.page")} - - {onCreateChildDatabase ? ( + {canCreateChild ? ( + + + + + + + + {t("sidebar.addChild")} + + onCreateChildDatabase(item)} + onSelect={() => onCreateChildPage?.(item)} > - - {t("sidebar.database")} + + {t("sidebar.page")} - ) : null} - - - ) : ( - - )} -
- )} -
- + {onCreateChildDatabase ? ( + onCreateChildDatabase(item)} + > + + {t("sidebar.database")} + + ) : null} + + + ) : ( + + )} +
+ } + + )} + ); } diff --git a/templates/content/app/components/sidebar/DocumentTreeItem.test.tsx b/templates/content/app/components/sidebar/DocumentTreeItem.test.tsx index f5c1b6f9fb3..b9831c4bd13 100644 --- a/templates/content/app/components/sidebar/DocumentTreeItem.test.tsx +++ b/templates/content/app/components/sidebar/DocumentTreeItem.test.tsx @@ -76,6 +76,7 @@ async function render(node: ReactNode) { database: { delete: "Delete" }, sidebar: { addChild: "Add child", + moreActionsFor: "More actions for {{label}}", addChildTo: "Add child to {{title}}", database: "Database", page: "Page", @@ -199,12 +200,12 @@ describe("sidebar document permission menus", () => { expect(onCreateChildDatabase).not.toHaveBeenCalled(); const menuItems = await openActions(container); - expect(menuItems.map((item) => item.textContent?.trim())).toEqual([ - "Pin to sidebar", - ]); + expect(menuItems.some((item) => item.textContent?.trim() === "Pin to sidebar")).toBe(true); + expect(menuItems.filter((item) => item.getAttribute("aria-disabled") === "true")).toHaveLength(2); + expect(menuItems.some((item) => item.textContent === "Delete")).toBe(false); await act(async () => { - menuItems[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + menuItems.find((item) => item.textContent?.trim() === "Pin to sidebar")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); }); expect(onToggleFavorite).toHaveBeenCalledOnce(); @@ -236,7 +237,7 @@ describe("sidebar document permission menus", () => { const menuItems = await openActions(container); expect( menuItems.map((item) => item.textContent?.trim().replace(/[.…]+$/, "")), - ).toEqual(expectedMenuItems); + ).toEqual(expect.arrayContaining([...expectedMenuItems])); expect(menuItems.some((item) => item.textContent === "Delete")).toBe( canManage, ); diff --git a/templates/content/app/components/sidebar/DocumentTreeItem.tsx b/templates/content/app/components/sidebar/DocumentTreeItem.tsx index d9d8e43aef4..21b3972aa60 100644 --- a/templates/content/app/components/sidebar/DocumentTreeItem.tsx +++ b/templates/content/app/components/sidebar/DocumentTreeItem.tsx @@ -13,17 +13,14 @@ import { IconFolder, IconFileText, IconPlus, - IconPin, - IconTrash, - IconDots, } from "@tabler/icons-react"; import { useState } from "react"; +import { Link } from "react-router"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { @@ -34,6 +31,7 @@ import { import { cn } from "@/lib/utils"; import { documentSidebarActionAvailability } from "./document-sidebar-actions"; +import { SidebarRowMenu } from "./SidebarRowMenu"; interface DocumentTreeItemProps { node: DocumentTreeNode; @@ -99,8 +97,10 @@ export function DocumentTreeItem({ const isActive = node.id === activeId; const isLocalFileNode = node.source?.mode === "local-files"; const isLocalFolder = isLocalFileNode && node.source?.kind === "folder"; - const { canEdit, canManage, canFavorite, hasMenuActions } = - documentSidebarActionAvailability(node, { favoriteAvailable: true }); + const { canEdit, canManage, canFavorite } = documentSidebarActionAvailability( + node, + { favoriteAvailable: true }, + ); const canCreateChild = canEdit && !isLocalFileNode; const [contextSheetOpen, setContextSheetOpen] = useState(false); const indent = depth * 12 + 12; @@ -127,203 +127,182 @@ export function DocumentTreeItem({ transition, }} > -
{ - if (isLocalFolder && hasChildren) { - onToggleExpanded(node.id); - return; - } - onSelect(node.id); - }} - aria-expanded={hasChildren ? expanded : undefined} + onToggleFavorite(node.id, !node.isFavorite) + : undefined + } + onDelete={ + canManage + ? () => onDelete(node.id, node.title || t("sidebar.untitled")) + : undefined + } + onAddContext={ + canEdit && !isLocalFileNode + ? () => setContextSheetOpen(true) + : undefined + } > - - ( +
- - - {hasChildren && ( - - )} - - - - {node.title || "Untitled"} - - -
e.stopPropagation()} - > - {hasMenuActions && ( - - + > + + + {hasChildren && ( - - - {canFavorite && ( - { - e.stopPropagation(); - onToggleFavorite(node.id, !node.isFavorite); - }} - > - - {node.isFavorite - ? t("sidebar.unpinFromSidebar") - : t("sidebar.pinToSidebar")} - - )} - {canFavorite && canManage && } - {canEdit && !isLocalFileNode && ( - { - event.preventDefault(); - event.stopPropagation(); - setContextSheetOpen(true); - }} - > - - {t("creativeContext.addToContext" /* i18n-key-ignore */)} - - )} - {canManage && ( - { - e.stopPropagation(); - onDelete(node.id, node.title || t("sidebar.untitled")); - }} - > - - {t("database.delete")} - - )} - - - )} - - {canCreateChild ? ( - - - - - - - - {t("sidebar.addChild")} - - - { - e.stopPropagation(); - onCreateChildPage(node.id); - }} - > - - {t("sidebar.page")} - - e.stopPropagation()} onClick={(e) => { e.stopPropagation(); - onCreateChildDatabase(node.id); + onToggleExpanded(node.id); }} > - - {t("sidebar.database")} - - - - ) : ( - + )} + + + {isLocalFolder ? ( + + ) : ( + { + if ( + event.button === 0 && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + !event.altKey + ) { + event.preventDefault(); + onSelect(node.id); + } + }} + > + {node.title || t("sidebar.untitled")} + + )} + +
e.stopPropagation()} > - - - )} -
-
+ {menuTrigger} + + {canCreateChild ? ( + + + + + + + + {t("sidebar.addChild")} + + + { + e.stopPropagation(); + onCreateChildPage(node.id); + }} + > + + {t("sidebar.page")} + + { + e.stopPropagation(); + onCreateChildDatabase(node.id); + }} + > + + {t("sidebar.database")} + + + + ) : ( + + )} +
+
+ )} + ({ + document: vi.fn(), + commands: vi.fn(), + update: vi.fn(), + move: vi.fn(), +})); + +vi.mock("@agent-native/core/client/i18n", () => ({ + useT: () => (key: string) => key, +})); +vi.mock("@agent-native/core/client/hooks", () => ({ + useActionQuery: mocks.commands, +})); +vi.mock("@/hooks/use-documents", () => ({ + useDocument: mocks.document, + useUpdateDocument: () => ({ mutateAsync: mocks.update, isPending: false }), + useMoveDocument: () => ({ mutateAsync: mocks.move, isPending: false }), +})); +vi.mock("@/components/editor/VisualEditor", () => ({ + VisualEditor: ({ content }: { content: string }) => ( +
{content}
+ ), +})); + +const initialDocument = { + id: "page", + title: "Stale sidebar title", + content: "Stale sidebar body", + parentId: null, + canEdit: true, +} as Document; + +let root: Root; +let container: HTMLDivElement; +let documentQuery: ReturnType; +let commandQuery: ReturnType; + +function loadedQuery(data: unknown) { + return { + data, + isLoading: false, + isFetching: false, + isFetchedAfterMount: true, + isError: false, + refetch: vi.fn(), + }; +} + +async function render(command: "preview" | "rename" | "move") { + await act(async () => { + root.render( + + + , + ); + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + documentQuery = loadedQuery({ + ...initialDocument, + title: "Cached secret title", + content: "Cached secret body", + }); + commandQuery = loadedQuery({ + title: "Authoritative current title", + writeReason: null, + destinations: [], + canMoveToRoot: true, + }); + mocks.document.mockImplementation(() => documentQuery); + mocks.commands.mockImplementation(() => commandQuery); + mocks.update.mockResolvedValue({ id: "page" }); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = false; +}); + +describe("sidebar command authority", () => { + it("withholds cached preview payload until the first authoritative fetch completes", async () => { + documentQuery.isFetching = true; + documentQuery.isFetchedAfterMount = false; + await render("preview"); + expect(document.body.textContent).not.toContain("Cached secret"); + expect(document.querySelector("[data-preview]")).toBeNull(); + + documentQuery = loadedQuery({ + ...initialDocument, + title: "Fresh title", + content: "Fresh body", + }); + await render("preview"); + await vi.waitFor(() => + expect(document.querySelector("[data-preview]")?.textContent).toBe( + "Fresh body", + ), + ); + expect(document.body.textContent).toContain("Fresh title"); + }); + + it("does not reveal cached title or body after an access failure", async () => { + documentQuery.isError = true; + await render("preview"); + expect(document.body.textContent).toContain("sidebarCommands.unavailable"); + expect(document.body.textContent).not.toContain("Cached secret"); + expect(document.querySelector("[data-preview]")).toBeNull(); + }); + + it("uses the authoritative rename title and preserves the user's edit across refetches", async () => { + await render("rename"); + const input = document.querySelector( + 'input[aria-label="sidebarCommands.name"]', + )!; + expect(input.value).toBe("Authoritative current title"); + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!.call(input, "My revised title"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + commandQuery = loadedQuery({ + title: "Another fetched title", + writeReason: null, + destinations: [], + canMoveToRoot: true, + }); + await render("rename"); + expect(input.value).toBe("My revised title"); + await act(async () => { + document + .querySelector("form")! + .dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + }); + expect(mocks.update).toHaveBeenCalledWith({ + id: "page", + title: "My revised title", + }); + }); + + it.each(["rename", "move"] as const)( + "never exposes a mutation for denied %s authority", + async (command) => { + commandQuery = loadedQuery({ + title: "Current title", + writeReason: "readOnly", + destinations: [{ id: "target", title: "Destination" }], + canMoveToRoot: true, + }); + await render(command); + expect(document.body.textContent).toContain("sidebarCommands.readOnly"); + expect(document.querySelector("form")).toBeNull(); + expect(document.querySelector("input")).toBeNull(); + await act(async () => { + for (const button of document.querySelectorAll("button")) + button.click(); + }); + expect(mocks.update).not.toHaveBeenCalled(); + expect(mocks.move).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/templates/content/app/components/sidebar/SidebarCommandDialog.tsx b/templates/content/app/components/sidebar/SidebarCommandDialog.tsx new file mode 100644 index 00000000000..936e744cce5 --- /dev/null +++ b/templates/content/app/components/sidebar/SidebarCommandDialog.tsx @@ -0,0 +1,306 @@ +import { useT } from "@agent-native/core/client/i18n"; +import { useActionQuery } from "@agent-native/core/client/hooks"; +import type { Document } from "@shared/api"; +import type { SidebarCommandsResponse } from "@shared/sidebar-commands"; +import { lazy, Suspense, useState } from "react"; +import { Link } from "react-router"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + useDocument, + useMoveDocument, + useUpdateDocument, +} from "@/hooks/use-documents"; + +import { + type SidebarCommandId, +} from "./sidebar-commands"; + +const PreviewEditor = lazy(async () => { + const module = await import("@/components/editor/VisualEditor"); + return { default: module.VisualEditor }; +}); + +function PreviewSkeleton() { + return ( + + ); +} + +export default function SidebarCommandDialog({ + document: initialDocument, + command, + onClose, + returnFocus, +}: { + document: Document; + command: SidebarCommandId; + onClose: () => void; + returnFocus: () => void; +}) { + const t = useT(); + const query = useDocument(initialDocument.id); + const commands = useActionQuery( + "get-document-sidebar-commands", + { documentId: initialDocument.id, includeDestinations: command === "move" }, + { enabled: command !== "preview", staleTime: 0, refetchOnMount: "always", retry: false }, + ); + const document = query.data; + const [editedTitle, setTitle] = useState(null); + const title = editedTitle ?? commands.data?.title ?? ""; + const [error, setError] = useState(null); + const update = useUpdateDocument(); + const move = useMoveDocument(); + const pending = update.isPending || move.isPending; + const awaitingDocument = query.isLoading || (query.isFetching && !query.isFetchedAfterMount); + const awaitingCommands = command !== "preview" && (commands.isLoading || (commands.isFetching && !commands.isFetchedAfterMount)); + const reason = commands.data?.writeReason; + const unavailable = query.isError || !document || document.canView === false || (command !== "preview" && (commands.isError || !commands.data)); + const close = () => { + if (!pending) onClose(); + }; + const restoreFocus = (event: Event) => { + event.preventDefault(); + returnFocus(); + }; + + async function rename() { + if (awaitingDocument || awaitingCommands || unavailable || reason || !title.trim()) return; + setError(null); + try { + const result = await update.mutateAsync({ + id: initialDocument.id, + title: title.trim(), + }); + if ("conflict" in result && result.conflict) { + setError(t("sidebarCommands.changed")); + return; + } + onClose(); + } catch { + setError(t("sidebarCommands.failed")); + } + } + + async function moveTo(parentId: string | null) { + if (awaitingDocument || awaitingCommands || unavailable || reason) return; + setError(null); + try { + await move.mutateAsync({ id: initialDocument.id, parentId }); + onClose(); + } catch { + setError(t("sidebarCommands.failed")); + } + } + + const status = awaitingDocument || awaitingCommands ? ( + + ) : unavailable ? ( +
+

{t("sidebarCommands.unavailable")}

+ +
+ ) : null; + + if (command === "preview") { + return ( + { + if (!open) close(); + }} + > + + + + {!unavailable && !awaitingDocument + ? document.title || t("sidebar.untitled") + : t("sidebarCommands.preview")} + + +
+ {status || + (document && ( + <> + + {document.source?.path && ( +

+ {document.source.path} +

+ )} + }> + {}} + localFileMode={document.source?.mode === "local-files"} + localFilePath={document.source?.path} + referenceDepth={1} + /> + + + ))} +
+
+
+ ); + } + + return ( + { + if (!open) close(); + }} + > + + + + {t( + command === "rename" + ? "sidebarCommands.rename" + : "sidebarCommands.move", + )} + + + {status || + (reason ? ( +

{t(`sidebarCommands.${reason}`)}

+ ) : command === "rename" ? ( +
{ + event.preventDefault(); + void rename(); + }} + > + setTitle(event.target.value)} + disabled={pending} + /> +
+ + +
+
+ ) : ( + document && ( + + ) + ))} + {error && ( +

+ {error} +

+ )} +
+
+ ); +} + +function MoveTargets({ + document, + commands, + onMove, + pending, +}: { + document: Document; + commands: SidebarCommandsResponse; + onMove: (id: string | null) => Promise; + pending: boolean; +}) { + const t = useT(); + const [search, setSearch] = useState(""); + const targets = commands.destinations.filter( + (target) => + target.title.toLocaleLowerCase().includes(search.toLocaleLowerCase()), + ); + return ( +
+ setSearch(event.target.value)} + /> +
+ + {targets.map((target) => ( + + ))} +
+
+ ); +} diff --git a/templates/content/app/components/sidebar/SidebarRowMenu.tsx b/templates/content/app/components/sidebar/SidebarRowMenu.tsx new file mode 100644 index 00000000000..b8c79a276ba --- /dev/null +++ b/templates/content/app/components/sidebar/SidebarRowMenu.tsx @@ -0,0 +1,256 @@ +import { useT } from "@agent-native/core/client/i18n"; +import type { Document } from "@shared/api"; +import { IconDots } from "@tabler/icons-react"; +import { + useRef, + useState, + type ReactElement, + type ReactNode, +} from "react"; +import { useHref } from "react-router"; +import { toast } from "sonner"; + +import { + ContextMenu, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +import { + sidebarWriteCommandReason, + type SidebarCommandId, +} from "./sidebar-commands"; + +import CommandDialog from "./SidebarCommandDialog"; + +export function SidebarRowMenu({ + document, + sourceOwned = false, + onFavorite, + onDelete, + onAddContext, + children, +}: { + document: Document; + sourceOwned?: boolean; + onFavorite?: () => void; + onDelete?: () => void; + onAddContext?: () => void; + children: (trigger: ReactNode) => ReactElement; +}) { + const t = useT(); + const href = useHref(`/page/${document.id}`); + const triggerRef = useRef(null); + const originRef = useRef(null); + const [menuOpen, setMenuOpen] = useState(false); + const [command, setCommand] = useState(null); + const reason = sourceOwned + ? "sourceUnsupported" + : sidebarWriteCommandReason(document); + const title = document.title || t("sidebar.untitled"); + const rememberFocus = (target: EventTarget | null) => { + originRef.current = + target instanceof HTMLElement + ? target.closest("a,button,[tabindex]") + : triggerRef.current; + }; + const returnFocus = () => { + const target = originRef.current?.isConnected + ? originRef.current + : triggerRef.current; + target?.focus(); + }; + const restoreMenuFocus = (event: Event) => { + event.preventDefault(); + if (!command) returnFocus(); + }; + async function copyLink() { + try { + await navigator.clipboard.writeText( + new URL(href, window.location.origin).href, + ); + toast.success(t("sidebarCommands.copied")); + } catch { + toast.error(t("sidebarCommands.copyFailed")); + } + } + + const commands: Array<{ + id: string; + label: string; + run?: () => void; + href?: string; + reason?: string; + destructive?: boolean; + }> = [ + { id: "open-new-tab", label: t("sidebarCommands.newTab"), href }, + { + id: "preview", + label: t("sidebarCommands.preview"), + run: () => setCommand("preview"), + }, + { + id: "copy-link", + label: t("sidebarCommands.copyLink"), + run: () => void copyLink(), + }, + ...(onFavorite + ? [ + { + id: document.isFavorite ? "unpin" : "pin", + label: t( + document.isFavorite + ? "sidebar.unpinFromSidebar" + : "sidebar.pinToSidebar", + ), + run: onFavorite, + }, + ] + : []), + { + id: "rename", + label: t("sidebarCommands.rename"), + reason: reason ? t(`sidebarCommands.${reason}`) : undefined, + run: () => setCommand("rename"), + }, + { + id: "move", + label: t("sidebarCommands.move"), + reason: reason ? t(`sidebarCommands.${reason}`) : undefined, + run: () => setCommand("move"), + }, + ...(onAddContext + ? [ + { + id: "add-context", + label: t("creativeContext.addToContext" /* i18n-key-ignore */), + run: onAddContext, + }, + ] + : []), + ...(onDelete + ? [ + { + id: "delete", + label: t("database.delete"), + run: onDelete, + destructive: true, + }, + ] + : []), + ]; + + function items(kind: "context" | "dropdown") { + const Item = kind === "context" ? ContextMenuItem : DropdownMenuItem; + return commands.map((entry) => + entry.href ? ( + + + {entry.label} + + + ) : ( + { + if (entry.reason) { + toast.error(entry.reason); + return; + } + entry.run?.(); + }} + > + + {entry.label} + {entry.reason && ( + + {entry.reason} + + )} + + + ), + ); + } + + const trigger = ( + + + + + event.stopPropagation()} + > + {items("dropdown")} + + + ); + + return ( + <> + + rememberFocus(event.target)} + onKeyDown={(event) => { + if ( + event.key === "ContextMenu" || + (event.shiftKey && event.key === "F10") + ) { + event.preventDefault(); + event.stopPropagation(); + rememberFocus(event.target); + setMenuOpen(true); + } + }} + > + {children(trigger)} + + event.stopPropagation()} + > + {items("context")} + + + {command && ( + setCommand(null)} + returnFocus={returnFocus} + /> + )} + + ); +} diff --git a/templates/content/app/components/sidebar/sidebar-commands.test.ts b/templates/content/app/components/sidebar/sidebar-commands.test.ts new file mode 100644 index 00000000000..983de41238c --- /dev/null +++ b/templates/content/app/components/sidebar/sidebar-commands.test.ts @@ -0,0 +1,27 @@ +import type { Document } from "@shared/api"; +import { describe, expect, it } from "vitest"; + +import { sidebarMoveTargets, sidebarWriteCommandReason } from "./sidebar-commands"; + +function page(id: string, parentId: string | null = null, overrides: Partial = {}): Document { + return { id, parentId, title: id, content: "", icon: null, position: 0, isFavorite: false, hideFromSearch: false, canEdit: true, visibility: "private", createdAt: "2026-09-09", updatedAt: "2026-09-09", ...overrides }; +} + +describe("sidebar write commands", () => { + it("requires affirmative edit authority and explains unsupported source writes", () => { + expect(sidebarWriteCommandReason(page("viewer", null, { canEdit: false }))).toBe("readOnly"); + expect(sidebarWriteCommandReason(page("unknown", null, { canEdit: undefined }))).toBe("readOnly"); + expect(sidebarWriteCommandReason(page("file", null, { source: { mode: "local-files" } }))).toBe("sourceUnsupported"); + expect(sidebarWriteCommandReason(page("notion", null, { notionPageId: "external" }))).toBe("sourceUnsupported"); + }); + + it("excludes containment descendants regardless of list order, without treating references as children", () => { + const source = page("source"); + const targets = sidebarMoveTargets([ + page("grandchild", "child"), page("other"), page("child", source.id), source, + page("viewer", null, { canEdit: false }), page("org", null, { visibility: "org" }), + page("reference", null, { content: "[source](/page/source)" }), + ], source); + expect(targets.map((target) => target.id)).toEqual(["other", "reference"]); + }); +}); diff --git a/templates/content/app/components/sidebar/sidebar-commands.ts b/templates/content/app/components/sidebar/sidebar-commands.ts new file mode 100644 index 00000000000..48f2d2d474e --- /dev/null +++ b/templates/content/app/components/sidebar/sidebar-commands.ts @@ -0,0 +1,42 @@ +import type { Document } from "@shared/api"; + +export type SidebarCommandId = "rename" | "move" | "preview"; +export type SidebarCommandReason = + | "readOnly" + | "sourceUnsupported" + | "typeUnsupported"; + +export function sidebarWriteCommandReason( + document: Document, +): SidebarCommandReason | null { + if (document.canEdit !== true) return "readOnly"; + if (document.source?.mode === "local-files" || document.notionPageId) { + return "sourceUnsupported"; + } + if (document.database) return "typeUnsupported"; + return null; +} + +export function sidebarMoveTargets(documents: Document[], source: Document) { + const descendants = new Set([source.id]); + let changed = true; + while (changed) { + changed = false; + for (const document of documents) { + if ( + document.parentId && + descendants.has(document.parentId) && + !descendants.has(document.id) + ) { + descendants.add(document.id); + changed = true; + } + } + } + return documents.filter( + (document) => + !descendants.has(document.id) && + sidebarWriteCommandReason(document) === null && + document.visibility === source.visibility, + ); +} diff --git a/templates/content/app/components/ui/context-menu.tsx b/templates/content/app/components/ui/context-menu.tsx new file mode 100644 index 00000000000..c51672717cf --- /dev/null +++ b/templates/content/app/components/ui/context-menu.tsx @@ -0,0 +1 @@ +export * from "@agent-native/toolkit/ui/context-menu"; diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index 1e67e25336d..2382010bdb0 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -3,6 +3,7 @@ import { creativeContextMessagesByLocale } from "@agent-native/creative-context/ import { commentAttributionMessagesByLocale } from "../shared/comment-attribution-messages"; import zhTW from "./i18n/zh-TW"; +import { sidebarCommandMessagesByLocale } from "./sidebar-command-messages"; const databaseMessages = { aField: "a field", @@ -3135,6 +3136,7 @@ const localFilesMessages = { }; const enUS = { + sidebarCommands: sidebarCommandMessagesByLocale["en-US"], creativeContext: creativeContextMessagesByLocale["en-US"], root: { commandContent: "Content", @@ -9925,6 +9927,7 @@ const landingMessagesByLocale = { function mergeMessages(overrides: PartialMessages): Messages { return { + sidebarCommands: { ...enUS.sidebarCommands, ...overrides.sidebarCommands }, root: { ...enUS.root, ...overrides.root }, theme: { ...enUS.theme, ...overrides.theme }, navigation: { ...enUS.navigation, ...overrides.navigation }, @@ -10021,6 +10024,7 @@ function mergeMessagesForLocale( }; const base = mergeMessages({ ...overrides, + sidebarCommands: sidebarCommandMessagesByLocale[locale], creativeContext: creativeContextMessagesByLocale[locale], }); return { diff --git a/templates/content/app/i18n/zh-TW.ts b/templates/content/app/i18n/zh-TW.ts index d5699828d51..b3b0c481ba0 100644 --- a/templates/content/app/i18n/zh-TW.ts +++ b/templates/content/app/i18n/zh-TW.ts @@ -1,6 +1,9 @@ import { creativeContextMessagesByLocale } from "@agent-native/creative-context/messages"; +import { sidebarCommandMessagesByLocale } from "../sidebar-command-messages"; + const messages = { + sidebarCommands: sidebarCommandMessagesByLocale["zh-TW"], creativeContext: creativeContextMessagesByLocale["zh-TW"], root: { commandContent: "內容", diff --git a/templates/content/app/sidebar-command-messages.ts b/templates/content/app/sidebar-command-messages.ts new file mode 100644 index 00000000000..989b0a1020c --- /dev/null +++ b/templates/content/app/sidebar-command-messages.ts @@ -0,0 +1,248 @@ +import type { LocaleCode } from "@agent-native/core/client/i18n"; + +const enUS = { + rename: "Rename", + move: "Move to", + preview: "Open in side preview", + newTab: "Open in new tab", + copyLink: "Copy link", + copied: "Link copied", + copyFailed: "Could not copy link", + readOnly: "Editor access required", + sourceUnsupported: "This source does not support this command", + typeUnsupported: "This command is available for pages", + unavailable: "This page is unavailable", + retry: "Try again", + openPage: "Open page", + name: "Name", + destination: "Destination", + root: "Workspace root", + cancel: "Cancel", + save: "Save", + failed: "Could not save this change", + changed: "The page changed. Try again.", +}; + +export const sidebarCommandMessagesByLocale = { + "en-US": enUS, + "zh-CN": { + rename: "重命名", + move: "移动到", + preview: "在侧边预览中打开", + newTab: "在新标签页中打开", + copyLink: "复制链接", + copied: "链接已复制", + copyFailed: "无法复制链接", + readOnly: "需要编辑权限", + sourceUnsupported: "此来源不支持此命令", + typeUnsupported: "此命令适用于页面", + unavailable: "此页面不可用", + retry: "重试", + openPage: "打开页面", + name: "名称", + destination: "目标位置", + root: "工作区根目录", + cancel: "取消", + save: "保存", + failed: "无法保存此更改", + changed: "页面已更改,请重试。", + }, + "zh-TW": { + rename: "重新命名", + move: "移動至", + preview: "在側邊預覽中開啟", + newTab: "在新分頁中開啟", + copyLink: "複製連結", + copied: "已複製連結", + copyFailed: "無法複製連結", + readOnly: "需要編輯權限", + sourceUnsupported: "此來源不支援此命令", + typeUnsupported: "此命令適用於頁面", + unavailable: "此頁面無法使用", + retry: "再試一次", + openPage: "開啟頁面", + name: "名稱", + destination: "目的地", + root: "工作區根目錄", + cancel: "取消", + save: "儲存", + failed: "無法儲存此變更", + changed: "頁面已變更,請再試一次。", + }, + "es-ES": { + rename: "Cambiar nombre", + move: "Mover a", + preview: "Abrir en vista previa lateral", + newTab: "Abrir en una pestaña nueva", + copyLink: "Copiar enlace", + copied: "Enlace copiado", + copyFailed: "No se pudo copiar el enlace", + readOnly: "Se requiere permiso de edición", + sourceUnsupported: "Esta fuente no admite este comando", + typeUnsupported: "Este comando está disponible para páginas", + unavailable: "Esta página no está disponible", + retry: "Volver a intentar", + openPage: "Abrir página", + name: "Nombre", + destination: "Ubicación de destino", + root: "Raíz del espacio de trabajo", + cancel: "Cancelar", + save: "Guardar", + failed: "No se pudo guardar este cambio", + changed: "La página ha cambiado. Vuelve a intentarlo.", + }, + "fr-FR": { + rename: "Renommer", + move: "Déplacer vers", + preview: "Ouvrir dans l’aperçu latéral", + newTab: "Ouvrir dans un nouvel onglet", + copyLink: "Copier le lien", + copied: "Lien copié", + copyFailed: "Impossible de copier le lien", + readOnly: "Droits de modification requis", + sourceUnsupported: "Cette source ne prend pas en charge cette commande", + typeUnsupported: "Cette commande est disponible pour les pages", + unavailable: "Cette page est indisponible", + retry: "Réessayer", + openPage: "Ouvrir la page", + name: "Nom", + destination: "Emplacement cible", + root: "Racine de l’espace de travail", + cancel: "Annuler", + save: "Enregistrer", + failed: "Impossible d’enregistrer cette modification", + changed: "La page a été modifiée. Réessayez.", + }, + "de-DE": { + rename: "Umbenennen", + move: "Verschieben nach", + preview: "In der seitlichen Vorschau öffnen", + newTab: "In neuem Tab öffnen", + copyLink: "Link kopieren", + copied: "Link kopiert", + copyFailed: "Link konnte nicht kopiert werden", + readOnly: "Bearbeitungsrechte erforderlich", + sourceUnsupported: "Diese Quelle unterstützt diesen Befehl nicht", + typeUnsupported: "Dieser Befehl ist für Seiten verfügbar", + unavailable: "Diese Seite ist nicht verfügbar", + retry: "Erneut versuchen", + openPage: "Seite öffnen", + name: "Bezeichnung", + destination: "Ziel", + root: "Stammverzeichnis des Arbeitsbereichs", + cancel: "Abbrechen", + save: "Speichern", + failed: "Diese Änderung konnte nicht gespeichert werden", + changed: "Die Seite wurde geändert. Versuche es erneut.", + }, + "ja-JP": { + rename: "名前を変更", + move: "移動先を選択", + preview: "サイドプレビューで開く", + newTab: "新しいタブで開く", + copyLink: "リンクをコピー", + copied: "リンクをコピーしました", + copyFailed: "リンクをコピーできませんでした", + readOnly: "編集権限が必要です", + sourceUnsupported: "このソースはこのコマンドに対応していません", + typeUnsupported: "このコマンドはページで使用できます", + unavailable: "このページは利用できません", + retry: "再試行", + openPage: "ページを開く", + name: "名前", + destination: "移動先", + root: "ワークスペースのルート", + cancel: "キャンセル", + save: "保存", + failed: "この変更を保存できませんでした", + changed: "ページが変更されました。もう一度お試しください。", + }, + "ko-KR": { + rename: "이름 바꾸기", + move: "다음으로 이동", + preview: "측면 미리보기에서 열기", + newTab: "새 탭에서 열기", + copyLink: "링크 복사", + copied: "링크가 복사되었습니다", + copyFailed: "링크를 복사하지 못했습니다", + readOnly: "편집 권한이 필요합니다", + sourceUnsupported: "이 소스는 이 명령을 지원하지 않습니다", + typeUnsupported: "이 명령은 페이지에서 사용할 수 있습니다", + unavailable: "이 페이지를 사용할 수 없습니다", + retry: "다시 시도", + openPage: "페이지 열기", + name: "이름", + destination: "대상 위치", + root: "워크스페이스 루트", + cancel: "취소", + save: "저장", + failed: "이 변경 사항을 저장하지 못했습니다", + changed: "페이지가 변경되었습니다. 다시 시도하세요.", + }, + "pt-BR": { + rename: "Renomear", + move: "Mover para", + preview: "Abrir na prévia lateral", + newTab: "Abrir em nova aba", + copyLink: "Copiar link", + copied: "Link copiado", + copyFailed: "Não foi possível copiar o link", + readOnly: "É necessário ter permissão de edição", + sourceUnsupported: "Esta fonte não oferece suporte a este comando", + typeUnsupported: "Este comando está disponível para páginas", + unavailable: "Esta página está indisponível", + retry: "Tentar novamente", + openPage: "Abrir página", + name: "Nome", + destination: "Destino", + root: "Raiz do espaço de trabalho", + cancel: "Cancelar", + save: "Salvar", + failed: "Não foi possível salvar esta alteração", + changed: "A página foi alterada. Tente novamente.", + }, + "hi-IN": { + rename: "नाम बदलें", + move: "यहाँ ले जाएँ", + preview: "साइड पूर्वावलोकन में खोलें", + newTab: "नए टैब में खोलें", + copyLink: "लिंक कॉपी करें", + copied: "लिंक कॉपी हो गया", + copyFailed: "लिंक कॉपी नहीं हो सका", + readOnly: "संपादन की अनुमति आवश्यक है", + sourceUnsupported: "यह स्रोत इस कमांड का समर्थन नहीं करता", + typeUnsupported: "यह कमांड पेजों के लिए उपलब्ध है", + unavailable: "यह पेज उपलब्ध नहीं है", + retry: "फिर से कोशिश करें", + openPage: "पेज खोलें", + name: "नाम", + destination: "गंतव्य", + root: "कार्यक्षेत्र का मूल स्थान", + cancel: "रद्द करें", + save: "सहेजें", + failed: "यह बदलाव सहेजा नहीं जा सका", + changed: "पेज बदल गया है। फिर से कोशिश करें।", + }, + "ar-SA": { + rename: "إعادة تسمية", + move: "نقل إلى", + preview: "فتح في المعاينة الجانبية", + newTab: "فتح في علامة تبويب جديدة", + copyLink: "نسخ الرابط", + copied: "تم نسخ الرابط", + copyFailed: "تعذر نسخ الرابط", + readOnly: "يلزم الحصول على إذن التعديل", + sourceUnsupported: "هذا المصدر لا يدعم هذا الأمر", + typeUnsupported: "هذا الأمر متاح للصفحات", + unavailable: "هذه الصفحة غير متاحة", + retry: "المحاولة مجددًا", + openPage: "فتح الصفحة", + name: "الاسم", + destination: "الوجهة", + root: "جذر مساحة العمل", + cancel: "إلغاء", + save: "حفظ", + failed: "تعذر حفظ هذا التغيير", + changed: "تغيرت الصفحة. حاول مجددًا.", + }, +} satisfies Record; diff --git a/templates/content/shared/sidebar-commands.ts b/templates/content/shared/sidebar-commands.ts new file mode 100644 index 00000000000..ae2a9722534 --- /dev/null +++ b/templates/content/shared/sidebar-commands.ts @@ -0,0 +1,12 @@ +export type SidebarCommandWriteReason = + | "readOnly" + | "sourceUnsupported" + | "typeUnsupported"; + +export interface SidebarCommandsResponse { + documentId: string; + title: string; + writeReason: SidebarCommandWriteReason | null; + canMoveToRoot: boolean; + destinations: Array<{ id: string; title: string; parentId: string | null }>; +} From f88ee7eb595a2d27b25897956c3b3bb424355116 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:50:05 -0400 Subject: [PATCH 2/2] Add permission-aware sidebar commands and private subtree duplication --- templates/content/AGENTS.md | 15 +- templates/content/actions/_database-utils.ts | 45 +- .../content/actions/_duplicate-document.ts | 654 ++++++++++++++++ templates/content/actions/_position-utils.ts | 12 + .../database-row-batch-actions.db.test.ts | 71 +- .../actions/duplicate-database-item.ts | 302 ++++---- .../actions/duplicate-database-items.ts | 398 +++++----- .../actions/duplicate-document.db.test.ts | 701 ++++++++++++++++++ .../content/actions/duplicate-document.ts | 32 + .../get-document-sidebar-commands.db.test.ts | 82 ++ .../actions/get-document-sidebar-commands.ts | 172 +++-- .../editor/database/sidebar.test.tsx | 13 +- .../sidebar/DocumentTreeItem.test.tsx | 12 +- .../sidebar/SidebarCommandDialog.test.tsx | 29 +- .../sidebar/SidebarCommandDialog.tsx | 111 ++- .../app/components/sidebar/SidebarRowMenu.tsx | 33 +- .../sidebar/sidebar-commands.test.ts | 54 +- .../components/sidebar/sidebar-commands.ts | 52 +- .../content/app/sidebar-command-messages.ts | 61 ++ templates/content/server/db/schema.ts | 19 + templates/content/server/plugins/db.ts | 15 + .../content/shared/duplicate-document.ts | 18 + 22 files changed, 2412 insertions(+), 489 deletions(-) create mode 100644 templates/content/actions/_duplicate-document.ts create mode 100644 templates/content/actions/duplicate-document.db.test.ts create mode 100644 templates/content/actions/duplicate-document.ts create mode 100644 templates/content/actions/get-document-sidebar-commands.db.test.ts create mode 100644 templates/content/shared/duplicate-document.ts diff --git a/templates/content/AGENTS.md b/templates/content/AGENTS.md index 804719ec0ad..6b738629770 100644 --- a/templates/content/AGENTS.md +++ b/templates/content/AGENTS.md @@ -1,13 +1,10 @@ # Documents — Agent Guide -Documents is an agent-native editor for docs, comments, media blocks, databases, -sharing, and Notion-connected content; the agent and the UI share the same -actions and application state. +Documents edits pages, comments, media, databases, and connected content. +The agent and UI share actions and application state. ## Skills -Read the relevant skill before deeper work: - - `content` — Markdown/MDX authoring, local folder sources, databases, intake forms, and Slack/A2A artifact replies. - `document-editing` — document and comment actions, screen context and IDs, @@ -77,6 +74,8 @@ Read the relevant skill before deeper work: | `pull-document` | Flush live collab state, then read (external edits) | | `get-blocks-field-word-count` | Count one exact Blocks field; omit `propertyId` for the primary Content body | | `create-document` | Create a page, optionally under a parent | +| `duplicate-document` | Private same-space root copy of a native Page tree; same retry key replays the receipt | +| `get-document-sidebar-commands` | Current title, write eligibility, and optional authorized Move destinations | | `resolve-content-landing` | Restore the caller's last authorized page or ensure their private Personal welcome page | | `edit-document` | Find/replace edit — preferred for small changes | | `update-document` | Full rewrite of title, content, or description | @@ -85,10 +84,8 @@ Read the relevant skill before deeper work: | `mutate-content-database-block` | Insert, update, upsert, delete, or reorder one supported stable block | | `migrate-content-database-rows` | Validate/apply/verify; terminal phases use `manage-content-database-migration` | -Every action carries its own schema, and the rest of the app-specific surface -(comments, sharing, databases, Notion, local file sources such as -`remove-local-file-source`) is registered too — use `tool-search` instead of -scanning a table here. +Use `tool-search` for other registered actions and their schemas, including +comments, sharing, databases, Notion, and local file sources. Sidebar ordering has two meanings. Reordering Pinned or workspace roots moves the exact `databaseId` + `itemId` membership, never the document. Files Custom diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index 38a371f6009..b0c7035b01d 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -673,16 +673,37 @@ export async function getContentDatabasePageResponse( ) ).map((document) => document.id) : null; - const organizationFilesItemFilter = + const organizationFilesDocumentFilter = database.systemRole === "files" && database.orgId - ? sql`exists ( + ? and( + eq(schema.documents.orgId, database.orgId), + or( + and( + or( + eq(schema.documents.visibility, "org"), + eq(schema.documents.visibility, "public"), + ), + or( + eq(schema.documents.hideFromSearch, 0), + isNull(schema.documents.hideFromSearch), + ), + ), + normalizedUserEmail + ? and( + eq(schema.documents.visibility, "private"), + sql`lower(${schema.documents.ownerEmail}) = ${normalizedUserEmail}`, + ) + : undefined, + ), + ) + : undefined; + const organizationFilesItemFilter = organizationFilesDocumentFilter + ? sql`exists ( select 1 from ${schema.documents} where ${schema.documents.id} = ${schema.contentDatabaseItems.documentId} - and ${schema.documents.orgId} = ${database.orgId} - and ${schema.documents.visibility} in ('org', 'public') - and (${schema.documents.hideFromSearch} = 0 or ${schema.documents.hideFromSearch} is null) + and ${organizationFilesDocumentFilter} )` - : undefined; + : undefined; const visibleItemFilter = and( eq(schema.contentDatabaseItems.databaseId, databaseId), options.documentIds !== undefined @@ -938,17 +959,7 @@ export async function getContentDatabasePageResponse( ? inArray(schema.documents.id, workspacesVisibleDocumentIds) : sql`1 = 0` : database.systemRole === "files" && database.orgId - ? and( - eq(schema.documents.orgId, database.orgId), - or( - eq(schema.documents.visibility, "org"), - eq(schema.documents.visibility, "public"), - ), - or( - eq(schema.documents.hideFromSearch, 0), - isNull(schema.documents.hideFromSearch), - ), - ) + ? organizationFilesDocumentFilter : eq(schema.documents.ownerEmail, database.ownerEmail), ), ) diff --git a/templates/content/actions/_duplicate-document.ts b/templates/content/actions/_duplicate-document.ts new file mode 100644 index 00000000000..a53d70573d8 --- /dev/null +++ b/templates/content/actions/_duplicate-document.ts @@ -0,0 +1,654 @@ +import { createHash, randomUUID } from "node:crypto"; + +import { ActionContractError } from "@agent-native/core"; +import type { ActionRunContext } from "@agent-native/core/action"; +import { accessFilter, currentAccess } from "@agent-native/core/sharing"; +import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import { resolveDocumentHistoryCause } from "../server/lib/document-history.js"; +import { + duplicateDocumentResultSchema, + type DuplicateDocumentResult, +} from "../shared/duplicate-document.js"; +import { persistBlocksFieldIdentity } from "./_blocks-field-identity.js"; +import { ensureDocumentsFilesMembership } from "./_content-files.js"; +import { resolveContentSpaceAccess } from "./_content-space-access.js"; +import { + documentsPositionScope, + nextAppendPosition, + withPositionLock, +} from "./_position-utils.js"; + +type Db = ReturnType; +type Page = typeof schema.documents.$inferSelect; +const MAX_PAGES = 500; +const MAX_CONTENT_BYTES = 8 * 1024 * 1024; + +function reject(code: string, message: string): never { + throw new ActionContractError(message, { errorCode: code, statusCode: 409 }); +} + +function checkPayload(value: string) { + if ( + /(?:\b(?:data|blob|private-blob):|["']opaque["']\s*:\s*true| + (page.sourceMode !== null && page.sourceMode !== "database") || + page.sourceKind !== null || + page.sourcePath !== null || + page.sourceRootPath !== null || + page.sourceUpdatedAt !== null, + ) + ) + reject( + "SOURCE_DUPLICATION_UNSUPPORTED", + "The page tree contains connected source content.", + ); + const ids = pages.map((page) => page.id); + const [databases, references, links, sourceRows] = await Promise.all([ + db + .select({ id: schema.contentDatabases.id }) + .from(schema.contentDatabases) + .where(inArray(schema.contentDatabases.documentId, ids)) + .limit(1), + db + .select({ id: schema.contentSpaceCatalogItems.id }) + .from(schema.contentSpaceCatalogItems) + .where(inArray(schema.contentSpaceCatalogItems.documentId, ids)) + .limit(1), + db + .select({ id: schema.documentSyncLinks.documentId }) + .from(schema.documentSyncLinks) + .where(inArray(schema.documentSyncLinks.documentId, ids)) + .limit(1), + db + .select({ id: schema.contentDatabaseSourceRows.id }) + .from(schema.contentDatabaseSourceRows) + .where(inArray(schema.contentDatabaseSourceRows.documentId, ids)) + .limit(1), + ]); + if (databases.length || references.length) + reject( + "PAGE_TREE_REQUIRED", + "Only native Pages and their native child Pages can be duplicated.", + ); + if (links.length || sourceRows.length) + reject( + "SOURCE_DUPLICATION_UNSUPPORTED", + "The page tree contains connected source content.", + ); +} + +async function readTree(db: Db, rootId: string): Promise { + const context = currentAccess(); + const [root] = await db + .select() + .from(schema.documents) + .where( + and( + eq(schema.documents.id, rootId), + isNull(schema.documents.trashedAt), + accessFilter( + schema.documents, + schema.documentShares, + context, + "editor", + ), + ), + ); + if (!root) + reject( + "PAGE_UNAVAILABLE", + "The page is unavailable or requires editor access.", + ); + const pages = [root]; + const seen = new Set([root.id]); + let frontier = [root.id]; + while (frontier.length) { + // Containment discovery reads only opaque IDs before checking every child's access. + const children = await db + .select({ id: schema.documents.id }) + .from(schema.documents) + .where( + and( + inArray(schema.documents.parentId, frontier), + isNull(schema.documents.trashedAt), + ), + ) + .limit(MAX_PAGES + 1); + if (!children.length) break; + if (pages.length + children.length > MAX_PAGES) + reject( + "DUPLICATE_LIMIT", + "This page tree is too large to duplicate in one operation.", + ); + if (children.some((child) => seen.has(child.id))) + reject("INVALID_PAGE_TREE", "The page hierarchy contains a cycle."); + frontier = children.map((child) => child.id); + const authorized = await db + .select() + .from(schema.documents) + .where( + and( + inArray(schema.documents.id, frontier), + accessFilter( + schema.documents, + schema.documentShares, + context, + "editor", + ), + ), + ) + .orderBy(asc(schema.documents.position), asc(schema.documents.id)); + if (authorized.length !== frontier.length) + reject( + "PAGE_TREE_UNAVAILABLE", + "The complete page tree is not available for duplication.", + ); + for (const child of authorized) { + if ( + child.spaceId !== root.spaceId || + child.ownerEmail !== root.ownerEmail || + child.orgId !== root.orgId + ) { + reject( + "INVALID_PAGE_TREE", + "The page tree crosses ownership or Content space boundaries.", + ); + } + seen.add(child.id); + pages.push(child); + } + } + await assertNativePages(db, pages); + if (root.parentId) { + const [databaseParent] = await db + .select({ id: schema.contentDatabases.id }) + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.documentId, root.parentId)) + .limit(1); + if (databaseParent) + reject( + "AMBIGUOUS_DATABASE_PARENT", + "Use a database row operation for a Page with a database parent.", + ); + } + return pages; +} + +function retryable(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const candidate = error as { code?: string; cause?: unknown }; + return ( + candidate.code === "40001" || + candidate.code === "40P01" || + candidate.code === "23505" || + retryable(candidate.cause) + ); +} + +export async function duplicateDocumentTree(args: { + id: string; + idempotencyKey: string; + ctx?: ActionRunContext; +}): Promise { + const authority = currentAccess(); + if (!authority.userEmail) + throw new ActionContractError("Authentication is required.", { + errorCode: "AUTH_REQUIRED", + statusCode: 401, + }); + const ownerEmail = authority.userEmail.toLowerCase(); + const callerScope = JSON.stringify([ownerEmail, authority.orgId ?? null]); + const payloadDigest = createHash("sha256") + .update( + JSON.stringify({ + id: args.id, + destination: "same-space-root", + version: 1, + }), + ) + .digest("hex"); + const db = getDb(); + for (let attempt = 0; ; attempt++) { + try { + return await withPositionLock( + documentsPositionScope(ownerEmail, null), + () => + db.transaction( + async (transaction) => { + const tx = transaction as unknown as Db; + const [stored] = await tx + .select() + .from(schema.documentDuplicationReceipts) + .where( + and( + eq( + schema.documentDuplicationReceipts.callerScope, + callerScope, + ), + eq( + schema.documentDuplicationReceipts.idempotencyKey, + args.idempotencyKey, + ), + ), + ); + if (stored) { + if (stored.payloadDigest !== payloadDigest) + reject( + "IDEMPOTENCY_KEY_REUSED", + "This retry key was used for a different duplication.", + ); + const result = duplicateDocumentResultSchema.parse( + JSON.parse(stored.resultJson), + ); + if ( + result.sourceDocumentId !== args.id || + result.documentIds.length !== result.duplicatedCount || + result.documentIds[0].id !== result.id || + result.documentIds[0].sourceId !== args.id + ) { + reject( + "INVALID_DUPLICATE_RECEIPT", + "The saved duplication receipt is inconsistent.", + ); + } + const visible = await tx + .select({ id: schema.documents.id }) + .from(schema.documents) + .where( + and( + eq(schema.documents.id, result.id), + accessFilter( + schema.documents, + schema.documentShares, + authority, + ), + isNull(schema.documents.trashedAt), + ), + ); + if (!visible.length) + reject( + "DUPLICATE_UNAVAILABLE", + "The previously duplicated page is no longer available.", + ); + return { ...result, replayed: true }; + } + + const pages = await readTree(tx, args.id); + const root = pages[0]; + if (!root.spaceId) + reject( + "SPACE_REQUIRED", + "The page must belong to a Content space.", + ); + const spaceAccess = await resolveContentSpaceAccess( + root.spaceId, + "contributor", + { db: tx }, + ).catch((error: unknown) => { + if ( + error instanceof Error && + /^(?:Not authorized for Content space|Contributor access is required|Content space .* not found)/.test( + error.message, + ) + ) { + reject( + "SPACE_CREATION_DENIED", + "You cannot create a private copy in this Content space.", + ); + } + throw error; + }); + const [files] = await tx + .select() + .from(schema.contentDatabases) + .where( + and( + eq( + schema.contentDatabases.id, + spaceAccess.space.filesDatabaseId, + ), + eq(schema.contentDatabases.spaceId, root.spaceId), + eq(schema.contentDatabases.systemRole, "files"), + isNull(schema.contentDatabases.deletedAt), + ), + ); + if (!files?.primaryBlocksPropertyId) + reject( + "FILES_UNAVAILABLE", + "The Content space Files database is not ready.", + ); + const ids = pages.map((page) => page.id); + const values = await tx + .select() + .from(schema.documentPropertyValues) + .where(inArray(schema.documentPropertyValues.documentId, ids)); + const extraFields = await tx + .select() + .from(schema.documentBlockFieldContents) + .where( + inArray(schema.documentBlockFieldContents.documentId, ids), + ); + const propertyIds = [ + ...new Set( + [...values, ...extraFields].map((value) => value.propertyId), + ), + ]; + const definitions = propertyIds.length + ? await tx + .select() + .from(schema.documentPropertyDefinitions) + .where( + inArray( + schema.documentPropertyDefinitions.id, + propertyIds, + ), + ) + : []; + const properties = new Map( + definitions.map((definition) => [definition.id, definition]), + ); + if (propertyIds.some((id) => !properties.has(id))) + reject( + "PROPERTY_UNAVAILABLE", + "The page contains an unavailable property definition.", + ); + const pageProperty = (id: string) => + properties.get(id)!.databaseId === null; + const ownedValues = values.filter((value) => + pageProperty(value.propertyId), + ); + const ownedFields = extraFields.filter((field) => + pageProperty(field.propertyId), + ); + const ownedDefinitions = new Map( + definitions + .filter((definition) => definition.databaseId === null) + .map((definition) => [definition.id, definition]), + ); + const definitionOptions = new Map< + string, + Record + >(); + for (const definition of ownedDefinitions.values()) { + let options: Record; + try { + options = JSON.parse(definition.optionsJson); + if ( + !options || + typeof options !== "object" || + Array.isArray(options) + ) + throw new Error("Invalid options"); + } catch { + reject( + "PROPERTY_UNAVAILABLE", + "A Page property definition could not be read.", + ); + } + definitionOptions.set(definition.id, options); + const rollup = options.rollup as + | { relationPropertyId?: unknown } + | undefined; + const dependencyId = rollup?.relationPropertyId; + if (dependencyId != null && typeof dependencyId !== "string") + reject( + "PROPERTY_UNAVAILABLE", + "A Page property dependency is invalid.", + ); + if ( + typeof dependencyId === "string" && + !ownedDefinitions.has(dependencyId) + ) { + const [dependency] = await tx + .select() + .from(schema.documentPropertyDefinitions) + .where( + eq(schema.documentPropertyDefinitions.id, dependencyId), + ); + if ( + !dependency || + dependency.databaseId !== null || + dependency.ownerEmail !== definition.ownerEmail + ) + reject( + "PROPERTY_UNAVAILABLE", + "A Page property depends on a definition that cannot be duplicated.", + ); + ownedDefinitions.set(dependency.id, dependency); + } + if (ownedDefinitions.size > 500) + reject( + "DUPLICATE_LIMIT", + "This page tree has too many property definitions to duplicate.", + ); + } + const propertyRemap = new Map( + [...ownedDefinitions.keys()].map((id) => [id, randomUUID()]), + ); + for (const value of ownedValues) { + try { + JSON.parse(value.valueJson); + } catch { + reject( + "INVALID_PROPERTY_VALUE", + "A Page property could not be read. Repair it before duplicating.", + ); + } + } + const pageById = new Map(pages.map((page) => [page.id, page])); + for (const value of [...ownedValues, ...ownedFields]) { + const definition = properties.get(value.propertyId)!; + if ( + definition.ownerEmail !== + pageById.get(value.documentId)!.ownerEmail || + value.ownerEmail !== definition.ownerEmail + ) { + reject( + "PROPERTY_UNAVAILABLE", + "The page contains a property with incompatible ownership.", + ); + } + } + let bytes = 0; + for (const content of [ + ...pages.map((page) => page.content), + ...ownedFields.map((field) => field.content), + ...ownedValues.map((value) => value.valueJson), + ...[...ownedDefinitions.values()].map( + (definition) => definition.optionsJson, + ), + ]) { + checkPayload(content); + bytes += Buffer.byteLength(content); + } + if (bytes > MAX_CONTENT_BYTES) + reject( + "DUPLICATE_LIMIT", + "This page tree is too large to duplicate in one operation.", + ); + + const now = new Date().toISOString(); + const receiptId = randomUUID(); + const documentIds = pages.map((page) => ({ + sourceId: page.id, + id: randomUUID(), + })); + const remap = new Map( + documentIds.map((entry) => [entry.sourceId, entry.id]), + ); + const [position] = await tx + .select({ + max: sql`coalesce(max(${schema.documents.position}), -1)`, + }) + .from(schema.documents) + .where( + and( + eq(schema.documents.ownerEmail, ownerEmail), + isNull(schema.documents.parentId), + ), + ); + const rootPosition = nextAppendPosition(position.max); + if (!Number.isSafeInteger(rootPosition)) + reject("INVALID_POSITION", "The destination order is invalid."); + const cause = { + ...resolveDocumentHistoryCause({ + ctx: args.ctx, + operation: "duplicate-document", + actorEmail: ownerEmail, + }), + groupId: receiptId, + }; + for (const page of pages) { + const id = remap.get(page.id)!; + await tx.insert(schema.documents).values({ + id, + parentId: + page.id === root.id ? null : remap.get(page.parentId!)!, + spaceId: root.spaceId, + ownerEmail, + orgId: spaceAccess.space.orgId, + visibility: "private", + title: page.title, + content: page.content, + description: page.description, + icon: page.icon, + hideFromSearch: page.hideFromSearch, + position: page.id === root.id ? rootPosition : page.position, + createdAt: now, + updatedAt: now, + }); + await tx.insert(schema.documentVersions).values({ + id: randomUUID(), + documentId: id, + ownerEmail, + title: page.title, + content: page.content, + ...cause, + checkpointKind: "after", + createdAt: now, + updatedAt: now, + }); + await persistBlocksFieldIdentity({ + db: tx, + ownerEmail, + documentId: id, + propertyId: files.primaryBlocksPropertyId, + previousMarkdown: "", + markdown: page.content, + now, + }); + } + for (const definition of ownedDefinitions.values()) { + const options = definitionOptions.get(definition.id)!; + const rollup = options.rollup as + | { relationPropertyId?: string | null } + | undefined; + // Only the host's relation property is a local dependency. The rollup's + // target property belongs to referenced Pages, whose identities stay put. + const optionsJson = JSON.stringify( + rollup?.relationPropertyId + ? { + ...options, + rollup: { + ...rollup, + relationPropertyId: propertyRemap.get( + rollup.relationPropertyId, + )!, + }, + } + : options, + ); + await tx + .insert(schema.documentPropertyDefinitions) + .values({ + ...definition, + id: propertyRemap.get(definition.id)!, + ownerEmail, + orgId: spaceAccess.space.orgId, + optionsJson, + createdAt: now, + updatedAt: now, + }); + } + for (const value of ownedValues) { + await tx.insert(schema.documentPropertyValues).values({ + id: randomUUID(), + documentId: remap.get(value.documentId)!, + ownerEmail, + propertyId: propertyRemap.get(value.propertyId)!, + valueJson: value.valueJson, + createdAt: now, + updatedAt: now, + }); + } + for (const field of ownedFields) { + const documentId = remap.get(field.documentId)!; + await tx.insert(schema.documentBlockFieldContents).values({ + id: randomUUID(), + documentId, + ownerEmail, + propertyId: propertyRemap.get(field.propertyId)!, + content: field.content, + createdAt: now, + updatedAt: now, + }); + await persistBlocksFieldIdentity({ + db: tx, + ownerEmail, + documentId, + propertyId: propertyRemap.get(field.propertyId)!, + previousMarkdown: "", + markdown: field.content, + now, + }); + } + await ensureDocumentsFilesMembership( + tx, + documentIds.map((entry) => entry.id), + now, + ownerEmail, + ); + const result: DuplicateDocumentResult = { + id: documentIds[0].id, + sourceDocumentId: args.id, + duplicatedCount: pages.length, + documentIds, + replayed: false, + placement: "root", + visibility: "private", + spaceId: root.spaceId, + }; + await tx.insert(schema.documentDuplicationReceipts).values({ + id: receiptId, + callerScope, + idempotencyKey: args.idempotencyKey, + payloadDigest, + sourceDocumentId: args.id, + resultJson: JSON.stringify(result), + createdAt: now, + }); + return result; + }, + { isolationLevel: "serializable" }, + ), + ); + } catch (error) { + if (attempt >= 2 || !retryable(error)) throw error; + } + } +} diff --git a/templates/content/actions/_position-utils.ts b/templates/content/actions/_position-utils.ts index f4b8496f66a..6dd618d88ee 100644 --- a/templates/content/actions/_position-utils.ts +++ b/templates/content/actions/_position-utils.ts @@ -100,6 +100,18 @@ export function documentsPositionScope( return `documents:${ownerEmail}:${parentId ?? "root"}`; } +export function withPositionLocks( + scopeKeys: string[], + fn: () => Promise, +): Promise { + const scopes = [...new Set(scopeKeys)].sort(); + const acquire = (index: number): Promise => + index === scopes.length + ? fn() + : withPositionLock(scopes[index], () => acquire(index + 1)); + return acquire(0); +} + /** * Lock scope for `content_database_items.position` rows within one * database. diff --git a/templates/content/actions/database-row-batch-actions.db.test.ts b/templates/content/actions/database-row-batch-actions.db.test.ts index d348cd7b608..a7cfc4371b6 100644 --- a/templates/content/actions/database-row-batch-actions.db.test.ts +++ b/templates/content/actions/database-row-batch-actions.db.test.ts @@ -282,10 +282,61 @@ describe("database row batch actions", () => { ).toBe(true); }); + it("serializes root copies across different databases and ordinary root writers", async () => { + const first = await createDatabaseWithRows(1); + const second = await createDatabaseWithRows(2); + const { withPositionLock, documentsPositionScope } = + await import("./_position-utils.js"); + let release!: () => void; + let entered!: () => void; + const ready = new Promise((resolve) => { + entered = resolve; + }); + const held = withPositionLock( + documentsPositionScope(OWNER, null), + async () => { + entered(); + await new Promise((resolve) => { + release = resolve; + }); + await createDocument({ position: 9000 }); + }, + ); + await ready; + const copies = Promise.all([ + runWithRequestContext({ userEmail: OWNER }, () => + duplicateDatabaseItemAction.run({ itemId: first.rows[0].itemId }), + ), + runWithRequestContext({ userEmail: OWNER }, () => + duplicateDatabaseItemsAction.run({ + databaseId: second.databaseId, + itemIds: second.rows.map((row) => row.itemId), + }), + ), + ]); + release(); + await held; + const [single, batch] = await copies; + const ids = [single.duplicatedDocumentId, ...batch.duplicatedDocumentIds]; + const copied = await getDb() + .select() + .from(schema.documents) + .where(inArray(schema.documents.id, ids)) + .orderBy(asc(schema.documents.position)); + expect(copied.map((row: any) => row.position)).toEqual([9001, 9002, 9003]); + expect(copied.every((row: any) => row.parentId === null)).toBe(true); + }); + it("duplicates selected rows as one ordered block with copied values and inherited shares", async () => { const db = getDb(); const { databaseId, databaseDocumentId, rows } = await createDatabaseWithRows(4); + await createDocument({ title: "Root sibling", position: 10000 }); + await createDocument({ + title: "True child", + parentId: databaseDocumentId, + position: 20000, + }); const now = new Date().toISOString(); const propertyId = nextId("property"); await db.insert(schema.documentPropertyDefinitions).values({ @@ -367,9 +418,19 @@ describe("database row batch actions", () => { "Row 3", ]); expect(allRows.map((row) => row.itemPosition)).toEqual([0, 1, 2, 3, 4, 5]); - expect(allRows.map((row) => row.documentPosition)).toEqual([ - 0, 1, 2, 3, 4, 5, - ]); + expect( + allRows + .filter((row) => + rows.some((source) => source.documentId === row.documentId), + ) + .map((row) => row.documentPosition), + ).toEqual([0, 1, 2, 3]); + expect( + result.duplicatedItems?.every((item) => item.document.parentId === null), + ).toBe(true); + expect( + result.duplicatedItems?.map((item) => item.document.position), + ).toEqual([10001, 10002]); const copiedValues = await db .select({ @@ -509,7 +570,7 @@ describe("database row batch actions", () => { expect(single.duplicatedItems).toHaveLength(1); expect(single.duplicatedItems?.[0]).toMatchObject({ id: single.duplicatedItemId, - document: { id: single.duplicatedDocumentId }, + document: { id: single.duplicatedDocumentId, parentId: null }, }); const batch = await runWithRequestContext({ userEmail: OWNER }, () => @@ -1261,7 +1322,7 @@ describe("database row batch actions", () => { const source = readFileSync( new URL("./duplicate-document-property.ts", import.meta.url), "utf8", - ); + ).replace(/\r\n/g, "\n"); const transactionStart = source.indexOf( "await db.transaction(async (tx) => {", ); diff --git a/templates/content/actions/duplicate-database-item.ts b/templates/content/actions/duplicate-database-item.ts index 8a28fae9367..bd8dfd6c244 100644 --- a/templates/content/actions/duplicate-database-item.ts +++ b/templates/content/actions/duplicate-database-item.ts @@ -13,6 +13,11 @@ import { import { ensureDocumentFilesMembership } from "./_content-files.js"; import { assertNotWorkspaceCatalogDocuments } from "./_content-space-catalog-guards.js"; import { getContentDatabaseResponse } from "./_database-utils.js"; +import { + documentsPositionScope, + nextAppendPosition, + withPositionLock, +} from "./_position-utils.js"; import { nanoid } from "./_property-utils.js"; export default defineAction({ @@ -82,156 +87,171 @@ export default defineAction({ .from(schema.documentShares) .where(eq(schema.documentShares.resourceId, row.database.documentId)); - await db.transaction(async (tx) => { - await lockContentDatabaseMutation( - tx as unknown as ReturnType, - row.database.id, - ); - await touchContentDatabase( - tx as unknown as ReturnType, - row.database.id, - now, - ); - const [lockedRow] = await tx - .select({ - item: schema.contentDatabaseItems, - document: schema.documents, - }) - .from(schema.contentDatabaseItems) - .innerJoin( - schema.documents, - eq(schema.documents.id, schema.contentDatabaseItems.documentId), - ) - .where( - and( - eq(schema.contentDatabaseItems.id, row.item.id), - eq(schema.contentDatabaseItems.databaseId, row.database.id), - eq(schema.contentDatabaseItems.documentId, row.document.id), - isNull(schema.documents.trashedAt), - ), - ); - if (!lockedRow) { - throw new Error("Database row changed while duplication was waiting."); - } - if (lockedRow.document.spaceId !== row.database.spaceId) { - throw new Error( - "Cannot duplicate a database row across Content spaces.", - ); - } - - const nextTitle = - title?.trim() || - `Copy of ${lockedRow.document.title.trim() || "Untitled"}`; - const nextPosition = lockedRow.item.position + 1; - const values = await tx - .select() - .from(schema.documentPropertyValues) - .where( - eq(schema.documentPropertyValues.documentId, lockedRow.document.id), - ); - const [claimedSource] = await tx - .select({ id: schema.contentDatabaseItemKeyClaims.id }) - .from(schema.contentDatabaseItemKeyClaims) - .where( - and( - eq(schema.contentDatabaseItemKeyClaims.databaseId, row.database.id), - eq(schema.contentDatabaseItemKeyClaims.documentId, row.document.id), - ), - ) - .limit(1); - if (claimedSource) { - throw new Error( - "Rows with active stable-key claims cannot be duplicated.", - ); - } - await tx - .update(schema.contentDatabaseItems) - .set({ - position: sql`${schema.contentDatabaseItems.position} + 1`, - updatedAt: now, - }) - .where( - and( - eq( - schema.contentDatabaseItems.databaseId, - lockedRow.item.databaseId, - ), - gte(schema.contentDatabaseItems.position, nextPosition), - ), - ); - - await tx - .update(schema.documents) - .set({ - position: sql`${schema.documents.position} + 1`, - updatedAt: now, - }) - .where( - and( - eq(schema.documents.ownerEmail, lockedRow.document.ownerEmail), - eq(schema.documents.parentId, row.database.documentId), - gte(schema.documents.position, nextPosition), - ), - ); + await withPositionLock( + documentsPositionScope(row.document.ownerEmail, null), + () => + db.transaction(async (tx) => { + await lockContentDatabaseMutation( + tx as unknown as ReturnType, + row.database.id, + ); + await touchContentDatabase( + tx as unknown as ReturnType, + row.database.id, + now, + ); + const [lockedRow] = await tx + .select({ + item: schema.contentDatabaseItems, + document: schema.documents, + }) + .from(schema.contentDatabaseItems) + .innerJoin( + schema.documents, + eq(schema.documents.id, schema.contentDatabaseItems.documentId), + ) + .where( + and( + eq(schema.contentDatabaseItems.id, row.item.id), + eq(schema.contentDatabaseItems.databaseId, row.database.id), + eq(schema.contentDatabaseItems.documentId, row.document.id), + isNull(schema.documents.trashedAt), + ), + ); + if ( + !lockedRow || + lockedRow.document.ownerEmail !== row.document.ownerEmail + ) { + throw new Error( + "Database row changed while duplication was waiting.", + ); + } + if (lockedRow.document.spaceId !== row.database.spaceId) { + throw new Error( + "Cannot duplicate a database row across Content spaces.", + ); + } - await tx.insert(schema.documents).values({ - id: nextDocumentId, - spaceId: row.database.spaceId, - ownerEmail: lockedRow.document.ownerEmail, - orgId: lockedRow.document.orgId, - parentId: row.database.documentId, - title: nextTitle, - content: lockedRow.document.content, - icon: lockedRow.document.icon, - position: nextPosition, - isFavorite: 0, - hideFromSearch: lockedRow.document.hideFromSearch, - visibility: lockedRow.document.visibility, - createdAt: now, - updatedAt: now, - }); + const nextTitle = + title?.trim() || + `Copy of ${lockedRow.document.title.trim() || "Untitled"}`; + const nextPosition = lockedRow.item.position + 1; + const values = await tx + .select() + .from(schema.documentPropertyValues) + .where( + eq( + schema.documentPropertyValues.documentId, + lockedRow.document.id, + ), + ); + const [claimedSource] = await tx + .select({ id: schema.contentDatabaseItemKeyClaims.id }) + .from(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq( + schema.contentDatabaseItemKeyClaims.databaseId, + row.database.id, + ), + eq( + schema.contentDatabaseItemKeyClaims.documentId, + row.document.id, + ), + ), + ) + .limit(1); + if (claimedSource) { + throw new Error( + "Rows with active stable-key claims cannot be duplicated.", + ); + } + await tx + .update(schema.contentDatabaseItems) + .set({ + position: sql`${schema.contentDatabaseItems.position} + 1`, + updatedAt: now, + }) + .where( + and( + eq( + schema.contentDatabaseItems.databaseId, + lockedRow.item.databaseId, + ), + gte(schema.contentDatabaseItems.position, nextPosition), + ), + ); - await tx.insert(schema.contentDatabaseItems).values({ - id: nextItemId, - ownerEmail: lockedRow.item.ownerEmail, - orgId: lockedRow.item.orgId, - databaseId: lockedRow.item.databaseId, - documentId: nextDocumentId, - position: nextPosition, - createdAt: now, - updatedAt: now, - }); + const [maxDocumentPosition] = await tx + .select({ max: sql`COALESCE(MAX(position), -1)` }) + .from(schema.documents) + .where( + and( + eq(schema.documents.ownerEmail, lockedRow.document.ownerEmail), + isNull(schema.documents.parentId), + ), + ); - if (inheritedShares.length > 0) { - await tx.insert(schema.documentShares).values( - inheritedShares.map((share) => ({ - id: nanoid(), - resourceId: nextDocumentId, - principalType: share.principalType, - principalId: share.principalId, - role: share.role, - createdBy: getRequestUserEmail() ?? lockedRow.document.ownerEmail, + await tx.insert(schema.documents).values({ + id: nextDocumentId, + spaceId: row.database.spaceId, + ownerEmail: lockedRow.document.ownerEmail, + orgId: lockedRow.document.orgId, + parentId: null, + title: nextTitle, + content: lockedRow.document.content, + icon: lockedRow.document.icon, + position: nextAppendPosition(maxDocumentPosition?.max), + isFavorite: 0, + hideFromSearch: lockedRow.document.hideFromSearch, + visibility: lockedRow.document.visibility, createdAt: now, - })), - ); - } + updatedAt: now, + }); - if (values.length > 0) { - await tx.insert(schema.documentPropertyValues).values( - values.map((value) => ({ - id: nanoid(), - ownerEmail: lockedRow.document.ownerEmail, + await tx.insert(schema.contentDatabaseItems).values({ + id: nextItemId, + ownerEmail: lockedRow.item.ownerEmail, + orgId: lockedRow.item.orgId, + databaseId: lockedRow.item.databaseId, documentId: nextDocumentId, - propertyId: value.propertyId, - valueJson: value.valueJson, + position: nextPosition, createdAt: now, updatedAt: now, - })), - ); - } + }); - await ensureDocumentFilesMembership(tx, nextDocumentId, now); - }); + if (inheritedShares.length > 0) { + await tx.insert(schema.documentShares).values( + inheritedShares.map((share) => ({ + id: nanoid(), + resourceId: nextDocumentId, + principalType: share.principalType, + principalId: share.principalId, + role: share.role, + createdBy: + getRequestUserEmail() ?? lockedRow.document.ownerEmail, + createdAt: now, + })), + ); + } + + if (values.length > 0) { + await tx.insert(schema.documentPropertyValues).values( + values.map((value) => ({ + id: nanoid(), + ownerEmail: lockedRow.document.ownerEmail, + documentId: nextDocumentId, + propertyId: value.propertyId, + valueJson: value.valueJson, + createdAt: now, + updatedAt: now, + })), + ); + } + + await ensureDocumentFilesMembership(tx, nextDocumentId, now); + }), + ); await writeAppState("refresh-signal", { ts: Date.now() }); diff --git a/templates/content/actions/duplicate-database-items.ts b/templates/content/actions/duplicate-database-items.ts index a92062ab08b..c507f47d7d7 100644 --- a/templates/content/actions/duplicate-database-items.ts +++ b/templates/content/actions/duplicate-database-items.ts @@ -16,6 +16,11 @@ import { resolveDatabaseRowsForBatch, } from "./_database-row-batch.js"; import { getContentDatabaseResponse } from "./_database-utils.js"; +import { + createAppendPositionAllocator, + documentsPositionScope, + withPositionLocks, +} from "./_position-utils.js"; import { nanoid } from "./_property-utils.js"; export default defineAction({ @@ -63,199 +68,222 @@ export default defineAction({ duplicatedDocumentId: nanoid(), })); - await db.transaction(async (tx) => { - await lockContentDatabaseMutation( - tx as unknown as ReturnType, - database.id, - ); - await touchContentDatabase( - tx as unknown as ReturnType, - database.id, - now, - ); - const lockedRows = await tx - .select({ - item: schema.contentDatabaseItems, - document: schema.documents, - }) - .from(schema.contentDatabaseItems) - .innerJoin( - schema.documents, - eq(schema.documents.id, schema.contentDatabaseItems.documentId), - ) - .where( - and( - eq(schema.contentDatabaseItems.databaseId, database.id), - inArray(schema.contentDatabaseItems.id, sourceItemIds), - isNull(schema.documents.trashedAt), - ), - ) - .orderBy(asc(schema.contentDatabaseItems.position)); - const lockedRowsByItemId = new Map( - lockedRows.map((lockedRow) => [lockedRow.item.id, lockedRow]), - ); - if ( - lockedRows.length !== rows.length || - rows.some( - (row) => - lockedRowsByItemId.get(row.item.id)?.document.id !== - row.document.id, - ) - ) { - throw new Error("Database rows changed while duplication was waiting."); - } - if ( - lockedRows.some( - (lockedRow) => lockedRow.document.spaceId !== database.spaceId, - ) - ) { - throw new Error( - "Cannot duplicate database rows across Content spaces.", - ); - } - const [claimedSource] = await tx - .select({ id: schema.contentDatabaseItemKeyClaims.id }) - .from(schema.contentDatabaseItemKeyClaims) - .where( - and( - eq(schema.contentDatabaseItemKeyClaims.databaseId, database.id), - inArray( - schema.contentDatabaseItemKeyClaims.documentId, - sourceDocumentIds, - ), - ), - ) - .limit(1); - if (claimedSource) { - throw new Error( - "Rows with active stable-key claims cannot be duplicated.", - ); - } - const insertionPosition = - Math.max(...lockedRows.map((lockedRow) => lockedRow.item.position)) + 1; - const lockedDuplicates = duplicates.map((duplicate, index) => ({ - ...duplicate, - position: insertionPosition + index, - row: lockedRowsByItemId.get(duplicate.sourceItemId)!, - })); - const values = - sourceDocumentIds.length > 0 - ? await tx - .select() - .from(schema.documentPropertyValues) - .where( + await withPositionLocks( + rows.map((row) => documentsPositionScope(row.document.ownerEmail, null)), + () => + db.transaction(async (tx) => { + await lockContentDatabaseMutation( + tx as unknown as ReturnType, + database.id, + ); + await touchContentDatabase( + tx as unknown as ReturnType, + database.id, + now, + ); + const lockedRows = await tx + .select({ + item: schema.contentDatabaseItems, + document: schema.documents, + }) + .from(schema.contentDatabaseItems) + .innerJoin( + schema.documents, + eq(schema.documents.id, schema.contentDatabaseItems.documentId), + ) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, database.id), + inArray(schema.contentDatabaseItems.id, sourceItemIds), + isNull(schema.documents.trashedAt), + ), + ) + .orderBy(asc(schema.contentDatabaseItems.position)); + const lockedRowsByItemId = new Map( + lockedRows.map((lockedRow) => [lockedRow.item.id, lockedRow]), + ); + if ( + lockedRows.length !== rows.length || + rows.some( + (row) => + lockedRowsByItemId.get(row.item.id)?.document.id !== + row.document.id || + lockedRowsByItemId.get(row.item.id)?.document.ownerEmail !== + row.document.ownerEmail, + ) + ) { + throw new Error( + "Database rows changed while duplication was waiting.", + ); + } + if ( + lockedRows.some( + (lockedRow) => lockedRow.document.spaceId !== database.spaceId, + ) + ) { + throw new Error( + "Cannot duplicate database rows across Content spaces.", + ); + } + const [claimedSource] = await tx + .select({ id: schema.contentDatabaseItemKeyClaims.id }) + .from(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq(schema.contentDatabaseItemKeyClaims.databaseId, database.id), inArray( - schema.documentPropertyValues.documentId, + schema.contentDatabaseItemKeyClaims.documentId, sourceDocumentIds, ), - ) - : []; - const valuesByDocumentId = new Map< - string, - Array - >(); - for (const value of values) { - const list = valuesByDocumentId.get(value.documentId) ?? []; - list.push(value); - valuesByDocumentId.set(value.documentId, list); - } - await tx - .update(schema.contentDatabaseItems) - .set({ - position: sql`${schema.contentDatabaseItems.position} + ${lockedDuplicates.length}`, - updatedAt: now, - }) - .where( - and( - eq(schema.contentDatabaseItems.databaseId, database.id), - gte(schema.contentDatabaseItems.position, insertionPosition), - ), - ); - - await tx - .update(schema.documents) - .set({ - position: sql`${schema.documents.position} + ${lockedDuplicates.length}`, - updatedAt: now, - }) - .where( - and( - eq(schema.documents.ownerEmail, database.ownerEmail), - eq(schema.documents.parentId, database.documentId), - gte(schema.documents.position, insertionPosition), - ), - ); + ), + ) + .limit(1); + if (claimedSource) { + throw new Error( + "Rows with active stable-key claims cannot be duplicated.", + ); + } + const insertionPosition = + Math.max( + ...lockedRows.map((lockedRow) => lockedRow.item.position), + ) + 1; + const lockedDuplicates = duplicates.map((duplicate, index) => ({ + ...duplicate, + position: insertionPosition + index, + row: lockedRowsByItemId.get(duplicate.sourceItemId)!, + })); + const values = + sourceDocumentIds.length > 0 + ? await tx + .select() + .from(schema.documentPropertyValues) + .where( + inArray( + schema.documentPropertyValues.documentId, + sourceDocumentIds, + ), + ) + : []; + const valuesByDocumentId = new Map< + string, + Array + >(); + for (const value of values) { + const list = valuesByDocumentId.get(value.documentId) ?? []; + list.push(value); + valuesByDocumentId.set(value.documentId, list); + } + await tx + .update(schema.contentDatabaseItems) + .set({ + position: sql`${schema.contentDatabaseItems.position} + ${lockedDuplicates.length}`, + updatedAt: now, + }) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, database.id), + gte(schema.contentDatabaseItems.position, insertionPosition), + ), + ); - await tx.insert(schema.documents).values( - lockedDuplicates.map((duplicate) => ({ - id: duplicate.duplicatedDocumentId, - spaceId: database.spaceId, - ownerEmail: duplicate.row.document.ownerEmail, - orgId: duplicate.row.document.orgId, - parentId: database.documentId, - title: `Copy of ${duplicate.row.document.title.trim() || "Untitled"}`, - content: duplicate.row.document.content, - icon: duplicate.row.document.icon, - position: duplicate.position, - isFavorite: 0, - hideFromSearch: duplicate.row.document.hideFromSearch, - visibility: duplicate.row.document.visibility, - createdAt: now, - updatedAt: now, - })), - ); - - await tx.insert(schema.contentDatabaseItems).values( - lockedDuplicates.map((duplicate) => ({ - id: duplicate.duplicatedItemId, - ownerEmail: duplicate.row.item.ownerEmail, - orgId: duplicate.row.item.orgId, - databaseId: database.id, - documentId: duplicate.duplicatedDocumentId, - position: duplicate.position, - createdAt: now, - updatedAt: now, - })), - ); + const nextDocumentPositions = new Map number>(); + for (const ownerEmail of new Set( + lockedDuplicates.map( + (duplicate) => duplicate.row.document.ownerEmail, + ), + )) { + const [maxDocumentPosition] = await tx + .select({ max: sql`COALESCE(MAX(position), -1)` }) + .from(schema.documents) + .where( + and( + eq(schema.documents.ownerEmail, ownerEmail), + isNull(schema.documents.parentId), + ), + ); + nextDocumentPositions.set( + ownerEmail, + createAppendPositionAllocator(maxDocumentPosition?.max), + ); + } - const duplicatedValues = lockedDuplicates.flatMap((duplicate) => - (valuesByDocumentId.get(duplicate.sourceDocumentId) ?? []).map( - (value) => ({ - id: nanoid(), - ownerEmail: duplicate.row.document.ownerEmail, - documentId: duplicate.duplicatedDocumentId, - propertyId: value.propertyId, - valueJson: value.valueJson, - createdAt: now, - updatedAt: now, - }), - ), - ); - if (duplicatedValues.length > 0) { - await tx.insert(schema.documentPropertyValues).values(duplicatedValues); - } + await tx.insert(schema.documents).values( + lockedDuplicates.map((duplicate) => { + const ownerEmail = duplicate.row.document.ownerEmail; + const position = nextDocumentPositions.get(ownerEmail)!(); + return { + id: duplicate.duplicatedDocumentId, + spaceId: database.spaceId, + ownerEmail: duplicate.row.document.ownerEmail, + orgId: duplicate.row.document.orgId, + parentId: null, + title: `Copy of ${duplicate.row.document.title.trim() || "Untitled"}`, + content: duplicate.row.document.content, + icon: duplicate.row.document.icon, + position, + isFavorite: 0, + hideFromSearch: duplicate.row.document.hideFromSearch, + visibility: duplicate.row.document.visibility, + createdAt: now, + updatedAt: now, + }; + }), + ); - if (inheritedShares.length > 0) { - await tx.insert(schema.documentShares).values( - lockedDuplicates.flatMap((duplicate) => - inheritedShares.map((share) => ({ - id: nanoid(), - resourceId: duplicate.duplicatedDocumentId, - principalType: share.principalType, - principalId: share.principalId, - role: share.role, - createdBy: currentUserEmail, + await tx.insert(schema.contentDatabaseItems).values( + lockedDuplicates.map((duplicate) => ({ + id: duplicate.duplicatedItemId, + ownerEmail: duplicate.row.item.ownerEmail, + orgId: duplicate.row.item.orgId, + databaseId: database.id, + documentId: duplicate.duplicatedDocumentId, + position: duplicate.position, createdAt: now, + updatedAt: now, })), - ), - ); - } - await ensureDocumentsFilesMembership( - tx, - lockedDuplicates.map((duplicate) => duplicate.duplicatedDocumentId), - now, - ); - }); + ); + + const duplicatedValues = lockedDuplicates.flatMap((duplicate) => + (valuesByDocumentId.get(duplicate.sourceDocumentId) ?? []).map( + (value) => ({ + id: nanoid(), + ownerEmail: duplicate.row.document.ownerEmail, + documentId: duplicate.duplicatedDocumentId, + propertyId: value.propertyId, + valueJson: value.valueJson, + createdAt: now, + updatedAt: now, + }), + ), + ); + if (duplicatedValues.length > 0) { + await tx + .insert(schema.documentPropertyValues) + .values(duplicatedValues); + } + + if (inheritedShares.length > 0) { + await tx.insert(schema.documentShares).values( + lockedDuplicates.flatMap((duplicate) => + inheritedShares.map((share) => ({ + id: nanoid(), + resourceId: duplicate.duplicatedDocumentId, + principalType: share.principalType, + principalId: share.principalId, + role: share.role, + createdBy: currentUserEmail, + createdAt: now, + })), + ), + ); + } + await ensureDocumentsFilesMembership( + tx, + lockedDuplicates.map((duplicate) => duplicate.duplicatedDocumentId), + now, + ); + }), + ); await writeAppState("refresh-signal", { ts: Date.now() }); diff --git a/templates/content/actions/duplicate-document.db.test.ts b/templates/content/actions/duplicate-document.db.test.ts new file mode 100644 index 00000000000..d21b9fd86e7 --- /dev/null +++ b/templates/content/actions/duplicate-document.db.test.ts @@ -0,0 +1,701 @@ +import { getDbExec } from "@agent-native/core/db"; +import { runWithRequestContext } from "@agent-native/core/server"; +import { eq, inArray } from "drizzle-orm"; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { documentsPositionScope, withPositionLock } from "./_position-utils.js"; + +const OWNER = "duplicate-owner@example.com"; +const VIEWER = "duplicate-viewer@example.com"; +let getDb: typeof import("../server/db/index.js").getDb; +let schema: typeof import("../server/db/schema.js"); +let duplicate: typeof import("./duplicate-document.js").default; +let identity: typeof import("./_blocks-field-identity.js"); + +beforeAll(async () => { + process.env.DATABASE_URL = "pglite:memory"; + ({ getDb, schema } = await import("../server/db/index.js")); + duplicate = (await import("./duplicate-document.js")).default; + identity = await import("./_blocks-field-identity.js"); + await ( + await import("../server/plugins/db.js") + ).runContentMigrations(undefined as never); + await getDbExec().execute(`CREATE TABLE IF NOT EXISTS organizations ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, created_by TEXT NOT NULL, created_at BIGINT NOT NULL, + identity_authority TEXT, identity_id TEXT + )`); + await getDbExec().execute(`CREATE TABLE IF NOT EXISTS org_members ( + id TEXT PRIMARY KEY, org_id TEXT NOT NULL, email TEXT NOT NULL, role TEXT NOT NULL, + joined_at BIGINT NOT NULL, federation_removal_pending_at BIGINT + )`); +}, 60_000); + +beforeEach(async () => { + const db = getDb(); + for (const table of [ + schema.documentDuplicationReceipts, + schema.documentBlocks, + schema.documentBlockFields, + schema.documentBlockFieldContents, + schema.documentPropertyValues, + schema.documentPropertyDefinitions, + schema.documentVersions, + schema.documentShares, + schema.documentComments, + schema.documentSyncLinks, + schema.contentDatabaseSourceRows, + schema.contentSpaceCatalogItems, + schema.contentDatabaseItems, + schema.contentDatabases, + schema.contentSpaces, + schema.documents, + ]) + await db.delete(table); + await db.insert(schema.documents).values({ + id: "files-page", + title: "Files", + ownerEmail: OWNER, + spaceId: "space", + }); + await db.insert(schema.contentSpaces).values({ + id: "space", + name: "Personal", + kind: "personal", + ownerEmail: OWNER, + filesDatabaseId: "files", + createdBy: OWNER, + }); + await db.insert(schema.contentDatabases).values({ + id: "files", + documentId: "files-page", + spaceId: "space", + ownerEmail: OWNER, + systemRole: "files", + primaryBlocksPropertyId: "body", + blocksSeeded: 1, + }); + await db.insert(schema.documentPropertyDefinitions).values({ + id: "body", + ownerEmail: OWNER, + databaseId: "files", + name: "Content", + type: "blocks", + }); + await db.insert(schema.documents).values([ + { + id: "root", + title: "Original", + content: + "# Original\n\n[Reference](/page/child)\n\n![Image](https://example.com/image.png)", + ownerEmail: OWNER, + spaceId: "space", + visibility: "public", + description: "Page guidance", + isFavorite: 1, + }, + { + id: "child", + parentId: "root", + title: "Child", + content: "Child body", + ownerEmail: OWNER, + spaceId: "space", + position: 3, + }, + { + id: "grandchild", + parentId: "child", + title: "Grandchild", + content: "Grandchild body", + ownerEmail: OWNER, + spaceId: "space", + }, + ]); +}); + +function copy(idempotencyKey = "request", userEmail = OWNER) { + return runWithRequestContext({ userEmail }, () => + duplicate.run( + { id: "root", idempotencyKey }, + { caller: "frontend", userEmail }, + ), + ); +} + +afterEach(() => vi.restoreAllMocks()); + +describe("atomic native subtree duplication", () => { + it("clones Page definitions for a different organization member and can duplicate that copy again", async () => { + const db = getDb(); + await getDbExec().execute({ + sql: "INSERT INTO organizations (id,name,created_by,created_at) VALUES ($1,$2,$3,$4)", + args: ["org", "Example organization", OWNER, Date.now()], + }); + await getDbExec().execute({ + sql: "INSERT INTO org_members (id,org_id,email,role,joined_at) VALUES ($1,$2,$3,$4,$5)", + args: ["member", "org", VIEWER, "member", Date.now()], + }); + await db + .update(schema.contentSpaces) + .set({ orgId: "org", kind: "organization" }); + await db.update(schema.documents).set({ orgId: "org" }); + await db.update(schema.contentDatabases).set({ orgId: "org" }); + await db.insert(schema.documentShares).values( + ["root", "child", "grandchild"].map((id) => ({ + id: `share-${id}`, + resourceId: id, + principalType: "user" as const, + principalId: VIEWER, + role: "editor" as const, + createdBy: OWNER, + })), + ); + await db.insert(schema.documentPropertyDefinitions).values([ + { + id: "relation", + ownerEmail: OWNER, + orgId: "org", + type: "relation", + name: "Related", + optionsJson: '{"relation":{"databaseId":"external"}}', + }, + { + id: "rollup", + ownerEmail: OWNER, + orgId: "org", + type: "rollup", + name: "Count", + optionsJson: + '{"rollup":{"relationPropertyId":"relation","targetPropertyId":"external-field","aggregation":"count"}}', + }, + { + id: "extra", + ownerEmail: OWNER, + orgId: "org", + type: "blocks", + name: "Extra", + }, + ]); + await db.insert(schema.documentPropertyValues).values([ + { + id: "relation-value", + ownerEmail: OWNER, + documentId: "root", + propertyId: "relation", + valueJson: '["child"]', + }, + { + id: "rollup-value", + ownerEmail: OWNER, + documentId: "root", + propertyId: "rollup", + valueJson: "1", + }, + ]); + await db.insert(schema.documentBlockFieldContents).values({ + id: "extra-value", + ownerEmail: OWNER, + documentId: "child", + propertyId: "extra", + content: "Extra owned content", + }); + const invoke = (id: string, idempotencyKey: string) => + runWithRequestContext({ userEmail: VIEWER, orgId: "org" }, () => + duplicate.run( + { id, idempotencyKey }, + { caller: "frontend", userEmail: VIEWER, orgId: "org" }, + ), + ); + const first = await invoke("root", "cross-owner"); + const firstDefinitions = await db + .select() + .from(schema.documentPropertyDefinitions) + .where(eq(schema.documentPropertyDefinitions.ownerEmail, VIEWER)); + expect(firstDefinitions).toHaveLength(3); + const relation = firstDefinitions.find( + (definition) => definition.type === "relation", + )!; + const rollup = firstDefinitions.find( + (definition) => definition.type === "rollup", + )!; + expect(relation.id).not.toBe("relation"); + expect(JSON.parse(rollup.optionsJson).rollup).toMatchObject({ + relationPropertyId: relation.id, + targetPropertyId: "external-field", + }); + const values = await db + .select() + .from(schema.documentPropertyValues) + .where(eq(schema.documentPropertyValues.documentId, first.id)); + expect( + values.find((value) => value.propertyId === relation.id)?.valueJson, + ).toBe('["child"]'); + const second = await invoke(first.id, "copy-the-copy"); + expect(second.duplicatedCount).toBe(3); + const secondFields = await db + .select() + .from(schema.documentBlockFieldContents) + .where( + inArray( + schema.documentBlockFieldContents.documentId, + second.documentIds.map((entry) => entry.id), + ), + ); + expect(secondFields).toMatchObject([ + { ownerEmail: VIEWER, content: "Extra owned content" }, + ]); + expect( + firstDefinitions.every( + (definition) => definition.id !== secondFields[0].propertyId, + ), + ).toBe(true); + const { getContentDatabasePageResponse } = + await import("./_database-utils.js"); + const list = (userEmail: string, offset = 0, documentIds?: string[]) => + runWithRequestContext({ userEmail, orgId: "org" }, () => + getContentDatabasePageResponse("files", { + limit: 1, + offset, + includeSources: false, + documentIds, + }), + ); + const copiedIds = [...first.documentIds, ...second.documentIds].map( + (entry) => entry.id, + ); + const seen = new Set(); + for (let offset = 0; offset < copiedIds.length; offset++) { + const page = await list(VIEWER, offset); + expect(page.pagination).toMatchObject({ + totalItems: 6, + returnedItems: 1, + hasMore: offset < 5, + }); + expect(page.items).toHaveLength(1); + seen.add(page.items[0].document.id); + } + expect(seen).toEqual(new Set(copiedIds)); + const otherMember = await list(OWNER); + expect(otherMember.items).toEqual([]); + expect(otherMember.pagination).toMatchObject({ + totalItems: 0, + returnedItems: 0, + hasMore: false, + }); + const targetedPrivateRead = await list(OWNER, 0, [first.id]); + expect(targetedPrivateRead.items).toEqual([]); + expect(targetedPrivateRead.pagination?.totalItems).toBe(0); + }); + + it("waits for the shared root allocator and appends after another space's root", async () => { + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const ordinaryRoot = withPositionLock( + documentsPositionScope(OWNER, null), + async () => { + await held; + await getDb().insert(schema.documents).values({ + id: "ordinary-root", + ownerEmail: OWNER, + spaceId: "other-space", + position: 30, + title: "Created concurrently", + }); + }, + ); + let completed = false; + const pending = copy().then((result) => { + completed = true; + return result; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(completed).toBe(false); + release(); + await ordinaryRoot; + const result = await pending; + const [root] = await getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, result.id)); + expect(root.position).toBe(31); + }); + + it("keeps shared Files visibility, hidden organization rows and foreign-organization isolation intact", async () => { + const db = getDb(); + await db + .update(schema.contentDatabases) + .set({ orgId: "org" }) + .where(eq(schema.contentDatabases.id, "files")); + await db.update(schema.documents).set({ orgId: "org" }); + await db + .update(schema.documents) + .set({ visibility: "org", hideFromSearch: 1 }) + .where(eq(schema.documents.id, "grandchild")); + await db + .insert(schema.documents) + .values({ + id: "foreign", + title: "Another organization", + ownerEmail: VIEWER, + spaceId: "space", + orgId: "other-org", + visibility: "private", + }); + await db + .insert(schema.contentDatabaseItems) + .values( + ["root", "child", "grandchild", "foreign"].map((id, position) => ({ + id: `files-${id}`, + databaseId: "files", + documentId: id, + ownerEmail: OWNER, + position, + })), + ); + const { getContentDatabasePageResponse } = + await import("./_database-utils.js"); + const list = (userEmail: string) => + runWithRequestContext({ userEmail, orgId: "org" }, () => + getContentDatabasePageResponse("files", { + limit: 20, + includeSources: false, + }), + ); + const owner = await list(OWNER); + expect(owner.items.map((item) => item.document.id)).toEqual([ + "root", + "child", + ]); + expect(owner.pagination?.totalItems).toBe(2); + const member = await list(VIEWER); + expect(member.items.map((item) => item.document.id)).toEqual(["root"]); + expect(member.pagination?.totalItems).toBe(1); + }); + + it("copies all native children with new identities, exact content, private access and only Files membership", async () => { + const db = getDb(); + await db.insert(schema.documents).values({ + id: "member", + title: "Membership is not containment", + ownerEmail: OWNER, + spaceId: "space", + }); + await db.insert(schema.contentDatabaseItems).values({ + id: "membership", + databaseId: "files", + documentId: "member", + ownerEmail: OWNER, + }); + await db.insert(schema.documentPropertyDefinitions).values([ + { id: "page-value", name: "Page value", type: "text", ownerEmail: OWNER }, + { id: "extra", name: "Extra body", type: "blocks", ownerEmail: OWNER }, + { + id: "member-value", + name: "Membership value", + type: "text", + databaseId: "files", + ownerEmail: OWNER, + }, + ]); + await db.insert(schema.documentPropertyValues).values([ + { + id: "value", + documentId: "root", + propertyId: "page-value", + ownerEmail: OWNER, + valueJson: '"kept"', + }, + { + id: "excluded", + documentId: "root", + propertyId: "member-value", + ownerEmail: OWNER, + valueJson: '"not cloned"', + }, + ]); + await db.insert(schema.documentBlockFieldContents).values({ + id: "extra-content", + documentId: "child", + propertyId: "extra", + ownerEmail: OWNER, + content: "Extra body [link](/page/root)", + }); + await identity.persistBlocksFieldIdentity({ + db, + documentId: "child", + propertyId: "body", + ownerEmail: OWNER, + previousMarkdown: "", + markdown: "Child body", + now: new Date().toISOString(), + }); + const beforeBlocks = await db.select().from(schema.documentBlocks); + await db.insert(schema.documentShares).values({ + id: "original-share", + resourceId: "root", + principalType: "user", + principalId: VIEWER, + role: "viewer", + createdBy: OWNER, + }); + const result = await copy(); + expect(result).toMatchObject({ + sourceDocumentId: "root", + duplicatedCount: 3, + placement: "root", + visibility: "private", + spaceId: "space", + replayed: false, + }); + const mapping = new Map( + result.documentIds.map((entry) => [entry.sourceId, entry.id]), + ); + const copies = await db + .select() + .from(schema.documents) + .where(inArray(schema.documents.id, [...mapping.values()])); + expect(copies).toHaveLength(3); + const original = await db + .select() + .from(schema.documents) + .where(eq(schema.documents.id, "root")); + expect(copies.find((page) => page.id === result.id)).toMatchObject({ + parentId: null, + title: "Original", + description: "Page guidance", + content: original[0].content, + }); + expect( + copies.find((page) => page.id === mapping.get("grandchild"))?.parentId, + ).toBe(mapping.get("child")); + for (const page of copies) + expect(page).toMatchObject({ + ownerEmail: OWNER, + visibility: "private", + isFavorite: 0, + sourceMode: null, + }); + expect( + await db + .select() + .from(schema.documentShares) + .where( + inArray(schema.documentShares.resourceId, [...mapping.values()]), + ), + ).toEqual([]); + expect( + await db + .select() + .from(schema.contentDatabaseItems) + .where( + inArray(schema.contentDatabaseItems.documentId, [ + ...mapping.values(), + ]), + ), + ).toHaveLength(3); + expect( + await db + .select() + .from(schema.documentPropertyValues) + .where(eq(schema.documentPropertyValues.documentId, result.id)), + ).toMatchObject([{ valueJson: '"kept"' }]); + expect( + await db + .select() + .from(schema.documentBlockFieldContents) + .where( + eq( + schema.documentBlockFieldContents.documentId, + mapping.get("child")!, + ), + ), + ).toMatchObject([{ content: "Extra body [link](/page/root)" }]); + const fields = await db + .select() + .from(schema.documentBlockFields) + .where( + inArray(schema.documentBlockFields.documentId, [...mapping.values()]), + ); + expect(fields).toHaveLength(4); + const blocks = await db + .select() + .from(schema.documentBlocks) + .where( + inArray( + schema.documentBlocks.fieldId, + fields.map((field) => field.id), + ), + ); + expect(blocks.length).toBeGreaterThan(3); + expect( + blocks.every((block) => !beforeBlocks.some((old) => old.id === block.id)), + ).toBe(true); + expect( + await db + .select() + .from(schema.documentVersions) + .where( + inArray(schema.documentVersions.documentId, [...mapping.values()]), + ), + ).toHaveLength(3); + }); + + it("replays concurrent delivery without another tree and rejects key reuse", async () => { + const [first, second] = await Promise.all([copy(), copy()]); + expect(first.id).toBe(second.id); + expect(new Set([first.replayed, second.replayed])).toEqual( + new Set([false, true]), + ); + expect( + await getDb().select().from(schema.documentDuplicationReceipts), + ).toHaveLength(1); + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + duplicate.run( + { id: "child", idempotencyKey: "request" }, + { caller: "frontend", userEmail: OWNER }, + ), + ), + ).rejects.toMatchObject({ errorCode: "IDEMPOTENCY_KEY_REUSED" }); + }); + + it("rejects an inaccessible child without creating a partial copy", async () => { + await getDb() + .update(schema.documents) + .set({ ownerEmail: VIEWER, visibility: "private" }) + .where(eq(schema.documents.id, "child")); + await expect(copy()).rejects.toMatchObject({ + errorCode: "PAGE_TREE_UNAVAILABLE", + }); + expect(await getDb().select().from(schema.documents)).toHaveLength(4); + expect( + await getDb().select().from(schema.documentDuplicationReceipts), + ).toEqual([]); + }); + + it("rejects viewer authority and shared access without space creation authority", async () => { + await getDb().insert(schema.documentShares).values({ + id: "shared", + resourceId: "root", + principalType: "user", + principalId: VIEWER, + role: "viewer", + createdBy: OWNER, + }); + await expect(copy("viewer", VIEWER)).rejects.toMatchObject({ + errorCode: "PAGE_UNAVAILABLE", + }); + await getDb().update(schema.documentShares).set({ role: "editor" }); + await getDb() + .delete(schema.documents) + .where(inArray(schema.documents.id, ["child", "grandchild"])); + await expect(copy("editor", VIEWER)).rejects.toMatchObject({ + errorCode: "SPACE_CREATION_DENIED", + }); + expect( + await getDb().select().from(schema.documentDuplicationReceipts), + ).toEqual([]); + }); + + it.each(["source", "database", "ephemeral"])( + "rejects an unsupported %s descendant atomically", + async (kind) => { + const db = getDb(); + if (kind === "source") + await db + .update(schema.documents) + .set({ sourceKind: "unknown" }) + .where(eq(schema.documents.id, "grandchild")); + if (kind === "database") + await db.insert(schema.contentDatabases).values({ + id: "embedded", + documentId: "child", + spaceId: "space", + ownerEmail: OWNER, + }); + if (kind === "ephemeral") + await db + .update(schema.documents) + .set({ content: "![pending](blob:example)" }) + .where(eq(schema.documents.id, "child")); + await expect(copy()).rejects.toThrow(); + expect(await db.select().from(schema.documents)).toHaveLength(4); + expect(await db.select().from(schema.documentVersions)).toEqual([]); + expect( + await db.select().from(schema.documentDuplicationReceipts), + ).toEqual([]); + }, + ); + + it("excludes trashed children without restoring or copying their descendants", async () => { + await getDb() + .update(schema.documents) + .set({ trashedAt: new Date().toISOString() }) + .where(eq(schema.documents.id, "child")); + const result = await copy(); + expect(result.duplicatedCount).toBe(1); + }); + + it("rejects ambiguous database-parent containment", async () => { + await getDb() + .update(schema.documents) + .set({ parentId: "files-page" }) + .where(eq(schema.documents.id, "root")); + await expect(copy()).rejects.toMatchObject({ + errorCode: "AMBIGUOUS_DATABASE_PARENT", + }); + }); + + it("rolls back Pages, history, Blocks and receipts if a later write fails, then permits the same retry", async () => { + vi.spyOn(identity, "persistBlocksFieldIdentity").mockRejectedValueOnce( + new Error("Injected storage failure"), + ); + await expect(copy()).rejects.toThrow("Injected storage failure"); + expect(await getDb().select().from(schema.documents)).toHaveLength(4); + expect(await getDb().select().from(schema.documentVersions)).toEqual([]); + expect(await getDb().select().from(schema.documentBlockFields)).toEqual([]); + expect( + await getDb().select().from(schema.documentDuplicationReceipts), + ).toEqual([]); + const result = await copy(); + expect(result.duplicatedCount).toBe(3); + }); + + it("detects a Source binding even when the document's source columns are empty", async () => { + await getDb().insert(schema.documentSyncLinks).values({ + documentId: "child", + ownerEmail: OWNER, + remotePageId: "example-remote-page", + }); + await expect(copy()).rejects.toMatchObject({ + errorCode: "SOURCE_DUPLICATION_UNSUPPORTED", + }); + expect( + await getDb().select().from(schema.documentDuplicationReceipts), + ).toEqual([]); + }); + + it("does not create another copy when the previous result was trashed before retry", async () => { + const first = await copy(); + await getDb() + .update(schema.documents) + .set({ trashedAt: new Date().toISOString() }) + .where(eq(schema.documents.id, first.id)); + await expect(copy()).rejects.toMatchObject({ + errorCode: "DUPLICATE_UNAVAILABLE", + }); + expect( + await getDb().select().from(schema.documentDuplicationReceipts), + ).toHaveLength(1); + }); +}); diff --git a/templates/content/actions/duplicate-document.ts b/templates/content/actions/duplicate-document.ts new file mode 100644 index 00000000000..efa467c6668 --- /dev/null +++ b/templates/content/actions/duplicate-document.ts @@ -0,0 +1,32 @@ +import { defineAction } from "@agent-native/core/action"; +import { writeAppState } from "@agent-native/core/application-state"; +import { z } from "zod"; + +import { duplicateDocumentTree } from "./_duplicate-document.js"; + +export default defineAction({ + description: + "Duplicate a native Page and its complete native child Page tree atomically into the same Content space. The copy is private and top-level; references keep their targets, and database memberships and source bindings are not copied. Reuse the same idempotencyKey when retrying the same request.", + schema: z.object({ + id: z + .string() + .min(1) + .describe("Native Page ID at the root of the tree to duplicate"), + idempotencyKey: z + .string() + .min(1) + .max(200) + .describe( + "Unique retry key for this duplication; reuse it after an uncertain response, use a new key for another copy", + ), + }), + run: async (args, ctx) => { + const result = await duplicateDocumentTree({ ...args, ctx }); + await writeAppState("refresh-signal", { ts: Date.now() }); + return result; + }, + link: ({ result }) => ({ + url: `/page/${(result as { id: string }).id}`, + label: "Open duplicated page", + }), +}); diff --git a/templates/content/actions/get-document-sidebar-commands.db.test.ts b/templates/content/actions/get-document-sidebar-commands.db.test.ts new file mode 100644 index 00000000000..a16e20658e8 --- /dev/null +++ b/templates/content/actions/get-document-sidebar-commands.db.test.ts @@ -0,0 +1,82 @@ +import { runWithRequestContext } from "@agent-native/core/server"; +import { beforeAll, describe, expect, it } from "vitest"; + +let dbModule: typeof import("../server/db/index.js"); +let action: typeof import("./get-document-sidebar-commands.js").default; +const owner = "sidebar-owner@example.com"; +const viewer = "sidebar-viewer@example.com"; + +beforeAll(async () => { + process.env.DATABASE_URL = "pglite:memory"; + dbModule = await import("../server/db/index.js"); + const plugin = (await import("../server/plugins/db.js")).default; + await plugin(undefined as never); + action = (await import("./get-document-sidebar-commands.js")).default; +}, 60000); + +async function page( + id: string, + values: Partial = {}, +) { + await dbModule + .getDb() + .insert(dbModule.schema.documents) + .values({ + id, + title: id, + ownerEmail: owner, + content: "", + visibility: "private", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...values, + }); +} + +function commands(documentId: string, userEmail = owner) { + return runWithRequestContext({ userEmail }, () => + action.run({ documentId, includeDestinations: true }), + ); +} + +describe("sidebar command destinations", () => { + it("offers only authorized same-space native Pages outside containment descendants", async () => { + await page("move-source", { spaceId: "space-one" }); + await page("move-target", { spaceId: "space-one" }); + await page("other-space", { spaceId: "space-two" }); + await page("child", { spaceId: "space-one", parentId: "move-source" }); + await page("grandchild", { spaceId: "space-one", parentId: "child" }); + await page("source-file", { + spaceId: "space-one", + sourceMode: "local-files", + sourcePath: "note.md", + }); + await page("trashed", { + spaceId: "space-one", + trashedAt: new Date().toISOString(), + }); + await page("different-owner", { spaceId: "space-one", ownerEmail: viewer }); + const result = await commands("move-source"); + expect(result.writeReason).toBeNull(); + expect(result.destinations.map((item) => item.id)).toEqual(["move-target"]); + expect(result.canMoveToRoot).toBe(false); + }); + + it("denies an inaccessible target before exposing its title or destinations", async () => { + await expect(commands("move-source", viewer)).rejects.toThrow(); + }); + + it("keeps viewer commands read-only and returns no destination metadata", async () => { + await page("public-read", { visibility: "public" }); + const result = await commands("public-read", viewer); + expect(result.writeReason).toBe("readOnly"); + expect(result.destinations).toEqual([]); + expect(result.canMoveToRoot).toBe(false); + }); + + it("explains unsupported Source commands without offering a mutation destination", async () => { + const result = await commands("source-file"); + expect(result.writeReason).toBe("sourceUnsupported"); + expect(result.destinations).toEqual([]); + }); +}); diff --git a/templates/content/actions/get-document-sidebar-commands.ts b/templates/content/actions/get-document-sidebar-commands.ts index b94014961db..66af01d6d38 100644 --- a/templates/content/actions/get-document-sidebar-commands.ts +++ b/templates/content/actions/get-document-sidebar-commands.ts @@ -1,5 +1,9 @@ import { defineAction } from "@agent-native/core/action"; -import { accessFilter, assertAccess, roleSatisfies } from "@agent-native/core/sharing"; +import { + accessFilter, + assertAccess, + roleSatisfies, +} from "@agent-native/core/sharing"; import { and, asc, eq, inArray, isNull, notExists, or } from "drizzle-orm"; import { z } from "zod"; @@ -13,52 +17,86 @@ function nativePageFilters(db: ReturnType) { isNull(document.sourceKind), isNull(document.sourcePath), isNull(document.sourceRootPath), - notExists(db.select({ id: schema.documentSyncLinks.documentId }) - .from(schema.documentSyncLinks) - .where(eq(schema.documentSyncLinks.documentId, document.id))), - notExists(db.select({ id: schema.contentDatabaseSourceRows.id }) - .from(schema.contentDatabaseSourceRows) - .where(eq(schema.contentDatabaseSourceRows.documentId, document.id))), + notExists( + db + .select({ id: schema.documentSyncLinks.documentId }) + .from(schema.documentSyncLinks) + .where(eq(schema.documentSyncLinks.documentId, document.id)), + ), + notExists( + db + .select({ id: schema.contentDatabaseSourceRows.id }) + .from(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.documentId, document.id)), + ), ); const pageType = and( - notExists(db.select({ id: schema.contentDatabases.id }) - .from(schema.contentDatabases) - .where(eq(schema.contentDatabases.documentId, document.id))), - notExists(db.select({ id: schema.contentSpaceCatalogItems.id }) - .from(schema.contentSpaceCatalogItems) - .where(eq(schema.contentSpaceCatalogItems.documentId, document.id))), + notExists( + db + .select({ id: schema.contentDatabases.id }) + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.documentId, document.id)), + ), + notExists( + db + .select({ id: schema.contentSpaceCatalogItems.id }) + .from(schema.contentSpaceCatalogItems) + .where(eq(schema.contentSpaceCatalogItems.documentId, document.id)), + ), ); return { nativeSource, pageType }; } export default defineAction({ - description: "Read authorized native Page rename and move eligibility and optionally complete same-section move destinations.", - agentTool: false, + description: + "Read authorized native Page rename and move eligibility and optionally complete same-section move destinations.", schema: z.object({ - documentId: z.string().min(1).describe("Page whose sidebar commands are being opened"), - includeDestinations: z.union([z.boolean(), z.enum(["true", "false"])]) + documentId: z + .string() + .min(1) + .describe("Page whose sidebar commands are being opened"), + includeDestinations: z + .union([z.boolean(), z.enum(["true", "false"])]) .transform((value) => value === true || value === "true") .default(false) - .describe("Include the complete list of authorized native Page move destinations"), + .describe( + "Include the complete list of authorized native Page move destinations", + ), }), http: { method: "GET" }, readOnly: true, - run: async ({ documentId, includeDestinations }): Promise => { - const access = await assertAccess("document", documentId, "viewer", undefined, { skipResourceBody: true }); + run: async ({ + documentId, + includeDestinations, + }): Promise => { + const access = await assertAccess( + "document", + documentId, + "viewer", + undefined, + { skipResourceBody: true }, + ); const db = getDb(); - const [document] = await db.select({ - id: schema.documents.id, - title: schema.documents.title, - ownerEmail: schema.documents.ownerEmail, - spaceId: schema.documents.spaceId, - parentId: schema.documents.parentId, - orgId: schema.documents.orgId, - visibility: schema.documents.visibility, - trashedAt: schema.documents.trashedAt, - }).from(schema.documents).where(eq(schema.documents.id, documentId)); + const [document] = await db + .select({ + id: schema.documents.id, + title: schema.documents.title, + ownerEmail: schema.documents.ownerEmail, + spaceId: schema.documents.spaceId, + parentId: schema.documents.parentId, + orgId: schema.documents.orgId, + visibility: schema.documents.visibility, + trashedAt: schema.documents.trashedAt, + }) + .from(schema.documents) + .where(eq(schema.documents.id, documentId)); if (!document) throw new Error("Document no longer exists"); const result: SidebarCommandsResponse = { - documentId, title: document.title, writeReason: null, canMoveToRoot: false, destinations: [], + documentId, + title: document.title, + writeReason: null, + canMoveToRoot: false, + destinations: [], }; if (!roleSatisfies(access.role, "editor") || document.trashedAt !== null) { result.writeReason = "readOnly"; @@ -66,11 +104,18 @@ export default defineAction({ } const { nativeSource, pageType } = nativePageFilters(db); const [nativeRows, pageRows] = await Promise.all([ - db.select({ id: schema.documents.id }).from(schema.documents).where(and(eq(schema.documents.id, documentId), nativeSource)), - db.select({ id: schema.documents.id }).from(schema.documents).where(and(eq(schema.documents.id, documentId), pageType)), + db + .select({ id: schema.documents.id }) + .from(schema.documents) + .where(and(eq(schema.documents.id, documentId), nativeSource)), + db + .select({ id: schema.documents.id }) + .from(schema.documents) + .where(and(eq(schema.documents.id, documentId), pageType)), ]); if (nativeRows.length === 0 || pageRows.length === 0) { - result.writeReason = nativeRows.length === 0 ? "sourceUnsupported" : "typeUnsupported"; + result.writeReason = + nativeRows.length === 0 ? "sourceUnsupported" : "typeUnsupported"; return result; } result.canMoveToRoot = document.parentId !== null; @@ -83,26 +128,59 @@ export default defineAction({ while (frontier.length > 0) { const next: string[] = []; for (let offset = 0; offset < frontier.length; offset += 200) { - const children = await db.select({ id: schema.documents.id }).from(schema.documents) - .where(and(eq(schema.documents.ownerEmail, document.ownerEmail), inArray(schema.documents.parentId, frontier.slice(offset, offset + 200)))); + const children = await db + .select({ id: schema.documents.id }) + .from(schema.documents) + .where( + and( + eq(schema.documents.ownerEmail, document.ownerEmail), + inArray( + schema.documents.parentId, + frontier.slice(offset, offset + 200), + ), + ), + ); for (const child of children) { - if (!descendants.has(child.id)) { descendants.add(child.id); next.push(child.id); } + if (!descendants.has(child.id)) { + descendants.add(child.id); + next.push(child.id); + } } } frontier = next; } - const candidates = await db.select({ id: schema.documents.id, title: schema.documents.title, parentId: schema.documents.parentId }) + const candidates = await db + .select({ + id: schema.documents.id, + title: schema.documents.title, + parentId: schema.documents.parentId, + }) .from(schema.documents) - .where(and( - accessFilter(schema.documents, schema.documentShares, undefined, "editor"), - eq(schema.documents.ownerEmail, document.ownerEmail), - document.spaceId === null ? isNull(schema.documents.spaceId) : eq(schema.documents.spaceId, document.spaceId), - document.orgId === null ? isNull(schema.documents.orgId) : eq(schema.documents.orgId, document.orgId), - eq(schema.documents.visibility, document.visibility), - isNull(schema.documents.trashedAt), nativeSource, pageType, - )) + .where( + and( + accessFilter( + schema.documents, + schema.documentShares, + undefined, + "editor", + ), + eq(schema.documents.ownerEmail, document.ownerEmail), + document.spaceId === null + ? isNull(schema.documents.spaceId) + : eq(schema.documents.spaceId, document.spaceId), + document.orgId === null + ? isNull(schema.documents.orgId) + : eq(schema.documents.orgId, document.orgId), + eq(schema.documents.visibility, document.visibility), + isNull(schema.documents.trashedAt), + nativeSource, + pageType, + ), + ) .orderBy(asc(schema.documents.title), asc(schema.documents.id)); - result.destinations = candidates.filter((candidate) => !descendants.has(candidate.id)); + result.destinations = candidates.filter( + (candidate) => !descendants.has(candidate.id), + ); return result; }, }); diff --git a/templates/content/app/components/editor/database/sidebar.test.tsx b/templates/content/app/components/editor/database/sidebar.test.tsx index 9e7df554097..be638a1fdd0 100644 --- a/templates/content/app/components/editor/database/sidebar.test.tsx +++ b/templates/content/app/components/editor/database/sidebar.test.tsx @@ -940,11 +940,22 @@ describe("DatabaseSidebarView", () => { document.querySelectorAll("[role=menuitem]"), ); expect(menuItems.map((menuItem) => menuItem.textContent?.trim())).toEqual([ + "New tab", + "Preview", + "Copy link", "Pin to sidebar", + "RenameRead only", + "MoveRead only", + "DuplicateRead only", ]); + expect( + menuItems + .slice(4) + .every((item) => item.getAttribute("aria-disabled") === "true"), + ).toBe(true); await act(async () => { - menuItems[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + menuItems[3]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); }); expect(onToggleFavorite).toHaveBeenCalledOnce(); diff --git a/templates/content/app/components/sidebar/DocumentTreeItem.test.tsx b/templates/content/app/components/sidebar/DocumentTreeItem.test.tsx index b9831c4bd13..93e49df627f 100644 --- a/templates/content/app/components/sidebar/DocumentTreeItem.test.tsx +++ b/templates/content/app/components/sidebar/DocumentTreeItem.test.tsx @@ -200,12 +200,18 @@ describe("sidebar document permission menus", () => { expect(onCreateChildDatabase).not.toHaveBeenCalled(); const menuItems = await openActions(container); - expect(menuItems.some((item) => item.textContent?.trim() === "Pin to sidebar")).toBe(true); - expect(menuItems.filter((item) => item.getAttribute("aria-disabled") === "true")).toHaveLength(2); + expect( + menuItems.some((item) => item.textContent?.trim() === "Pin to sidebar"), + ).toBe(true); + expect( + menuItems.filter((item) => item.getAttribute("aria-disabled") === "true"), + ).toHaveLength(3); expect(menuItems.some((item) => item.textContent === "Delete")).toBe(false); await act(async () => { - menuItems.find((item) => item.textContent?.trim() === "Pin to sidebar")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + menuItems + .find((item) => item.textContent?.trim() === "Pin to sidebar") + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); }); expect(onToggleFavorite).toHaveBeenCalledOnce(); diff --git a/templates/content/app/components/sidebar/SidebarCommandDialog.test.tsx b/templates/content/app/components/sidebar/SidebarCommandDialog.test.tsx index 81e21714204..c84eef56c6c 100644 --- a/templates/content/app/components/sidebar/SidebarCommandDialog.test.tsx +++ b/templates/content/app/components/sidebar/SidebarCommandDialog.test.tsx @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ commands: vi.fn(), update: vi.fn(), move: vi.fn(), + duplicate: vi.fn(), })); vi.mock("@agent-native/core/client/i18n", () => ({ @@ -20,6 +21,7 @@ vi.mock("@agent-native/core/client/i18n", () => ({ })); vi.mock("@agent-native/core/client/hooks", () => ({ useActionQuery: mocks.commands, + useActionMutation: () => ({ mutateAsync: mocks.duplicate, isPending: false }), })); vi.mock("@/hooks/use-documents", () => ({ useDocument: mocks.document, @@ -56,7 +58,7 @@ function loadedQuery(data: unknown) { }; } -async function render(command: "preview" | "rename" | "move") { +async function render(command: "preview" | "rename" | "move" | "duplicate") { await act(async () => { root.render( @@ -104,6 +106,31 @@ afterEach(() => { }); describe("sidebar command authority", () => { + it("reuses the same receipt key when retrying an uncertain duplicate", async () => { + mocks.duplicate.mockRejectedValue(new Error("Network response lost")); + await render("duplicate"); + expect(document.body.textContent).toContain( + "sidebarCommands.duplicateTitle", + ); + const submit = Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent === "database.duplicate", + )!; + await act(async () => { + submit.click(); + }); + expect(document.body.textContent).toContain("sidebarCommands.failed"); + await act(async () => { + submit.click(); + }); + expect(mocks.duplicate).toHaveBeenCalledTimes(2); + expect(mocks.duplicate.mock.calls[0][0]).toEqual( + mocks.duplicate.mock.calls[1][0], + ); + expect(mocks.duplicate.mock.calls[0][0]).toEqual({ + id: "page", + idempotencyKey: expect.any(String), + }); + }); it("withholds cached preview payload until the first authoritative fetch completes", async () => { documentQuery.isFetching = true; documentQuery.isFetchedAfterMount = false; diff --git a/templates/content/app/components/sidebar/SidebarCommandDialog.tsx b/templates/content/app/components/sidebar/SidebarCommandDialog.tsx index 936e744cce5..196264bff5d 100644 --- a/templates/content/app/components/sidebar/SidebarCommandDialog.tsx +++ b/templates/content/app/components/sidebar/SidebarCommandDialog.tsx @@ -1,9 +1,13 @@ +import { + useActionMutation, + useActionQuery, +} from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; -import { useActionQuery } from "@agent-native/core/client/hooks"; import type { Document } from "@shared/api"; +import type { DuplicateDocumentResult } from "@shared/duplicate-document"; import type { SidebarCommandsResponse } from "@shared/sidebar-commands"; import { lazy, Suspense, useState } from "react"; -import { Link } from "react-router"; +import { Link, useNavigate } from "react-router"; import { Button } from "@/components/ui/button"; import { @@ -27,6 +31,7 @@ import { } from "@/hooks/use-documents"; import { + sidebarDuplicateErrorKey, type SidebarCommandId, } from "./sidebar-commands"; @@ -58,11 +63,18 @@ export default function SidebarCommandDialog({ returnFocus: () => void; }) { const t = useT(); + const navigate = useNavigate(); + const [idempotencyKey] = useState(() => crypto.randomUUID()); const query = useDocument(initialDocument.id); const commands = useActionQuery( "get-document-sidebar-commands", { documentId: initialDocument.id, includeDestinations: command === "move" }, - { enabled: command !== "preview", staleTime: 0, refetchOnMount: "always", retry: false }, + { + enabled: command !== "preview", + staleTime: 0, + refetchOnMount: "always", + retry: false, + }, ); const document = query.data; const [editedTitle, setTitle] = useState(null); @@ -70,11 +82,23 @@ export default function SidebarCommandDialog({ const [error, setError] = useState(null); const update = useUpdateDocument(); const move = useMoveDocument(); - const pending = update.isPending || move.isPending; - const awaitingDocument = query.isLoading || (query.isFetching && !query.isFetchedAfterMount); - const awaitingCommands = command !== "preview" && (commands.isLoading || (commands.isFetching && !commands.isFetchedAfterMount)); + const duplicate = useActionMutation< + DuplicateDocumentResult, + { id: string; idempotencyKey: string } + >("duplicate-document"); + const pending = update.isPending || move.isPending || duplicate.isPending; + const awaitingDocument = + query.isLoading || (query.isFetching && !query.isFetchedAfterMount); + const awaitingCommands = + command !== "preview" && + (commands.isLoading || + (commands.isFetching && !commands.isFetchedAfterMount)); const reason = commands.data?.writeReason; - const unavailable = query.isError || !document || document.canView === false || (command !== "preview" && (commands.isError || !commands.data)); + const unavailable = + query.isError || + !document || + document.canView === false || + (command !== "preview" && (commands.isError || !commands.data)); const close = () => { if (!pending) onClose(); }; @@ -84,7 +108,14 @@ export default function SidebarCommandDialog({ }; async function rename() { - if (awaitingDocument || awaitingCommands || unavailable || reason || !title.trim()) return; + if ( + awaitingDocument || + awaitingCommands || + unavailable || + reason || + !title.trim() + ) + return; setError(null); try { const result = await update.mutateAsync({ @@ -112,16 +143,38 @@ export default function SidebarCommandDialog({ } } - const status = awaitingDocument || awaitingCommands ? ( - - ) : unavailable ? ( -
-

{t("sidebarCommands.unavailable")}

- -
- ) : null; + async function duplicatePage() { + if (awaitingDocument || awaitingCommands || unavailable || reason) return; + setError(null); + try { + const result = await duplicate.mutateAsync({ + id: initialDocument.id, + idempotencyKey, + }); + onClose(); + navigate(`/page/${result.id}`); + } catch (error) { + setError(t(sidebarDuplicateErrorKey(error))); + } + } + + const status = + awaitingDocument || awaitingCommands ? ( + + ) : unavailable ? ( +
+

{t("sidebarCommands.unavailable")}

+ +
+ ) : null; if (command === "preview") { return ( @@ -195,15 +248,26 @@ export default function SidebarCommandDialog({ {t( - command === "rename" - ? "sidebarCommands.rename" - : "sidebarCommands.move", + command === "duplicate" + ? "sidebarCommands.duplicateTitle" + : command === "rename" + ? "sidebarCommands.rename" + : "sidebarCommands.move", )} {status || (reason ? (

{t(`sidebarCommands.${reason}`)}

+ ) : command === "duplicate" ? ( +
+ + +
) : command === "rename" ? (
- target.title.toLocaleLowerCase().includes(search.toLocaleLowerCase()), + const targets = commands.destinations.filter((target) => + target.title.toLocaleLowerCase().includes(search.toLocaleLowerCase()), ); return (
diff --git a/templates/content/app/components/sidebar/SidebarRowMenu.tsx b/templates/content/app/components/sidebar/SidebarRowMenu.tsx index b8c79a276ba..650ca39d51f 100644 --- a/templates/content/app/components/sidebar/SidebarRowMenu.tsx +++ b/templates/content/app/components/sidebar/SidebarRowMenu.tsx @@ -1,12 +1,8 @@ +import { writeClipboardText } from "@agent-native/core/client/clipboard"; import { useT } from "@agent-native/core/client/i18n"; import type { Document } from "@shared/api"; import { IconDots } from "@tabler/icons-react"; -import { - useRef, - useState, - type ReactElement, - type ReactNode, -} from "react"; +import { useRef, useState, type ReactElement, type ReactNode } from "react"; import { useHref } from "react-router"; import { toast } from "sonner"; @@ -29,7 +25,6 @@ import { sidebarWriteCommandReason, type SidebarCommandId, } from "./sidebar-commands"; - import CommandDialog from "./SidebarCommandDialog"; export function SidebarRowMenu({ @@ -74,12 +69,12 @@ export function SidebarRowMenu({ if (!command) returnFocus(); }; async function copyLink() { - try { - await navigator.clipboard.writeText( - new URL(href, window.location.origin).href, - ); + const copied = await writeClipboardText( + new URL(href, window.location.origin).href, + ); + if (copied) { toast.success(t("sidebarCommands.copied")); - } catch { + } else { toast.error(t("sidebarCommands.copyFailed")); } } @@ -128,6 +123,12 @@ export function SidebarRowMenu({ reason: reason ? t(`sidebarCommands.${reason}`) : undefined, run: () => setCommand("move"), }, + { + id: "duplicate", + label: t("database.duplicate"), + reason: reason ? t(`sidebarCommands.${reason}`) : undefined, + run: () => setCommand("duplicate"), + }, ...(onAddContext ? [ { @@ -245,10 +246,10 @@ export function SidebarRowMenu({ {command && ( setCommand(null)} - returnFocus={returnFocus} + document={document} + command={command} + onClose={() => setCommand(null)} + returnFocus={returnFocus} /> )} diff --git a/templates/content/app/components/sidebar/sidebar-commands.test.ts b/templates/content/app/components/sidebar/sidebar-commands.test.ts index 983de41238c..8158e3699bb 100644 --- a/templates/content/app/components/sidebar/sidebar-commands.test.ts +++ b/templates/content/app/components/sidebar/sidebar-commands.test.ts @@ -1,27 +1,47 @@ import type { Document } from "@shared/api"; import { describe, expect, it } from "vitest"; -import { sidebarMoveTargets, sidebarWriteCommandReason } from "./sidebar-commands"; +import { sidebarWriteCommandReason } from "./sidebar-commands"; -function page(id: string, parentId: string | null = null, overrides: Partial = {}): Document { - return { id, parentId, title: id, content: "", icon: null, position: 0, isFavorite: false, hideFromSearch: false, canEdit: true, visibility: "private", createdAt: "2026-09-09", updatedAt: "2026-09-09", ...overrides }; +function page( + id: string, + parentId: string | null = null, + overrides: Partial = {}, +): Document { + return { + id, + parentId, + title: id, + content: "", + icon: null, + position: 0, + isFavorite: false, + hideFromSearch: false, + canEdit: true, + visibility: "private", + createdAt: "2026-09-09", + updatedAt: "2026-09-09", + ...overrides, + }; } describe("sidebar write commands", () => { it("requires affirmative edit authority and explains unsupported source writes", () => { - expect(sidebarWriteCommandReason(page("viewer", null, { canEdit: false }))).toBe("readOnly"); - expect(sidebarWriteCommandReason(page("unknown", null, { canEdit: undefined }))).toBe("readOnly"); - expect(sidebarWriteCommandReason(page("file", null, { source: { mode: "local-files" } }))).toBe("sourceUnsupported"); - expect(sidebarWriteCommandReason(page("notion", null, { notionPageId: "external" }))).toBe("sourceUnsupported"); - }); - - it("excludes containment descendants regardless of list order, without treating references as children", () => { - const source = page("source"); - const targets = sidebarMoveTargets([ - page("grandchild", "child"), page("other"), page("child", source.id), source, - page("viewer", null, { canEdit: false }), page("org", null, { visibility: "org" }), - page("reference", null, { content: "[source](/page/source)" }), - ], source); - expect(targets.map((target) => target.id)).toEqual(["other", "reference"]); + expect( + sidebarWriteCommandReason(page("viewer", null, { canEdit: false })), + ).toBe("readOnly"); + expect( + sidebarWriteCommandReason(page("unknown", null, { canEdit: undefined })), + ).toBe("readOnly"); + expect( + sidebarWriteCommandReason( + page("file", null, { source: { mode: "local-files" } }), + ), + ).toBe("sourceUnsupported"); + expect( + sidebarWriteCommandReason( + page("notion", null, { notionPageId: "external" }), + ), + ).toBe("sourceUnsupported"); }); }); diff --git a/templates/content/app/components/sidebar/sidebar-commands.ts b/templates/content/app/components/sidebar/sidebar-commands.ts index 48f2d2d474e..5e6741803a4 100644 --- a/templates/content/app/components/sidebar/sidebar-commands.ts +++ b/templates/content/app/components/sidebar/sidebar-commands.ts @@ -1,6 +1,6 @@ import type { Document } from "@shared/api"; -export type SidebarCommandId = "rename" | "move" | "preview"; +export type SidebarCommandId = "rename" | "move" | "preview" | "duplicate"; export type SidebarCommandReason = | "readOnly" | "sourceUnsupported" @@ -17,26 +17,32 @@ export function sidebarWriteCommandReason( return null; } -export function sidebarMoveTargets(documents: Document[], source: Document) { - const descendants = new Set([source.id]); - let changed = true; - while (changed) { - changed = false; - for (const document of documents) { - if ( - document.parentId && - descendants.has(document.parentId) && - !descendants.has(document.id) - ) { - descendants.add(document.id); - changed = true; - } - } - } - return documents.filter( - (document) => - !descendants.has(document.id) && - sidebarWriteCommandReason(document) === null && - document.visibility === source.visibility, - ); +export function sidebarDuplicateErrorKey(error: unknown) { + const code = + error && typeof error === "object" && "errorCode" in error + ? error.errorCode + : null; + if (code === "DUPLICATE_LIMIT") return "sidebarCommands.duplicateLimit"; + if ( + [ + "SOURCE_DUPLICATION_UNSUPPORTED", + "PAGE_TREE_REQUIRED", + "AMBIGUOUS_DATABASE_PARENT", + "UNSUPPORTED_DUPLICATE_PAYLOAD", + "PROPERTY_UNAVAILABLE", + "INVALID_PAGE_TREE", + ].includes(String(code)) + ) + return "sidebarCommands.duplicateUnsupported"; + if ( + [ + "PAGE_UNAVAILABLE", + "PAGE_TREE_UNAVAILABLE", + "SPACE_CREATION_DENIED", + "SPACE_REQUIRED", + "FILES_UNAVAILABLE", + ].includes(String(code)) + ) + return "sidebarCommands.duplicateDenied"; + return "sidebarCommands.failed"; } diff --git a/templates/content/app/sidebar-command-messages.ts b/templates/content/app/sidebar-command-messages.ts index 989b0a1020c..55b55d3c7b6 100644 --- a/templates/content/app/sidebar-command-messages.ts +++ b/templates/content/app/sidebar-command-messages.ts @@ -1,6 +1,11 @@ import type { LocaleCode } from "@agent-native/core/client/i18n"; const enUS = { + duplicateUnsupported: + "This page tree contains content that cannot be copied.", + duplicateDenied: "You cannot copy this page tree into this workspace.", + duplicateLimit: "This page tree is too large to copy in one operation.", + duplicateTitle: "Duplicate privately at workspace root", rename: "Rename", move: "Move to", preview: "Open in side preview", @@ -26,6 +31,10 @@ const enUS = { export const sidebarCommandMessagesByLocale = { "en-US": enUS, "zh-CN": { + duplicateUnsupported: "此页面树包含无法复制的内容。", + duplicateDenied: "你无法将此页面树复制到此工作区。", + duplicateLimit: "此页面树过大,无法一次复制。", + duplicateTitle: "在工作区根目录创建私有副本", rename: "重命名", move: "移动到", preview: "在侧边预览中打开", @@ -48,6 +57,10 @@ export const sidebarCommandMessagesByLocale = { changed: "页面已更改,请重试。", }, "zh-TW": { + duplicateUnsupported: "此頁面樹包含無法複製的內容。", + duplicateDenied: "你無法將此頁面樹複製到此工作區。", + duplicateLimit: "此頁面樹過大,無法一次複製。", + duplicateTitle: "在工作區根目錄建立私人副本", rename: "重新命名", move: "移動至", preview: "在側邊預覽中開啟", @@ -70,6 +83,12 @@ export const sidebarCommandMessagesByLocale = { changed: "頁面已變更,請再試一次。", }, "es-ES": { + duplicateUnsupported: + "Este árbol de páginas contiene contenido que no se puede copiar.", + duplicateDenied: "No puedes copiar este árbol de páginas en este espacio.", + duplicateLimit: + "Este árbol de páginas es demasiado grande para copiarlo de una vez.", + duplicateTitle: "Duplicar de forma privada en la raíz del espacio", rename: "Cambiar nombre", move: "Mover a", preview: "Abrir en vista previa lateral", @@ -92,6 +111,13 @@ export const sidebarCommandMessagesByLocale = { changed: "La página ha cambiado. Vuelve a intentarlo.", }, "fr-FR": { + duplicateUnsupported: + "Cette arborescence contient du contenu qui ne peut pas être copié.", + duplicateDenied: + "Vous ne pouvez pas copier cette arborescence dans cet espace.", + duplicateLimit: + "Cette arborescence est trop grande pour être copiée en une seule fois.", + duplicateTitle: "Dupliquer en privé à la racine de l’espace", rename: "Renommer", move: "Déplacer vers", preview: "Ouvrir dans l’aperçu latéral", @@ -114,6 +140,13 @@ export const sidebarCommandMessagesByLocale = { changed: "La page a été modifiée. Réessayez.", }, "de-DE": { + duplicateUnsupported: + "Dieser Seitenbaum enthält Inhalte, die nicht kopiert werden können.", + duplicateDenied: + "Du kannst diesen Seitenbaum nicht in diesen Arbeitsbereich kopieren.", + duplicateLimit: + "Dieser Seitenbaum ist zu groß, um ihn auf einmal zu kopieren.", + duplicateTitle: "Privat im Stamm des Arbeitsbereichs duplizieren", rename: "Umbenennen", move: "Verschieben nach", preview: "In der seitlichen Vorschau öffnen", @@ -136,6 +169,12 @@ export const sidebarCommandMessagesByLocale = { changed: "Die Seite wurde geändert. Versuche es erneut.", }, "ja-JP": { + duplicateUnsupported: + "このページツリーには複製できないコンテンツが含まれています。", + duplicateDenied: + "このページツリーをこのワークスペースに複製する権限がありません。", + duplicateLimit: "このページツリーは大きすぎるため、一度に複製できません。", + duplicateTitle: "ワークスペースのルートに非公開の複製を作成", rename: "名前を変更", move: "移動先を選択", preview: "サイドプレビューで開く", @@ -158,6 +197,12 @@ export const sidebarCommandMessagesByLocale = { changed: "ページが変更されました。もう一度お試しください。", }, "ko-KR": { + duplicateUnsupported: + "이 페이지 트리에는 복사할 수 없는 콘텐츠가 있습니다.", + duplicateDenied: + "이 페이지 트리를 이 워크스페이스에 복사할 권한이 없습니다.", + duplicateLimit: "이 페이지 트리는 너무 커서 한 번에 복사할 수 없습니다.", + duplicateTitle: "워크스페이스 루트에 비공개 복제본 만들기", rename: "이름 바꾸기", move: "다음으로 이동", preview: "측면 미리보기에서 열기", @@ -180,6 +225,13 @@ export const sidebarCommandMessagesByLocale = { changed: "페이지가 변경되었습니다. 다시 시도하세요.", }, "pt-BR": { + duplicateUnsupported: + "Esta árvore de páginas contém conteúdo que não pode ser copiado.", + duplicateDenied: + "Você não pode copiar esta árvore de páginas para este espaço.", + duplicateLimit: + "Esta árvore de páginas é grande demais para copiar de uma vez.", + duplicateTitle: "Duplicar de forma privada na raiz do espaço", rename: "Renomear", move: "Mover para", preview: "Abrir na prévia lateral", @@ -202,6 +254,11 @@ export const sidebarCommandMessagesByLocale = { changed: "A página foi alterada. Tente novamente.", }, "hi-IN": { + duplicateUnsupported: + "इस पेज ट्री में ऐसी सामग्री है जिसकी प्रतिलिपि नहीं बनाई जा सकती।", + duplicateDenied: "आप इस पेज ट्री को इस कार्यक्षेत्र में कॉपी नहीं कर सकते।", + duplicateLimit: "यह पेज ट्री एक बार में कॉपी करने के लिए बहुत बड़ा है।", + duplicateTitle: "कार्यक्षेत्र के मूल में निजी प्रतिलिपि बनाएँ", rename: "नाम बदलें", move: "यहाँ ले जाएँ", preview: "साइड पूर्वावलोकन में खोलें", @@ -224,6 +281,10 @@ export const sidebarCommandMessagesByLocale = { changed: "पेज बदल गया है। फिर से कोशिश करें।", }, "ar-SA": { + duplicateUnsupported: "تحتوي شجرة الصفحات هذه على محتوى لا يمكن نسخه.", + duplicateDenied: "لا يمكنك نسخ شجرة الصفحات هذه إلى مساحة العمل هذه.", + duplicateLimit: "شجرة الصفحات هذه أكبر من أن تُنسخ في عملية واحدة.", + duplicateTitle: "إنشاء نسخة خاصة في جذر مساحة العمل", rename: "إعادة تسمية", move: "نقل إلى", preview: "فتح في المعاينة الجانبية", diff --git a/templates/content/server/db/schema.ts b/templates/content/server/db/schema.ts index 859aa53dab9..5aeb2106a99 100644 --- a/templates/content/server/db/schema.ts +++ b/templates/content/server/db/schema.ts @@ -690,3 +690,22 @@ export const documentBlocks = table( ); export const documentShares = createSharesTable("document_shares"); + +export const documentDuplicationReceipts = table( + "document_duplication_receipts", + { + id: text("id").primaryKey(), + callerScope: text("caller_scope").notNull(), + idempotencyKey: text("idempotency_key").notNull(), + payloadDigest: text("payload_digest").notNull(), + sourceDocumentId: text("source_document_id").notNull(), + resultJson: text("result_json").notNull(), + createdAt: text("created_at").notNull().default(now()), + }, + (receipt) => [ + uniqueIndex("document_duplication_receipts_scope_key_unique").on( + receipt.callerScope, + receipt.idempotencyKey, + ), + ], +); diff --git a/templates/content/server/plugins/db.ts b/templates/content/server/plugins/db.ts index 1911f94c50e..4a5d843af1a 100644 --- a/templates/content/server/plugins/db.ts +++ b/templates/content/server/plugins/db.ts @@ -1085,6 +1085,21 @@ const contentMigrations = [ export const runContentMigrations = runMigrations( [ ...contentMigrations, + { + version: 91, + name: "content-document-subtree-duplication-receipts", + sql: `CREATE TABLE IF NOT EXISTS document_duplication_receipts ( + id TEXT PRIMARY KEY, + caller_scope TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + payload_digest TEXT NOT NULL, + source_document_id TEXT NOT NULL, + result_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) + ); + CREATE UNIQUE INDEX IF NOT EXISTS document_duplication_receipts_scope_key_unique + ON document_duplication_receipts (caller_scope, idempotency_key)`, + }, { version: 90, name: "content-document-history-grouping", diff --git a/templates/content/shared/duplicate-document.ts b/templates/content/shared/duplicate-document.ts new file mode 100644 index 00000000000..2a5648034a6 --- /dev/null +++ b/templates/content/shared/duplicate-document.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const duplicateDocumentResultSchema = z.object({ + id: z.string().min(1), + sourceDocumentId: z.string().min(1), + duplicatedCount: z.number().int().positive(), + documentIds: z + .array(z.object({ sourceId: z.string().min(1), id: z.string().min(1) })) + .min(1), + replayed: z.boolean(), + placement: z.literal("root"), + visibility: z.literal("private"), + spaceId: z.string().min(1), +}); + +export type DuplicateDocumentResult = z.infer< + typeof duplicateDocumentResultSchema +>;