From 98b858b9fa731053e2f610d07157a322d82365f6 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Mon, 16 Feb 2026 17:18:30 -0800 Subject: [PATCH 01/25] feat(document-api): bootstrap package with type model and read API core --- packages/document-api/package.json | 22 +++ packages/document-api/src/find/find.test.ts | 99 +++++++++++++ packages/document-api/src/find/find.ts | 77 ++++++++++ .../src/get-node/get-node.test.ts | 40 +++++ .../document-api/src/get-node/get-node.ts | 53 +++++++ .../src/get-text/get-text.test.ts | 15 ++ .../document-api/src/get-text/get-text.ts | 22 +++ packages/document-api/src/index.ts | 77 ++++++++++ packages/document-api/src/info/info.test.ts | 22 +++ packages/document-api/src/info/info.ts | 24 +++ packages/document-api/src/types/address.ts | 28 ++++ packages/document-api/src/types/base.ts | 138 ++++++++++++++++++ .../document-api/src/types/comments.types.ts | 20 +++ .../document-api/src/types/create.types.ts | 28 ++++ packages/document-api/src/types/index.ts | 15 ++ packages/document-api/src/types/info.types.ts | 27 ++++ .../document-api/src/types/inline.types.ts | 31 ++++ .../document-api/src/types/media.types.ts | 20 +++ packages/document-api/src/types/node.ts | 29 ++++ .../document-api/src/types/paragraph.types.ts | 71 +++++++++ packages/document-api/src/types/query.ts | 87 +++++++++++ packages/document-api/src/types/receipt.ts | 56 +++++++ .../src/types/references.types.ts | 11 ++ .../src/types/structured.types.ts | 38 +++++ .../document-api/src/types/tables.types.ts | 50 +++++++ .../src/types/track-changes.types.ts | 27 ++++ packages/document-api/tsconfig.json | 12 ++ packages/document-api/vite.config.js | 10 ++ 28 files changed, 1149 insertions(+) create mode 100644 packages/document-api/package.json create mode 100644 packages/document-api/src/find/find.test.ts create mode 100644 packages/document-api/src/find/find.ts create mode 100644 packages/document-api/src/get-node/get-node.test.ts create mode 100644 packages/document-api/src/get-node/get-node.ts create mode 100644 packages/document-api/src/get-text/get-text.test.ts create mode 100644 packages/document-api/src/get-text/get-text.ts create mode 100644 packages/document-api/src/index.ts create mode 100644 packages/document-api/src/info/info.test.ts create mode 100644 packages/document-api/src/info/info.ts create mode 100644 packages/document-api/src/types/address.ts create mode 100644 packages/document-api/src/types/base.ts create mode 100644 packages/document-api/src/types/comments.types.ts create mode 100644 packages/document-api/src/types/create.types.ts create mode 100644 packages/document-api/src/types/index.ts create mode 100644 packages/document-api/src/types/info.types.ts create mode 100644 packages/document-api/src/types/inline.types.ts create mode 100644 packages/document-api/src/types/media.types.ts create mode 100644 packages/document-api/src/types/node.ts create mode 100644 packages/document-api/src/types/paragraph.types.ts create mode 100644 packages/document-api/src/types/query.ts create mode 100644 packages/document-api/src/types/receipt.ts create mode 100644 packages/document-api/src/types/references.types.ts create mode 100644 packages/document-api/src/types/structured.types.ts create mode 100644 packages/document-api/src/types/tables.types.ts create mode 100644 packages/document-api/src/types/track-changes.types.ts create mode 100644 packages/document-api/tsconfig.json create mode 100644 packages/document-api/vite.config.js diff --git a/packages/document-api/package.json b/packages/document-api/package.json new file mode 100644 index 000000000..914a51997 --- /dev/null +++ b/packages/document-api/package.json @@ -0,0 +1,22 @@ +{ + "name": "@superdoc/document-api", + "version": "0.0.1", + "private": true, + "description": "The SuperDoc document API", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "source": "./src/index.ts", + "default": "./src/index.ts" + }, + "./types": { + "types": "./src/types/index.ts", + "source": "./src/types/index.ts", + "default": "./src/types/index.ts" + } + }, + "files": [ + "src" + ] +} diff --git a/packages/document-api/src/find/find.test.ts b/packages/document-api/src/find/find.test.ts new file mode 100644 index 000000000..271ca9583 --- /dev/null +++ b/packages/document-api/src/find/find.test.ts @@ -0,0 +1,99 @@ +import { executeFind, normalizeFindQuery } from './find.js'; +import type { Query, QueryResult, Selector } from '../types/index.js'; +import type { FindAdapter } from './find.js'; + +describe('normalizeFindQuery', () => { + it('passes through a full Query object with canonical selector', () => { + const query: Query = { + select: { type: 'node', nodeType: 'paragraph' }, + limit: 10, + }; + + const result = normalizeFindQuery(query); + expect(result).toStrictEqual(query); + }); + + it('wraps a NodeSelector into a Query', () => { + const selector: Selector = { type: 'node', nodeType: 'heading' }; + + expect(normalizeFindQuery(selector)).toEqual({ select: selector }); + }); + + it('normalizes the nodeType shorthand into a canonical NodeSelector', () => { + const selector: Selector = { nodeType: 'paragraph' }; + + expect(normalizeFindQuery(selector)).toEqual({ + select: { type: 'node', nodeType: 'paragraph' }, + limit: undefined, + offset: undefined, + within: undefined, + includeNodes: undefined, + includeUnknown: undefined, + }); + }); + + it('maps FindOptions fields onto the Query', () => { + const selector: Selector = { type: 'text', pattern: 'hello' }; + const within = { kind: 'block' as const, nodeType: 'paragraph' as const, nodeId: 'p1' }; + + const result = normalizeFindQuery(selector, { + limit: 5, + offset: 2, + within, + includeNodes: true, + includeUnknown: true, + }); + + expect(result).toEqual({ + select: selector, + limit: 5, + offset: 2, + within, + includeNodes: true, + includeUnknown: true, + }); + }); + + it('leaves optional fields undefined when options are omitted', () => { + const selector: Selector = { type: 'node', nodeType: 'table' }; + + const result = normalizeFindQuery(selector); + + expect(result.select).toStrictEqual(selector); + expect(result.limit).toBeUndefined(); + expect(result.offset).toBeUndefined(); + expect(result.within).toBeUndefined(); + expect(result.includeNodes).toBeUndefined(); + expect(result.includeUnknown).toBeUndefined(); + }); +}); + +describe('executeFind', () => { + it('normalizes the input and delegates to the adapter', () => { + const expected: QueryResult = { matches: [], total: 0 }; + const adapter: FindAdapter = { find: vi.fn(() => expected) }; + + const result = executeFind(adapter, { nodeType: 'paragraph' }, { limit: 5 }); + + expect(result).toBe(expected); + expect(adapter.find).toHaveBeenCalledWith({ + select: { type: 'node', nodeType: 'paragraph' }, + limit: 5, + offset: undefined, + within: undefined, + includeNodes: undefined, + includeUnknown: undefined, + }); + }); + + it('passes a full Query through to the adapter', () => { + const expected: QueryResult = { matches: [], total: 0 }; + const adapter: FindAdapter = { find: vi.fn(() => expected) }; + const query: Query = { select: { type: 'text', pattern: 'hello' }, limit: 10 }; + + const result = executeFind(adapter, query); + + expect(result).toBe(expected); + expect(adapter.find).toHaveBeenCalledWith(query); + }); +}); diff --git a/packages/document-api/src/find/find.ts b/packages/document-api/src/find/find.ts new file mode 100644 index 000000000..3f3259570 --- /dev/null +++ b/packages/document-api/src/find/find.ts @@ -0,0 +1,77 @@ +import type { NodeAddress, NodeSelector, Query, QueryResult, Selector, TextSelector } from '../types/index.js'; + +/** + * Options for the `find` method when using a selector shorthand. + */ +export interface FindOptions { + /** Maximum number of results to return. */ + limit?: number; + /** Number of results to skip before returning matches. */ + offset?: number; + /** Constrain the search to descendants of the specified node. */ + within?: NodeAddress; + /** Whether to hydrate `result.nodes` for matched addresses. */ + includeNodes?: Query['includeNodes']; + /** Whether to include unknown/unsupported nodes in diagnostics. */ + includeUnknown?: Query['includeUnknown']; +} + +/** + * Engine-specific adapter that the find API delegates to. + */ +export interface FindAdapter { + /** + * Execute a normalized query against the document. + * + * @param query - The normalized query to execute. + * @returns The query result containing matches and metadata. + */ + find(query: Query): QueryResult; +} + +/** Normalizes a selector shorthand into its canonical discriminated-union form. */ +function normalizeSelector(selector: Selector): NodeSelector | TextSelector { + if ('type' in selector) { + return selector; + } + return { type: 'node', nodeType: selector.nodeType }; +} + +/** + * Normalizes a selector-or-query argument into a canonical {@link Query} object. + * + * @param selectorOrQuery - A selector shorthand or a full query object. + * @param options - Options applied when `selectorOrQuery` is a selector. + * @returns A normalized query. + */ +export function normalizeFindQuery(selectorOrQuery: Selector | Query, options?: FindOptions): Query { + if ('select' in selectorOrQuery) { + return { ...selectorOrQuery, select: normalizeSelector(selectorOrQuery.select) }; + } + + return { + select: normalizeSelector(selectorOrQuery), + limit: options?.limit, + offset: options?.offset, + within: options?.within, + includeNodes: options?.includeNodes, + includeUnknown: options?.includeUnknown, + }; +} + +/** + * Executes a find operation by normalizing the input and delegating to the adapter. + * + * @param adapter - The engine-specific find adapter. + * @param selectorOrQuery - A selector shorthand or a full query object. + * @param options - Options applied when `selectorOrQuery` is a selector. + * @returns The query result from the adapter. + */ +export function executeFind( + adapter: FindAdapter, + selectorOrQuery: Selector | Query, + options?: FindOptions, +): QueryResult { + const query = normalizeFindQuery(selectorOrQuery, options); + return adapter.find(query); +} diff --git a/packages/document-api/src/get-node/get-node.test.ts b/packages/document-api/src/get-node/get-node.test.ts new file mode 100644 index 000000000..d1bb08780 --- /dev/null +++ b/packages/document-api/src/get-node/get-node.test.ts @@ -0,0 +1,40 @@ +import type { NodeAddress, NodeInfo } from '../types/index.js'; +import { executeGetNode, executeGetNodeById } from './get-node.js'; +import type { GetNodeAdapter } from './get-node.js'; + +const PARAGRAPH_ADDRESS: NodeAddress = { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }; + +const PARAGRAPH_INFO: NodeInfo = { + nodeType: 'paragraph', + kind: 'block', + properties: {}, +}; + +describe('executeGetNode', () => { + it('delegates to adapter.getNode with the address', () => { + const adapter: GetNodeAdapter = { + getNode: vi.fn(() => PARAGRAPH_INFO), + getNodeById: vi.fn(() => PARAGRAPH_INFO), + }; + + const result = executeGetNode(adapter, PARAGRAPH_ADDRESS); + + expect(result).toBe(PARAGRAPH_INFO); + expect(adapter.getNode).toHaveBeenCalledWith(PARAGRAPH_ADDRESS); + }); +}); + +describe('executeGetNodeById', () => { + it('delegates to adapter.getNodeById with the input', () => { + const adapter: GetNodeAdapter = { + getNode: vi.fn(() => PARAGRAPH_INFO), + getNodeById: vi.fn(() => PARAGRAPH_INFO), + }; + const input = { nodeId: 'p1', nodeType: 'paragraph' as const }; + + const result = executeGetNodeById(adapter, input); + + expect(result).toBe(PARAGRAPH_INFO); + expect(adapter.getNodeById).toHaveBeenCalledWith(input); + }); +}); diff --git a/packages/document-api/src/get-node/get-node.ts b/packages/document-api/src/get-node/get-node.ts new file mode 100644 index 000000000..4c73cfed8 --- /dev/null +++ b/packages/document-api/src/get-node/get-node.ts @@ -0,0 +1,53 @@ +import type { BlockNodeType, NodeAddress, NodeInfo } from '../types/index.js'; + +/** + * Input for resolving a block node by its unique ID. + */ +export interface GetNodeByIdInput { + nodeId: string; + nodeType?: BlockNodeType; +} + +/** + * Engine-specific adapter that the getNode API delegates to. + */ +export interface GetNodeAdapter { + /** + * Resolve a node address to full node information. + * + * @param address - The node address to resolve. + * @returns Full node information including typed properties. + * @throws When the address cannot be resolved. + */ + getNode(address: NodeAddress): NodeInfo; + /** + * Resolve a block node by its ID. + * + * @param input - The node-id input payload. + * @returns Full node information including typed properties. + * @throws When the node ID cannot be found. + */ + getNodeById(input: GetNodeByIdInput): NodeInfo; +} + +/** + * Execute a getNode operation via the provided adapter. + * + * @param adapter - Engine-specific getNode adapter. + * @param address - The node address to resolve. + * @returns Full node information including typed properties. + */ +export function executeGetNode(adapter: GetNodeAdapter, address: NodeAddress): NodeInfo { + return adapter.getNode(address); +} + +/** + * Execute a getNodeById operation via the provided adapter. + * + * @param adapter - Engine-specific getNode adapter. + * @param input - The node-id input payload. + * @returns Full node information including typed properties. + */ +export function executeGetNodeById(adapter: GetNodeAdapter, input: GetNodeByIdInput): NodeInfo { + return adapter.getNodeById(input); +} diff --git a/packages/document-api/src/get-text/get-text.test.ts b/packages/document-api/src/get-text/get-text.test.ts new file mode 100644 index 000000000..911cd3a53 --- /dev/null +++ b/packages/document-api/src/get-text/get-text.test.ts @@ -0,0 +1,15 @@ +import { executeGetText } from './get-text.js'; +import type { GetTextAdapter } from './get-text.js'; + +describe('executeGetText', () => { + it('delegates to adapter.getText with the input', () => { + const adapter: GetTextAdapter = { + getText: vi.fn(() => 'Hello world'), + }; + + const result = executeGetText(adapter, {}); + + expect(result).toBe('Hello world'); + expect(adapter.getText).toHaveBeenCalledWith({}); + }); +}); diff --git a/packages/document-api/src/get-text/get-text.ts b/packages/document-api/src/get-text/get-text.ts new file mode 100644 index 000000000..e9132d27e --- /dev/null +++ b/packages/document-api/src/get-text/get-text.ts @@ -0,0 +1,22 @@ +export type GetTextInput = Record; + +/** + * Engine-specific adapter that the getText API delegates to. + */ +export interface GetTextAdapter { + /** + * Return the full document text content. + */ + getText(input: GetTextInput): string; +} + +/** + * Execute a getText operation via the provided adapter. + * + * @param adapter - Engine-specific getText adapter. + * @param input - Canonical getText input object. + * @returns The full document text content. + */ +export function executeGetText(adapter: GetTextAdapter, input: GetTextInput): string { + return adapter.getText(input); +} diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts new file mode 100644 index 000000000..077c18087 --- /dev/null +++ b/packages/document-api/src/index.ts @@ -0,0 +1,77 @@ +/** + * Engine-agnostic Document API surface. + */ + +export * from './types/index.js'; + +import type { DocumentInfo, NodeAddress, NodeInfo, Query, QueryResult, Selector } from './types/index.js'; +import { executeFind, type FindAdapter, type FindOptions } from './find/find.js'; +import type { GetNodeAdapter, GetNodeByIdInput } from './get-node/get-node.js'; +import { executeGetNode, executeGetNodeById } from './get-node/get-node.js'; +import { executeGetText, type GetTextAdapter, type GetTextInput } from './get-text/get-text.js'; +import { executeInfo, type InfoAdapter, type InfoInput } from './info/info.js'; + +export type { FindAdapter, FindOptions } from './find/find.js'; +export type { GetNodeAdapter, GetNodeByIdInput } from './get-node/get-node.js'; +export type { GetTextAdapter, GetTextInput } from './get-text/get-text.js'; +export type { InfoAdapter, InfoInput } from './info/info.js'; + +/** + * Read-focused Document API interface used by adapter-backed consumers. + */ +export interface DocumentApi { + /** + * Find nodes in the document matching a query. + */ + find(query: Query): QueryResult; + /** + * Find nodes in the document matching a selector with optional options. + */ + find(selector: Selector, options?: FindOptions): QueryResult; + /** + * Get detailed information about a specific node by its address. + */ + getNode(address: NodeAddress): NodeInfo; + /** + * Get detailed information about a block node by its ID. + */ + getNodeById(input: GetNodeByIdInput): NodeInfo; + /** + * Return the full document text content. + */ + getText(input: GetTextInput): string; + /** + * Return document summary info used by `doc.info`. + */ + info(input: InfoInput): DocumentInfo; +} + +export interface DocumentApiAdapters { + find: FindAdapter; + getNode: GetNodeAdapter; + getText: GetTextAdapter; + info: InfoAdapter; +} + +/** + * Creates a read-focused Document API instance from the provided adapters. + */ +export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { + return { + find(selectorOrQuery: Selector | Query, options?: FindOptions): QueryResult { + return executeFind(adapters.find, selectorOrQuery, options); + }, + getNode(address: NodeAddress): NodeInfo { + return executeGetNode(adapters.getNode, address); + }, + getNodeById(input: GetNodeByIdInput): NodeInfo { + return executeGetNodeById(adapters.getNode, input); + }, + getText(input: GetTextInput): string { + return executeGetText(adapters.getText, input); + }, + info(input: InfoInput): DocumentInfo { + return executeInfo(adapters.info, input); + }, + }; +} diff --git a/packages/document-api/src/info/info.test.ts b/packages/document-api/src/info/info.test.ts new file mode 100644 index 000000000..ed9588d1b --- /dev/null +++ b/packages/document-api/src/info/info.test.ts @@ -0,0 +1,22 @@ +import type { DocumentInfo } from '../types/index.js'; +import { executeInfo } from './info.js'; +import type { InfoAdapter } from './info.js'; + +const DEFAULT_INFO: DocumentInfo = { + counts: { words: 42, paragraphs: 3, headings: 1, tables: 0, images: 0, comments: 0 }, + outline: [{ level: 1, text: 'Heading', nodeId: 'h1' }], + capabilities: { canFind: true, canGetNode: true, canComment: true, canReplace: true }, +}; + +describe('executeInfo', () => { + it('delegates to adapter.info with the input', () => { + const adapter: InfoAdapter = { + info: vi.fn(() => DEFAULT_INFO), + }; + + const result = executeInfo(adapter, {}); + + expect(result).toBe(DEFAULT_INFO); + expect(adapter.info).toHaveBeenCalledWith({}); + }); +}); diff --git a/packages/document-api/src/info/info.ts b/packages/document-api/src/info/info.ts new file mode 100644 index 000000000..34e5b8b85 --- /dev/null +++ b/packages/document-api/src/info/info.ts @@ -0,0 +1,24 @@ +import type { DocumentInfo } from '../types/info.types.js'; + +export type InfoInput = Record; + +/** + * Engine-specific adapter that provides document summary information. + */ +export interface InfoAdapter { + /** + * Return summary info used by the `doc.info` operation. + */ + info(input: InfoInput): DocumentInfo; +} + +/** + * Execute an info operation through the provided adapter. + * + * @param adapter - Engine-specific info adapter. + * @param input - Canonical info input object. + * @returns Structured document summary info. + */ +export function executeInfo(adapter: InfoAdapter, input: InfoInput): DocumentInfo { + return adapter.info(input); +} diff --git a/packages/document-api/src/types/address.ts b/packages/document-api/src/types/address.ts new file mode 100644 index 000000000..534fd90d1 --- /dev/null +++ b/packages/document-api/src/types/address.ts @@ -0,0 +1,28 @@ +export type Range = { + /** Inclusive start offset (0-based, UTF-16 code units). */ + start: number; + /** Exclusive end offset (0-based, UTF-16 code units). */ + end: number; +}; + +export type TextAddress = { + kind: 'text'; + blockId: string; + range: Range; +}; + +export type EntityType = 'comment' | 'trackedChange'; + +export type CommentAddress = { + kind: 'entity'; + entityType: 'comment'; + entityId: string; +}; + +export type TrackedChangeAddress = { + kind: 'entity'; + entityType: 'trackedChange'; + entityId: string; +}; + +export type EntityAddress = CommentAddress | TrackedChangeAddress; diff --git a/packages/document-api/src/types/base.ts b/packages/document-api/src/types/base.ts new file mode 100644 index 000000000..7891ca433 --- /dev/null +++ b/packages/document-api/src/types/base.ts @@ -0,0 +1,138 @@ +/** + * Base types for the Document API node model. + * + * This file is the foundation of the type hierarchy — leaf node-info files + * (paragraph.types.ts, inline.types.ts, etc.) import from here, and node.ts + * assembles the full NodeInfo union from those leaves. + * + * Nothing in this file imports from leaf node-info files. + */ + +export type NodeKind = 'block' | 'inline'; + +export const NODE_KINDS = ['block', 'inline'] as const satisfies readonly NodeKind[]; + +export type NodeType = + // Block-level + | 'paragraph' + | 'heading' + | 'listItem' + | 'table' + | 'tableRow' + | 'tableCell' + // Inline-level + | 'run' + | 'bookmark' + | 'comment' + | 'hyperlink' + | 'footnoteRef' + | 'tab' + | 'lineBreak' + + // Both block and inline + | 'image' + | 'sdt'; + +export const NODE_TYPES = [ + 'paragraph', + 'heading', + 'listItem', + 'table', + 'tableRow', + 'tableCell', + 'image', + 'sdt', + 'run', + 'bookmark', + 'comment', + 'hyperlink', + 'footnoteRef', + 'tab', + 'lineBreak', +] as const satisfies readonly NodeType[]; + +/** + * Node types that can appear in block context. + * Note: 'sdt' and 'image' can appear in both block and inline contexts. + */ +export type BlockNodeType = Extract< + NodeType, + 'paragraph' | 'heading' | 'listItem' | 'table' | 'tableRow' | 'tableCell' | 'image' | 'sdt' +>; + +export const BLOCK_NODE_TYPES = [ + 'paragraph', + 'heading', + 'listItem', + 'table', + 'tableRow', + 'tableCell', + 'image', + 'sdt', +] as const satisfies readonly BlockNodeType[]; + +/** + * Node types that can appear in inline context. + * Note: 'sdt' and 'image' can appear in both block and inline contexts. + */ +export type InlineNodeType = Extract< + NodeType, + 'run' | 'bookmark' | 'comment' | 'hyperlink' | 'sdt' | 'image' | 'footnoteRef' | 'tab' | 'lineBreak' +>; + +export const INLINE_NODE_TYPES = [ + 'run', + 'bookmark', + 'comment', + 'hyperlink', + 'sdt', + 'image', + 'footnoteRef', + 'tab', + 'lineBreak', +] as const satisfies readonly InlineNodeType[]; + +export type Position = { + blockId: string; + /** + * 0-based offset into the block's flattened text representation. + * + * - Text runs contribute their character length. + * - Leaf inline nodes (images, tabs, etc.) contribute a single placeholder character. + * - Transparent inline wrappers (hyperlinks, bookmarks, etc.) contribute only their inner text. + */ + offset: number; +}; + +export type InlineAnchor = { + start: Position; + end: Position; +}; + +export type BlockNodeAddress = { + kind: 'block'; + nodeType: BlockNodeType; + nodeId: string; +}; + +export type InlineNodeAddress = { + kind: 'inline'; + nodeType: InlineNodeType; + anchor: InlineAnchor; +}; + +export type NodeAddress = BlockNodeAddress | InlineNodeAddress; + +export type NodeSummary = { + label?: string; + text?: string; +}; + +export interface BaseNodeInfo { + nodeType: NodeType; + kind: NodeKind; + summary?: NodeSummary; + text?: string; + /** Child nodes. Typed as BaseNodeInfo[] to avoid circular imports; narrow via `nodeType`. */ + nodes?: BaseNodeInfo[]; +} diff --git a/packages/document-api/src/types/comments.types.ts b/packages/document-api/src/types/comments.types.ts new file mode 100644 index 000000000..649b70c66 --- /dev/null +++ b/packages/document-api/src/types/comments.types.ts @@ -0,0 +1,20 @@ +import type { BaseNodeInfo } from './base.js'; + +export interface CommentNodeInfo extends BaseNodeInfo { + nodeType: 'comment'; + kind: 'inline'; + properties: CommentProperties; + bodyText?: string; + bodyNodes?: BaseNodeInfo[]; +} + +export type CommentStatus = 'open' | 'resolved'; + +export interface CommentProperties { + commentId: string; + author?: string; + status?: CommentStatus; + createdAt?: string; + /** User-visible sidebar text */ + commentText?: string; +} diff --git a/packages/document-api/src/types/create.types.ts b/packages/document-api/src/types/create.types.ts new file mode 100644 index 000000000..3d1ce3132 --- /dev/null +++ b/packages/document-api/src/types/create.types.ts @@ -0,0 +1,28 @@ +import type { TextAddress } from './address.js'; +import type { BlockNodeAddress } from './base.js'; +import type { ReceiptFailure, ReceiptInsert } from './receipt.js'; + +export type ParagraphCreateLocation = + | { kind: 'documentStart' } + | { kind: 'documentEnd' } + | { kind: 'before'; target: BlockNodeAddress } + | { kind: 'after'; target: BlockNodeAddress }; + +export interface CreateParagraphInput { + at?: ParagraphCreateLocation; + text?: string; +} + +export interface CreateParagraphSuccessResult { + success: true; + paragraph: BlockNodeAddress; + insertionPoint: TextAddress; + trackedChangeRefs?: ReceiptInsert[]; +} + +export interface CreateParagraphFailureResult { + success: false; + failure: ReceiptFailure; +} + +export type CreateParagraphResult = CreateParagraphSuccessResult | CreateParagraphFailureResult; diff --git a/packages/document-api/src/types/index.ts b/packages/document-api/src/types/index.ts new file mode 100644 index 000000000..a109a6397 --- /dev/null +++ b/packages/document-api/src/types/index.ts @@ -0,0 +1,15 @@ +export * from './base.js'; +export * from './node.js'; +export * from './query.js'; +export * from './address.js'; +export * from './receipt.js'; +export * from './paragraph.types.js'; +export * from './inline.types.js'; +export * from './tables.types.js'; +export * from './media.types.js'; +export * from './structured.types.js'; +export * from './comments.types.js'; +export * from './references.types.js'; +export * from './track-changes.types.js'; +export * from './create.types.js'; +export * from './info.types.js'; diff --git a/packages/document-api/src/types/info.types.ts b/packages/document-api/src/types/info.types.ts new file mode 100644 index 000000000..48b7344d9 --- /dev/null +++ b/packages/document-api/src/types/info.types.ts @@ -0,0 +1,27 @@ +export interface DocumentInfoCounts { + words: number; + paragraphs: number; + headings: number; + tables: number; + images: number; + comments: number; +} + +export interface DocumentInfoOutlineItem { + level: number; + text: string; + nodeId: string; +} + +export interface DocumentInfoCapabilities { + canFind: boolean; + canGetNode: boolean; + canComment: boolean; + canReplace: boolean; +} + +export interface DocumentInfo { + counts: DocumentInfoCounts; + outline: DocumentInfoOutlineItem[]; + capabilities: DocumentInfoCapabilities; +} diff --git a/packages/document-api/src/types/inline.types.ts b/packages/document-api/src/types/inline.types.ts new file mode 100644 index 000000000..1cb68b1ec --- /dev/null +++ b/packages/document-api/src/types/inline.types.ts @@ -0,0 +1,31 @@ +import type { BaseNodeInfo } from './base.js'; + +export interface RunNodeInfo extends BaseNodeInfo { + nodeType: 'run'; + kind: 'inline'; + properties: RunProperties; +} + +export interface TabNodeInfo extends BaseNodeInfo { + nodeType: 'tab'; + kind: 'inline'; + properties: Record; +} + +export interface LineBreakNodeInfo extends BaseNodeInfo { + nodeType: 'lineBreak'; + kind: 'inline'; + properties: Record; +} + +export interface RunProperties { + bold?: boolean; + italic?: boolean; + underline?: boolean; + font?: string; + size?: number; + color?: string; + highlight?: string; + styleId?: string; + language?: string; +} diff --git a/packages/document-api/src/types/media.types.ts b/packages/document-api/src/types/media.types.ts new file mode 100644 index 000000000..058d20c75 --- /dev/null +++ b/packages/document-api/src/types/media.types.ts @@ -0,0 +1,20 @@ +import type { BaseNodeInfo, NodeKind } from './base.js'; + +export interface ImageNodeInfo extends BaseNodeInfo { + nodeType: 'image'; + kind: NodeKind; + properties: ImageProperties; +} + +export interface ImageSize { + width?: number; + height?: number; + unit?: 'px' | 'pt' | 'twip'; +} + +export interface ImageProperties { + src?: string; + alt?: string; + size?: ImageSize; + wrap?: string; +} diff --git a/packages/document-api/src/types/node.ts b/packages/document-api/src/types/node.ts new file mode 100644 index 000000000..25177d710 --- /dev/null +++ b/packages/document-api/src/types/node.ts @@ -0,0 +1,29 @@ +/** + * Full NodeInfo union — assembled from leaf node-info files. + * Base types (NodeKind, NodeType, BaseNodeInfo, addresses) live in base.ts. + */ + +import type { HeadingNodeInfo, ListItemNodeInfo, ParagraphNodeInfo } from './paragraph.types.js'; +import type { LineBreakNodeInfo, RunNodeInfo, TabNodeInfo } from './inline.types.js'; +import type { TableCellNodeInfo, TableNodeInfo, TableRowNodeInfo } from './tables.types.js'; +import type { ImageNodeInfo } from './media.types.js'; +import type { BookmarkNodeInfo, HyperlinkNodeInfo, SdtNodeInfo } from './structured.types.js'; +import type { CommentNodeInfo } from './comments.types.js'; +import type { FootnoteRefNodeInfo } from './references.types.js'; + +export type NodeInfo = + | ParagraphNodeInfo + | HeadingNodeInfo + | ListItemNodeInfo + | TableNodeInfo + | TableRowNodeInfo + | TableCellNodeInfo + | ImageNodeInfo + | SdtNodeInfo + | RunNodeInfo + | BookmarkNodeInfo + | CommentNodeInfo + | HyperlinkNodeInfo + | FootnoteRefNodeInfo + | TabNodeInfo + | LineBreakNodeInfo; diff --git a/packages/document-api/src/types/paragraph.types.ts b/packages/document-api/src/types/paragraph.types.ts new file mode 100644 index 000000000..34758273e --- /dev/null +++ b/packages/document-api/src/types/paragraph.types.ts @@ -0,0 +1,71 @@ +import type { BaseNodeInfo } from './base.js'; + +export interface ParagraphNodeInfo extends BaseNodeInfo { + nodeType: 'paragraph'; + kind: 'block'; + properties: ParagraphProperties; +} + +export interface HeadingNodeInfo extends BaseNodeInfo { + nodeType: 'heading'; + kind: 'block'; + properties: HeadingProperties; +} + +export interface ListItemNodeInfo extends BaseNodeInfo { + nodeType: 'listItem'; + kind: 'block'; + properties: ListItemProperties; +} + +export type ParagraphIndentation = { + left?: number; + right?: number; + firstLine?: number; + hanging?: number; + unit?: 'twip' | 'pt' | 'px'; +}; + +export type ParagraphSpacing = { + before?: number; + after?: number; + line?: number; + unit?: 'twip' | 'pt' | 'px'; +}; + +export type ParagraphNumbering = { + numId?: number; + level?: number; +}; + +export type ListNumbering = { + marker?: string; + path?: number[]; + ordinal?: number; + listIndex?: number; +}; + +export interface ParagraphProperties { + styleId?: string; + alignment?: 'left' | 'right' | 'center' | 'justify' | 'start' | 'end' | 'distributed'; + indentation?: ParagraphIndentation; + spacing?: ParagraphSpacing; + keepWithNext?: boolean; + outlineLevel?: number; + paragraphNumbering?: ParagraphNumbering; +} + +export interface HeadingProperties extends ParagraphProperties { + /** + * Headings are paragraphs with a heading style. + */ + headingLevel: 1 | 2 | 3 | 4 | 5 | 6; +} + +export interface ListItemProperties extends ParagraphProperties { + /** + * List items are paragraphs with numbering. + * This keeps list semantics explicit without creating a separate structure. + */ + numbering?: ListNumbering; +} diff --git a/packages/document-api/src/types/query.ts b/packages/document-api/src/types/query.ts new file mode 100644 index 000000000..c1af9a0c2 --- /dev/null +++ b/packages/document-api/src/types/query.ts @@ -0,0 +1,87 @@ +import type { NodeAddress, NodeKind, NodeType } from './base.js'; +import type { NodeInfo } from './node.js'; +import type { Range, TextAddress } from './address.js'; + +export interface TextSelector { + type: 'text'; + pattern: string; + /** + * Controls text matching strategy. + * - `contains`: literal substring matching (default) + * - `regex`: regular expression matching + */ + mode?: 'contains' | 'regex'; + /** + * Controls case sensitivity for text matching. + * Defaults to false (case-insensitive). + */ + caseSensitive?: boolean; +} + +export interface NodeSelector { + type: 'node'; + nodeType?: NodeType; + kind?: NodeKind; +} + +/** + * Selector shorthand for find queries. + * + * `{ nodeType: 'paragraph' }` is sugar for `{ type: 'node', nodeType: 'paragraph' }`. + * + * For dual-context node types (`sdt`, `image`), omitting `kind` + * may return both block and inline matches. + */ +export type Selector = { nodeType: NodeType } | NodeSelector | TextSelector; + +export interface Query { + /** Selector that determines which nodes to match. */ + select: NodeSelector | TextSelector; + within?: NodeAddress; + limit?: number; + offset?: number; + /** + * Whether to hydrate `result.nodes` for matched addresses. + * This is independent from text-match context, which is intrinsic for text selectors. + */ + includeNodes?: boolean; + /** + * Controls whether unknown nodes are returned in diagnostics. + * Unknown nodes are never included in matches. + */ + includeUnknown?: boolean; +} + +export interface MatchContext { + address: NodeAddress; + snippet: string; + highlightRange: Range; + /** + * Text ranges matching the query, expressed as block-relative offsets. + * For cross-paragraph matches, this will include one range per block. + * + * These ranges can be passed as targets to mutation operations. + */ + textRanges?: TextAddress[]; +} + +export interface UnknownNodeDiagnostic { + message: string; + address?: NodeAddress; + hint?: string; +} + +export interface QueryResult { + /** + * Matched node addresses. + * + * For text selectors, these addresses identify containing block nodes. + * Exact matched spans are exposed via `context[*].textRanges`. + */ + matches: NodeAddress[]; + total: number; + /** Optional hydrated node payloads aligned with `matches` when `includeNodes` is true. */ + nodes?: NodeInfo[]; + context?: MatchContext[]; + diagnostics?: UnknownNodeDiagnostic[]; +} diff --git a/packages/document-api/src/types/receipt.ts b/packages/document-api/src/types/receipt.ts new file mode 100644 index 000000000..78a358102 --- /dev/null +++ b/packages/document-api/src/types/receipt.ts @@ -0,0 +1,56 @@ +import type { EntityAddress, TextAddress, TrackedChangeAddress } from './address.js'; + +export type ReceiptInsert = TrackedChangeAddress; +export type ReceiptEntity = EntityAddress; + +export type ReceiptFailureCode = 'NO_OP' | 'INVALID_TARGET' | 'TARGET_NOT_FOUND' | 'CAPABILITY_UNAVAILABLE'; + +export type ReceiptFailure = { + code: ReceiptFailureCode; + message: string; + details?: unknown; +}; + +export type ReceiptSuccess = { + success: true; + inserted?: ReceiptEntity[]; + updated?: ReceiptEntity[]; + removed?: ReceiptEntity[]; +}; + +export type ReceiptFailureResult = { + success: false; + failure: ReceiptFailure; +}; + +export type Receipt = ReceiptSuccess | ReceiptFailureResult; + +export type TextMutationRange = { + from: number; + to: number; +}; + +export type TextMutationResolution = { + /** + * Requested input target from the caller, when provided. + * For insert-without-target calls this is omitted. + */ + requestedTarget?: TextAddress; + /** + * Effective target used by the adapter after canonical resolution. + */ + target: TextAddress; + /** + * Engine-resolved absolute document range for the effective target. + */ + range: TextMutationRange; + /** + * Snapshot of text currently covered by the resolved range. + * Empty for collapsed insert targets. + */ + text: string; +}; + +export type TextMutationReceipt = + | (ReceiptSuccess & { resolution: TextMutationResolution }) + | (ReceiptFailureResult & { resolution: TextMutationResolution }); diff --git a/packages/document-api/src/types/references.types.ts b/packages/document-api/src/types/references.types.ts new file mode 100644 index 000000000..6cf807482 --- /dev/null +++ b/packages/document-api/src/types/references.types.ts @@ -0,0 +1,11 @@ +import type { BaseNodeInfo } from './base.js'; + +export interface FootnoteRefNodeInfo extends BaseNodeInfo { + nodeType: 'footnoteRef'; + kind: 'inline'; + properties: FootnoteRefProperties; +} + +export interface FootnoteRefProperties { + noteId?: string; +} diff --git a/packages/document-api/src/types/structured.types.ts b/packages/document-api/src/types/structured.types.ts new file mode 100644 index 000000000..459d49347 --- /dev/null +++ b/packages/document-api/src/types/structured.types.ts @@ -0,0 +1,38 @@ +import type { BaseNodeInfo, NodeKind } from './base.js'; + +export interface SdtNodeInfo extends BaseNodeInfo { + nodeType: 'sdt'; + kind: NodeKind; + properties: SdtProperties; +} + +export interface SdtProperties { + tag?: string; + alias?: string; + type?: string; + appearance?: string; + placeholder?: string; +} + +export interface BookmarkNodeInfo extends BaseNodeInfo { + nodeType: 'bookmark'; + kind: 'inline'; + properties: BookmarkProperties; +} + +export interface HyperlinkNodeInfo extends BaseNodeInfo { + nodeType: 'hyperlink'; + kind: 'inline'; + properties: HyperlinkProperties; +} + +export interface BookmarkProperties { + name?: string; + bookmarkId?: string; +} + +export interface HyperlinkProperties { + href?: string; + anchor?: string; + tooltip?: string; +} diff --git a/packages/document-api/src/types/tables.types.ts b/packages/document-api/src/types/tables.types.ts new file mode 100644 index 000000000..f4e6c0910 --- /dev/null +++ b/packages/document-api/src/types/tables.types.ts @@ -0,0 +1,50 @@ +import type { BaseNodeInfo } from './base.js'; + +export interface TableNodeInfo extends BaseNodeInfo { + nodeType: 'table'; + kind: 'block'; + properties: TableProperties; +} + +export interface TableRowNodeInfo extends BaseNodeInfo { + nodeType: 'tableRow'; + kind: 'block'; + properties: TableRowProperties; +} + +export interface TableCellNodeInfo extends BaseNodeInfo { + nodeType: 'tableCell'; + kind: 'block'; + properties: TableCellProperties; +} + +export interface TableBorders { + top?: string; + right?: string; + bottom?: string; + left?: string; + insideH?: string; + insideV?: string; +} + +export interface TableProperties { + layout?: string; + width?: number; + alignment?: 'left' | 'center' | 'right' | 'inside' | 'outside'; + borders?: TableBorders; +} + +export interface TableRowProperties { + rowIndex?: number; +} + +export interface TableCellProperties { + rowIndex?: number; + colIndex?: number; + width?: number; + shading?: string; + vMerge?: boolean; + gridSpan?: number; + padding?: number; + borders?: TableBorders; +} diff --git a/packages/document-api/src/types/track-changes.types.ts b/packages/document-api/src/types/track-changes.types.ts new file mode 100644 index 000000000..ae2938ca6 --- /dev/null +++ b/packages/document-api/src/types/track-changes.types.ts @@ -0,0 +1,27 @@ +import type { TrackedChangeAddress } from './address.js'; + +export type TrackChangeType = 'insert' | 'delete' | 'format'; + +export interface TrackChangeInfo { + address: TrackedChangeAddress; + /** Convenience alias for `address.entityId`. */ + id: string; + type: TrackChangeType; + author?: string; + authorEmail?: string; + authorImage?: string; + date?: string; + excerpt?: string; +} + +export interface TrackChangesListQuery { + limit?: number; + offset?: number; + type?: TrackChangeType; +} + +export interface TrackChangesListResult { + matches: TrackedChangeAddress[]; + total: number; + changes?: TrackChangeInfo[]; +} diff --git a/packages/document-api/tsconfig.json b/packages/document-api/tsconfig.json new file mode 100644 index 000000000..a2da0aeb8 --- /dev/null +++ b/packages/document-api/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "dist", + "types": ["vitest/globals"] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/document-api/vite.config.js b/packages/document-api/vite.config.js new file mode 100644 index 000000000..4f34b51ad --- /dev/null +++ b/packages/document-api/vite.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + name: '@superdoc/document-api', + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}); From a48aeed119d7e084b9572e2a4d384d323b11e8fb Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Mon, 16 Feb 2026 17:25:49 -0800 Subject: [PATCH 02/25] feat(document-api): add mutating/comment/list/track-changes API modules --- packages/document-api/src/README.md | 67 ++ .../document-api/src/comments/comments.ts | 141 +++++ .../src/comments/comments.types.ts | 26 + .../document-api/src/create/create.test.ts | 62 ++ packages/document-api/src/create/create.ts | 28 + packages/document-api/src/delete/delete.ts | 22 + packages/document-api/src/format/format.ts | 21 + packages/document-api/src/index.test.ts | 593 ++++++++++++++++++ packages/document-api/src/index.ts | 301 ++++++++- packages/document-api/src/insert/insert.ts | 26 + packages/document-api/src/lists/lists.ts | 103 +++ .../document-api/src/lists/lists.types.ts | 91 +++ packages/document-api/src/replace/replace.ts | 23 + .../src/track-changes/track-changes.ts | 69 ++ packages/document-api/src/write/write.test.ts | 23 + packages/document-api/src/write/write.ts | 61 ++ 16 files changed, 1654 insertions(+), 3 deletions(-) create mode 100644 packages/document-api/src/README.md create mode 100644 packages/document-api/src/comments/comments.ts create mode 100644 packages/document-api/src/comments/comments.types.ts create mode 100644 packages/document-api/src/create/create.test.ts create mode 100644 packages/document-api/src/create/create.ts create mode 100644 packages/document-api/src/delete/delete.ts create mode 100644 packages/document-api/src/format/format.ts create mode 100644 packages/document-api/src/index.test.ts create mode 100644 packages/document-api/src/insert/insert.ts create mode 100644 packages/document-api/src/lists/lists.ts create mode 100644 packages/document-api/src/lists/lists.types.ts create mode 100644 packages/document-api/src/replace/replace.ts create mode 100644 packages/document-api/src/track-changes/track-changes.ts create mode 100644 packages/document-api/src/write/write.test.ts create mode 100644 packages/document-api/src/write/write.ts diff --git a/packages/document-api/src/README.md b/packages/document-api/src/README.md new file mode 100644 index 000000000..e36a42f8f --- /dev/null +++ b/packages/document-api/src/README.md @@ -0,0 +1,67 @@ +# Document API + +## Non-Negotiables + +- The Document API modules are engine-agnostic and must never parse or depend on ProseMirror directly. +- The Document API must not implement new engine-specific domain logic. It defines types/contracts and delegates to adapters. +- Adapters are engine-specific implementations (for `super-editor`, ProseMirror adapters) and may use engine internals and bridging logic to satisfy the API contract. +- The Document API must receive adapters via dependency injection. +- If a capability is missing, prefer adding an editor command. If a gap remains, put bridge logic in adapters, not in `document-api/*`. + +## Packaging Assumptions (Internal Only) + +- `@superdoc/document-api` is an internal workspace package (`"private": true`) with no external consumers. +- Package exports intentionally point to source files (no `dist` build output) to match the monorepo's source-resolution setup. +- This is valid only while all consumers resolve workspace source with the same conditions/tooling. +- If this package is ever published or consumed outside this monorepo resolution model, add a build step and export compiled JS + `.d.ts` from `dist`. + +## Purpose + +This package defines the Document API surface and type contracts. Editor-specific behavior +lives in adapter layers that map engine behavior into `QueryResult` and other API outputs. + +## Selector Semantics + +- For dual-context types (`sdt`, `image`), selectors without an explicit `kind` may return both block and inline matches. +- Set `kind: 'block'` or `kind: 'inline'` on `{ type: 'node' }` selectors when you need only one context. + +## Find Result Contract + +- `find` always returns `matches` as `NodeAddress[]`. +- For text selectors (`{ type: 'text', ... }`), `matches` are containing block addresses. +- Exact matched spans are returned in `context[*].textRanges` as `TextAddress`. +- Mutating operations should target `TextAddress` values from `context[*].textRanges`. +- `insert` also supports omitting `target`; adapters resolve a deterministic default insertion point (first paragraph start when available). +- Structural creation is exposed under `create.*` (for example `create.paragraph`), separate from text mutations. + +## Adapter Error Convention + +- Return diagnostics for query/content issues (invalid regex input, unknown selector types, unresolved `within` targets). +- Throw errors for engine capability/configuration failures (for example, required editor commands not being available). +- For mutating operations, failure outcomes must be non-applied outcomes. + - `success: false` means the operation did not apply a durable document mutation. + - If a mutation is applied, adapters must return success (or a typed partial/warning outcome when explicitly modeled) and must not throw a post-apply not-found error. + +## Tracked-Change Semantics + +- Tracking is operation-scoped (`changeMode: 'direct' | 'tracked'`), not global editor-mode state. +- `insert`, `replace`, `delete`, `format.bold`, and `create.paragraph` may run in tracked mode. +- `trackChanges.*` (`list`, `get`, `accept`, `reject`, `acceptAll`, `rejectAll`) is the review lifecycle namespace. +- `lists.insert` may run in tracked mode; `lists.setType|indent|outdent|restart|exit` are direct-only in v1. + +## List Namespace Semantics + +- `lists.*` projects paragraph-based numbering into first-class `listItem` addresses. +- `ListItemAddress.nodeId` reuses the underlying paragraph node id directly. +- `lists.list({ within })` is inclusive when `within` itself is a list item. +- `lists.setType` normalizes deterministically to canonical defaults (`ordered` decimal / `bullet` default bullet). +- `lists.insert` returns `insertionPoint` at the inserted item start (`offset: 0`) even when text is provided. +- `lists.restart` returns `NO_OP` only when target is already the first item of its contiguous run and effectively starts at `1`. + +Deterministic outcomes: +- Unknown tracked-change ids must fail with `TARGET_NOT_FOUND` at adapter level. +- `acceptAll`/`rejectAll` with no applicable changes must return `Receipt.failure.code = 'NO_OP'`. +- Missing tracked-change capabilities must fail with `CAPABILITY_UNAVAILABLE`. +- Text/format targets that cannot be resolved after remote edits must fail deterministically (`TARGET_NOT_FOUND` / `NO_OP`), never silently mutate the wrong range. +- Tracked entity IDs returned by mutation receipts (`insert` / `replace` / `delete`) and `create.paragraph.trackedChangeRefs` must match canonical IDs from `trackChanges.list`. +- `trackChanges.get` / `accept` / `reject` accept canonical IDs only. diff --git a/packages/document-api/src/comments/comments.ts b/packages/document-api/src/comments/comments.ts new file mode 100644 index 000000000..719465a34 --- /dev/null +++ b/packages/document-api/src/comments/comments.ts @@ -0,0 +1,141 @@ +import type { Receipt, TextAddress } from '../types/index.js'; +import type { CommentInfo, CommentsListQuery, CommentsListResult } from './comments.types.js'; + +/** + * Input for adding a comment to a text range. + */ +export interface AddCommentInput { + /** + * The text range to attach the comment to. + * + * Note: text matches can span multiple blocks; callers should pick a single + * block range (e.g., the first `textRanges` entry from `find`) until + * multi-block comment targets are supported. + */ + target: TextAddress; + /** The comment body text. */ + text: string; +} + +export interface EditCommentInput { + commentId: string; + text: string; +} + +export interface ReplyToCommentInput { + parentCommentId: string; + text: string; +} + +export interface MoveCommentInput { + commentId: string; + target: TextAddress; +} + +export interface ResolveCommentInput { + commentId: string; +} + +export interface RemoveCommentInput { + commentId: string; +} + +export interface SetCommentInternalInput { + commentId: string; + isInternal: boolean; +} + +export interface SetCommentActiveInput { + commentId: string | null; +} + +export interface GoToCommentInput { + commentId: string; +} + +export interface GetCommentInput { + commentId: string; +} + +/** + * Engine-specific adapter that the comments API delegates to. + */ +export interface CommentsAdapter { + /** Add a comment at the specified text range. */ + add(input: AddCommentInput): Receipt; + /** Edit the body text of an existing comment. */ + edit(input: EditCommentInput): Receipt; + /** Reply to an existing comment thread. */ + reply(input: ReplyToCommentInput): Receipt; + /** Move a comment to a different text range. */ + move(input: MoveCommentInput): Receipt; + /** Resolve an open comment. */ + resolve(input: ResolveCommentInput): Receipt; + /** Remove a comment from the document. */ + remove(input: RemoveCommentInput): Receipt; + /** Set the internal/private flag on a comment. */ + setInternal(input: SetCommentInternalInput): Receipt; + /** Set which comment is currently active/focused. Pass `null` to clear. */ + setActive(input: SetCommentActiveInput): Receipt; + /** Scroll to and focus a comment in the document. */ + goTo(input: GoToCommentInput): Receipt; + /** Retrieve full information for a single comment. */ + get(input: GetCommentInput): CommentInfo; + /** List comments matching the given query. */ + list(query?: CommentsListQuery): CommentsListResult; +} + +/** + * Public comments API surface exposed on `editor.doc.comments`. + */ +export type CommentsApi = CommentsAdapter; + +/** + * Execute wrappers below are the canonical interception point for input + * normalization and validation. Query-only operations currently pass through + * directly. Mutation operations will gain validation as the API matures. + * Keep the wrappers to preserve this extension surface. + */ +export function executeAddComment(adapter: CommentsAdapter, input: AddCommentInput): Receipt { + return adapter.add(input); +} + +export function executeEditComment(adapter: CommentsAdapter, input: EditCommentInput): Receipt { + return adapter.edit(input); +} + +export function executeReplyToComment(adapter: CommentsAdapter, input: ReplyToCommentInput): Receipt { + return adapter.reply(input); +} + +export function executeMoveComment(adapter: CommentsAdapter, input: MoveCommentInput): Receipt { + return adapter.move(input); +} + +export function executeResolveComment(adapter: CommentsAdapter, input: ResolveCommentInput): Receipt { + return adapter.resolve(input); +} + +export function executeRemoveComment(adapter: CommentsAdapter, input: RemoveCommentInput): Receipt { + return adapter.remove(input); +} + +export function executeSetCommentInternal(adapter: CommentsAdapter, input: SetCommentInternalInput): Receipt { + return adapter.setInternal(input); +} + +export function executeSetCommentActive(adapter: CommentsAdapter, input: SetCommentActiveInput): Receipt { + return adapter.setActive(input); +} + +export function executeGoToComment(adapter: CommentsAdapter, input: GoToCommentInput): Receipt { + return adapter.goTo(input); +} + +export function executeGetComment(adapter: CommentsAdapter, input: GetCommentInput): CommentInfo { + return adapter.get(input); +} + +export function executeListComments(adapter: CommentsAdapter, query?: CommentsListQuery): CommentsListResult { + return adapter.list(query); +} diff --git a/packages/document-api/src/comments/comments.types.ts b/packages/document-api/src/comments/comments.types.ts new file mode 100644 index 000000000..6ba083e20 --- /dev/null +++ b/packages/document-api/src/comments/comments.types.ts @@ -0,0 +1,26 @@ +import type { CommentAddress, CommentStatus, TextAddress } from '../types/index.js'; + +export type { CommentStatus } from '../types/index.js'; + +export interface CommentInfo { + address: CommentAddress; + commentId: string; + importedId?: string; + parentCommentId?: string; + text?: string; + isInternal?: boolean; + status: CommentStatus; + target?: TextAddress; + createdTime?: number; + creatorName?: string; + creatorEmail?: string; +} + +export interface CommentsListQuery { + includeResolved?: boolean; +} + +export interface CommentsListResult { + matches: CommentInfo[]; + total: number; +} diff --git a/packages/document-api/src/create/create.test.ts b/packages/document-api/src/create/create.test.ts new file mode 100644 index 000000000..f125a65f2 --- /dev/null +++ b/packages/document-api/src/create/create.test.ts @@ -0,0 +1,62 @@ +import { normalizeCreateParagraphInput } from './create.js'; + +describe('normalizeCreateParagraphInput', () => { + it('defaults location to documentEnd when at is omitted', () => { + const result = normalizeCreateParagraphInput({}); + + expect(result.at).toEqual({ kind: 'documentEnd' }); + }); + + it('defaults text to empty string when omitted', () => { + const result = normalizeCreateParagraphInput({}); + + expect(result.text).toBe(''); + }); + + it('defaults both at and text when input is empty', () => { + const result = normalizeCreateParagraphInput({}); + + expect(result).toEqual({ + at: { kind: 'documentEnd' }, + text: '', + }); + }); + + it('preserves explicit documentStart location', () => { + const result = normalizeCreateParagraphInput({ at: { kind: 'documentStart' } }); + + expect(result.at).toEqual({ kind: 'documentStart' }); + }); + + it('preserves explicit before location with target', () => { + const target = { kind: 'block' as const, nodeType: 'paragraph' as const, nodeId: 'p1' }; + const result = normalizeCreateParagraphInput({ at: { kind: 'before', target } }); + + expect(result.at).toEqual({ kind: 'before', target }); + }); + + it('preserves explicit after location with target', () => { + const target = { kind: 'block' as const, nodeType: 'heading' as const, nodeId: 'h1' }; + const result = normalizeCreateParagraphInput({ at: { kind: 'after', target } }); + + expect(result.at).toEqual({ kind: 'after', target }); + }); + + it('preserves explicit text', () => { + const result = normalizeCreateParagraphInput({ text: 'Hello world' }); + + expect(result.text).toBe('Hello world'); + }); + + it('preserves both explicit at and text', () => { + const result = normalizeCreateParagraphInput({ + at: { kind: 'documentStart' }, + text: 'First paragraph', + }); + + expect(result).toEqual({ + at: { kind: 'documentStart' }, + text: 'First paragraph', + }); + }); +}); diff --git a/packages/document-api/src/create/create.ts b/packages/document-api/src/create/create.ts new file mode 100644 index 000000000..7645c982c --- /dev/null +++ b/packages/document-api/src/create/create.ts @@ -0,0 +1,28 @@ +import type { MutationOptions } from '../write/write.js'; +import { normalizeMutationOptions } from '../write/write.js'; +import type { CreateParagraphInput, CreateParagraphResult, ParagraphCreateLocation } from '../types/create.types.js'; + +export interface CreateApi { + paragraph(input: CreateParagraphInput, options?: MutationOptions): CreateParagraphResult; +} + +export type CreateAdapter = CreateApi; + +function normalizeParagraphCreateLocation(location?: ParagraphCreateLocation): ParagraphCreateLocation { + return location ?? { kind: 'documentEnd' }; +} + +export function normalizeCreateParagraphInput(input: CreateParagraphInput): CreateParagraphInput { + return { + at: normalizeParagraphCreateLocation(input.at), + text: input.text ?? '', + }; +} + +export function executeCreateParagraph( + adapter: CreateAdapter, + input: CreateParagraphInput, + options?: MutationOptions, +): CreateParagraphResult { + return adapter.paragraph(normalizeCreateParagraphInput(input), normalizeMutationOptions(options)); +} diff --git a/packages/document-api/src/delete/delete.ts b/packages/document-api/src/delete/delete.ts new file mode 100644 index 000000000..6616814a8 --- /dev/null +++ b/packages/document-api/src/delete/delete.ts @@ -0,0 +1,22 @@ +import { executeWrite, type MutationOptions, type WriteAdapter } from '../write/write.js'; +import type { TextAddress, TextMutationReceipt } from '../types/index.js'; + +export interface DeleteInput { + target: TextAddress; +} + +export function executeDelete( + adapter: WriteAdapter, + input: DeleteInput, + options?: MutationOptions, +): TextMutationReceipt { + return executeWrite( + adapter, + { + kind: 'delete', + target: input.target, + text: '', + }, + options, + ); +} diff --git a/packages/document-api/src/format/format.ts b/packages/document-api/src/format/format.ts new file mode 100644 index 000000000..688871408 --- /dev/null +++ b/packages/document-api/src/format/format.ts @@ -0,0 +1,21 @@ +import { normalizeMutationOptions, type MutationOptions } from '../write/write.js'; +import type { TextAddress, TextMutationReceipt } from '../types/index.js'; + +export interface FormatBoldInput { + target: TextAddress; +} + +export interface FormatAdapter { + /** Apply or toggle bold formatting on the target text range. */ + bold(input: FormatBoldInput, options?: MutationOptions): TextMutationReceipt; +} + +export type FormatApi = FormatAdapter; + +export function executeFormatBold( + adapter: FormatAdapter, + input: FormatBoldInput, + options?: MutationOptions, +): TextMutationReceipt { + return adapter.bold(input, normalizeMutationOptions(options)); +} diff --git a/packages/document-api/src/index.test.ts b/packages/document-api/src/index.test.ts new file mode 100644 index 000000000..d123f2aac --- /dev/null +++ b/packages/document-api/src/index.test.ts @@ -0,0 +1,593 @@ +import type { DocumentInfo, NodeAddress, NodeInfo, Query, QueryResult } from './types/index.js'; +import type { + AddCommentInput, + CommentsAdapter, + EditCommentInput, + GetCommentInput, + GoToCommentInput, + MoveCommentInput, + RemoveCommentInput, + ReplyToCommentInput, + ResolveCommentInput, + SetCommentActiveInput, + SetCommentInternalInput, +} from './comments/comments.js'; +import type { FormatAdapter } from './format/format.js'; +import type { FindAdapter } from './find/find.js'; +import type { GetNodeAdapter } from './get-node/get-node.js'; +import type { TrackChangesAdapter } from './track-changes/track-changes.js'; +import type { WriteAdapter } from './write/write.js'; +import { createDocumentApi } from './index.js'; +import type { CommentInfo, CommentsListQuery, CommentsListResult } from './comments/comments.types.js'; +import type { CreateAdapter } from './create/create.js'; +import type { ListsAdapter } from './lists/lists.js'; + +function makeFindAdapter(result: QueryResult): FindAdapter { + return { find: vi.fn(() => result) }; +} + +function makeGetNodeAdapter(info: NodeInfo): GetNodeAdapter { + return { + getNode: vi.fn(() => info), + getNodeById: vi.fn((_input) => info), + }; +} + +function makeGetTextAdapter(text = '') { + return { + getText: vi.fn((_input) => text), + }; +} + +function makeInfoAdapter(result?: Partial) { + const defaultResult: DocumentInfo = { + counts: { + words: 0, + paragraphs: 0, + headings: 0, + tables: 0, + images: 0, + comments: 0, + }, + outline: [], + capabilities: { + canFind: true, + canGetNode: true, + canComment: true, + canReplace: true, + }, + }; + + return { + info: vi.fn((_input) => ({ + ...defaultResult, + ...result, + counts: { + ...defaultResult.counts, + ...(result?.counts ?? {}), + }, + capabilities: { + ...defaultResult.capabilities, + ...(result?.capabilities ?? {}), + }, + outline: result?.outline ?? defaultResult.outline, + })), + }; +} + +function makeCommentsAdapter(): CommentsAdapter { + return { + add: vi.fn(() => ({ success: true as const })), + edit: vi.fn(() => ({ success: true as const })), + reply: vi.fn(() => ({ success: true as const })), + move: vi.fn(() => ({ success: true as const })), + resolve: vi.fn(() => ({ success: true as const })), + remove: vi.fn(() => ({ success: true as const })), + setInternal: vi.fn(() => ({ success: true as const })), + setActive: vi.fn(() => ({ success: true as const })), + goTo: vi.fn(() => ({ success: true as const })), + get: vi.fn(() => ({ + address: { kind: 'entity' as const, entityType: 'comment' as const, entityId: 'c1' }, + commentId: 'c1', + status: 'open' as const, + })), + list: vi.fn(() => ({ matches: [], total: 0 })), + }; +} + +function makeWriteAdapter(): WriteAdapter { + return { + write: vi.fn(() => ({ + success: true as const, + resolution: { + target: { kind: 'text' as const, blockId: 'p1', range: { start: 0, end: 0 } }, + range: { from: 1, to: 1 }, + text: '', + }, + })), + }; +} + +function makeFormatAdapter(): FormatAdapter { + return { + bold: vi.fn(() => ({ + success: true as const, + resolution: { + target: { kind: 'text' as const, blockId: 'p1', range: { start: 0, end: 2 } }, + range: { from: 1, to: 3 }, + text: 'Hi', + }, + })), + }; +} + +function makeTrackChangesAdapter(): TrackChangesAdapter { + return { + list: vi.fn((_input) => ({ matches: [], total: 0 })), + get: vi.fn((input: { id: string }) => ({ + address: { kind: 'entity' as const, entityType: 'trackedChange' as const, entityId: input.id }, + id: input.id, + type: 'insert' as const, + })), + accept: vi.fn((_input) => ({ success: true as const })), + reject: vi.fn((_input) => ({ success: true as const })), + acceptAll: vi.fn((_input) => ({ success: true as const })), + rejectAll: vi.fn((_input) => ({ success: true as const })), + }; +} + +function makeCreateAdapter(): CreateAdapter { + return { + paragraph: vi.fn(() => ({ + success: true as const, + paragraph: { kind: 'block' as const, nodeType: 'paragraph' as const, nodeId: 'new-p' }, + insertionPoint: { kind: 'text' as const, blockId: 'new-p', range: { start: 0, end: 0 } }, + })), + }; +} + +function makeListsAdapter(): ListsAdapter { + return { + list: vi.fn(() => ({ matches: [], total: 0, items: [] })), + get: vi.fn(() => ({ + address: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + kind: 'ordered' as const, + level: 0, + text: 'List item', + })), + insert: vi.fn(() => ({ + success: true as const, + item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-2' }, + insertionPoint: { kind: 'text' as const, blockId: 'li-2', range: { start: 0, end: 0 } }, + })), + setType: vi.fn(() => ({ + success: true as const, + item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + })), + indent: vi.fn(() => ({ + success: true as const, + item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + })), + outdent: vi.fn(() => ({ + success: true as const, + item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + })), + restart: vi.fn(() => ({ + success: true as const, + item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + })), + exit: vi.fn(() => ({ + success: true as const, + paragraph: { kind: 'block' as const, nodeType: 'paragraph' as const, nodeId: 'p1' }, + })), + }; +} + +const PARAGRAPH_ADDRESS: NodeAddress = { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }; + +const PARAGRAPH_INFO: NodeInfo = { + nodeType: 'paragraph', + kind: 'block', + properties: {}, +}; + +const QUERY_RESULT: QueryResult = { + matches: [PARAGRAPH_ADDRESS], + total: 1, +}; + +describe('createDocumentApi', () => { + it('delegates find to the find adapter', () => { + const findAdapter = makeFindAdapter(QUERY_RESULT); + const api = createDocumentApi({ + find: findAdapter, + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const query: Query = { select: { nodeType: 'paragraph' } }; + const result = api.find(query); + + expect(result).toEqual(QUERY_RESULT); + expect(findAdapter.find).toHaveBeenCalledTimes(1); + }); + + it('delegates find with selector shorthand', () => { + const findAdapter = makeFindAdapter(QUERY_RESULT); + const api = createDocumentApi({ + find: findAdapter, + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const result = api.find({ nodeType: 'paragraph' }, { limit: 5 }); + + expect(result).toEqual(QUERY_RESULT); + expect(findAdapter.find).toHaveBeenCalledTimes(1); + }); + + it('delegates getNode to the getNode adapter', () => { + const getNodeAdpt = makeGetNodeAdapter(PARAGRAPH_INFO); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: getNodeAdpt, + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const info = api.getNode(PARAGRAPH_ADDRESS); + + expect(info).toEqual(PARAGRAPH_INFO); + expect(getNodeAdpt.getNode).toHaveBeenCalledWith(PARAGRAPH_ADDRESS); + }); + + it('delegates getNodeById to the getNode adapter', () => { + const getNodeAdpt = makeGetNodeAdapter(PARAGRAPH_INFO); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: getNodeAdpt, + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const info = api.getNodeById({ nodeId: 'p1', nodeType: 'paragraph' }); + + expect(info).toEqual(PARAGRAPH_INFO); + expect(getNodeAdpt.getNodeById).toHaveBeenCalledWith({ nodeId: 'p1', nodeType: 'paragraph' }); + }); + + it('delegates getText to the getText adapter', () => { + const getTextAdpt = makeGetTextAdapter('Hello world'); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: getTextAdpt, + info: makeInfoAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const text = api.getText({}); + + expect(text).toBe('Hello world'); + expect(getTextAdpt.getText).toHaveBeenCalledWith({}); + }); + + it('delegates info to the info adapter', () => { + const infoAdpt = makeInfoAdapter({ + counts: { words: 42 }, + outline: [{ level: 1, text: 'Heading', nodeId: 'h1' }], + }); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: infoAdpt, + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const result = api.info({}); + + expect(result.counts.words).toBe(42); + expect(result.outline).toEqual([{ level: 1, text: 'Heading', nodeId: 'h1' }]); + expect(infoAdpt.info).toHaveBeenCalledWith({}); + }); + + it('delegates comments.add through the comments adapter', () => { + const commentsAdpt = makeCommentsAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + comments: commentsAdpt, + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const input: AddCommentInput = { + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'test comment', + }; + const receipt = api.comments.add(input); + + expect(receipt.success).toBe(true); + expect(commentsAdpt.add).toHaveBeenCalledWith(input); + }); + + it('delegates all comments namespace commands through the comments adapter', () => { + const commentsAdpt = makeCommentsAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + comments: commentsAdpt, + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const editInput: EditCommentInput = { commentId: 'c1', text: 'edited' }; + const replyInput: ReplyToCommentInput = { parentCommentId: 'c1', text: 'reply' }; + const moveInput: MoveCommentInput = { + commentId: 'c1', + target: { kind: 'text', blockId: 'p1', range: { start: 1, end: 3 } }, + }; + const resolveInput: ResolveCommentInput = { commentId: 'c1' }; + const removeInput: RemoveCommentInput = { commentId: 'c1' }; + const setInternalInput: SetCommentInternalInput = { commentId: 'c1', isInternal: true }; + const setActiveInput: SetCommentActiveInput = { commentId: 'c1' }; + const goToInput: GoToCommentInput = { commentId: 'c1' }; + const getInput: GetCommentInput = { commentId: 'c1' }; + const listQuery: CommentsListQuery = { includeResolved: false }; + + const editReceipt = api.comments.edit(editInput); + const replyReceipt = api.comments.reply(replyInput); + const moveReceipt = api.comments.move(moveInput); + const resolveReceipt = api.comments.resolve(resolveInput); + const removeReceipt = api.comments.remove(removeInput); + const setInternalReceipt = api.comments.setInternal(setInternalInput); + const setActiveReceipt = api.comments.setActive(setActiveInput); + const goToReceipt = api.comments.goTo(goToInput); + const getResult = api.comments.get(getInput); + const listResult = api.comments.list(listQuery); + + expect(editReceipt.success).toBe(true); + expect(replyReceipt.success).toBe(true); + expect(moveReceipt.success).toBe(true); + expect(resolveReceipt.success).toBe(true); + expect(removeReceipt.success).toBe(true); + expect(setInternalReceipt.success).toBe(true); + expect(setActiveReceipt.success).toBe(true); + expect(goToReceipt.success).toBe(true); + expect((getResult as CommentInfo).commentId).toBe('c1'); + expect((listResult as CommentsListResult).total).toBe(0); + + expect(commentsAdpt.edit).toHaveBeenCalledWith(editInput); + expect(commentsAdpt.reply).toHaveBeenCalledWith(replyInput); + expect(commentsAdpt.move).toHaveBeenCalledWith(moveInput); + expect(commentsAdpt.resolve).toHaveBeenCalledWith(resolveInput); + expect(commentsAdpt.remove).toHaveBeenCalledWith(removeInput); + expect(commentsAdpt.setInternal).toHaveBeenCalledWith(setInternalInput); + expect(commentsAdpt.setActive).toHaveBeenCalledWith(setActiveInput); + expect(commentsAdpt.goTo).toHaveBeenCalledWith(goToInput); + expect(commentsAdpt.get).toHaveBeenCalledWith(getInput); + expect(commentsAdpt.list).toHaveBeenCalledWith(listQuery); + }); + + it('delegates write operations through the shared write adapter', () => { + const writeAdpt = makeWriteAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + comments: makeCommentsAdapter(), + write: writeAdpt, + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } as const; + api.insert({ text: 'Hi' }); + api.insert({ target, text: 'Yo' }); + api.replace({ target, text: 'Hello' }, { changeMode: 'tracked' }); + api.delete({ target }); + + expect(writeAdpt.write).toHaveBeenNthCalledWith( + 1, + { kind: 'insert', text: 'Hi' }, + { changeMode: 'direct', dryRun: false }, + ); + expect(writeAdpt.write).toHaveBeenNthCalledWith( + 2, + { kind: 'insert', target, text: 'Yo' }, + { changeMode: 'direct', dryRun: false }, + ); + expect(writeAdpt.write).toHaveBeenNthCalledWith( + 3, + { kind: 'replace', target, text: 'Hello' }, + { changeMode: 'tracked', dryRun: false }, + ); + expect(writeAdpt.write).toHaveBeenNthCalledWith( + 4, + { kind: 'delete', target, text: '' }, + { changeMode: 'direct', dryRun: false }, + ); + }); + + it('delegates format.bold to the format adapter', () => { + const formatAdpt = makeFormatAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: formatAdpt, + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } as const; + api.format.bold({ target }, { changeMode: 'tracked' }); + expect(formatAdpt.bold).toHaveBeenCalledWith({ target }, { changeMode: 'tracked', dryRun: false }); + }); + + it('delegates trackChanges namespace operations', () => { + const trackAdpt = makeTrackChangesAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: trackAdpt, + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const listResult = api.trackChanges.list({ limit: 1 }); + const getResult = api.trackChanges.get({ id: 'tc-1' }); + const acceptResult = api.trackChanges.accept({ id: 'tc-1' }); + const rejectResult = api.trackChanges.reject({ id: 'tc-1' }); + const acceptAllResult = api.trackChanges.acceptAll({}); + const rejectAllResult = api.trackChanges.rejectAll({}); + + expect(listResult.total).toBe(0); + expect(getResult.id).toBe('tc-1'); + expect(acceptResult.success).toBe(true); + expect(rejectResult.success).toBe(true); + expect(acceptAllResult.success).toBe(true); + expect(rejectAllResult.success).toBe(true); + expect(trackAdpt.list).toHaveBeenCalledWith({ limit: 1 }); + expect(trackAdpt.get).toHaveBeenCalledWith({ id: 'tc-1' }); + }); + + it('delegates create.paragraph to the create adapter', () => { + const createAdpt = makeCreateAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: createAdpt, + lists: makeListsAdapter(), + }); + + const result = api.create.paragraph( + { + at: { kind: 'documentEnd' }, + text: 'Created paragraph', + }, + { changeMode: 'tracked' }, + ); + + expect(result.success).toBe(true); + expect(createAdpt.paragraph).toHaveBeenCalledWith( + { + at: { kind: 'documentEnd' }, + text: 'Created paragraph', + }, + { changeMode: 'tracked', dryRun: false }, + ); + }); + + it('delegates lists namespace operations', () => { + const listsAdpt = makeListsAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: listsAdpt, + }); + + const target = { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } as const; + const listResult = api.lists.list({ limit: 1 }); + const getResult = api.lists.get({ address: target }); + const insertResult = api.lists.insert({ target, position: 'after', text: 'Inserted' }, { changeMode: 'tracked' }); + const setTypeResult = api.lists.setType({ target, kind: 'bullet' }); + const indentResult = api.lists.indent({ target }); + const outdentResult = api.lists.outdent({ target }); + const restartResult = api.lists.restart({ target }); + const exitResult = api.lists.exit({ target }); + + expect(listResult.total).toBe(0); + expect(getResult.address).toEqual(target); + expect(insertResult.success).toBe(true); + expect(setTypeResult.success).toBe(true); + expect(indentResult.success).toBe(true); + expect(outdentResult.success).toBe(true); + expect(restartResult.success).toBe(true); + expect(exitResult.success).toBe(true); + + expect(listsAdpt.list).toHaveBeenCalledWith({ limit: 1 }); + expect(listsAdpt.get).toHaveBeenCalledWith({ address: target }); + expect(listsAdpt.insert).toHaveBeenCalledWith( + { target, position: 'after', text: 'Inserted' }, + { changeMode: 'tracked', dryRun: false }, + ); + expect(listsAdpt.setType).toHaveBeenCalledWith({ target, kind: 'bullet' }, { changeMode: 'direct', dryRun: false }); + expect(listsAdpt.indent).toHaveBeenCalledWith({ target }, { changeMode: 'direct', dryRun: false }); + expect(listsAdpt.outdent).toHaveBeenCalledWith({ target }, { changeMode: 'direct', dryRun: false }); + expect(listsAdpt.restart).toHaveBeenCalledWith({ target }, { changeMode: 'direct', dryRun: false }); + expect(listsAdpt.exit).toHaveBeenCalledWith({ target }, { changeMode: 'direct', dryRun: false }); + }); +}); diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index 077c18087..32f7c2b8e 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -4,36 +4,179 @@ export * from './types/index.js'; -import type { DocumentInfo, NodeAddress, NodeInfo, Query, QueryResult, Selector } from './types/index.js'; +import type { + CreateParagraphInput, + CreateParagraphResult, + DocumentInfo, + NodeAddress, + NodeInfo, + Query, + QueryResult, + Receipt, + Selector, + TextMutationReceipt, + TrackChangeInfo, + TrackChangesListResult, +} from './types/index.js'; +import type { CommentInfo, CommentsListQuery, CommentsListResult } from './comments/comments.types.js'; +import type { + AddCommentInput, + CommentsAdapter, + CommentsApi, + EditCommentInput, + GetCommentInput, + GoToCommentInput, + MoveCommentInput, + RemoveCommentInput, + ReplyToCommentInput, + ResolveCommentInput, + SetCommentActiveInput, + SetCommentInternalInput, +} from './comments/comments.js'; +import { + executeAddComment, + executeEditComment, + executeGetComment, + executeGoToComment, + executeListComments, + executeMoveComment, + executeRemoveComment, + executeReplyToComment, + executeResolveComment, + executeSetCommentActive, + executeSetCommentInternal, +} from './comments/comments.js'; +import type { DeleteInput } from './delete/delete.js'; import { executeFind, type FindAdapter, type FindOptions } from './find/find.js'; +import type { FormatAdapter, FormatApi, FormatBoldInput } from './format/format.js'; +import { executeFormatBold } from './format/format.js'; import type { GetNodeAdapter, GetNodeByIdInput } from './get-node/get-node.js'; import { executeGetNode, executeGetNodeById } from './get-node/get-node.js'; import { executeGetText, type GetTextAdapter, type GetTextInput } from './get-text/get-text.js'; import { executeInfo, type InfoAdapter, type InfoInput } from './info/info.js'; +import type { InsertInput } from './insert/insert.js'; +import { executeDelete } from './delete/delete.js'; +import { executeInsert } from './insert/insert.js'; +import type { ListsAdapter, ListsApi } from './lists/lists.js'; +import type { + ListItemInfo, + ListInsertInput, + ListSetTypeInput, + ListsExitResult, + ListsGetInput, + ListsInsertResult, + ListsListQuery, + ListsListResult, + ListsMutateItemResult, + ListTargetInput, +} from './lists/lists.types.js'; +import { + executeListsExit, + executeListsGet, + executeListsIndent, + executeListsInsert, + executeListsList, + executeListsOutdent, + executeListsRestart, + executeListsSetType, +} from './lists/lists.js'; +import { executeReplace, type ReplaceInput } from './replace/replace.js'; +import type { CreateAdapter, CreateApi } from './create/create.js'; +import { executeCreateParagraph } from './create/create.js'; +import type { + TrackChangesAcceptAllInput, + TrackChangesAcceptInput, + TrackChangesAdapter, + TrackChangesApi, + TrackChangesGetInput, + TrackChangesListInput, + TrackChangesRejectAllInput, + TrackChangesRejectInput, +} from './track-changes/track-changes.js'; +import { + executeTrackChangesAccept, + executeTrackChangesAcceptAll, + executeTrackChangesGet, + executeTrackChangesList, + executeTrackChangesReject, + executeTrackChangesRejectAll, +} from './track-changes/track-changes.js'; +import type { MutationOptions, WriteAdapter } from './write/write.js'; export type { FindAdapter, FindOptions } from './find/find.js'; export type { GetNodeAdapter, GetNodeByIdInput } from './get-node/get-node.js'; export type { GetTextAdapter, GetTextInput } from './get-text/get-text.js'; export type { InfoAdapter, InfoInput } from './info/info.js'; +export type { MutationOptions, WriteAdapter, WriteRequest } from './write/write.js'; +export type { FormatAdapter, FormatBoldInput } from './format/format.js'; +export type { CreateAdapter } from './create/create.js'; +export type { + TrackChangesAcceptAllInput, + TrackChangesAcceptInput, + TrackChangesAdapter, + TrackChangesGetInput, + TrackChangesListInput, + TrackChangesRejectAllInput, + TrackChangesRejectInput, +} from './track-changes/track-changes.js'; +export type { ListsAdapter } from './lists/lists.js'; +export type { + ListInsertInput, + ListItemAddress, + ListItemInfo, + ListKind, + ListsExitResult, + ListsGetInput, + ListsInsertResult, + ListsListQuery, + ListsListResult, + ListsMutateItemResult, + ListSetTypeInput, + ListTargetInput, +} from './lists/lists.types.js'; +export { LIST_KINDS, LIST_INSERT_POSITIONS } from './lists/lists.types.js'; +export type { + AddCommentInput, + CommentsAdapter, + EditCommentInput, + GetCommentInput, + GoToCommentInput, + MoveCommentInput, + RemoveCommentInput, + ReplyToCommentInput, + ResolveCommentInput, + SetCommentActiveInput, + SetCommentInternalInput, +} from './comments/comments.js'; +export type { CommentInfo, CommentsListQuery, CommentsListResult } from './comments/comments.types.js'; /** - * Read-focused Document API interface used by adapter-backed consumers. + * The Document API interface for querying and inspecting document nodes. */ export interface DocumentApi { /** * Find nodes in the document matching a query. + * @param query - A full query object specifying selection criteria. + * @returns The query result containing matches and metadata. */ find(query: Query): QueryResult; /** * Find nodes in the document matching a selector with optional options. + * @param selector - A selector specifying what to find. + * @param options - Optional find options (limit, offset, within, etc.). + * @returns The query result containing matches and metadata. */ find(selector: Selector, options?: FindOptions): QueryResult; /** * Get detailed information about a specific node by its address. + * @param address - The node address to resolve. + * @returns Full node information including typed properties. */ getNode(address: NodeAddress): NodeInfo; /** * Get detailed information about a block node by its ID. + * @param input - The node-id input payload. + * @returns Full node information including typed properties. */ getNodeById(input: GetNodeByIdInput): NodeInfo; /** @@ -44,6 +187,39 @@ export interface DocumentApi { * Return document summary info used by `doc.info`. */ info(input: InfoInput): DocumentInfo; + /** + * Comment operations. + */ + comments: CommentsApi; + /** + * Insert text at a target location. + * If target is omitted, adapters resolve a deterministic default insertion point. + */ + insert(input: InsertInput, options?: MutationOptions): TextMutationReceipt; + /** + * Replace text at a target range. + */ + replace(input: ReplaceInput, options?: MutationOptions): TextMutationReceipt; + /** + * Delete text at a target range. + */ + delete(input: DeleteInput, options?: MutationOptions): TextMutationReceipt; + /** + * Formatting operations. + */ + format: FormatApi; + /** + * Tracked-change lifecycle operations. + */ + trackChanges: TrackChangesApi; + /** + * Structural creation operations. + */ + create: CreateApi; + /** + * List item operations. + */ + lists: ListsApi; } export interface DocumentApiAdapters { @@ -51,10 +227,29 @@ export interface DocumentApiAdapters { getNode: GetNodeAdapter; getText: GetTextAdapter; info: InfoAdapter; + comments: CommentsAdapter; + write: WriteAdapter; + format: FormatAdapter; + trackChanges: TrackChangesAdapter; + create: CreateAdapter; + lists: ListsAdapter; } /** - * Creates a read-focused Document API instance from the provided adapters. + * Creates a Document API instance from the provided adapters. + * + * @param adapters - Engine-specific adapters (find, getNode, comments, write, format, trackChanges, create, lists). + * @returns A {@link DocumentApi} instance. + * + * @example + * ```ts + * const api = createDocumentApi(adapters); + * const result = api.find({ nodeType: 'heading' }); + * for (const address of result.matches) { + * const node = api.getNode(address); + * console.log(node.properties); + * } + * ``` */ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { return { @@ -73,5 +268,105 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { info(input: InfoInput): DocumentInfo { return executeInfo(adapters.info, input); }, + comments: { + add(input: AddCommentInput): Receipt { + return executeAddComment(adapters.comments, input); + }, + edit(input: EditCommentInput): Receipt { + return executeEditComment(adapters.comments, input); + }, + reply(input: ReplyToCommentInput): Receipt { + return executeReplyToComment(adapters.comments, input); + }, + move(input: MoveCommentInput): Receipt { + return executeMoveComment(adapters.comments, input); + }, + resolve(input: ResolveCommentInput): Receipt { + return executeResolveComment(adapters.comments, input); + }, + remove(input: RemoveCommentInput): Receipt { + return executeRemoveComment(adapters.comments, input); + }, + setInternal(input: SetCommentInternalInput): Receipt { + return executeSetCommentInternal(adapters.comments, input); + }, + setActive(input: SetCommentActiveInput): Receipt { + return executeSetCommentActive(adapters.comments, input); + }, + goTo(input: GoToCommentInput): Receipt { + return executeGoToComment(adapters.comments, input); + }, + get(input: GetCommentInput): CommentInfo { + return executeGetComment(adapters.comments, input); + }, + list(query?: CommentsListQuery): CommentsListResult { + return executeListComments(adapters.comments, query); + }, + }, + insert(input: InsertInput, options?: MutationOptions): TextMutationReceipt { + return executeInsert(adapters.write, input, options); + }, + replace(input: ReplaceInput, options?: MutationOptions): TextMutationReceipt { + return executeReplace(adapters.write, input, options); + }, + delete(input: DeleteInput, options?: MutationOptions): TextMutationReceipt { + return executeDelete(adapters.write, input, options); + }, + format: { + bold(input: FormatBoldInput, options?: MutationOptions): TextMutationReceipt { + return executeFormatBold(adapters.format, input, options); + }, + }, + trackChanges: { + list(input?: TrackChangesListInput): TrackChangesListResult { + return executeTrackChangesList(adapters.trackChanges, input); + }, + get(input: TrackChangesGetInput): TrackChangeInfo { + return executeTrackChangesGet(adapters.trackChanges, input); + }, + accept(input: TrackChangesAcceptInput): Receipt { + return executeTrackChangesAccept(adapters.trackChanges, input); + }, + reject(input: TrackChangesRejectInput): Receipt { + return executeTrackChangesReject(adapters.trackChanges, input); + }, + acceptAll(input: TrackChangesAcceptAllInput): Receipt { + return executeTrackChangesAcceptAll(adapters.trackChanges, input); + }, + rejectAll(input: TrackChangesRejectAllInput): Receipt { + return executeTrackChangesRejectAll(adapters.trackChanges, input); + }, + }, + create: { + paragraph(input: CreateParagraphInput, options?: MutationOptions): CreateParagraphResult { + return executeCreateParagraph(adapters.create, input, options); + }, + }, + lists: { + list(query?: ListsListQuery): ListsListResult { + return executeListsList(adapters.lists, query); + }, + get(input: ListsGetInput): ListItemInfo { + return executeListsGet(adapters.lists, input); + }, + insert(input: ListInsertInput, options?: MutationOptions): ListsInsertResult { + return executeListsInsert(adapters.lists, input, options); + }, + setType(input: ListSetTypeInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsSetType(adapters.lists, input, options); + }, + indent(input: ListTargetInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsIndent(adapters.lists, input, options); + }, + outdent(input: ListTargetInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsOutdent(adapters.lists, input, options); + }, + restart(input: ListTargetInput, options?: MutationOptions): ListsMutateItemResult { + return executeListsRestart(adapters.lists, input, options); + }, + exit(input: ListTargetInput, options?: MutationOptions): ListsExitResult { + return executeListsExit(adapters.lists, input, options); + }, + }, }; } diff --git a/packages/document-api/src/insert/insert.ts b/packages/document-api/src/insert/insert.ts new file mode 100644 index 000000000..b603c8331 --- /dev/null +++ b/packages/document-api/src/insert/insert.ts @@ -0,0 +1,26 @@ +import { executeWrite, type MutationOptions, type WriteAdapter } from '../write/write.js'; +import type { TextAddress, TextMutationReceipt } from '../types/index.js'; + +export interface InsertInput { + target?: TextAddress; + text: string; +} + +export function executeInsert( + adapter: WriteAdapter, + input: InsertInput, + options?: MutationOptions, +): TextMutationReceipt { + const request = input.target + ? { + kind: 'insert' as const, + target: input.target, + text: input.text, + } + : { + kind: 'insert' as const, + text: input.text, + }; + + return executeWrite(adapter, request, options); +} diff --git a/packages/document-api/src/lists/lists.ts b/packages/document-api/src/lists/lists.ts new file mode 100644 index 000000000..ab78b4e58 --- /dev/null +++ b/packages/document-api/src/lists/lists.ts @@ -0,0 +1,103 @@ +import type { MutationOptions } from '../write/write.js'; +import { normalizeMutationOptions } from '../write/write.js'; +import type { + ListInsertInput, + ListSetTypeInput, + ListsExitResult, + ListsGetInput, + ListsInsertResult, + ListsListQuery, + ListsListResult, + ListsMutateItemResult, + ListTargetInput, + ListItemInfo, +} from './lists.types.js'; +export type { + ListInsertInput, + ListSetTypeInput, + ListsExitResult, + ListsGetInput, + ListsInsertResult, + ListsListQuery, + ListsListResult, + ListsMutateItemResult, + ListTargetInput, + ListItemInfo, +} from './lists.types.js'; + +export interface ListsAdapter { + /** List items matching the given query. */ + list(query?: ListsListQuery): ListsListResult; + /** Retrieve full information for a single list item. */ + get(input: ListsGetInput): ListItemInfo; + /** Insert a new list item relative to the target. */ + insert(input: ListInsertInput, options?: MutationOptions): ListsInsertResult; + /** Change the list kind (ordered/bullet) for the target item. */ + setType(input: ListSetTypeInput, options?: MutationOptions): ListsMutateItemResult; + /** Increase the nesting level of the target item. */ + indent(input: ListTargetInput, options?: MutationOptions): ListsMutateItemResult; + /** Decrease the nesting level of the target item. */ + outdent(input: ListTargetInput, options?: MutationOptions): ListsMutateItemResult; + /** Restart numbering at the target item. */ + restart(input: ListTargetInput, options?: MutationOptions): ListsMutateItemResult; + /** Exit the list, converting the target item to a plain paragraph. */ + exit(input: ListTargetInput, options?: MutationOptions): ListsExitResult; +} + +export type ListsApi = ListsAdapter; + +export function executeListsList(adapter: ListsAdapter, query?: ListsListQuery): ListsListResult { + return adapter.list(query); +} + +export function executeListsGet(adapter: ListsAdapter, input: ListsGetInput): ListItemInfo { + return adapter.get(input); +} + +export function executeListsInsert( + adapter: ListsAdapter, + input: ListInsertInput, + options?: MutationOptions, +): ListsInsertResult { + return adapter.insert(input, normalizeMutationOptions(options)); +} + +export function executeListsSetType( + adapter: ListsAdapter, + input: ListSetTypeInput, + options?: MutationOptions, +): ListsMutateItemResult { + return adapter.setType(input, normalizeMutationOptions(options)); +} + +export function executeListsIndent( + adapter: ListsAdapter, + input: ListTargetInput, + options?: MutationOptions, +): ListsMutateItemResult { + return adapter.indent(input, normalizeMutationOptions(options)); +} + +export function executeListsOutdent( + adapter: ListsAdapter, + input: ListTargetInput, + options?: MutationOptions, +): ListsMutateItemResult { + return adapter.outdent(input, normalizeMutationOptions(options)); +} + +export function executeListsRestart( + adapter: ListsAdapter, + input: ListTargetInput, + options?: MutationOptions, +): ListsMutateItemResult { + return adapter.restart(input, normalizeMutationOptions(options)); +} + +export function executeListsExit( + adapter: ListsAdapter, + input: ListTargetInput, + options?: MutationOptions, +): ListsExitResult { + return adapter.exit(input, normalizeMutationOptions(options)); +} diff --git a/packages/document-api/src/lists/lists.types.ts b/packages/document-api/src/lists/lists.types.ts new file mode 100644 index 000000000..7d1ecb0a9 --- /dev/null +++ b/packages/document-api/src/lists/lists.types.ts @@ -0,0 +1,91 @@ +import type { BlockNodeType, ReceiptFailure, ReceiptInsert, TextAddress } from '../types/index.js'; + +export type ListItemAddress = { + kind: 'block'; + nodeType: 'listItem'; + nodeId: string; +}; + +export type ListWithinAddress = { + kind: 'block'; + nodeType: BlockNodeType; + nodeId: string; +}; +export type ListKind = 'ordered' | 'bullet'; +export type ListInsertPosition = 'before' | 'after'; + +export const LIST_KINDS = ['ordered', 'bullet'] as const satisfies readonly ListKind[]; +export const LIST_INSERT_POSITIONS = ['before', 'after'] as const satisfies readonly ListInsertPosition[]; + +export interface ListsListQuery { + within?: ListWithinAddress; + limit?: number; + offset?: number; + kind?: ListKind; + level?: number; + ordinal?: number; +} + +export interface ListsGetInput { + address: ListItemAddress; +} + +export interface ListItemInfo { + address: ListItemAddress; + marker?: string; + ordinal?: number; + path?: number[]; + level?: number; + kind?: ListKind; + text?: string; +} + +export interface ListsListResult { + matches: ListItemAddress[]; + total: number; + items: ListItemInfo[]; +} + +export interface ListInsertInput { + target: ListItemAddress; + position: ListInsertPosition; + text?: string; +} + +export interface ListTargetInput { + target: ListItemAddress; +} + +export interface ListSetTypeInput extends ListTargetInput { + kind: ListKind; +} + +export interface ListsInsertSuccessResult { + success: true; + item: ListItemAddress; + insertionPoint: TextAddress; + trackedChangeRefs?: ReceiptInsert[]; +} + +export interface ListsMutateItemSuccessResult { + success: true; + item: ListItemAddress; +} + +export interface ListsExitSuccessResult { + success: true; + paragraph: { + kind: 'block'; + nodeType: 'paragraph'; + nodeId: string; + }; +} + +export interface ListsFailureResult { + success: false; + failure: ReceiptFailure; +} + +export type ListsInsertResult = ListsInsertSuccessResult | ListsFailureResult; +export type ListsMutateItemResult = ListsMutateItemSuccessResult | ListsFailureResult; +export type ListsExitResult = ListsExitSuccessResult | ListsFailureResult; diff --git a/packages/document-api/src/replace/replace.ts b/packages/document-api/src/replace/replace.ts new file mode 100644 index 000000000..60563e3b5 --- /dev/null +++ b/packages/document-api/src/replace/replace.ts @@ -0,0 +1,23 @@ +import { executeWrite, type MutationOptions, type WriteAdapter } from '../write/write.js'; +import type { TextAddress, TextMutationReceipt } from '../types/index.js'; + +export interface ReplaceInput { + target: TextAddress; + text: string; +} + +export function executeReplace( + adapter: WriteAdapter, + input: ReplaceInput, + options?: MutationOptions, +): TextMutationReceipt { + return executeWrite( + adapter, + { + kind: 'replace', + target: input.target, + text: input.text, + }, + options, + ); +} diff --git a/packages/document-api/src/track-changes/track-changes.ts b/packages/document-api/src/track-changes/track-changes.ts new file mode 100644 index 000000000..a46bc40f1 --- /dev/null +++ b/packages/document-api/src/track-changes/track-changes.ts @@ -0,0 +1,69 @@ +import type { Receipt, TrackChangeInfo, TrackChangesListQuery, TrackChangesListResult } from '../types/index.js'; + +export type TrackChangesListInput = TrackChangesListQuery; + +export interface TrackChangesGetInput { + id: string; +} + +export interface TrackChangesAcceptInput { + id: string; +} + +export interface TrackChangesRejectInput { + id: string; +} + +export type TrackChangesAcceptAllInput = Record; + +export type TrackChangesRejectAllInput = Record; + +export interface TrackChangesAdapter { + /** List tracked changes matching the given query. */ + list(input?: TrackChangesListInput): TrackChangesListResult; + /** Retrieve full information for a single tracked change. */ + get(input: TrackChangesGetInput): TrackChangeInfo; + /** Accept a tracked change, applying it to the document. */ + accept(input: TrackChangesAcceptInput): Receipt; + /** Reject a tracked change, reverting it from the document. */ + reject(input: TrackChangesRejectInput): Receipt; + /** Accept all tracked changes in the document. */ + acceptAll(input: TrackChangesAcceptAllInput): Receipt; + /** Reject all tracked changes in the document. */ + rejectAll(input: TrackChangesRejectAllInput): Receipt; +} + +export type TrackChangesApi = TrackChangesAdapter; + +/** + * Execute wrappers below are the canonical interception point for input + * normalization and validation. Query-only operations currently pass through + * directly. Mutation operations will gain validation as the API matures. + * Keep the wrappers to preserve this extension surface. + */ +export function executeTrackChangesList( + adapter: TrackChangesAdapter, + input?: TrackChangesListInput, +): TrackChangesListResult { + return adapter.list(input); +} + +export function executeTrackChangesGet(adapter: TrackChangesAdapter, input: TrackChangesGetInput): TrackChangeInfo { + return adapter.get(input); +} + +export function executeTrackChangesAccept(adapter: TrackChangesAdapter, input: TrackChangesAcceptInput): Receipt { + return adapter.accept(input); +} + +export function executeTrackChangesReject(adapter: TrackChangesAdapter, input: TrackChangesRejectInput): Receipt { + return adapter.reject(input); +} + +export function executeTrackChangesAcceptAll(adapter: TrackChangesAdapter, input: TrackChangesAcceptAllInput): Receipt { + return adapter.acceptAll(input); +} + +export function executeTrackChangesRejectAll(adapter: TrackChangesAdapter, input: TrackChangesRejectAllInput): Receipt { + return adapter.rejectAll(input); +} diff --git a/packages/document-api/src/write/write.test.ts b/packages/document-api/src/write/write.test.ts new file mode 100644 index 000000000..3035fc948 --- /dev/null +++ b/packages/document-api/src/write/write.test.ts @@ -0,0 +1,23 @@ +import { normalizeMutationOptions } from './write.js'; + +describe('normalizeMutationOptions', () => { + it('defaults changeMode to direct when options are omitted', () => { + expect(normalizeMutationOptions()).toEqual({ changeMode: 'direct', dryRun: false }); + }); + + it('defaults changeMode to direct when changeMode is undefined', () => { + expect(normalizeMutationOptions({})).toEqual({ changeMode: 'direct', dryRun: false }); + }); + + it('preserves explicit direct changeMode', () => { + expect(normalizeMutationOptions({ changeMode: 'direct' })).toEqual({ changeMode: 'direct', dryRun: false }); + }); + + it('preserves explicit tracked changeMode', () => { + expect(normalizeMutationOptions({ changeMode: 'tracked' })).toEqual({ changeMode: 'tracked', dryRun: false }); + }); + + it('preserves explicit dryRun true', () => { + expect(normalizeMutationOptions({ dryRun: true })).toEqual({ changeMode: 'direct', dryRun: true }); + }); +}); diff --git a/packages/document-api/src/write/write.ts b/packages/document-api/src/write/write.ts new file mode 100644 index 000000000..dd7abb799 --- /dev/null +++ b/packages/document-api/src/write/write.ts @@ -0,0 +1,61 @@ +import type { TextAddress, TextMutationReceipt } from '../types/index.js'; + +export type ChangeMode = 'direct' | 'tracked'; + +export interface MutationOptions { + /** + * Controls whether mutation applies directly or as a tracked change. + * Defaults to `direct`. + */ + changeMode?: ChangeMode; + /** + * When true, adapters validate and resolve the operation but must not mutate state. + * Defaults to `false`. + */ + dryRun?: boolean; +} + +export type WriteKind = 'insert' | 'replace' | 'delete'; + +export type InsertWriteRequest = { + kind: 'insert'; + /** + * Optional insertion target. + * When omitted, adapters may resolve a deterministic default insertion point. + */ + target?: TextAddress; + text: string; +}; + +export type ReplaceWriteRequest = { + kind: 'replace'; + target: TextAddress; + text: string; +}; + +export type DeleteWriteRequest = { + kind: 'delete'; + target: TextAddress; + text?: ''; +}; + +export type WriteRequest = InsertWriteRequest | ReplaceWriteRequest | DeleteWriteRequest; + +export interface WriteAdapter { + write(request: WriteRequest, options?: MutationOptions): TextMutationReceipt; +} + +export function normalizeMutationOptions(options?: MutationOptions): MutationOptions { + return { + changeMode: options?.changeMode ?? 'direct', + dryRun: options?.dryRun ?? false, + }; +} + +export function executeWrite( + adapter: WriteAdapter, + request: WriteRequest, + options?: MutationOptions, +): TextMutationReceipt { + return adapter.write(request, normalizeMutationOptions(options)); +} From 5fa2513b9f98bd94fc81a994bc3d75771c71387e Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Mon, 16 Feb 2026 17:44:49 -0800 Subject: [PATCH 03/25] feat(super-editor): add read-path document-api adapters and resolver helpers --- packages/super-editor/package.json | 1 + .../src/document-api-adapters/errors.test.ts | 64 ++ .../src/document-api-adapters/errors.ts | 36 + .../find-adapter.test.ts | 719 ++++++++++++++++++ .../src/document-api-adapters/find-adapter.ts | 50 ++ .../find/block-strategy.ts | 50 ++ .../src/document-api-adapters/find/common.ts | 219 ++++++ .../find/dual-kind-strategy.ts | 42 + .../find/inline-strategy.ts | 67 ++ .../find/text-strategy.ts | 151 ++++ .../get-node-adapter.test.ts | 218 ++++++ .../document-api-adapters/get-node-adapter.ts | 99 +++ .../get-text-adapter.test.ts | 23 + .../document-api-adapters/get-text-adapter.ts | 12 + .../helpers/adapter-utils.test.ts | 211 +++++ .../helpers/adapter-utils.ts | 204 +++++ .../helpers/index-cache.test.ts | 122 +++ .../helpers/index-cache.ts | 65 ++ .../helpers/inline-address-resolver.test.ts | 130 ++++ .../helpers/inline-address-resolver.ts | 402 ++++++++++ .../helpers/node-address-resolver.test.ts | 534 +++++++++++++ .../helpers/node-address-resolver.ts | 224 ++++++ .../helpers/node-info-mapper.test.ts | 335 ++++++++ .../helpers/node-info-mapper.ts | 422 ++++++++++ .../helpers/node-info-resolver.ts | 61 ++ .../helpers/text-offset-resolver.test.ts | 103 +++ .../helpers/text-offset-resolver.ts | 107 +++ .../helpers/value-utils.test.ts | 123 +++ .../helpers/value-utils.ts | 60 ++ .../info-adapter.test.ts | 133 ++++ .../src/document-api-adapters/info-adapter.ts | 108 +++ .../src/extensions/search/search.js | 2 + .../extensions/types/specialized-commands.ts | 2 + packages/super-editor/tsconfig.types.json | 3 +- pnpm-lock.yaml | 149 ++-- tsconfig.references.json | 1 + 36 files changed, 5179 insertions(+), 73 deletions(-) create mode 100644 packages/super-editor/src/document-api-adapters/errors.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/errors.ts create mode 100644 packages/super-editor/src/document-api-adapters/find-adapter.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/find-adapter.ts create mode 100644 packages/super-editor/src/document-api-adapters/find/block-strategy.ts create mode 100644 packages/super-editor/src/document-api-adapters/find/common.ts create mode 100644 packages/super-editor/src/document-api-adapters/find/dual-kind-strategy.ts create mode 100644 packages/super-editor/src/document-api-adapters/find/inline-strategy.ts create mode 100644 packages/super-editor/src/document-api-adapters/find/text-strategy.ts create mode 100644 packages/super-editor/src/document-api-adapters/get-node-adapter.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/get-node-adapter.ts create mode 100644 packages/super-editor/src/document-api-adapters/get-text-adapter.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/get-text-adapter.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/adapter-utils.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/adapter-utils.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/index-cache.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/index-cache.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/inline-address-resolver.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/inline-address-resolver.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/node-info-resolver.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/text-offset-resolver.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/text-offset-resolver.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/value-utils.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/value-utils.ts create mode 100644 packages/super-editor/src/document-api-adapters/info-adapter.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/info-adapter.ts diff --git a/packages/super-editor/package.json b/packages/super-editor/package.json index 9ffc0ec3f..935ab73f8 100644 --- a/packages/super-editor/package.json +++ b/packages/super-editor/package.json @@ -115,6 +115,7 @@ "devDependencies": { "@floating-ui/dom": "catalog:", "@superdoc/common": "workspace:*", + "@superdoc/document-api": "workspace:*", "@superdoc/contracts": "workspace:*", "@superdoc/layout-bridge": "workspace:*", "@superdoc/measuring-dom": "workspace:*", diff --git a/packages/super-editor/src/document-api-adapters/errors.test.ts b/packages/super-editor/src/document-api-adapters/errors.test.ts new file mode 100644 index 000000000..9dbd9008a --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/errors.test.ts @@ -0,0 +1,64 @@ +import { DocumentApiAdapterError, isDocumentApiAdapterError } from './errors.js'; + +describe('DocumentApiAdapterError', () => { + it('extends Error with name, code, and message', () => { + const error = new DocumentApiAdapterError('TARGET_NOT_FOUND', 'Node not found.'); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(DocumentApiAdapterError); + expect(error.name).toBe('DocumentApiAdapterError'); + expect(error.code).toBe('TARGET_NOT_FOUND'); + expect(error.message).toBe('Node not found.'); + expect(error.details).toBeUndefined(); + }); + + it('stores optional details payload', () => { + const details = { nodeId: 'p1', nodeType: 'paragraph' }; + const error = new DocumentApiAdapterError('INVALID_TARGET', 'Bad target.', details); + + expect(error.details).toEqual(details); + }); + + it('supports all error codes', () => { + const codes = [ + 'TARGET_NOT_FOUND', + 'TRACK_CHANGE_COMMAND_UNAVAILABLE', + 'INVALID_TARGET', + 'COMMAND_UNAVAILABLE', + ] as const; + + for (const code of codes) { + const error = new DocumentApiAdapterError(code, `Error: ${code}`); + expect(error.code).toBe(code); + } + }); + + it('is caught by instanceof checks after setPrototypeOf', () => { + const error = new DocumentApiAdapterError('COMMAND_UNAVAILABLE', 'test'); + + try { + throw error; + } catch (caught) { + expect(caught instanceof DocumentApiAdapterError).toBe(true); + } + }); +}); + +describe('isDocumentApiAdapterError', () => { + it('returns true for DocumentApiAdapterError instances', () => { + const error = new DocumentApiAdapterError('TARGET_NOT_FOUND', 'test'); + expect(isDocumentApiAdapterError(error)).toBe(true); + }); + + it('returns false for plain Error', () => { + expect(isDocumentApiAdapterError(new Error('test'))).toBe(false); + }); + + it('returns false for non-error values', () => { + expect(isDocumentApiAdapterError(null)).toBe(false); + expect(isDocumentApiAdapterError(undefined)).toBe(false); + expect(isDocumentApiAdapterError('string')).toBe(false); + expect(isDocumentApiAdapterError(42)).toBe(false); + expect(isDocumentApiAdapterError({ code: 'TARGET_NOT_FOUND', message: 'fake' })).toBe(false); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/errors.ts b/packages/super-editor/src/document-api-adapters/errors.ts new file mode 100644 index 000000000..1fd9eb158 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/errors.ts @@ -0,0 +1,36 @@ +/** Error codes used by {@link DocumentApiAdapterError} to classify adapter failures. */ +export type DocumentApiAdapterErrorCode = + | 'TARGET_NOT_FOUND' + | 'TRACK_CHANGE_COMMAND_UNAVAILABLE' + | 'INVALID_TARGET' + | 'COMMAND_UNAVAILABLE'; + +/** + * Structured error thrown by document-api adapter functions. + * + * @param code - Machine-readable error classification. + * @param message - Human-readable description. + * @param details - Optional payload with additional context. + */ +export class DocumentApiAdapterError extends Error { + readonly code: DocumentApiAdapterErrorCode; + readonly details?: unknown; + + constructor(code: DocumentApiAdapterErrorCode, message: string, details?: unknown) { + super(message); + this.name = 'DocumentApiAdapterError'; + this.code = code; + this.details = details; + Object.setPrototypeOf(this, DocumentApiAdapterError.prototype); + } +} + +/** + * Type guard that narrows an unknown value to {@link DocumentApiAdapterError}. + * + * @param error - The value to test. + * @returns `true` if the value is a `DocumentApiAdapterError` instance. + */ +export function isDocumentApiAdapterError(error: unknown): error is DocumentApiAdapterError { + return error instanceof DocumentApiAdapterError; +} diff --git a/packages/super-editor/src/document-api-adapters/find-adapter.test.ts b/packages/super-editor/src/document-api-adapters/find-adapter.test.ts new file mode 100644 index 000000000..6c92b3fb3 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/find-adapter.test.ts @@ -0,0 +1,719 @@ +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import type { Editor } from '../core/Editor.js'; +import type { Query } from '@superdoc/document-api'; +import { findAdapter } from './find-adapter.js'; + +// --------------------------------------------------------------------------- +// Helpers — lightweight ProseMirror-like stubs +// --------------------------------------------------------------------------- + +/** + * Creates a minimal ProseMirrorNode stub. + * + * `textContent` is an optional flat string representing the text in the doc. + * `textBetween(from, to, blockSep)` slices from it, inserting `blockSep` at + * every position where a child boundary is crossed. This is a simplified model + * sufficient for testing snippet generation. + */ +function makeNode( + typeName: string, + attrs: Record = {}, + nodeSize = 10, + children: Array<{ node: ProseMirrorNode; offset: number }> = [], + textContent = '', +): ProseMirrorNode { + const inlineTypes = new Set([ + 'text', + 'run', + 'image', + 'tab', + 'lineBreak', + 'hardBreak', + 'bookmarkStart', + 'bookmarkEnd', + 'commentRangeStart', + 'commentRangeEnd', + 'commentReference', + 'structuredContent', + 'footnoteReference', + ]); + const isText = typeName === 'text'; + const isInline = inlineTypes.has(typeName); + const isBlock = typeName !== 'doc' && !isInline; + const inlineContent = isBlock && typeName === 'paragraph'; + const computedNodeSize = isText ? textContent.length : nodeSize; + const contentSize = children.reduce((max, child) => Math.max(max, child.offset + child.node.nodeSize), 0); + + // Collect boundary positions where block separators should be inserted. + const boundaries = new Set(); + for (const child of children) { + boundaries.add(child.offset); + boundaries.add(child.offset + (child.node as unknown as { nodeSize: number }).nodeSize); + } + + return { + type: { name: typeName }, + attrs, + nodeSize: computedNodeSize, + content: { size: contentSize }, + textContent, + text: isText ? textContent : undefined, + isText, + isLeaf: isText || (isInline && children.length === 0), + isInline, + isBlock, + inlineContent, + isTextblock: inlineContent, + marks: (attrs.__marks ?? []) as unknown as ProseMirrorNode['marks'], + textBetween(from: number, to: number, blockSep = '') { + // Build text character-by-character, inserting blockSep at boundaries + let result = ''; + for (let i = from; i < to; i++) { + if (i > from && boundaries.has(i) && blockSep) { + result += blockSep; + } + if (i < textContent.length) { + result += textContent[i]; + } + } + return result; + }, + descendants(callback: (node: ProseMirrorNode, pos: number) => void) { + for (const child of children) { + callback(child.node, child.offset); + } + }, + childCount: children.length, + child(index: number) { + return children[index]!.node; + }, + forEach(callback: (node: ProseMirrorNode, offset: number) => void) { + for (const child of children) { + callback(child.node, child.offset); + } + }, + } as unknown as ProseMirrorNode; +} + +type SearchFn = (pattern: string | RegExp, options?: Record) => unknown[]; + +function makeEditor(docNode: ProseMirrorNode, search?: SearchFn): Editor { + return { + state: { doc: docNode }, + commands: search ? { search } : {}, + } as unknown as Editor; +} + +/** Builds a doc with paragraph children at specified offsets. */ +function buildDoc( + ...entries: Array<{ typeName: string; attrs?: Record; nodeSize?: number; offset: number }> +): ProseMirrorNode; +function buildDoc( + textContent: string, + ...entries: Array<{ typeName: string; attrs?: Record; nodeSize?: number; offset: number }> +): ProseMirrorNode; +function buildDoc(...args: unknown[]): ProseMirrorNode { + let textContent = ''; + let entries: Array<{ typeName: string; attrs?: Record; nodeSize?: number; offset: number }>; + if (typeof args[0] === 'string') { + textContent = args[0] as string; + entries = args.slice(1) as typeof entries; + } else { + entries = args as typeof entries; + } + const children = entries.map((e) => ({ + node: makeNode(e.typeName, e.attrs ?? {}, e.nodeSize ?? 10), + offset: e.offset, + })); + const totalSize = entries.reduce((max, e) => Math.max(max, e.offset + (e.nodeSize ?? 10)), 0) + 2; + return makeNode('doc', {}, totalSize, children, textContent); +} + +// --------------------------------------------------------------------------- +// Block selector queries +// --------------------------------------------------------------------------- + +describe('findAdapter — block selectors', () => { + it('returns all paragraphs when select.type is "paragraph"', () => { + const doc = buildDoc( + { typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, offset: 0 }, + { typeName: 'paragraph', attrs: { sdBlockId: 'p2' }, offset: 12 }, + { typeName: 'image', attrs: { sdBlockId: 'img1' }, offset: 24 }, + ); + const editor = makeEditor(doc); + const query: Query = { select: { type: 'node', nodeType: 'paragraph' } }; + + const result = findAdapter(editor, query); + + expect(result.total).toBe(2); + expect(result.matches).toEqual([ + { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + { kind: 'block', nodeType: 'paragraph', nodeId: 'p2' }, + ]); + expect(result.diagnostics).toBeUndefined(); + }); + + it('returns headings for paragraphs with heading styleId', () => { + const doc = buildDoc( + { typeName: 'paragraph', attrs: { sdBlockId: 'h1', paragraphProperties: { styleId: 'Heading1' } }, offset: 0 }, + { typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, offset: 12 }, + ); + const editor = makeEditor(doc); + const query: Query = { select: { type: 'node', nodeType: 'heading' } }; + + const result = findAdapter(editor, query); + + expect(result.total).toBe(1); + expect(result.matches[0]).toEqual({ kind: 'block', nodeType: 'heading', nodeId: 'h1' }); + }); + + it('uses node selector with kind filter', () => { + const doc = buildDoc( + { typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, offset: 0 }, + { typeName: 'table', attrs: { sdBlockId: 't1' }, offset: 12 }, + ); + const editor = makeEditor(doc); + const query: Query = { select: { type: 'node', kind: 'block' } }; + + const result = findAdapter(editor, query); + + expect(result.total).toBe(2); + }); + + it('uses node selector with nodeType filter', () => { + const doc = buildDoc( + { typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, offset: 0 }, + { typeName: 'table', attrs: { sdBlockId: 't1' }, offset: 12 }, + ); + const editor = makeEditor(doc); + const query: Query = { select: { type: 'node', nodeType: 'table' } }; + + const result = findAdapter(editor, query); + + expect(result.total).toBe(1); + expect(result.matches[0].nodeId).toBe('t1'); + }); + + it('emits diagnostic for includeUnknown', () => { + const doc = buildDoc( + { typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, offset: 0 }, + { typeName: 'mysteryBlock', attrs: { sdBlockId: 'm1' }, offset: 12 }, + ); + const editor = makeEditor(doc); + const query: Query = { select: { type: 'node', nodeType: 'paragraph' }, includeUnknown: true }; + + const result = findAdapter(editor, query); + + expect(result.matches).toEqual([{ kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }]); + expect(result.total).toBe(1); + expect(result.diagnostics).toBeDefined(); + expect(result.diagnostics![0].message).toContain('Unknown block node type'); + expect(result.diagnostics![0].message).not.toContain('position'); + expect(result.diagnostics![0].hint).toContain('stable id "m1"'); + }); + + it('emits actionable diagnostics for unknown inline nodes without raw positions', () => { + const doc = buildDoc( + { typeName: 'paragraph', attrs: { sdBlockId: 'p-inline' }, nodeSize: 12, offset: 0 }, + { typeName: 'commentReference', nodeSize: 1, offset: 3 }, + ); + const editor = makeEditor(doc); + const query: Query = { select: { type: 'node', nodeType: 'paragraph' }, includeUnknown: true }; + + const result = findAdapter(editor, query); + + const diagnostic = result.diagnostics?.find((entry) => entry.message.includes('Unknown inline node type')); + expect(diagnostic).toBeDefined(); + expect(diagnostic!.message).not.toContain('position'); + expect(diagnostic!.address).toEqual({ + kind: 'block', + nodeType: 'paragraph', + nodeId: 'p-inline', + }); + expect(diagnostic!.hint).toContain('p-inline'); + }); +}); + +// --------------------------------------------------------------------------- +// Within scope +// --------------------------------------------------------------------------- + +describe('findAdapter — within scope', () => { + it('limits block results to within a parent node', () => { + const doc = buildDoc( + { typeName: 'table', attrs: { sdBlockId: 'tbl1' }, nodeSize: 50, offset: 0 }, + { typeName: 'paragraph', attrs: { sdBlockId: 'p-inside' }, nodeSize: 10, offset: 5 }, + { typeName: 'paragraph', attrs: { sdBlockId: 'p-outside' }, nodeSize: 10, offset: 60 }, + ); + const editor = makeEditor(doc); + const query: Query = { + select: { type: 'node', nodeType: 'paragraph' }, + within: { kind: 'block', nodeType: 'table', nodeId: 'tbl1' }, + }; + + const result = findAdapter(editor, query); + + expect(result.total).toBe(1); + expect(result.matches[0].nodeId).toBe('p-inside'); + }); + + it('returns empty when within target is not found', () => { + const doc = buildDoc({ typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, offset: 0 }); + const editor = makeEditor(doc); + const query: Query = { + select: { type: 'node', nodeType: 'paragraph' }, + within: { kind: 'block', nodeType: 'table', nodeId: 'no-such-table' }, + }; + + const result = findAdapter(editor, query); + + expect(result.matches).toEqual([]); + expect(result.diagnostics).toBeDefined(); + expect(result.diagnostics![0].message).toContain('was not found'); + }); + + it('returns empty with diagnostic for inline within scope', () => { + const doc = buildDoc({ typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, offset: 0 }); + const editor = makeEditor(doc); + const query: Query = { + select: { type: 'node', nodeType: 'paragraph' }, + within: { + kind: 'inline', + nodeType: 'run', + anchor: { start: { blockId: 'p1', offset: 0 }, end: { blockId: 'p1', offset: 5 } }, + }, + }; + + const result = findAdapter(editor, query); + + expect(result.matches).toEqual([]); + expect(result.diagnostics![0].message).toContain('Inline'); + }); +}); + +// --------------------------------------------------------------------------- +// Pagination +// --------------------------------------------------------------------------- + +describe('findAdapter — pagination', () => { + function buildThreeParagraphs() { + return buildDoc( + { typeName: 'paragraph', attrs: { sdBlockId: 'a' }, offset: 0 }, + { typeName: 'paragraph', attrs: { sdBlockId: 'b' }, offset: 12 }, + { typeName: 'paragraph', attrs: { sdBlockId: 'c' }, offset: 24 }, + ); + } + + it('limits results with limit', () => { + const editor = makeEditor(buildThreeParagraphs()); + const query: Query = { select: { type: 'node', nodeType: 'paragraph' }, limit: 2 }; + + const result = findAdapter(editor, query); + + expect(result.total).toBe(3); + expect(result.matches).toHaveLength(2); + expect(result.matches[0].nodeId).toBe('a'); + expect(result.matches[1].nodeId).toBe('b'); + }); + + it('skips results with offset', () => { + const editor = makeEditor(buildThreeParagraphs()); + const query: Query = { select: { type: 'node', nodeType: 'paragraph' }, offset: 1 }; + + const result = findAdapter(editor, query); + + expect(result.total).toBe(3); + expect(result.matches).toHaveLength(2); + expect(result.matches[0].nodeId).toBe('b'); + }); + + it('combines offset and limit', () => { + const editor = makeEditor(buildThreeParagraphs()); + const query: Query = { select: { type: 'node', nodeType: 'paragraph' }, offset: 1, limit: 1 }; + + const result = findAdapter(editor, query); + + expect(result.total).toBe(3); + expect(result.matches).toHaveLength(1); + expect(result.matches[0].nodeId).toBe('b'); + }); +}); + +// --------------------------------------------------------------------------- +// Inline selectors +// --------------------------------------------------------------------------- + +describe('findAdapter — inline selectors', () => { + it('returns run matches', () => { + const runText = makeNode('text', {}, 2, [], 'Hi'); + const runNode = makeNode('run', { runProperties: { bold: true } }, 4, [{ node: runText, offset: 0 }]); + const paragraph = makeNode('paragraph', { sdBlockId: 'p-run' }, 6, [{ node: runNode, offset: 0 }]); + const doc = makeNode('doc', {}, 8, [{ node: paragraph, offset: 0 }]); + const editor = makeEditor(doc); + + const result = findAdapter(editor, { select: { type: 'node', nodeType: 'run' } }); + + expect(result.total).toBe(1); + expect(result.matches[0]).toEqual({ + kind: 'inline', + nodeType: 'run', + anchor: { start: { blockId: 'p-run', offset: 0 }, end: { blockId: 'p-run', offset: 2 } }, + }); + }); + + it('returns hyperlink matches from inline marks', () => { + const linkMark = { + type: { name: 'link' }, + attrs: { href: 'https://example.com' }, + } as unknown as ProseMirrorNode['marks'][number]; + const textNode = makeNode('text', { __marks: [linkMark] }, 2, [], 'Hi'); + const imageNode = makeNode('image', {}, 1, []); + const paragraph = makeNode('paragraph', { sdBlockId: 'p1' }, 5, [ + { node: textNode, offset: 0 }, + { node: imageNode, offset: 2 }, + ]); + const doc = makeNode('doc', {}, 7, [{ node: paragraph, offset: 0 }]); + const editor = makeEditor(doc); + + const result = findAdapter(editor, { select: { type: 'node', nodeType: 'hyperlink' } }); + + expect(result.total).toBe(1); + expect(result.matches[0]).toEqual({ + kind: 'inline', + nodeType: 'hyperlink', + anchor: { start: { blockId: 'p1', offset: 0 }, end: { blockId: 'p1', offset: 2 } }, + }); + }); + + it('returns inline image matches', () => { + const textNode = makeNode('text', {}, 2, [], 'Hi'); + const imageNode = makeNode('image', { src: 'x' }, 1, []); + const paragraph = makeNode('paragraph', { sdBlockId: 'p2' }, 5, [ + { node: textNode, offset: 0 }, + { node: imageNode, offset: 2 }, + ]); + const doc = makeNode('doc', {}, 7, [{ node: paragraph, offset: 0 }]); + const editor = makeEditor(doc); + + const result = findAdapter(editor, { select: { type: 'node', nodeType: 'image' } }); + + expect(result.total).toBe(1); + expect(result.matches[0]).toEqual({ + kind: 'inline', + nodeType: 'image', + anchor: { start: { blockId: 'p2', offset: 2 }, end: { blockId: 'p2', offset: 3 } }, + }); + }); + + it('returns both block and inline sdts when kind is omitted', () => { + const inlineSdt = makeNode('structuredContent', { tag: 'inline-sdt' }, 1, []); + const paragraph = makeNode('paragraph', { sdBlockId: 'p-sdt' }, 5, [{ node: inlineSdt, offset: 0 }]); + const blockSdt = makeNode('structuredContentBlock', { sdBlockId: 'sdt-block' }, 4, []); + const doc = makeNode('doc', {}, 20, [ + { node: paragraph, offset: 0 }, + { node: blockSdt, offset: 10 }, + ]); + const editor = makeEditor(doc); + + const shorthand = findAdapter(editor, { select: { type: 'node', nodeType: 'sdt' } }); + expect(shorthand.total).toBe(2); + expect(shorthand.matches).toEqual( + expect.arrayContaining([ + { kind: 'block', nodeType: 'sdt', nodeId: 'sdt-block' }, + { + kind: 'inline', + nodeType: 'sdt', + anchor: { start: { blockId: 'p-sdt', offset: 0 }, end: { blockId: 'p-sdt', offset: 1 } }, + }, + ]), + ); + + const nodeSelector = findAdapter(editor, { select: { type: 'node', nodeType: 'sdt' } }); + expect(nodeSelector.total).toBe(2); + expect(nodeSelector.matches).toEqual( + expect.arrayContaining([ + { kind: 'block', nodeType: 'sdt', nodeId: 'sdt-block' }, + { + kind: 'inline', + nodeType: 'sdt', + anchor: { start: { blockId: 'p-sdt', offset: 0 }, end: { blockId: 'p-sdt', offset: 1 } }, + }, + ]), + ); + }); + + it('respects explicit kind for sdt node selector', () => { + const inlineSdt = makeNode('structuredContent', { tag: 'inline-sdt' }, 1, []); + const paragraph = makeNode('paragraph', { sdBlockId: 'p-sdt' }, 5, [{ node: inlineSdt, offset: 0 }]); + const blockSdt = makeNode('structuredContentBlock', { sdBlockId: 'sdt-block' }, 4, []); + const doc = makeNode('doc', {}, 20, [ + { node: paragraph, offset: 0 }, + { node: blockSdt, offset: 10 }, + ]); + const editor = makeEditor(doc); + + const blockResult = findAdapter(editor, { select: { type: 'node', kind: 'block', nodeType: 'sdt' } }); + expect(blockResult.total).toBe(1); + expect(blockResult.matches[0]).toEqual({ kind: 'block', nodeType: 'sdt', nodeId: 'sdt-block' }); + + const inlineResult = findAdapter(editor, { select: { type: 'node', kind: 'inline', nodeType: 'sdt' } }); + expect(inlineResult.total).toBe(1); + expect(inlineResult.matches[0]).toEqual({ + kind: 'inline', + nodeType: 'sdt', + anchor: { start: { blockId: 'p-sdt', offset: 0 }, end: { blockId: 'p-sdt', offset: 1 } }, + }); + }); + + it('returns mapped nodes when includeNodes is true', () => { + const runText = makeNode('text', {}, 2, [], 'Hi'); + const runNode = makeNode('run', { runProperties: { bold: true } }, 4, [{ node: runText, offset: 0 }]); + const paragraph = makeNode('paragraph', { sdBlockId: 'p-run' }, 6, [{ node: runNode, offset: 0 }]); + const doc = makeNode('doc', {}, 8, [{ node: paragraph, offset: 0 }]); + const editor = makeEditor(doc); + + const result = findAdapter(editor, { select: { type: 'node', nodeType: 'run' }, includeNodes: true }); + + expect(result.total).toBe(1); + expect(result.nodes).toHaveLength(1); + expect(result.nodes![0]).toMatchObject({ + nodeType: 'run', + kind: 'inline', + properties: { bold: true }, + }); + }); +}); + +// --------------------------------------------------------------------------- +// Text selector queries +// --------------------------------------------------------------------------- + +describe('findAdapter — text selectors', () => { + // Pad textContent to 102 chars so textBetween returns something for any position in the two paragraphs. + const defaultText = 'a'.repeat(102); + + function makeSearchableEditor( + searchResults: Array<{ from: number; to: number; text: string }>, + textContent = defaultText, + ) { + const doc = buildDoc( + textContent, + { typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, nodeSize: 50, offset: 0 }, + { typeName: 'paragraph', attrs: { sdBlockId: 'p2' }, nodeSize: 50, offset: 52 }, + ); + const search: SearchFn = () => searchResults; + return makeEditor(doc, search); + } + + it('returns text matches with context', () => { + // Place "hello" at positions 5-10 in the text content + const text = ' hello' + 'a'.repeat(92); + const editor = makeSearchableEditor([{ from: 5, to: 10, text: 'hello' }], text); + const query: Query = { select: { type: 'text', pattern: 'hello' } }; + + const result = findAdapter(editor, query); + + expect(result.total).toBe(1); + expect(result.matches[0]).toEqual({ kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }); + expect(result.context).toBeDefined(); + expect(result.context![0].snippet).toContain('hello'); + expect(result.context![0].textRanges).toEqual([{ kind: 'text', blockId: 'p1', range: { start: 4, end: 9 } }]); + }); + + it('maps matches to their containing blocks', () => { + const editor = makeSearchableEditor([ + { from: 5, to: 10, text: 'first' }, + { from: 60, to: 65, text: 'second' }, + ]); + const query: Query = { select: { type: 'text', pattern: 'test' } }; + + const result = findAdapter(editor, query); + + expect(result.total).toBe(2); + expect(result.matches[0].nodeId).toBe('p1'); + expect(result.matches[1].nodeId).toBe('p2'); + }); + + it('returns empty with diagnostic for empty pattern', () => { + const editor = makeSearchableEditor([]); + const query: Query = { select: { type: 'text', pattern: '' } }; + + const result = findAdapter(editor, query); + + expect(result.matches).toEqual([]); + expect(result.diagnostics).toBeDefined(); + expect(result.diagnostics![0].message).toContain('non-empty'); + }); + + it('returns empty with diagnostic for invalid regex', () => { + const editor = makeSearchableEditor([]); + const query: Query = { select: { type: 'text', pattern: '[invalid', mode: 'regex' } }; + + const result = findAdapter(editor, query); + + expect(result.matches).toEqual([]); + expect(result.diagnostics).toBeDefined(); + expect(result.diagnostics![0].message).toContain('Invalid text query regex'); + }); + + it('passes regex pattern to search for regex mode', () => { + let capturedPattern: string | RegExp | undefined; + const doc = buildDoc('a'.repeat(52), { + typeName: 'paragraph', + attrs: { sdBlockId: 'p1' }, + nodeSize: 50, + offset: 0, + }); + const search: SearchFn = (pattern) => { + capturedPattern = pattern; + return [{ from: 5, to: 10, text: 'hello' }]; + }; + const editor = makeEditor(doc, search); + const query: Query = { select: { type: 'text', pattern: 'hel+o', mode: 'regex' } }; + + findAdapter(editor, query); + + expect(capturedPattern).toBeInstanceOf(RegExp); + expect((capturedPattern as RegExp).source).toBe('hel+o'); + expect((capturedPattern as RegExp).flags).toContain('i'); + }); + + it('passes case-sensitive regex for regex mode', () => { + let capturedPattern: string | RegExp | undefined; + const doc = buildDoc({ typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, nodeSize: 50, offset: 0 }); + const search: SearchFn = (pattern) => { + capturedPattern = pattern; + return []; + }; + const editor = makeEditor(doc, search); + const query: Query = { select: { type: 'text', pattern: 'Hello', mode: 'regex', caseSensitive: true } }; + + findAdapter(editor, query); + + expect(capturedPattern).toBeInstanceOf(RegExp); + expect((capturedPattern as RegExp).flags).not.toContain('i'); + }); + + it('passes string pattern for default contains mode', () => { + let capturedPattern: string | RegExp | undefined; + const doc = buildDoc({ typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, nodeSize: 50, offset: 0 }); + const search: SearchFn = (pattern) => { + capturedPattern = pattern; + return []; + }; + const editor = makeEditor(doc, search); + const query: Query = { select: { type: 'text', pattern: 'hello' } }; + + findAdapter(editor, query); + + expect(typeof capturedPattern).toBe('string'); + expect(capturedPattern).toBe('hello'); + }); + + it('passes string pattern for case-sensitive contains mode', () => { + let capturedPattern: string | RegExp | undefined; + const doc = buildDoc({ typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, nodeSize: 50, offset: 0 }); + const search: SearchFn = (pattern) => { + capturedPattern = pattern; + return []; + }; + const editor = makeEditor(doc, search); + const query: Query = { select: { type: 'text', pattern: 'Hello', caseSensitive: true } }; + + findAdapter(editor, query); + + expect(typeof capturedPattern).toBe('string'); + expect(capturedPattern).toBe('Hello'); + }); + + it('throws when editor has no search command', () => { + const doc = buildDoc({ typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, nodeSize: 50, offset: 0 }); + const editor = makeEditor(doc); // no search command + const query: Query = { select: { type: 'text', pattern: 'hello' } }; + + expect(() => findAdapter(editor, query)).toThrow('search command is not available'); + }); + + it('paginates text results and contexts together', () => { + const editor = makeSearchableEditor([ + { from: 5, to: 10, text: 'aaa' }, + { from: 15, to: 20, text: 'bbb' }, + { from: 25, to: 30, text: 'ccc' }, + ]); + const query: Query = { select: { type: 'text', pattern: 'test' }, offset: 1, limit: 1 }; + + const result = findAdapter(editor, query); + + expect(result.total).toBe(3); + expect(result.matches).toHaveLength(1); + expect(result.context).toHaveLength(1); + // The second match (from 15-20) should be the one returned + expect(result.context![0].snippet).toBeDefined(); + }); + + it('filters text matches by within scope', () => { + const doc = buildDoc( + 'a'.repeat(70), + { typeName: 'table', attrs: { sdBlockId: 'tbl1' }, nodeSize: 40, offset: 0 }, + { typeName: 'paragraph', attrs: { sdBlockId: 'p-in' }, nodeSize: 10, offset: 5 }, + { typeName: 'paragraph', attrs: { sdBlockId: 'p-out' }, nodeSize: 10, offset: 50 }, + ); + const search: SearchFn = () => [ + { from: 8, to: 13, text: 'inside' }, + { from: 55, to: 60, text: 'outside' }, + ]; + const editor = makeEditor(doc, search); + const query: Query = { + select: { type: 'text', pattern: 'test' }, + within: { kind: 'block', nodeType: 'table', nodeId: 'tbl1' }, + }; + + const result = findAdapter(editor, query); + + expect(result.total).toBe(1); + expect(result.matches[0].nodeId).toBe('p-in'); + }); + + it('skips matches whose position does not resolve to a block', () => { + const doc = buildDoc('a'.repeat(22), { + typeName: 'paragraph', + attrs: { sdBlockId: 'p1' }, + nodeSize: 10, + offset: 10, + }); + // Match at pos 0 is before any block candidate (paragraph starts at 10) + const search: SearchFn = () => [ + { from: 0, to: 5, text: 'ghost' }, + { from: 12, to: 17, text: 'real' }, + ]; + const editor = makeEditor(doc, search); + const query: Query = { select: { type: 'text', pattern: 'test' } }; + + const result = findAdapter(editor, query); + + expect(result.total).toBe(1); + expect(result.matches[0].nodeId).toBe('p1'); + }); +}); + +// --------------------------------------------------------------------------- +// Context / snippet building +// --------------------------------------------------------------------------- + +describe('findAdapter — snippet context', () => { + it('includes highlight range in context', () => { + // Text: 40 chars of padding, then "hello" at positions 40-45, then more padding + const text = 'a'.repeat(40) + 'hello' + 'a'.repeat(55); + const doc = buildDoc(text, { typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, nodeSize: 100, offset: 0 }); + const search: SearchFn = () => [{ from: 40, to: 45, text: 'hello' }]; + const editor = makeEditor(doc, search); + const query: Query = { select: { type: 'text', pattern: 'hello' } }; + + const result = findAdapter(editor, query); + + expect(result.context).toBeDefined(); + const ctx = result.context![0]; + // The snippet should contain the match, and the highlight range should point to it + expect(ctx.snippet.slice(ctx.highlightRange.start, ctx.highlightRange.end)).toBe('hello'); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/find-adapter.ts b/packages/super-editor/src/document-api-adapters/find-adapter.ts new file mode 100644 index 000000000..ecf3a69dc --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/find-adapter.ts @@ -0,0 +1,50 @@ +import type { Editor } from '../core/Editor.js'; +import type { Query, QueryResult, UnknownNodeDiagnostic } from '@superdoc/document-api'; +import { dedupeDiagnostics } from './helpers/adapter-utils.js'; +import { getBlockIndex } from './helpers/index-cache.js'; +import { resolveIncludedNodes } from './helpers/node-info-resolver.js'; +import { collectUnknownNodeDiagnostics, isInlineQuery, shouldQueryBothKinds } from './find/common.js'; +import { executeBlockSelector } from './find/block-strategy.js'; +import { executeDualKindSelector } from './find/dual-kind-strategy.js'; +import { executeInlineSelector } from './find/inline-strategy.js'; +import { executeTextSelector } from './find/text-strategy.js'; + +/** + * Executes a document query against the editor's current state. + * + * Supports block-node selectors (by type) and text selectors (literal/regex) + * with optional `within` scoping and offset/limit pagination. + * + * @param editor - The editor instance to query. + * @param query - The query specifying what to find. + * @returns Query result with matches, total count, and any diagnostics. + * @throws {Error} If the editor's search command is unavailable (text queries only). + */ +export function findAdapter(editor: Editor, query: Query): QueryResult { + const diagnostics: UnknownNodeDiagnostic[] = []; + const index = getBlockIndex(editor); + if (query.includeUnknown) { + collectUnknownNodeDiagnostics(editor, index, diagnostics); + } + + const isInlineSelector = query.select.type !== 'text' && isInlineQuery(query.select); + const isDualKindSelector = query.select.type !== 'text' && shouldQueryBothKinds(query.select); + + const result = + query.select.type === 'text' + ? executeTextSelector(editor, index, query, diagnostics) + : isDualKindSelector + ? executeDualKindSelector(editor, index, query, diagnostics) + : isInlineSelector + ? executeInlineSelector(editor, index, query, diagnostics) + : executeBlockSelector(index, query, diagnostics); + + const uniqueDiagnostics = dedupeDiagnostics(diagnostics); + const includedNodes = query.includeNodes ? resolveIncludedNodes(editor, index, result.matches) : undefined; + + return { + ...result, + nodes: includedNodes?.length ? includedNodes : undefined, + diagnostics: uniqueDiagnostics.length ? uniqueDiagnostics : undefined, + }; +} diff --git a/packages/super-editor/src/document-api-adapters/find/block-strategy.ts b/packages/super-editor/src/document-api-adapters/find/block-strategy.ts new file mode 100644 index 000000000..607af2e96 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/find/block-strategy.ts @@ -0,0 +1,50 @@ +import type { Query, QueryResult, UnknownNodeDiagnostic } from '@superdoc/document-api'; +import { addDiagnostic, paginate, resolveWithinScope, scopeByRange } from '../helpers/adapter-utils.js'; +import type { BlockCandidate, BlockIndex } from '../helpers/node-address-resolver.js'; + +/** + * Executes a block-level node selector against the block index. + * + * @param index - Pre-built block index to search. + * @param query - The query with a node selector and optional pagination/scope. + * @param diagnostics - Mutable array to collect diagnostics into. + * @returns Paginated query result containing block-kind matches. + */ +export function executeBlockSelector( + index: BlockIndex, + query: Query, + diagnostics: UnknownNodeDiagnostic[], +): QueryResult { + const scope = resolveWithinScope(index, query, diagnostics); + if (!scope.ok) return { matches: [], total: 0 }; + + const scoped = scopeByRange(index.candidates, scope.range); + const select = query.select; + let filtered: BlockCandidate[] = []; + + if (select.type === 'node') { + if (select.kind && select.kind !== 'block') { + addDiagnostic(diagnostics, 'Only block nodes are supported by the current adapter.'); + } else { + filtered = scoped.filter((candidate) => { + if (select.nodeType) { + if (candidate.nodeType !== select.nodeType) return false; + } + return true; + }); + } + } + // text selectors are handled by text-strategy, not block + + const addresses = filtered.map((candidate) => ({ + kind: 'block' as const, + nodeType: candidate.nodeType, + nodeId: candidate.nodeId, + })); + const paged = paginate(addresses, query.offset, query.limit); + + return { + matches: paged.items, + total: paged.total, + }; +} diff --git a/packages/super-editor/src/document-api-adapters/find/common.ts b/packages/super-editor/src/document-api-adapters/find/common.ts new file mode 100644 index 000000000..75dd6a3a4 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/find/common.ts @@ -0,0 +1,219 @@ +import type { Editor } from '../../core/Editor.js'; +import type { + MatchContext, + NodeAddress, + NodeType, + Query, + TextAddress, + UnknownNodeDiagnostic, +} from '@superdoc/document-api'; +import { toId } from '../helpers/value-utils.js'; +import { getInlineIndex } from '../helpers/index-cache.js'; +import { + findBlockById, + toBlockAddress, + type BlockCandidate, + type BlockIndex, +} from '../helpers/node-address-resolver.js'; +import { findInlineByAnchor, isInlineQueryType } from '../helpers/inline-address-resolver.js'; +import { findCandidateByPos } from '../helpers/adapter-utils.js'; + +/** Characters of document text to include before and after a match in snippet context. */ +const SNIPPET_PADDING = 30; +const DUAL_KIND_TYPES = new Set(['sdt', 'image']); + +const KNOWN_BLOCK_PM_NODE_TYPES = new Set([ + 'paragraph', + 'table', + 'tableRow', + 'tableCell', + 'tableHeader', + 'structuredContentBlock', + 'sdt', + 'image', +]); + +const KNOWN_INLINE_PM_NODE_TYPES = new Set([ + 'text', + 'run', + 'structuredContent', + 'image', + 'tab', + 'lineBreak', + 'hardBreak', + 'hard_break', + 'footnoteReference', + 'bookmarkStart', + 'bookmarkEnd', + 'commentRangeStart', + 'commentRangeEnd', +]); + +function resolveUnknownBlockId(attrs: Record | undefined): string | undefined { + if (!attrs) return undefined; + return toId(attrs.sdBlockId) ?? toId(attrs.paraId) ?? toId(attrs.blockId) ?? toId(attrs.id) ?? toId(attrs.uuid); +} + +function isDualKindType(nodeType: NodeType | undefined): boolean { + return Boolean(nodeType && DUAL_KIND_TYPES.has(nodeType)); +} + +function getAddressStartPos(editor: Editor, index: BlockIndex, address: NodeAddress): number { + if (address.kind === 'block') { + const block = findBlockById(index, address); + return block?.pos ?? Number.MAX_SAFE_INTEGER; + } + + const inlineIndex = getInlineIndex(editor); + const inline = findInlineByAnchor(inlineIndex, address); + return inline?.pos ?? Number.MAX_SAFE_INTEGER; +} + +/** + * Builds a snippet context for a text match, including surrounding text and highlight offsets. + * + * @param editor - The editor instance. + * @param address - The address of the block containing the match. + * @param matchFrom - Absolute document position of the match start. + * @param matchTo - Absolute document position of the match end. + * @param textRanges - Optional block-relative text ranges for the match. + * @returns A {@link MatchContext} with snippet, highlight range, and text ranges. + */ +export function buildTextContext( + editor: Editor, + address: NodeAddress, + matchFrom: number, + matchTo: number, + textRanges?: TextAddress[], +): MatchContext { + const docSize = editor.state.doc.content.size; + const snippetFrom = Math.max(0, matchFrom - SNIPPET_PADDING); + const snippetTo = Math.min(docSize, matchTo + SNIPPET_PADDING); + const rawSnippet = editor.state.doc.textBetween(snippetFrom, snippetTo, ' '); + const snippet = rawSnippet.replace(/ {2,}/g, ' '); + + const rawPrefix = editor.state.doc.textBetween(snippetFrom, matchFrom, ' '); + const rawMatch = editor.state.doc.textBetween(matchFrom, matchTo, ' '); + const prefix = rawPrefix.replace(/ {2,}/g, ' '); + const matchNormalized = rawMatch.replace(/ {2,}/g, ' '); + + return { + address, + snippet, + highlightRange: { + start: prefix.length, + end: prefix.length + matchNormalized.length, + }, + textRanges: textRanges?.length ? textRanges : undefined, + }; +} + +/** + * Converts an absolute document range to a block-relative {@link TextAddress}. + * + * @param editor - The editor instance. + * @param block - The block candidate containing the range. + * @param range - Absolute document positions. + * @returns A text address, or `undefined` if the range falls outside the block. + */ +export function toTextAddress( + editor: Editor, + block: BlockCandidate, + range: { from: number; to: number }, +): TextAddress | undefined { + const blockStart = block.pos + 1; + const blockEnd = block.end - 1; + if (range.from < blockStart || range.to > blockEnd) return undefined; + + const start = editor.state.doc.textBetween(blockStart, range.from, '\n', '\ufffc').length; + const end = editor.state.doc.textBetween(blockStart, range.to, '\n', '\ufffc').length; + + return { + kind: 'text', + blockId: block.nodeId, + range: { start, end }, + }; +} + +/** + * Returns `true` if the selector targets a node type that exists as both block and inline + * and no explicit `kind` is specified, requiring a dual-kind query. + * + * @param select - The query selector. + */ +export function shouldQueryBothKinds(select: Query['select']): boolean { + if (select.type === 'node') { + return !select.kind && isDualKindType(select.nodeType); + } + return false; +} + +/** + * Sorts node addresses by their absolute document position (ascending). + * Block addresses are ordered before inline addresses at the same position. + * + * @param editor - The editor instance. + * @param index - Pre-built block index for position lookup. + * @param addresses - The addresses to sort. + * @returns A new sorted array. + */ +export function sortAddressesByPosition(editor: Editor, index: BlockIndex, addresses: NodeAddress[]): NodeAddress[] { + return [...addresses].sort((a, b) => { + const aPos = getAddressStartPos(editor, index, a); + const bPos = getAddressStartPos(editor, index, b); + if (aPos !== bPos) return aPos - bPos; + if (a.kind === b.kind) return 0; + return a.kind === 'block' ? -1 : 1; + }); +} + +/** + * Walks the document and pushes diagnostics for block/inline nodes that are + * not part of the stable Document API match set. + * + * @param editor - The editor instance. + * @param index - Pre-built block index (used to resolve containing blocks for inline nodes). + * @param diagnostics - Mutable array to push diagnostics into. + */ +export function collectUnknownNodeDiagnostics( + editor: Editor, + index: BlockIndex, + diagnostics: UnknownNodeDiagnostic[], +): void { + editor.state.doc.descendants((node, pos) => { + if (node.isBlock && !KNOWN_BLOCK_PM_NODE_TYPES.has(node.type.name)) { + const blockId = resolveUnknownBlockId((node.attrs ?? {}) as Record); + diagnostics.push({ + message: `Unknown block node type "${node.type.name}" is not part of the stable Document API match set.`, + hint: blockId + ? `Skipped unknown block with stable id "${blockId}".` + : 'Skipped unknown block with no stable id available.', + }); + return; + } + + if (node.isInline && !KNOWN_INLINE_PM_NODE_TYPES.has(node.type.name)) { + const container = findCandidateByPos(index.candidates, pos); + diagnostics.push({ + message: `Unknown inline node type "${node.type.name}" is not part of the stable Document API match set.`, + address: container ? toBlockAddress(container) : undefined, + hint: container + ? `Skipped unknown inline node inside block "${container.nodeType}" with id "${container.nodeId}".` + : 'Skipped unknown inline node outside resolvable block scope.', + }); + } + }); +} + +/** + * Returns `true` if the selector exclusively targets inline nodes. + * + * @param select - The query selector. + */ +export function isInlineQuery(select: Query['select']): boolean { + if (select.type === 'node') { + if (select.kind) return select.kind === 'inline'; + return Boolean(select.nodeType && isInlineQueryType(select.nodeType)); + } + return false; +} diff --git a/packages/super-editor/src/document-api-adapters/find/dual-kind-strategy.ts b/packages/super-editor/src/document-api-adapters/find/dual-kind-strategy.ts new file mode 100644 index 000000000..50e74fd56 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/find/dual-kind-strategy.ts @@ -0,0 +1,42 @@ +import type { Editor } from '../../core/Editor.js'; +import type { Query, QueryResult, UnknownNodeDiagnostic } from '@superdoc/document-api'; +import { paginate } from '../helpers/adapter-utils.js'; +import type { BlockIndex } from '../helpers/node-address-resolver.js'; +import { sortAddressesByPosition } from './common.js'; +import { executeBlockSelector } from './block-strategy.js'; +import { executeInlineSelector } from './inline-strategy.js'; + +/** + * Executes a selector for node types that exist as both block and inline + * (e.g. `sdt`, `image`). Merges and sorts results from both strategies. + * + * @param editor - The editor instance. + * @param index - Pre-built block index. + * @param query - The query to execute. + * @param diagnostics - Mutable array to collect diagnostics into. + * @returns Paginated query result with merged block and inline matches. + */ +export function executeDualKindSelector( + editor: Editor, + index: BlockIndex, + query: Query, + diagnostics: UnknownNodeDiagnostic[], +): QueryResult { + const queryWithoutPagination: Query = { + ...query, + offset: undefined, + limit: undefined, + }; + + const blockResult = executeBlockSelector(index, queryWithoutPagination, diagnostics); + const inlineResult = executeInlineSelector(editor, index, queryWithoutPagination, diagnostics); + + const mergedMatches = [...blockResult.matches, ...inlineResult.matches]; + const sortedMatches = sortAddressesByPosition(editor, index, mergedMatches); + const paged = paginate(sortedMatches, query.offset, query.limit); + + return { + matches: paged.items, + total: paged.total, + }; +} diff --git a/packages/super-editor/src/document-api-adapters/find/inline-strategy.ts b/packages/super-editor/src/document-api-adapters/find/inline-strategy.ts new file mode 100644 index 000000000..e6ecadc8b --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/find/inline-strategy.ts @@ -0,0 +1,67 @@ +import type { Editor } from '../../core/Editor.js'; +import type { InlineNodeType, NodeAddress, Query, QueryResult, UnknownNodeDiagnostic } from '@superdoc/document-api'; +import { getInlineIndex } from '../helpers/index-cache.js'; +import { findInlineByType, isInlineQueryType, type InlineCandidate } from '../helpers/inline-address-resolver.js'; +import { addDiagnostic, paginate, resolveWithinScope, scopeByRange } from '../helpers/adapter-utils.js'; +import type { BlockIndex } from '../helpers/node-address-resolver.js'; + +function toInlineAddress(candidate: InlineCandidate, nodeTypeOverride?: InlineNodeType): NodeAddress { + return { + kind: 'inline', + nodeType: nodeTypeOverride ?? candidate.nodeType, + anchor: candidate.anchor, + }; +} + +/** + * Executes an inline-level node selector against the inline index. + * + * @param editor - The editor instance. + * @param index - Pre-built block index (used for within-scope resolution). + * @param query - The query with an inline node selector. + * @param diagnostics - Mutable array to collect diagnostics into. + * @returns Paginated query result containing inline-kind matches. + */ +export function executeInlineSelector( + editor: Editor, + index: BlockIndex, + query: Query, + diagnostics: UnknownNodeDiagnostic[], +): QueryResult { + const scope = resolveWithinScope(index, query, diagnostics); + if (!scope.ok) return { matches: [], total: 0 }; + + const inlineIndex = getInlineIndex(editor); + const select = query.select; + let requestedType: InlineNodeType | undefined; + let addressType: InlineNodeType | undefined; + + if (select.type === 'node') { + if (select.kind && select.kind !== 'inline') { + addDiagnostic(diagnostics, 'Only inline nodes are supported by the current inline adapter.'); + return { matches: [], total: 0 }; + } + if (select.nodeType) { + if (!isInlineQueryType(select.nodeType)) { + addDiagnostic(diagnostics, `Node type "${select.nodeType}" is not an inline type.`); + return { matches: [], total: 0 }; + } + requestedType = select.nodeType; + addressType = select.nodeType; + } + } else { + // text selectors are handled by text-strategy, not inline + return { matches: [], total: 0 }; + } + + let candidates = requestedType ? findInlineByType(inlineIndex, requestedType) : inlineIndex.candidates; + candidates = scopeByRange(candidates, scope.range); + + const addresses = candidates.map((candidate) => toInlineAddress(candidate, addressType)); + const paged = paginate(addresses, query.offset, query.limit); + + return { + matches: paged.items, + total: paged.total, + }; +} diff --git a/packages/super-editor/src/document-api-adapters/find/text-strategy.ts b/packages/super-editor/src/document-api-adapters/find/text-strategy.ts new file mode 100644 index 000000000..96e7b7f47 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/find/text-strategy.ts @@ -0,0 +1,151 @@ +import type { Editor } from '../../core/Editor.js'; +import type { + MatchContext, + NodeAddress, + Query, + QueryResult, + TextAddress, + TextSelector, + UnknownNodeDiagnostic, +} from '@superdoc/document-api'; +import { + findBlockByPos, + isTextBlockCandidate, + toBlockAddress, + type BlockCandidate, + type BlockIndex, +} from '../helpers/node-address-resolver.js'; +import { addDiagnostic, findCandidateByPos, paginate, resolveWithinScope } from '../helpers/adapter-utils.js'; +import { buildTextContext, toTextAddress } from './common.js'; +import { DocumentApiAdapterError } from '../errors.js'; + +/** Shape returned by `editor.commands.search`. */ +type SearchMatch = { + from: number; + to: number; + text: string; + ranges?: Array<{ from: number; to: number }>; +}; + +function compileRegex(selector: TextSelector, diagnostics: UnknownNodeDiagnostic[]): RegExp | null { + const flags = selector.caseSensitive ? 'g' : 'gi'; + try { + return new RegExp(selector.pattern, flags); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + addDiagnostic(diagnostics, `Invalid text query regex: ${reason}`); + return null; + } +} + +function buildSearchPattern(selector: TextSelector, diagnostics: UnknownNodeDiagnostic[]): string | RegExp | null { + const mode = selector.mode ?? 'contains'; + if (mode === 'regex') { + return compileRegex(selector, diagnostics); + } + return selector.pattern; +} + +/** + * Executes a text-based search query using the editor's search command. + * + * @param editor - The editor instance (must expose `commands.search`). + * @param index - Pre-built block index for position resolution. + * @param query - The query with a text selector. + * @param diagnostics - Mutable array to collect diagnostics into. + * @returns Paginated query result with block matches and snippet context. + * @throws {DocumentApiAdapterError} If the editor's search command is unavailable. + */ +export function executeTextSelector( + editor: Editor, + index: BlockIndex, + query: Query, + diagnostics: UnknownNodeDiagnostic[], +): QueryResult { + if (query.select.type !== 'text') { + addDiagnostic(diagnostics, `Text strategy received a non-text selector (type="${query.select.type}").`); + return { matches: [], total: 0 }; + } + + const selector: TextSelector = query.select; + if (!selector.pattern.length) { + addDiagnostic(diagnostics, 'Text query pattern must be non-empty.'); + return { matches: [], total: 0 }; + } + + const scope = resolveWithinScope(index, query, diagnostics); + if (!scope.ok) return { matches: [], total: 0 }; + + const pattern = buildSearchPattern(selector, diagnostics); + if (!pattern) return { matches: [], total: 0 }; + + const search = editor.commands?.search; + if (!search) { + throw new DocumentApiAdapterError( + 'COMMAND_UNAVAILABLE', + 'Editor search command is not available on this editor instance.', + ); + } + + // When there is no within scope, we can limit the search engine to only + // produce the matches we need (offset + limit). With a scope, post-search + // filtering makes the needed count unpredictable, so fetch all. + const effectiveMaxMatches = + !scope.range && query.limit != null && Number.isFinite(query.limit) + ? (query.offset ?? 0) + query.limit + : Number.MAX_SAFE_INTEGER; + + const rawResult = search(pattern, { + highlight: false, + maxMatches: effectiveMaxMatches, + caseSensitive: selector.caseSensitive ?? false, + }); + + if (!Array.isArray(rawResult)) { + throw new DocumentApiAdapterError( + 'COMMAND_UNAVAILABLE', + 'Editor search command returned an unexpected result format.', + ); + } + const allMatches = rawResult as SearchMatch[]; + + const scopeRange = scope.range; + const matches = scopeRange + ? allMatches.filter((m) => m.from >= scopeRange.start && m.to <= scopeRange.end) + : allMatches; + + const textBlocks = index.candidates.filter(isTextBlockCandidate); + const contexts: MatchContext[] = []; + const addresses: NodeAddress[] = []; + + for (const match of matches) { + const ranges = match.ranges?.length ? match.ranges : [{ from: match.from, to: match.to }]; + let source: BlockCandidate | undefined; + const textRanges = ranges + .map((range) => { + const block = findCandidateByPos(textBlocks, range.from); + if (!block) return undefined; + if (!source) source = block; + return toTextAddress(editor, block, range); + }) + .filter((range): range is TextAddress => Boolean(range)); + + if (!source) { + source = findCandidateByPos(textBlocks, match.from) ?? findBlockByPos(index, match.from); + } + if (!source) continue; + + const address = toBlockAddress(source); + addresses.push(address); + contexts.push(buildTextContext(editor, address, match.from, match.to, textRanges)); + } + + const paged = paginate(addresses, query.offset, query.limit); + const pagedContexts = paginate(contexts, query.offset, query.limit).items; + + return { + matches: paged.items, + total: paged.total, + context: pagedContexts.length ? pagedContexts : undefined, + }; +} diff --git a/packages/super-editor/src/document-api-adapters/get-node-adapter.test.ts b/packages/super-editor/src/document-api-adapters/get-node-adapter.test.ts new file mode 100644 index 000000000..1247bda87 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/get-node-adapter.test.ts @@ -0,0 +1,218 @@ +import type { Node as ProseMirrorNode, Mark as ProseMirrorMark } from 'prosemirror-model'; +import type { Editor } from '../core/Editor.js'; +import type { BlockIndex } from './helpers/node-address-resolver.js'; +import { buildInlineIndex, findInlineByType } from './helpers/inline-address-resolver.js'; +import { getNodeAdapter, getNodeByIdAdapter } from './get-node-adapter.js'; + +function makeMark(name: string, attrs: Record = {}): ProseMirrorMark { + return { type: { name }, attrs } as unknown as ProseMirrorMark; +} + +type NodeOptions = { + attrs?: Record; + marks?: ProseMirrorMark[]; + text?: string; + isInline?: boolean; + isBlock?: boolean; + isLeaf?: boolean; + inlineContent?: boolean; +}; + +function createNode(typeName: string, children: ProseMirrorNode[] = [], options: NodeOptions = {}): ProseMirrorNode { + const attrs = options.attrs ?? {}; + const marks = options.marks ?? []; + const text = options.text ?? ''; + const isText = typeName === 'text'; + const isInline = options.isInline ?? isText; + const isBlock = options.isBlock ?? (!isInline && typeName !== 'doc'); + const inlineContent = options.inlineContent ?? isBlock; + const isLeaf = options.isLeaf ?? (isInline && children.length === 0 && !isText); + + const contentSize = children.reduce((sum, child) => sum + child.nodeSize, 0); + const nodeSize = isText ? text.length : isLeaf ? 1 : contentSize + 2; + + return { + type: { name: typeName }, + attrs, + marks, + text: isText ? text : undefined, + nodeSize, + content: { size: contentSize }, + isText, + isInline, + isBlock, + inlineContent, + isTextblock: inlineContent, + isLeaf, + childCount: children.length, + child(index: number) { + return children[index]!; + }, + forEach(callback: (node: ProseMirrorNode, offset: number) => void) { + let offset = 0; + for (const child of children) { + callback(child, offset); + offset += child.nodeSize; + } + }, + descendants(callback: (node: ProseMirrorNode, pos: number) => void) { + let offset = 0; + for (const child of children) { + callback(child, offset); + offset += child.nodeSize; + } + }, + } as unknown as ProseMirrorNode; +} + +function makeEditor(docNode: ProseMirrorNode): Editor { + return { state: { doc: docNode } } as unknown as Editor; +} + +function buildBlockIndexFromParagraph(paragraph: ProseMirrorNode, nodeId: string): BlockIndex { + const candidate = { + node: paragraph, + pos: 0, + end: paragraph.nodeSize, + nodeType: 'paragraph' as const, + nodeId, + }; + const byId = new Map(); + byId.set(`paragraph:${nodeId}`, candidate); + return { candidates: [candidate], byId }; +} + +describe('getNodeAdapter — inline', () => { + it('resolves inline images by anchor', () => { + const textNode = createNode('text', [], { text: 'Hi' }); + const imageNode = createNode('image', [], { isInline: true, isLeaf: true, attrs: { src: 'x' } }); + const paragraph = createNode('paragraph', [textNode, imageNode], { + attrs: { sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + + const editor = makeEditor(doc); + const blockIndex = buildBlockIndexFromParagraph(paragraph, 'p1'); + const inlineIndex = buildInlineIndex(editor, blockIndex); + const imageCandidate = findInlineByType(inlineIndex, 'image')[0]; + if (!imageCandidate) throw new Error('Expected image candidate'); + + const result = getNodeAdapter(editor, { + kind: 'inline', + nodeType: 'image', + anchor: imageCandidate.anchor, + }); + + expect(result.nodeType).toBe('image'); + expect(result.kind).toBe('inline'); + }); + + it('resolves hyperlink marks by anchor', () => { + const linkMark = makeMark('link', { href: 'https://example.com' }); + const textNode = createNode('text', [], { text: 'Hi', marks: [linkMark] }); + const paragraph = createNode('paragraph', [textNode], { + attrs: { sdBlockId: 'p2' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + + const editor = makeEditor(doc); + const blockIndex = buildBlockIndexFromParagraph(paragraph, 'p2'); + const inlineIndex = buildInlineIndex(editor, blockIndex); + const hyperlink = findInlineByType(inlineIndex, 'hyperlink')[0]; + if (!hyperlink) throw new Error('Expected hyperlink candidate'); + + const result = getNodeAdapter(editor, { + kind: 'inline', + nodeType: 'hyperlink', + anchor: hyperlink.anchor, + }); + + expect(result.nodeType).toBe('hyperlink'); + expect(result.kind).toBe('inline'); + }); +}); + +describe('getNodeAdapter — block', () => { + it('throws when a block address matches multiple nodes with the same type and id', () => { + const first = createNode('paragraph', [], { attrs: { sdBlockId: 'dup' }, isBlock: true, inlineContent: true }); + const second = createNode('paragraph', [], { attrs: { sdBlockId: 'dup' }, isBlock: true, inlineContent: true }); + const doc = createNode('doc', [first, second], { isBlock: false }); + const editor = makeEditor(doc); + + expect(() => + getNodeAdapter(editor, { + kind: 'block', + nodeType: 'paragraph', + nodeId: 'dup', + }), + ).toThrow('Multiple nodes share paragraph id "dup".'); + }); +}); + +describe('getNodeByIdAdapter', () => { + it('resolves a block node by id without nodeType', () => { + const textNode = createNode('text', [], { text: 'Hi' }); + const paragraph = createNode('paragraph', [textNode], { + attrs: { sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + + const editor = makeEditor(doc); + const result = getNodeByIdAdapter(editor, { nodeId: 'p1' }); + + expect(result.nodeType).toBe('paragraph'); + expect(result.kind).toBe('block'); + }); + + it('resolves a block node by id with nodeType', () => { + const paragraph = createNode('paragraph', [], { attrs: { sdBlockId: 'p2' }, isBlock: true, inlineContent: true }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + const editor = makeEditor(doc); + + const result = getNodeByIdAdapter(editor, { nodeId: 'p2', nodeType: 'paragraph' }); + + expect(result.nodeType).toBe('paragraph'); + }); + + it('throws when nodeId is missing', () => { + const paragraph = createNode('paragraph', [], { attrs: { sdBlockId: 'p3' }, isBlock: true, inlineContent: true }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + const editor = makeEditor(doc); + + expect(() => getNodeByIdAdapter(editor, { nodeId: 'missing' })).toThrow(); + }); + + it('throws when nodeId is ambiguous without nodeType', () => { + const paragraph = createNode('paragraph', [], { attrs: { sdBlockId: 'dup' }, isBlock: true, inlineContent: true }); + const table = createNode('table', [], { attrs: { sdBlockId: 'dup' }, isBlock: true }); + const doc = createNode('doc', [paragraph, table], { isBlock: false }); + const editor = makeEditor(doc); + + expect(() => getNodeByIdAdapter(editor, { nodeId: 'dup' })).toThrow(); + }); + + it('throws when nodeId is ambiguous for the same nodeType', () => { + const first = createNode('paragraph', [], { + attrs: { sdBlockId: 'dup-typed' }, + isBlock: true, + inlineContent: true, + }); + const second = createNode('paragraph', [], { + attrs: { sdBlockId: 'dup-typed' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [first, second], { isBlock: false }); + const editor = makeEditor(doc); + + expect(() => getNodeByIdAdapter(editor, { nodeId: 'dup-typed', nodeType: 'paragraph' })).toThrow( + 'Multiple nodes share paragraph id "dup-typed".', + ); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/get-node-adapter.ts b/packages/super-editor/src/document-api-adapters/get-node-adapter.ts new file mode 100644 index 000000000..888a774c2 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/get-node-adapter.ts @@ -0,0 +1,99 @@ +import type { Editor } from '../core/Editor.js'; +import type { BlockNodeType, GetNodeByIdInput, NodeAddress, NodeInfo } from '@superdoc/document-api'; +import type { BlockCandidate, BlockIndex } from './helpers/node-address-resolver.js'; +import { getBlockIndex, getInlineIndex } from './helpers/index-cache.js'; +import { findInlineByAnchor } from './helpers/inline-address-resolver.js'; +import { mapNodeInfo } from './helpers/node-info-mapper.js'; +import { DocumentApiAdapterError } from './errors.js'; + +function findBlocksByTypeAndId(blockIndex: BlockIndex, nodeType: BlockNodeType, nodeId: string): BlockCandidate[] { + return blockIndex.candidates.filter((candidate) => candidate.nodeType === nodeType && candidate.nodeId === nodeId); +} + +/** + * Resolves a {@link NodeAddress} to full {@link NodeInfo} by looking up the + * node in the editor's current document state. + * + * @param editor - The editor instance to query. + * @param address - The node address to resolve. + * @returns Detailed node information with typed properties. + * @throws {DocumentApiAdapterError} If no node is found for the given address. + */ +export function getNodeAdapter(editor: Editor, address: NodeAddress): NodeInfo { + const blockIndex = getBlockIndex(editor); + + if (address.kind === 'block') { + const matches = findBlocksByTypeAndId(blockIndex, address.nodeType, address.nodeId); + if (matches.length === 0) { + throw new DocumentApiAdapterError( + 'TARGET_NOT_FOUND', + `Node "${address.nodeType}" not found for id "${address.nodeId}".`, + ); + } + if (matches.length > 1) { + throw new DocumentApiAdapterError( + 'TARGET_NOT_FOUND', + `Multiple nodes share ${address.nodeType} id "${address.nodeId}".`, + ); + } + + return mapNodeInfo(matches[0]!, address.nodeType); + } + + const inlineIndex = getInlineIndex(editor); + const candidate = findInlineByAnchor(inlineIndex, address); + if (!candidate) { + throw new DocumentApiAdapterError( + 'TARGET_NOT_FOUND', + `Inline node "${address.nodeType}" not found for the provided anchor.`, + ); + } + + return mapNodeInfo(candidate, address.nodeType); +} + +function resolveBlockById( + editor: Editor, + nodeId: string, + nodeType?: BlockNodeType, +): { candidate: BlockCandidate; resolvedType: BlockNodeType } { + const blockIndex = getBlockIndex(editor); + if (nodeType) { + const matches = findBlocksByTypeAndId(blockIndex, nodeType, nodeId); + if (matches.length === 0) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', `Node "${nodeType}" not found for id "${nodeId}".`); + } + if (matches.length > 1) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', `Multiple nodes share ${nodeType} id "${nodeId}".`); + } + return { candidate: matches[0]!, resolvedType: nodeType }; + } + + const matches = blockIndex.candidates.filter((candidate) => candidate.nodeId === nodeId); + if (matches.length === 0) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', `Node not found for id "${nodeId}".`); + } + if (matches.length > 1) { + throw new DocumentApiAdapterError( + 'TARGET_NOT_FOUND', + `Multiple nodes share id "${nodeId}". Provide nodeType to disambiguate.`, + ); + } + + return { candidate: matches[0]!, resolvedType: matches[0]!.nodeType }; +} + +/** + * Resolves a block node by its ID (and optional type) to full {@link NodeInfo}. + * + * @param editor - The editor instance to query. + * @param input - The block node id input payload. + * @returns Detailed node information with typed properties. + * @throws {DocumentApiAdapterError} If no node matches or multiple nodes match without a type disambiguator. + */ +export function getNodeByIdAdapter(editor: Editor, input: GetNodeByIdInput): NodeInfo { + const { nodeId, nodeType } = input; + const { candidate, resolvedType } = resolveBlockById(editor, nodeId, nodeType); + const displayType = nodeType ?? resolvedType; + return mapNodeInfo(candidate, displayType); +} diff --git a/packages/super-editor/src/document-api-adapters/get-text-adapter.test.ts b/packages/super-editor/src/document-api-adapters/get-text-adapter.test.ts new file mode 100644 index 000000000..173fb0b46 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/get-text-adapter.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import type { Editor } from '../core/Editor.js'; +import { getTextAdapter } from './get-text-adapter.js'; + +function makeEditor(textContent: string): Editor { + return { + state: { + doc: { textContent }, + }, + } as unknown as Editor; +} + +describe('getTextAdapter', () => { + it('returns the document text content', () => { + const editor = makeEditor('Hello world'); + expect(getTextAdapter(editor, {})).toBe('Hello world'); + }); + + it('returns an empty string for an empty document', () => { + const editor = makeEditor(''); + expect(getTextAdapter(editor, {})).toBe(''); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/get-text-adapter.ts b/packages/super-editor/src/document-api-adapters/get-text-adapter.ts new file mode 100644 index 000000000..a11a3b6b5 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/get-text-adapter.ts @@ -0,0 +1,12 @@ +import type { Editor } from '../core/Editor.js'; +import type { GetTextInput } from '@superdoc/document-api'; + +/** + * Return the full document text content from the ProseMirror document. + * + * @param editor - The editor instance. + * @returns Plain text content of the document. + */ +export function getTextAdapter(editor: Editor, _input: GetTextInput): string { + return editor.state.doc.textContent; +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/adapter-utils.test.ts b/packages/super-editor/src/document-api-adapters/helpers/adapter-utils.test.ts new file mode 100644 index 000000000..a06d5abfe --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/adapter-utils.test.ts @@ -0,0 +1,211 @@ +import type { UnknownNodeDiagnostic } from '@superdoc/document-api'; +import { addDiagnostic, dedupeDiagnostics, findCandidateByPos, paginate, scopeByRange } from './adapter-utils.js'; + +// --------------------------------------------------------------------------- +// paginate +// --------------------------------------------------------------------------- + +describe('paginate', () => { + const items = ['a', 'b', 'c', 'd', 'e']; + + it('returns all items when no offset or limit is provided', () => { + const result = paginate(items); + expect(result).toEqual({ total: 5, items: ['a', 'b', 'c', 'd', 'e'] }); + }); + + it('applies offset', () => { + const result = paginate(items, 2); + expect(result).toEqual({ total: 5, items: ['c', 'd', 'e'] }); + }); + + it('applies limit', () => { + const result = paginate(items, 0, 3); + expect(result).toEqual({ total: 5, items: ['a', 'b', 'c'] }); + }); + + it('combines offset and limit', () => { + const result = paginate(items, 1, 2); + expect(result).toEqual({ total: 5, items: ['b', 'c'] }); + }); + + it('returns empty when offset exceeds length', () => { + const result = paginate(items, 10); + expect(result).toEqual({ total: 5, items: [] }); + }); + + it('returns empty for limit 0', () => { + const result = paginate(items, 0, 0); + expect(result).toEqual({ total: 5, items: [] }); + }); + + it('clamps negative offset to 0', () => { + const result = paginate(items, -5, 2); + expect(result).toEqual({ total: 5, items: ['a', 'b'] }); + }); + + it('clamps negative limit to 0', () => { + const result = paginate(items, 0, -1); + expect(result).toEqual({ total: 5, items: [] }); + }); + + it('handles empty array', () => { + const result = paginate([]); + expect(result).toEqual({ total: 0, items: [] }); + }); +}); + +// --------------------------------------------------------------------------- +// dedupeDiagnostics +// --------------------------------------------------------------------------- + +describe('dedupeDiagnostics', () => { + it('removes duplicate diagnostics by message', () => { + const diagnostics: UnknownNodeDiagnostic[] = [ + { message: 'error A' }, + { message: 'error A' }, + { message: 'error B' }, + ]; + + const result = dedupeDiagnostics(diagnostics); + + expect(result).toEqual([{ message: 'error A' }, { message: 'error B' }]); + }); + + it('considers hint in deduplication key', () => { + const diagnostics: UnknownNodeDiagnostic[] = [ + { message: 'same', hint: 'hint1' }, + { message: 'same', hint: 'hint2' }, + ]; + + const result = dedupeDiagnostics(diagnostics); + + expect(result).toHaveLength(2); + }); + + it('considers address in deduplication key', () => { + const diagnostics: UnknownNodeDiagnostic[] = [ + { message: 'same', address: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' } }, + { message: 'same', address: { kind: 'block', nodeType: 'paragraph', nodeId: 'p2' } }, + ]; + + const result = dedupeDiagnostics(diagnostics); + + expect(result).toHaveLength(2); + }); + + it('preserves insertion order', () => { + const diagnostics: UnknownNodeDiagnostic[] = [{ message: 'first' }, { message: 'second' }, { message: 'first' }]; + + const result = dedupeDiagnostics(diagnostics); + + expect(result[0].message).toBe('first'); + expect(result[1].message).toBe('second'); + }); + + it('returns empty array for empty input', () => { + expect(dedupeDiagnostics([])).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// addDiagnostic +// --------------------------------------------------------------------------- + +describe('addDiagnostic', () => { + it('pushes a diagnostic with the given message', () => { + const diagnostics: UnknownNodeDiagnostic[] = []; + addDiagnostic(diagnostics, 'Something went wrong.'); + + expect(diagnostics).toEqual([{ message: 'Something went wrong.' }]); + }); +}); + +// --------------------------------------------------------------------------- +// scopeByRange +// --------------------------------------------------------------------------- + +describe('scopeByRange', () => { + const candidates = [ + { pos: 0, end: 10 }, + { pos: 15, end: 25 }, + { pos: 30, end: 40 }, + ]; + + it('returns all candidates when range is undefined', () => { + const result = scopeByRange(candidates, undefined); + expect(result).toHaveLength(3); + }); + + it('filters to candidates fully within the range', () => { + const result = scopeByRange(candidates, { start: 0, end: 30 }); + expect(result).toHaveLength(2); + expect(result[0].pos).toBe(0); + expect(result[1].pos).toBe(15); + }); + + it('excludes candidates partially outside the range', () => { + const result = scopeByRange(candidates, { start: 5, end: 40 }); + expect(result).toHaveLength(2); + expect(result[0].pos).toBe(15); + expect(result[1].pos).toBe(30); + }); + + it('returns empty when no candidates fit', () => { + const result = scopeByRange(candidates, { start: 11, end: 14 }); + expect(result).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// findCandidateByPos +// --------------------------------------------------------------------------- + +describe('findCandidateByPos', () => { + // Candidates: [0, 10), [15, 25), [30, 40) + const candidates = [ + { pos: 0, end: 10, id: 'a' }, + { pos: 15, end: 25, id: 'b' }, + { pos: 30, end: 40, id: 'c' }, + ]; + + it('finds a candidate at start of range', () => { + expect(findCandidateByPos(candidates, 0)?.id).toBe('a'); + }); + + it('finds a candidate in the middle of range', () => { + expect(findCandidateByPos(candidates, 5)?.id).toBe('a'); + }); + + it('treats end as exclusive (pos at end returns undefined for gap)', () => { + // pos 10 is at candidate.end, which is exclusive — should not match 'a' + expect(findCandidateByPos(candidates, 10)).toBeUndefined(); + }); + + it('finds the middle candidate', () => { + expect(findCandidateByPos(candidates, 20)?.id).toBe('b'); + }); + + it('finds the last candidate', () => { + expect(findCandidateByPos(candidates, 35)?.id).toBe('c'); + }); + + it('returns undefined for position in a gap', () => { + expect(findCandidateByPos(candidates, 12)).toBeUndefined(); + }); + + it('returns undefined for position beyond all candidates', () => { + expect(findCandidateByPos(candidates, 50)).toBeUndefined(); + }); + + it('returns undefined for empty array', () => { + expect(findCandidateByPos([], 5)).toBeUndefined(); + }); + + it('finds the only candidate in a single-element array', () => { + const single = [{ pos: 5, end: 15, id: 'only' }]; + expect(findCandidateByPos(single, 5)?.id).toBe('only'); + expect(findCandidateByPos(single, 10)?.id).toBe('only'); + expect(findCandidateByPos(single, 4)).toBeUndefined(); + expect(findCandidateByPos(single, 15)).toBeUndefined(); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/adapter-utils.ts b/packages/super-editor/src/document-api-adapters/helpers/adapter-utils.ts new file mode 100644 index 000000000..f243e6e77 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/adapter-utils.ts @@ -0,0 +1,204 @@ +import type { Query, TextAddress, UnknownNodeDiagnostic } from '@superdoc/document-api'; +import { getBlockIndex } from './index-cache.js'; +import { findBlockById, isTextBlockCandidate, type BlockCandidate, type BlockIndex } from './node-address-resolver.js'; +import { resolveTextRangeInBlock } from './text-offset-resolver.js'; +import type { Editor } from '../../core/Editor.js'; + +export type WithinResult = { ok: true; range: { start: number; end: number } | undefined } | { ok: false }; +export type ResolvedTextTarget = { from: number; to: number }; + +function findInlineWithinTextBlock(index: BlockIndex, blockId: string): BlockCandidate | undefined { + return index.candidates.find((candidate) => candidate.nodeId === blockId && isTextBlockCandidate(candidate)); +} + +/** + * Resolves a {@link TextAddress} to absolute ProseMirror positions. + * + * @param editor - The editor instance. + * @param target - The text address to resolve. + * @returns Absolute `{ from, to }` positions, or `null` if the target block cannot be found. + */ +export function resolveTextTarget(editor: Editor, target: TextAddress): ResolvedTextTarget | null { + const index = getBlockIndex(editor); + const block = index.candidates.find( + (candidate) => candidate.nodeId === target.blockId && isTextBlockCandidate(candidate), + ); + if (!block) return null; + return resolveTextRangeInBlock(block.node, block.pos, target.range); +} + +/** + * Resolves the deterministic default insertion target for insert-without-target calls. + * + * Priority: + * 1) First paragraph block in document order. + * 2) First editable text block in document order. + */ +export function resolveDefaultInsertTarget(editor: Editor): { target: TextAddress; range: ResolvedTextTarget } | null { + const index = getBlockIndex(editor); + const firstParagraph = index.candidates.find( + (candidate) => candidate.nodeType === 'paragraph' && isTextBlockCandidate(candidate), + ); + const firstTextBlock = firstParagraph ?? index.candidates.find((candidate) => isTextBlockCandidate(candidate)); + if (!firstTextBlock) return null; + + const range = resolveTextRangeInBlock(firstTextBlock.node, firstTextBlock.pos, { start: 0, end: 0 }); + if (!range) return null; + + return { + target: { + kind: 'text', + blockId: firstTextBlock.nodeId, + range: { start: 0, end: 0 }, + }, + range, + }; +} + +/** + * Appends a diagnostic message to the mutable diagnostics array. + * + * @param diagnostics - Array to push the diagnostic into. + * @param message - Human-readable diagnostic message. + */ +export function addDiagnostic(diagnostics: UnknownNodeDiagnostic[], message: string): void { + diagnostics.push({ message }); +} + +/** + * Applies offset/limit pagination to an array, returning the total count and the sliced page. + * + * @param items - The full result array. + * @param offset - Number of items to skip (default `0`). + * @param limit - Maximum items to return (default: all remaining). + * @returns An object with `total` (pre-pagination count) and `items` (the sliced page). + */ +export function paginate(items: T[], offset = 0, limit = items.length): { total: number; items: T[] } { + const total = items.length; + const safeOffset = Math.max(0, offset ?? 0); + const safeLimit = Math.max(0, limit ?? total); + return { total, items: items.slice(safeOffset, safeOffset + safeLimit) }; +} + +/** + * Deduplicates diagnostics by message + hint + address, preserving insertion order. + * + * @param diagnostics - The diagnostics to deduplicate. + * @returns A new array with unique diagnostics. + */ +export function dedupeDiagnostics(diagnostics: UnknownNodeDiagnostic[]): UnknownNodeDiagnostic[] { + const seen = new Set(); + const unique: UnknownNodeDiagnostic[] = []; + + for (const diagnostic of diagnostics) { + const key = `${diagnostic.message}|${diagnostic.hint ?? ''}|${ + diagnostic.address ? JSON.stringify(diagnostic.address) : '' + }`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(diagnostic); + } + + return unique; +} + +/** + * Resolves the `within` scope of a query to an absolute position range. + * + * @param index - Pre-built block index. + * @param query - The query whose `within` clause should be resolved. + * @param diagnostics - Mutable array to collect diagnostics into. + * @returns `{ ok: true, range }` on success (range is `undefined` when no scope), or `{ ok: false }` with a diagnostic. + */ +export function resolveWithinScope( + index: BlockIndex, + query: Query, + diagnostics: UnknownNodeDiagnostic[], +): WithinResult { + if (!query.within) return { ok: true, range: undefined }; + + if (query.within.kind === 'block') { + const within = findBlockById(index, query.within); + if (!within) { + addDiagnostic( + diagnostics, + `Within block "${query.within.nodeType}" with id "${query.within.nodeId}" was not found in the document.`, + ); + return { ok: false }; + } + return { ok: true, range: { start: within.pos, end: within.end } }; + } + + if (query.within.anchor.start.blockId !== query.within.anchor.end.blockId) { + addDiagnostic(diagnostics, 'Inline within anchors that span multiple blocks are not supported.'); + return { ok: false }; + } + + const block = findInlineWithinTextBlock(index, query.within.anchor.start.blockId); + if (!block) { + addDiagnostic( + diagnostics, + `Within inline anchor block "${query.within.anchor.start.blockId}" was not found in the document.`, + ); + return { ok: false }; + } + + const resolved = resolveTextRangeInBlock(block.node, block.pos, { + start: query.within.anchor.start.offset, + end: query.within.anchor.end.offset, + }); + if (!resolved) { + addDiagnostic(diagnostics, 'Inline within anchor offsets could not be resolved in the target block.'); + return { ok: false }; + } + + return { ok: true, range: { start: resolved.from, end: resolved.to } }; +} + +/** + * Filters candidates to those fully contained within the given position range. + * Returns the full array unchanged when `range` is `undefined`. + * + * @param candidates - Candidates with `pos` and `end` fields. + * @param range - Optional absolute position range to filter by. + * @returns Filtered candidates. + */ +export function scopeByRange( + candidates: T[], + range: { start: number; end: number } | undefined, +): T[] { + if (!range) return candidates; + return candidates.filter((candidate) => candidate.pos >= range.start && candidate.end <= range.end); +} + +/** + * Binary-searches a sorted candidate array for the entry containing `pos`. + * Uses half-open interval `[candidate.pos, candidate.end)`. + * + * @param candidates - Sorted array of candidates with `pos` and `end` fields. + * @param pos - The absolute document position to look up. + * @returns The matching candidate, or `undefined` if no candidate contains the position. + */ +export function findCandidateByPos( + candidates: T[], + pos: number, +): T | undefined { + let low = 0; + let high = candidates.length - 1; + + while (low <= high) { + const mid = (low + high) >> 1; + const candidate = candidates[mid]; + if (pos < candidate.pos) { + high = mid - 1; + continue; + } + if (pos >= candidate.end) { + low = mid + 1; + continue; + } + return candidate; + } + + return undefined; +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/index-cache.test.ts b/packages/super-editor/src/document-api-adapters/helpers/index-cache.test.ts new file mode 100644 index 000000000..e19b2baa3 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/index-cache.test.ts @@ -0,0 +1,122 @@ +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import type { Editor } from '../../core/Editor.js'; +import { getBlockIndex, getInlineIndex } from './index-cache.js'; + +function createTextNode(text: string): ProseMirrorNode { + return { + type: { name: 'text' }, + attrs: {}, + marks: [], + text, + nodeSize: text.length, + content: { size: 0 }, + isText: true, + isInline: true, + isBlock: false, + isLeaf: true, + childCount: 0, + child() { + throw new Error('Text nodes do not have children.'); + }, + forEach() { + // Text nodes do not expose children. + }, + } as unknown as ProseMirrorNode; +} + +function createParagraphNode(nodeId: string, text = 'Hello'): ProseMirrorNode { + const textNode = createTextNode(text); + return { + type: { name: 'paragraph' }, + attrs: { sdBlockId: nodeId }, + marks: [], + nodeSize: textNode.nodeSize + 2, + content: { size: textNode.nodeSize }, + isText: false, + isInline: false, + isBlock: true, + inlineContent: true, + isTextblock: true, + isLeaf: false, + childCount: 1, + child(index: number) { + if (index !== 0) throw new Error('Paragraph has only one child.'); + return textNode; + }, + forEach(callback: (node: ProseMirrorNode, offset: number) => void) { + callback(textNode, 0); + }, + } as unknown as ProseMirrorNode; +} + +function createDocNode(paragraph: ProseMirrorNode): ProseMirrorNode { + return { + type: { name: 'doc' }, + attrs: {}, + marks: [], + nodeSize: paragraph.nodeSize + 2, + content: { size: paragraph.nodeSize }, + isText: false, + isInline: false, + isBlock: false, + isLeaf: false, + childCount: 1, + child(index: number) { + if (index !== 0) throw new Error('Doc has only one child.'); + return paragraph; + }, + forEach(callback: (node: ProseMirrorNode, offset: number) => void) { + callback(paragraph, 0); + }, + descendants(callback: (node: ProseMirrorNode, pos: number) => void) { + callback(paragraph, 0); + }, + } as unknown as ProseMirrorNode; +} + +function makeEditor(doc: ProseMirrorNode): Editor { + return { + state: { + doc, + }, + } as unknown as Editor; +} + +describe('index-cache', () => { + it('reuses block index for the same document snapshot', () => { + const editor = makeEditor(createDocNode(createParagraphNode('p1'))); + + const first = getBlockIndex(editor); + const second = getBlockIndex(editor); + + expect(second).toBe(first); + }); + + it('lazily builds and reuses inline index for the same document snapshot', () => { + const editor = makeEditor(createDocNode(createParagraphNode('p1'))); + + const block = getBlockIndex(editor); + const firstInline = getInlineIndex(editor); + const secondInline = getInlineIndex(editor); + + expect(secondInline).toBe(firstInline); + expect(getBlockIndex(editor)).toBe(block); + }); + + it('invalidates block and inline indexes when the document snapshot changes', () => { + const firstDoc = createDocNode(createParagraphNode('p1')); + const secondDoc = createDocNode(createParagraphNode('p2')); + const editor = makeEditor(firstDoc) as Editor & { state: { doc: ProseMirrorNode } }; + + const firstBlock = getBlockIndex(editor); + const firstInline = getInlineIndex(editor); + + editor.state.doc = secondDoc; + + const secondBlock = getBlockIndex(editor); + const secondInline = getInlineIndex(editor); + + expect(secondBlock).not.toBe(firstBlock); + expect(secondInline).not.toBe(firstInline); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/index-cache.ts b/packages/super-editor/src/document-api-adapters/helpers/index-cache.ts new file mode 100644 index 000000000..55c6aec7e --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/index-cache.ts @@ -0,0 +1,65 @@ +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import type { Editor } from '../../core/Editor.js'; +import { buildInlineIndex, type InlineIndex } from './inline-address-resolver.js'; +import { buildBlockIndex, type BlockIndex } from './node-address-resolver.js'; + +type CacheEntry = { + doc: ProseMirrorNode; + blockIndex: BlockIndex; + inlineIndex: InlineIndex | null; +}; + +const cacheByEditor = new WeakMap(); + +function createCacheEntry(editor: Editor): CacheEntry { + return { + doc: editor.state.doc, + blockIndex: buildBlockIndex(editor), + inlineIndex: null, + }; +} + +function getCacheEntry(editor: Editor): CacheEntry { + const doc = editor.state.doc; + const existing = cacheByEditor.get(editor); + if (existing && existing.doc === doc) return existing; + + const next = createCacheEntry(editor); + cacheByEditor.set(editor, next); + return next; +} + +/** + * Returns the cached block index for the editor's current document. + * Rebuilds automatically when the document snapshot changes. + * + * @param editor - The editor instance. + * @returns The block-level positional index. + */ +export function getBlockIndex(editor: Editor): BlockIndex { + return getCacheEntry(editor).blockIndex; +} + +/** + * Returns the cached inline index for the editor's current document. + * Lazily built on first access; rebuilt when the document snapshot changes. + * + * @param editor - The editor instance. + * @returns The inline-level positional index. + */ +export function getInlineIndex(editor: Editor): InlineIndex { + const entry = getCacheEntry(editor); + if (!entry.inlineIndex) { + entry.inlineIndex = buildInlineIndex(editor, entry.blockIndex); + } + return entry.inlineIndex; +} + +/** + * Removes cached indexes for the given editor instance. + * + * @param editor - The editor whose cache entry should be cleared. + */ +export function clearIndexCache(editor: Editor): void { + cacheByEditor.delete(editor); +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/inline-address-resolver.test.ts b/packages/super-editor/src/document-api-adapters/helpers/inline-address-resolver.test.ts new file mode 100644 index 000000000..a7cf2eebe --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/inline-address-resolver.test.ts @@ -0,0 +1,130 @@ +import type { Node as ProseMirrorNode, Mark as ProseMirrorMark } from 'prosemirror-model'; +import type { Editor } from '../../core/Editor.js'; +import type { BlockIndex } from './node-address-resolver.js'; +import { buildInlineIndex, findInlineByType } from './inline-address-resolver.js'; + +function makeMark(name: string, attrs: Record = {}): ProseMirrorMark { + return { type: { name }, attrs } as unknown as ProseMirrorMark; +} + +type NodeOptions = { + attrs?: Record; + marks?: ProseMirrorMark[]; + text?: string; + isInline?: boolean; + isBlock?: boolean; + isLeaf?: boolean; + inlineContent?: boolean; +}; + +function createNode(typeName: string, children: ProseMirrorNode[] = [], options: NodeOptions = {}): ProseMirrorNode { + const attrs = options.attrs ?? {}; + const marks = options.marks ?? []; + const text = options.text ?? ''; + const isText = typeName === 'text'; + const isInline = options.isInline ?? isText; + const isBlock = options.isBlock ?? (!isInline && typeName !== 'doc'); + const inlineContent = options.inlineContent ?? isBlock; + const isLeaf = options.isLeaf ?? (isInline && children.length === 0 && !isText); + + const childEntries = children.map((child) => ({ node: child })); + let offset = 0; + const contentSize = children.reduce((sum, child) => sum + child.nodeSize, 0); + const nodeSize = isText ? text.length : isLeaf ? 1 : contentSize + 2; + + return { + type: { name: typeName }, + attrs, + marks, + text: isText ? text : undefined, + nodeSize, + content: { size: contentSize }, + isText, + isInline, + isBlock, + inlineContent, + isTextblock: inlineContent, + isLeaf, + childCount: children.length, + child(index: number) { + return children[index]!; + }, + forEach(callback: (node: ProseMirrorNode, offset: number) => void) { + offset = 0; + for (const child of childEntries) { + callback(child.node, offset); + offset += child.node.nodeSize; + } + }, + } as unknown as ProseMirrorNode; +} + +function makeEditor(docNode: ProseMirrorNode): Editor { + return { state: { doc: docNode } } as unknown as Editor; +} + +function buildBlockIndexFromParagraph(paragraph: ProseMirrorNode, nodeId: string): BlockIndex { + const candidate = { + node: paragraph, + pos: 0, + end: paragraph.nodeSize, + nodeType: 'paragraph' as const, + nodeId, + }; + const byId = new Map(); + byId.set(`paragraph:${nodeId}`, candidate); + return { candidates: [candidate], byId }; +} + +describe('inline-address-resolver', () => { + it('builds inline candidates for marks and atoms', () => { + const linkMark = makeMark('link', { href: 'https://example.com' }); + const textNode = createNode('text', [], { text: 'Hi', marks: [linkMark] }); + const imageNode = createNode('image', [], { isInline: true, isLeaf: true, attrs: { src: 'x' } }); + const paragraph = createNode('paragraph', [textNode, imageNode], { + attrs: { sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + + const editor = makeEditor(doc); + const blockIndex = buildBlockIndexFromParagraph(paragraph, 'p1'); + const inlineIndex = buildInlineIndex(editor, blockIndex); + + const hyperlinks = findInlineByType(inlineIndex, 'hyperlink'); + expect(hyperlinks).toHaveLength(1); + expect(hyperlinks[0]!.anchor.start.offset).toBe(0); + expect(hyperlinks[0]!.anchor.end.offset).toBe(2); + + const images = findInlineByType(inlineIndex, 'image'); + expect(images).toHaveLength(1); + expect(images[0]!.anchor.start.offset).toBe(2); + expect(images[0]!.anchor.end.offset).toBe(3); + }); + + it('pairs bookmark start and end nodes into a single anchor', () => { + const bookmarkStart = createNode('bookmarkStart', [], { + isInline: true, + isLeaf: false, + attrs: { id: 'b1', name: 'bm' }, + }); + const textNode = createNode('text', [], { text: 'A' }); + const bookmarkEnd = createNode('bookmarkEnd', [], { isInline: true, isLeaf: true, attrs: { id: 'b1' } }); + const paragraph = createNode('paragraph', [bookmarkStart, textNode, bookmarkEnd], { + attrs: { sdBlockId: 'p2' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + + const editor = makeEditor(doc); + const blockIndex = buildBlockIndexFromParagraph(paragraph, 'p2'); + const inlineIndex = buildInlineIndex(editor, blockIndex); + + const bookmarks = findInlineByType(inlineIndex, 'bookmark'); + expect(bookmarks).toHaveLength(1); + expect(bookmarks[0]!.anchor.start.offset).toBe(0); + expect(bookmarks[0]!.anchor.end.offset).toBe(1); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/inline-address-resolver.ts b/packages/super-editor/src/document-api-adapters/helpers/inline-address-resolver.ts new file mode 100644 index 000000000..3a0def034 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/inline-address-resolver.ts @@ -0,0 +1,402 @@ +import type { Mark, Node as ProseMirrorNode } from 'prosemirror-model'; +import type { Editor } from '../../core/Editor.js'; +import type { BlockIndex } from './node-address-resolver.js'; +import type { InlineAnchor, InlineNodeType, NodeAddress, NodeType } from '@superdoc/document-api'; +import { CommentMarkName } from '../../extensions/comment/comments-constants.js'; + +const LINK_MARK_NAME = 'link'; +const COMMENT_MARK_NAME = CommentMarkName; + +const SUPPORTED_INLINE_TYPES: ReadonlySet = new Set([ + 'run', + 'bookmark', + 'comment', + 'hyperlink', + 'sdt', + 'image', + 'footnoteRef', + 'tab', + 'lineBreak', +]); + +export type InlineCandidate = { + nodeType: InlineNodeType; + anchor: InlineAnchor; + blockId: string; + pos: number; + end: number; + node?: ProseMirrorNode; + mark?: Mark; + attrs?: Record; +}; + +export type InlineIndex = { + candidates: InlineCandidate[]; + byType: Map; + byKey: Map; +}; + +/** + * Returns `true` if `nodeType` is an inline type recognised by the inline adapter. + * + * @param nodeType - A node type string. + * @returns Whether the type is an {@link InlineNodeType}. + */ +export function isInlineQueryType(nodeType: NodeType): nodeType is InlineNodeType { + return SUPPORTED_INLINE_TYPES.has(nodeType as InlineNodeType); +} + +function stableStringify(value: unknown): string { + if (value == null) return ''; + if (typeof value !== 'object') return String(value); + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`; + const entries = Object.entries(value as Record).sort(([a], [b]) => a.localeCompare(b)); + return `{${entries.map(([key, val]) => `${key}:${stableStringify(val)}`).join(',')}}`; +} + +function markKey(mark: Mark): string { + return `${mark.type.name}:${stableStringify(mark.attrs ?? {})}`; +} + +function mapInlineNodeType(node: ProseMirrorNode): InlineNodeType | undefined { + switch (node.type.name) { + case 'run': + return 'run'; + case 'image': + return 'image'; + case 'tab': + return 'tab'; + case 'lineBreak': + case 'hardBreak': + case 'hard_break': + return 'lineBreak'; + case 'footnoteReference': + return 'footnoteRef'; + case 'structuredContent': + return 'sdt'; + default: + return undefined; + } +} + +function makeAnchor(blockId: string, start: number, end: number): InlineAnchor { + return { + start: { blockId, offset: start }, + end: { blockId, offset: end }, + }; +} + +function inlineKey(nodeType: InlineNodeType, anchor: InlineAnchor): string { + return `${nodeType}:${anchor.start.blockId}:${anchor.start.offset}:${anchor.end.offset}`; +} + +type ActiveMark = { + mark: Mark; + startOffset: number; + startPos: number; +}; + +/** + * Mutable state carried through the block-content walker. + * + * **Purpose**: Walks ProseMirror block content to build an index of inline + * elements (marks, atoms, range markers) with block-relative text offsets. + * + * **Offset model**: Text nodes contribute their UTF-16 length. Leaf atoms + * (images, tabs, breaks) contribute 1. Block separators (between sibling + * blocks) contribute 1. This mirrors ProseMirror's + * `textBetween(from, to, '\n', '\ufffc')` model. + * + * **Mark lifecycle**: `syncMarks()` opens/closes mark spans when the active + * mark set changes between nodes. All open marks auto-close at block + * boundaries via `closeAllMarks()`. + * + * **Range markers**: Bookmark and comment ranges use start/end element pairs. + * Starts are buffered in maps (`bookmarkStarts`, `commentRangeStarts`); ends + * close the range and emit a candidate. Unpaired starts/ends are discarded. + * + * **Deduplication**: Comments found via marks and via range markers are + * deduplicated via `commentIdsWithMarks` — marks take priority. + */ +export type BlockWalkState = { + blockId: string; + offset: number; + candidates: InlineCandidate[]; + activeMarks: Map; + bookmarkStarts: Map }>; + commentRangeStarts: Map }>; + commentIdsWithMarks: Set; +}; + +function createBlockWalkState(blockId: string, candidates: InlineCandidate[]): BlockWalkState { + return { + blockId, + offset: 0, + candidates, + activeMarks: new Map(), + bookmarkStarts: new Map(), + commentRangeStarts: new Map(), + commentIdsWithMarks: new Set(), + }; +} + +function isInlineHost(node: ProseMirrorNode): boolean { + return ( + Boolean((node as unknown as { inlineContent?: boolean }).inlineContent) || + Boolean((node as unknown as { isTextblock?: boolean }).isTextblock) + ); +} + +function relevantMarks(marks: readonly Mark[] | null | undefined): Mark[] { + if (!marks?.length) return []; + return marks.filter((mark) => mark.type?.name === LINK_MARK_NAME || mark.type?.name === COMMENT_MARK_NAME); +} + +function closeMarkSpan(state: BlockWalkState, key: string, endOffset: number, endPos: number): void { + const active = state.activeMarks.get(key); + if (!active) return; + state.activeMarks.delete(key); + if (endOffset <= active.startOffset) return; + + const markType = active.mark.type?.name; + const nodeType: InlineNodeType | undefined = + markType === LINK_MARK_NAME ? 'hyperlink' : markType === COMMENT_MARK_NAME ? 'comment' : undefined; + if (!nodeType) return; + + const attrs = (active.mark.attrs ?? {}) as Record; + if (nodeType === 'comment') { + const commentId = + typeof attrs.commentId === 'string' + ? attrs.commentId + : typeof attrs.importedId === 'string' + ? attrs.importedId + : undefined; + if (commentId) state.commentIdsWithMarks.add(commentId); + } + + state.candidates.push({ + nodeType, + anchor: makeAnchor(state.blockId, active.startOffset, endOffset), + blockId: state.blockId, + pos: active.startPos, + end: endPos, + mark: active.mark, + attrs, + }); +} + +function closeAllMarks(state: BlockWalkState, endPos: number): void { + for (const key of Array.from(state.activeMarks.keys())) { + closeMarkSpan(state, key, state.offset, endPos); + } +} + +function syncMarks(state: BlockWalkState, marks: readonly Mark[] | null | undefined, docPos: number): void { + const marksOfInterest = relevantMarks(marks); + const nextKeys = new Set(); + for (const mark of marksOfInterest) { + const key = markKey(mark); + nextKeys.add(key); + if (!state.activeMarks.has(key)) { + state.activeMarks.set(key, { mark, startOffset: state.offset, startPos: docPos }); + } + } + + for (const [key] of state.activeMarks.entries()) { + if (!nextKeys.has(key)) { + closeMarkSpan(state, key, state.offset, docPos); + } + } +} + +function handleBookmarkStart(state: BlockWalkState, node: ProseMirrorNode, docPos: number): void { + const attrs = (node.attrs ?? {}) as Record; + const id = typeof attrs.id === 'string' ? attrs.id : typeof attrs.name === 'string' ? attrs.name : undefined; + if (!id) return; + state.bookmarkStarts.set(id, { offset: state.offset, pos: docPos, attrs }); +} + +function handleBookmarkEnd(state: BlockWalkState, node: ProseMirrorNode, docPos: number): void { + const attrs = (node.attrs ?? {}) as Record; + const id = typeof attrs.id === 'string' ? attrs.id : undefined; + if (!id) return; + const start = state.bookmarkStarts.get(id); + if (!start) return; + state.bookmarkStarts.delete(id); + if (state.offset < start.offset) return; + state.candidates.push({ + nodeType: 'bookmark', + anchor: makeAnchor(state.blockId, start.offset, state.offset), + blockId: state.blockId, + pos: start.pos, + end: docPos, + attrs: start.attrs, + }); +} + +function handleCommentRangeStart(state: BlockWalkState, node: ProseMirrorNode, docPos: number): void { + const attrs = (node.attrs ?? {}) as Record; + const id = typeof attrs['w:id'] === 'string' ? (attrs['w:id'] as string) : undefined; + if (!id) return; + state.commentRangeStarts.set(id, { offset: state.offset, pos: docPos, attrs }); +} + +function handleCommentRangeEnd(state: BlockWalkState, node: ProseMirrorNode, docPos: number): void { + const attrs = (node.attrs ?? {}) as Record; + const id = typeof attrs['w:id'] === 'string' ? (attrs['w:id'] as string) : undefined; + if (!id) return; + if (state.commentIdsWithMarks.has(id)) return; + const start = state.commentRangeStarts.get(id); + if (!start) return; + state.commentRangeStarts.delete(id); + if (state.offset < start.offset) return; + state.candidates.push({ + nodeType: 'comment', + anchor: makeAnchor(state.blockId, start.offset, state.offset), + blockId: state.blockId, + pos: start.pos, + end: docPos, + attrs: { ...start.attrs, ...attrs }, + }); +} + +function walkNodeContent(state: BlockWalkState, node: ProseMirrorNode, contentStart: number): void { + let firstChild = true; + node.forEach((child: ProseMirrorNode, childOffset: number) => { + const childDocPos = contentStart + childOffset; + if (child.isBlock && !firstChild) { + closeAllMarks(state, childDocPos); + state.offset += 1; + } + walkNode(state, child, childDocPos); + firstChild = false; + }); +} + +function walkNode(state: BlockWalkState, node: ProseMirrorNode, docPos: number): void { + const isBookmarkStart = node.type?.name === 'bookmarkStart'; + if (isBookmarkStart) { + handleBookmarkStart(state, node, docPos); + } + + if (node.isText) { + const text = node.text ?? ''; + syncMarks(state, node.marks, docPos); + state.offset += text.length; + return; + } + + if (node.isLeaf) { + syncMarks(state, node.marks, docPos); + + if (node.type?.name === 'bookmarkEnd') { + handleBookmarkEnd(state, node, docPos); + } else if (node.type?.name === 'commentRangeStart') { + handleCommentRangeStart(state, node, docPos); + } else if (node.type?.name === 'commentRangeEnd') { + handleCommentRangeEnd(state, node, docPos); + } else if (!isBookmarkStart) { + const nodeType = mapInlineNodeType(node); + if (nodeType) { + state.candidates.push({ + nodeType, + anchor: makeAnchor(state.blockId, state.offset, state.offset + 1), + blockId: state.blockId, + pos: docPos, + end: docPos + node.nodeSize, + node, + }); + } + } + + state.offset += 1; + return; + } + + if (node.isInline) { + const nodeType = mapInlineNodeType(node); + const startOffset = state.offset; + const startPos = docPos; + walkNodeContent(state, node, docPos + 1); + const endOffset = state.offset; + const endPos = docPos + node.nodeSize; + + if (nodeType) { + state.candidates.push({ + nodeType, + anchor: makeAnchor(state.blockId, startOffset, endOffset), + blockId: state.blockId, + pos: startPos, + end: endPos, + node, + }); + } + return; + } + + walkNodeContent(state, node, docPos + 1); +} + +function buildIndexMaps(candidates: InlineCandidate[]): InlineIndex { + const byType = new Map(); + const byKey = new Map(); + + for (const candidate of candidates) { + if (!byType.has(candidate.nodeType)) { + byType.set(candidate.nodeType, []); + } + byType.get(candidate.nodeType)!.push(candidate); + byKey.set(inlineKey(candidate.nodeType, candidate.anchor), candidate); + } + + return { candidates, byType, byKey }; +} + +/** + * Walks all inline-hosting blocks and builds an index of inline-level nodes + * (marks, atoms, and range markers). + * + * @param editor - The editor instance to inspect. + * @param blockIndex - A pre-built block index to iterate over. + * @returns An {@link InlineIndex} with sorted candidates and lookup maps. + */ +export function buildInlineIndex(editor: Editor, blockIndex: BlockIndex): InlineIndex { + const candidates: InlineCandidate[] = []; + + for (const block of blockIndex.candidates) { + if (!isInlineHost(block.node)) continue; + + const state = createBlockWalkState(block.nodeId, candidates); + walkNodeContent(state, block.node, block.pos + 1); + closeAllMarks(state, block.pos + block.node.nodeSize); + } + + candidates.sort((a, b) => (a.pos === b.pos ? a.end - b.end : a.pos - b.pos)); + return buildIndexMaps(candidates); +} + +/** + * Looks up an inline candidate by its {@link NodeAddress} anchor. + * + * @param index - The inline index to search. + * @param address - The inline address to resolve. + * @returns The matching candidate, or `undefined` if not found. + */ +export function findInlineByAnchor(index: InlineIndex, address: NodeAddress): InlineCandidate | undefined { + if (address.kind !== 'inline') return undefined; + if (address.anchor.start.blockId !== address.anchor.end.blockId) return undefined; + const nodeType = address.nodeType as InlineNodeType; + return index.byKey.get(inlineKey(nodeType, address.anchor)); +} + +/** + * Returns all inline candidates matching a given type, or all candidates if no type is specified. + * + * @param index - The inline index to search. + * @param nodeType - Optional inline node type to filter by. + * @returns Matching inline candidates. + */ +export function findInlineByType(index: InlineIndex, nodeType?: InlineNodeType): InlineCandidate[] { + if (!nodeType) return index.candidates; + return index.byType.get(nodeType) ?? []; +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.test.ts b/packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.test.ts new file mode 100644 index 000000000..b710bcb7b --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.test.ts @@ -0,0 +1,534 @@ +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import type { Editor } from '../../core/Editor.js'; +import type { NodeAddress } from '@superdoc/document-api'; +import { + buildBlockIndex, + findBlockById, + findBlockByPos, + isSupportedNodeType, + toBlockAddress, + type BlockCandidate, + type BlockIndex, +} from './node-address-resolver.js'; + +// --------------------------------------------------------------------------- +// Helpers — lightweight ProseMirror-like stubs +// --------------------------------------------------------------------------- + +/** + * Creates a minimal ProseMirrorNode stub. + * + * `children` is a flat list of `{ node, offset }` pairs where `offset` is the + * **absolute** document position of the child — matching how ProseMirror's + * `descendants` callback provides positions. + */ +function makeNode( + typeName: string, + attrs: Record = {}, + nodeSize = 10, + children: Array<{ node: ProseMirrorNode; offset: number }> = [], +): ProseMirrorNode { + const inlineTypes = new Set(['image', 'run', 'bookmarkStart', 'bookmarkEnd', 'commentRangeStart', 'commentRangeEnd']); + const isBlock = typeName !== 'doc' && !inlineTypes.has(typeName); + return { + type: { name: typeName }, + attrs, + nodeSize, + isBlock, + descendants(callback: (node: ProseMirrorNode, pos: number) => void) { + for (const child of children) { + callback(child.node, child.offset); + } + }, + } as unknown as ProseMirrorNode; +} + +function makeEditor(docNode: ProseMirrorNode): Editor { + return { state: { doc: docNode } } as unknown as Editor; +} + +function indexFromNodes( + ...entries: Array<{ typeName: string; attrs?: Record; nodeSize?: number; offset: number }> +): BlockIndex { + const children = entries.map((e) => ({ + node: makeNode(e.typeName, e.attrs ?? {}, e.nodeSize ?? 10), + offset: e.offset, + })); + const totalSize = entries.reduce((max, e) => Math.max(max, e.offset + (e.nodeSize ?? 10)), 0) + 2; + const doc = makeNode('doc', {}, totalSize, children); + return buildBlockIndex(makeEditor(doc)); +} + +// --------------------------------------------------------------------------- +// isSupportedNodeType +// --------------------------------------------------------------------------- + +describe('isSupportedNodeType', () => { + it.each(['paragraph', 'heading', 'listItem', 'table', 'tableRow', 'tableCell', 'image', 'sdt'] as const)( + 'returns true for supported block type "%s"', + (nodeType) => { + expect(isSupportedNodeType(nodeType)).toBe(true); + }, + ); + + it.each(['text', 'run', 'field', 'bookmark', 'comment', 'hyperlink', 'footnoteRef', 'tab', 'lineBreak'] as const)( + 'returns false for unsupported type "%s"', + (nodeType) => { + expect(isSupportedNodeType(nodeType)).toBe(false); + }, + ); +}); + +// --------------------------------------------------------------------------- +// toBlockAddress +// --------------------------------------------------------------------------- + +describe('toBlockAddress', () => { + it('converts a BlockCandidate to a block NodeAddress', () => { + const candidate: BlockCandidate = { + node: makeNode('paragraph'), + pos: 5, + end: 15, + nodeType: 'paragraph', + nodeId: 'abc', + }; + + expect(toBlockAddress(candidate)).toEqual({ + kind: 'block', + nodeType: 'paragraph', + nodeId: 'abc', + }); + }); + + it('does not include pos/end/node in the address', () => { + const candidate: BlockCandidate = { + node: makeNode('table'), + pos: 0, + end: 50, + nodeType: 'table', + nodeId: 't1', + }; + + const address = toBlockAddress(candidate); + expect(Object.keys(address).sort()).toEqual(['kind', 'nodeId', 'nodeType']); + }); +}); + +// --------------------------------------------------------------------------- +// buildBlockIndex — node type mapping +// --------------------------------------------------------------------------- + +describe('buildBlockIndex', () => { + describe('paragraph type mapping', () => { + it('maps a plain paragraph to "paragraph"', () => { + const index = indexFromNodes({ typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, offset: 0 }); + expect(index.candidates[0].nodeType).toBe('paragraph'); + }); + + it('maps a paragraph with heading styleId to "heading"', () => { + const index = indexFromNodes({ + typeName: 'paragraph', + attrs: { sdBlockId: 'p1', paragraphProperties: { styleId: 'Heading1' } }, + offset: 0, + }); + expect(index.candidates[0].nodeType).toBe('heading'); + }); + + it.each(['heading 2', 'Heading3', 'heading 6', 'HEADING 4'])( + 'recognises heading styleId variation "%s"', + (styleId) => { + const index = indexFromNodes({ + typeName: 'paragraph', + attrs: { sdBlockId: 'p1', paragraphProperties: { styleId } }, + offset: 0, + }); + expect(index.candidates[0].nodeType).toBe('heading'); + }, + ); + + it('does not treat non-heading styleId as heading', () => { + const index = indexFromNodes({ + typeName: 'paragraph', + attrs: { sdBlockId: 'p1', paragraphProperties: { styleId: 'Normal' } }, + offset: 0, + }); + expect(index.candidates[0].nodeType).toBe('paragraph'); + }); + + it('does not treat heading7+ as heading', () => { + const index = indexFromNodes({ + typeName: 'paragraph', + attrs: { sdBlockId: 'p1', paragraphProperties: { styleId: 'heading7' } }, + offset: 0, + }); + expect(index.candidates[0].nodeType).toBe('paragraph'); + }); + + it('maps paragraph with numberingProperties (numId + ilvl) to "listItem"', () => { + const index = indexFromNodes({ + typeName: 'paragraph', + attrs: { sdBlockId: 'p1', paragraphProperties: { numberingProperties: { numId: 1, ilvl: 0 } } }, + offset: 0, + }); + expect(index.candidates[0].nodeType).toBe('listItem'); + }); + + it('maps paragraph with numberingProperties (ilvl only) to "listItem"', () => { + const index = indexFromNodes({ + typeName: 'paragraph', + attrs: { sdBlockId: 'p1', paragraphProperties: { numberingProperties: { ilvl: 2 } } }, + offset: 0, + }); + expect(index.candidates[0].nodeType).toBe('listItem'); + }); + + it('maps paragraph with listRendering.markerText to "listItem"', () => { + const index = indexFromNodes({ + typeName: 'paragraph', + attrs: { sdBlockId: 'p1', listRendering: { markerText: '1.' } }, + offset: 0, + }); + expect(index.candidates[0].nodeType).toBe('listItem'); + }); + + it('maps paragraph with listRendering.path to "listItem"', () => { + const index = indexFromNodes({ + typeName: 'paragraph', + attrs: { sdBlockId: 'p1', listRendering: { path: [0, 1] } }, + offset: 0, + }); + expect(index.candidates[0].nodeType).toBe('listItem'); + }); + + it('does not map paragraph with empty listRendering.path to "listItem"', () => { + const index = indexFromNodes({ + typeName: 'paragraph', + attrs: { sdBlockId: 'p1', listRendering: { path: [] } }, + offset: 0, + }); + expect(index.candidates[0].nodeType).toBe('paragraph'); + }); + + it('heading takes priority over listItem when both are present', () => { + const index = indexFromNodes({ + typeName: 'paragraph', + attrs: { + sdBlockId: 'p1', + paragraphProperties: { + styleId: 'Heading1', + numberingProperties: { numId: 1, ilvl: 0 }, + }, + }, + offset: 0, + }); + expect(index.candidates[0].nodeType).toBe('heading'); + }); + }); + + describe('non-paragraph type mapping', () => { + it.each([ + ['table', 'table'], + ['tableRow', 'tableRow'], + ['tableCell', 'tableCell'], + ['tableHeader', 'tableCell'], + ['sdt', 'sdt'], + ['structuredContentBlock', 'sdt'], + ] as const)('maps PM node type "%s" to block type "%s"', (pmType, expectedBlockType) => { + const index = indexFromNodes({ + typeName: pmType, + attrs: { sdBlockId: 'test-id' }, + offset: 0, + }); + expect(index.candidates[0].nodeType).toBe(expectedBlockType); + }); + + it('skips unsupported node types', () => { + const index = indexFromNodes({ typeName: 'hardBreak', offset: 0 }); + expect(index.candidates).toHaveLength(0); + }); + + it('skips unknown node types', () => { + const index = indexFromNodes({ typeName: 'someCustomNode', offset: 0 }); + expect(index.candidates).toHaveLength(0); + }); + }); + + describe('ID resolution — paragraph nodes', () => { + it('prefers paraId over sdBlockId when both are present', () => { + const index = indexFromNodes({ + typeName: 'paragraph', + attrs: { sdBlockId: 'sd1', paraId: 'p1' }, + offset: 0, + }); + expect(index.candidates[0].nodeId).toBe('p1'); + }); + + it('falls back to paraId when sdBlockId is absent', () => { + const index = indexFromNodes({ + typeName: 'paragraph', + attrs: { paraId: 'p1' }, + offset: 0, + }); + expect(index.candidates[0].nodeId).toBe('p1'); + }); + + it('falls back to paraId when sdBlockId is null', () => { + const index = indexFromNodes({ + typeName: 'paragraph', + attrs: { sdBlockId: null, paraId: 'p1' }, + offset: 0, + }); + expect(index.candidates[0].nodeId).toBe('p1'); + }); + + it('skips paragraph candidates when no explicit id attrs exist', () => { + const index = indexFromNodes({ + typeName: 'paragraph', + attrs: {}, + offset: 7, + }); + expect(index.candidates).toHaveLength(0); + }); + }); + + describe('ID resolution — non-paragraph nodes', () => { + it('prefers imported IDs over sdBlockId when both are present', () => { + const index = indexFromNodes({ + typeName: 'table', + attrs: { sdBlockId: 'sd1', blockId: 'b1', id: 'i1', paraId: 'p1', uuid: 'u1' }, + offset: 0, + }); + expect(index.candidates[0].nodeId).toBe('b1'); + }); + + it('uses sdBlockId when imported IDs are absent', () => { + const index = indexFromNodes({ + typeName: 'table', + attrs: { sdBlockId: 'sd1' }, + offset: 0, + }); + expect(index.candidates[0].nodeId).toBe('sd1'); + }); + + it('falls back to blockId', () => { + const index = indexFromNodes({ + typeName: 'table', + attrs: { blockId: 'b1', id: 'i1' }, + offset: 0, + }); + expect(index.candidates[0].nodeId).toBe('b1'); + }); + + it('falls back to id', () => { + const index = indexFromNodes({ + typeName: 'table', + attrs: { id: 'i1', paraId: 'p1' }, + offset: 0, + }); + expect(index.candidates[0].nodeId).toBe('i1'); + }); + + it('falls back to paraId', () => { + const index = indexFromNodes({ + typeName: 'table', + attrs: { paraId: 'p1', uuid: 'u1' }, + offset: 0, + }); + expect(index.candidates[0].nodeId).toBe('p1'); + }); + + it('falls back to uuid', () => { + const index = indexFromNodes({ + typeName: 'table', + attrs: { uuid: 'u1' }, + offset: 0, + }); + expect(index.candidates[0].nodeId).toBe('u1'); + }); + + it('skips non-paragraph candidates when no id attrs exist', () => { + const index = indexFromNodes({ + typeName: 'table', + attrs: {}, + offset: 3, + }); + expect(index.candidates).toHaveLength(0); + }); + + it('ignores empty string attrs', () => { + const index = indexFromNodes({ + typeName: 'table', + attrs: { sdBlockId: '', blockId: '', id: 'real' }, + offset: 0, + }); + expect(index.candidates[0].nodeId).toBe('real'); + }); + }); + + describe('index structure', () => { + it('populates byId with "nodeType:nodeId" keys', () => { + const index = indexFromNodes( + { typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, offset: 0 }, + { typeName: 'table', attrs: { sdBlockId: 't1' }, offset: 12 }, + ); + expect(index.byId.has('paragraph:p1')).toBe(true); + expect(index.byId.has('table:t1')).toBe(true); + }); + + it('sets end = pos + nodeSize on each candidate', () => { + const index = indexFromNodes({ + typeName: 'paragraph', + attrs: { sdBlockId: 'p1' }, + nodeSize: 15, + offset: 5, + }); + expect(index.candidates[0].pos).toBe(5); + expect(index.candidates[0].end).toBe(20); + }); + + it('preserves insertion order in candidates array', () => { + const index = indexFromNodes( + { typeName: 'paragraph', attrs: { sdBlockId: 'a' }, offset: 0 }, + { typeName: 'paragraph', attrs: { sdBlockId: 'b' }, offset: 12 }, + { typeName: 'paragraph', attrs: { sdBlockId: 'c' }, offset: 24 }, + ); + expect(index.candidates.map((c) => c.nodeId)).toEqual(['a', 'b', 'c']); + }); + + it('last candidate wins in byId when composite keys collide', () => { + // Duplicate IDs are invalid, but if present, the map keeps the last seen node. + const p1 = makeNode('paragraph', { sdBlockId: 'dup' }, 10); + const p2 = makeNode('paragraph', { sdBlockId: 'dup' }, 10); + const doc = makeNode('doc', {}, 24, [ + { node: p1, offset: 0 }, + { node: p2, offset: 12 }, + ]); + const index = buildBlockIndex(makeEditor(doc)); + + // The map keeps the last one set + const found = index.byId.get('paragraph:dup'); + expect(found?.pos).toBe(12); + }); + + it('returns empty index for a document with no block nodes', () => { + const doc = makeNode('doc', {}, 2); + const index = buildBlockIndex(makeEditor(doc)); + expect(index.candidates).toHaveLength(0); + expect(index.byId.size).toBe(0); + }); + }); +}); + +// --------------------------------------------------------------------------- +// findBlockById +// --------------------------------------------------------------------------- + +describe('findBlockById', () => { + function buildMultiTypeIndex(): BlockIndex { + return indexFromNodes( + { typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, offset: 0 }, + { typeName: 'table', attrs: { sdBlockId: 't1' }, offset: 12 }, + ); + } + + it('returns the candidate matching a block address', () => { + const index = buildMultiTypeIndex(); + const result = findBlockById(index, { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }); + expect(result).toBeDefined(); + expect(result!.nodeId).toBe('p1'); + expect(result!.nodeType).toBe('paragraph'); + }); + + it('returns undefined for a non-existent nodeId', () => { + const index = buildMultiTypeIndex(); + expect(findBlockById(index, { kind: 'block', nodeType: 'paragraph', nodeId: 'nope' })).toBeUndefined(); + }); + + it('returns undefined when nodeId matches but nodeType does not', () => { + const index = buildMultiTypeIndex(); + // 'p1' exists as paragraph, not as table + expect(findBlockById(index, { kind: 'block', nodeType: 'table', nodeId: 'p1' })).toBeUndefined(); + }); + + it('returns undefined for an inline address', () => { + const index = buildMultiTypeIndex(); + const address: NodeAddress = { + kind: 'inline', + nodeType: 'run', + anchor: { start: { blockId: 'p1', offset: 0 }, end: { blockId: 'p1', offset: 5 } }, + }; + expect(findBlockById(index, address)).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// findBlockByPos +// --------------------------------------------------------------------------- + +describe('findBlockByPos', () => { + // Three non-overlapping paragraphs with gaps between them: + // a: [0, 10] gap: (10, 15) b: [15, 25] gap: (25, 30) c: [30, 40] + function buildGappedIndex(): BlockIndex { + return indexFromNodes( + { typeName: 'paragraph', attrs: { sdBlockId: 'a' }, nodeSize: 10, offset: 0 }, + { typeName: 'paragraph', attrs: { sdBlockId: 'b' }, nodeSize: 10, offset: 15 }, + { typeName: 'paragraph', attrs: { sdBlockId: 'c' }, nodeSize: 10, offset: 30 }, + ); + } + + it('finds the first block at its start position', () => { + const index = buildGappedIndex(); + expect(findBlockByPos(index, 0)?.nodeId).toBe('a'); + }); + + it('finds the first block at its end position (inclusive)', () => { + const index = buildGappedIndex(); + // end = pos + nodeSize = 0 + 10 = 10; comparison is pos > candidate.end → 10 > 10 is false → found + expect(findBlockByPos(index, 10)?.nodeId).toBe('a'); + }); + + it('finds the middle block at a position within its range', () => { + const index = buildGappedIndex(); + expect(findBlockByPos(index, 20)?.nodeId).toBe('b'); + }); + + it('finds the last block at its start position', () => { + const index = buildGappedIndex(); + expect(findBlockByPos(index, 30)?.nodeId).toBe('c'); + }); + + it('finds the last block at its end position', () => { + const index = buildGappedIndex(); + expect(findBlockByPos(index, 40)?.nodeId).toBe('c'); + }); + + it('returns undefined for a position in a gap between blocks', () => { + const index = buildGappedIndex(); + expect(findBlockByPos(index, 12)).toBeUndefined(); + }); + + it('returns undefined for a position beyond all blocks', () => { + const index = buildGappedIndex(); + expect(findBlockByPos(index, 100)).toBeUndefined(); + }); + + it('returns undefined for an empty index', () => { + const doc = makeNode('doc', {}, 2); + const index = buildBlockIndex(makeEditor(doc)); + expect(findBlockByPos(index, 0)).toBeUndefined(); + }); + + it('finds the only block in a single-element index', () => { + const index = indexFromNodes({ + typeName: 'paragraph', + attrs: { sdBlockId: 'solo' }, + nodeSize: 10, + offset: 5, + }); + expect(findBlockByPos(index, 5)?.nodeId).toBe('solo'); + expect(findBlockByPos(index, 10)?.nodeId).toBe('solo'); + expect(findBlockByPos(index, 15)?.nodeId).toBe('solo'); + expect(findBlockByPos(index, 4)).toBeUndefined(); + expect(findBlockByPos(index, 16)).toBeUndefined(); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.ts b/packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.ts new file mode 100644 index 000000000..12d3d89f9 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.ts @@ -0,0 +1,224 @@ +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import type { Editor } from '../../core/Editor.js'; +import type { BlockNodeAttributes } from '../../core/types/NodeCategories.js'; +import type { BlockNodeAddress, BlockNodeType, NodeAddress, NodeType } from '@superdoc/document-api'; +import type { ParagraphAttrs } from '../../extensions/types/node-attributes.js'; +import { toId } from './value-utils.js'; + +/** Superset of all possible ID attributes across block node types. */ +type BlockIdAttrs = BlockNodeAttributes & { + blockId?: string | null; + id?: string | null; + paraId?: string | null; + uuid?: string | null; +}; + +/** A block-level node found during document traversal, with its position and resolved identity. */ +export type BlockCandidate = { + node: ProseMirrorNode; + pos: number; + end: number; + nodeType: BlockNodeType; + nodeId: string; +}; + +/** + * Positional index of all block-level nodes in the document. + * + * Built by {@link buildBlockIndex}. The index is a snapshot — it must be + * rebuilt after any document mutation. + */ +export type BlockIndex = { + candidates: BlockCandidate[]; + byId: Map; +}; + +// Keep in sync with BlockNodeType in document-api/types/node.ts +const SUPPORTED_BLOCK_NODE_TYPES: ReadonlySet = new Set([ + 'paragraph', + 'heading', + 'listItem', + 'table', + 'tableRow', + 'tableCell', + 'image', + 'sdt', +]); + +/** + * Returns `true` if `nodeType` is a block-level type supported by the adapter index. + * + * @param nodeType - A node type string (block, inline, or the literal `'text'`). + * @returns Whether the type is a supported {@link BlockNodeType}. + */ +export function isSupportedNodeType(nodeType: NodeType | 'text'): nodeType is BlockNodeType { + return SUPPORTED_BLOCK_NODE_TYPES.has(nodeType as BlockNodeType); +} + +function isListItem(attrs: ParagraphAttrs | null | undefined): boolean { + const numbering = attrs?.paragraphProperties?.numberingProperties; + if (numbering && (numbering.numId != null || numbering.ilvl != null)) return true; + const listRendering = attrs?.listRendering; + if (listRendering?.markerText) return true; + if (Array.isArray(listRendering?.path) && listRendering.path.length > 0) return true; + return false; +} + +/** + * Extracts the heading level (1–6) from an OOXML styleId string. + * + * @param styleId - A paragraph styleId (e.g. `"Heading1"`, `"heading 3"`). + * @returns The heading level, or `undefined` if the styleId is not a heading. + */ +export function getHeadingLevel(styleId?: string | null): number | undefined { + if (!styleId) return undefined; + const match = /heading\s*([1-6])/i.exec(styleId); + if (!match) return undefined; + return Number(match[1]); +} + +function mapBlockNodeType(node: ProseMirrorNode): BlockNodeType | undefined { + if (!node.isBlock) return undefined; + switch (node.type.name) { + case 'paragraph': { + const attrs = node.attrs as ParagraphAttrs | undefined; + const styleId = attrs?.paragraphProperties?.styleId ?? undefined; + if (getHeadingLevel(styleId) != null) return 'heading'; + if (isListItem(attrs)) return 'listItem'; + return 'paragraph'; + } + case 'table': + return 'table'; + case 'tableRow': + return 'tableRow'; + case 'tableCell': + case 'tableHeader': + return 'tableCell'; + case 'image': + return 'image'; + case 'structuredContentBlock': + case 'sdt': + return 'sdt'; + default: + return undefined; + } +} + +function resolveBlockNodeId(node: ProseMirrorNode): string | undefined { + if (node.type.name === 'paragraph') { + const attrs = node.attrs as ParagraphAttrs | undefined; + // NOTE: Migration surface for the stable-addresses plan. + // Today we preserve DOCX-import identity precedence (`paraId` first) for + // paragraph nodes. Any future switch to `sdBlockId` canonical precedence + // must be handled as an explicit compatibility migration. + return toId(attrs?.paraId) ?? toId(attrs?.sdBlockId); + } + + const attrs = (node.attrs ?? {}) as BlockIdAttrs; + // NOTE: Migration surface for the stable-addresses plan. + // Imported IDs currently win over `sdBlockId` to preserve historical + // identity during DOCX round-trips. + return toId(attrs.blockId) ?? toId(attrs.id) ?? toId(attrs.paraId) ?? toId(attrs.uuid) ?? toId(attrs.sdBlockId); +} + +/** + * Converts a {@link BlockCandidate} into a stable {@link NodeAddress}. + * + * @param candidate - The block candidate to convert. + * @returns A block-kind node address. + */ +export function toBlockAddress(candidate: BlockCandidate): BlockNodeAddress { + return { + kind: 'block', + nodeType: candidate.nodeType, + nodeId: candidate.nodeId, + }; +} + +/** + * Walks the editor document and builds a positional index of all recognised + * block-level nodes. + * + * The returned index is a **snapshot** tied to the current document state. + * It must be rebuilt after any transaction that mutates the document. + * + * @param editor - The editor whose document will be indexed. + * @returns A {@link BlockIndex} containing ordered candidates and a lookup map. + */ +export function buildBlockIndex(editor: Editor): BlockIndex { + const candidates: BlockCandidate[] = []; + const byId = new Map(); + + // This traversal is a hot path for adapter workflows (for example find -> + // getNode). Keep this pure snapshot builder so a transaction-invalidated + // cache can be layered on later without API changes. + editor.state.doc.descendants((node, pos) => { + const nodeType = mapBlockNodeType(node); + if (!nodeType) return; + const nodeId = resolveBlockNodeId(node); + if (!nodeId) return; + + const candidate: BlockCandidate = { + node, + pos, + end: pos + node.nodeSize, + nodeType, + nodeId, + }; + + candidates.push(candidate); + byId.set(`${candidate.nodeType}:${candidate.nodeId}`, candidate); + }); + + return { candidates, byId }; +} + +/** + * Looks up a block candidate by its {@link NodeAddress}. + * + * @param index - The block index to search. + * @param address - The address to resolve. Non-block addresses return `undefined`. + * @returns The matching candidate, or `undefined` if not found. + */ +export function findBlockById(index: BlockIndex, address: NodeAddress): BlockCandidate | undefined { + if (address.kind !== 'block') return undefined; + return index.byId.get(`${address.nodeType}:${address.nodeId}`); +} + +/** + * Returns true for block candidates that accept inline text content. + */ +export function isTextBlockCandidate(candidate: BlockCandidate): boolean { + const node = candidate.node as unknown as { inlineContent?: boolean; isTextblock?: boolean }; + return Boolean(node?.inlineContent || node?.isTextblock); +} + +/** + * Finds a block candidate whose range contains the given position. + * + * Note: nested blocks (e.g. table > row > cell > paragraph) produce overlapping + * candidates. This returns whichever the binary search lands on first, not + * necessarily the innermost. This is sufficient for resolving a containing block + * for match context but callers needing the most specific block should filter further. + */ +export function findBlockByPos(index: BlockIndex, pos: number): BlockCandidate | undefined { + const candidates = index.candidates; + let low = 0; + let high = candidates.length - 1; + + while (low <= high) { + const mid = (low + high) >> 1; + const candidate = candidates[mid]; + if (pos < candidate.pos) { + high = mid - 1; + continue; + } + if (pos > candidate.end) { + low = mid + 1; + continue; + } + return candidate; + } + + return undefined; +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.test.ts b/packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.test.ts new file mode 100644 index 000000000..15b29b368 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.test.ts @@ -0,0 +1,335 @@ +import type { Node as ProseMirrorNode, Mark as ProseMirrorMark } from 'prosemirror-model'; +import type { BlockCandidate } from './node-address-resolver.js'; +import type { InlineCandidate } from './inline-address-resolver.js'; +import type { InlineAnchor, InlineNodeType, NodeType } from '@superdoc/document-api'; +import { mapNodeInfo } from './node-info-mapper.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeBlockNode(typeName: string, attrs: Record = {}): ProseMirrorNode { + return { + type: { name: typeName }, + attrs, + isBlock: true, + isInline: false, + isText: false, + } as unknown as ProseMirrorNode; +} + +function makeBlockCandidate( + nodeType: BlockCandidate['nodeType'], + nodeId: string, + attrs: Record = {}, + typeName?: string, +): BlockCandidate { + return { + node: makeBlockNode(typeName ?? nodeType, attrs), + pos: 0, + end: 10, + nodeType, + nodeId, + }; +} + +function makeAnchor(blockId: string, start = 0, end = 1): InlineAnchor { + return { + start: { blockId, offset: start }, + end: { blockId, offset: end }, + }; +} + +function makeInlineCandidate( + nodeType: InlineNodeType, + options: { + blockId?: string; + attrs?: Record; + markAttrs?: Record; + markName?: string; + nodeAttrs?: Record; + } = {}, +): InlineCandidate { + const blockId = options.blockId ?? 'p1'; + return { + nodeType, + anchor: makeAnchor(blockId), + blockId, + pos: 0, + end: 1, + attrs: options.attrs, + mark: options.markAttrs + ? ({ type: { name: options.markName ?? nodeType }, attrs: options.markAttrs } as unknown as ProseMirrorMark) + : undefined, + node: options.nodeAttrs + ? ({ type: { name: nodeType }, attrs: options.nodeAttrs } as unknown as ProseMirrorNode) + : undefined, + }; +} + +// --------------------------------------------------------------------------- +// Block node mapping +// --------------------------------------------------------------------------- + +describe('mapNodeInfo — block nodes', () => { + it('maps paragraph with properties', () => { + const result = mapNodeInfo( + makeBlockCandidate('paragraph', 'p1', { + paragraphProperties: { styleId: 'Normal', justification: 'center' }, + }), + ); + + expect(result.nodeType).toBe('paragraph'); + expect(result.kind).toBe('block'); + expect(result.properties).toMatchObject({ + styleId: 'Normal', + alignment: 'center', + }); + }); + + it('maps heading with level from styleId', () => { + const result = mapNodeInfo( + makeBlockCandidate('heading', 'h1', { + paragraphProperties: { styleId: 'Heading2' }, + }), + ); + + expect(result.nodeType).toBe('heading'); + expect(result.properties).toMatchObject({ headingLevel: 2 }); + }); + + it('throws for heading without valid level', () => { + expect(() => + mapNodeInfo( + makeBlockCandidate('heading', 'h1', { + paragraphProperties: { styleId: 'Normal' }, + }), + ), + ).toThrow('does not have a valid heading level'); + }); + + it('maps listItem with numbering', () => { + const result = mapNodeInfo( + makeBlockCandidate('listItem', 'li1', { + listRendering: { markerText: '1.', path: [1] }, + }), + ); + + expect(result.nodeType).toBe('listItem'); + expect(result.properties).toMatchObject({ + numbering: { marker: '1.', path: [1], ordinal: 1 }, + }); + }); + + it('maps table with layout and width', () => { + const result = mapNodeInfo( + makeBlockCandidate('table', 't1', { + tableProperties: { tableLayout: 'fixed', tableWidth: 5000, justification: 'center' }, + }), + ); + + expect(result.nodeType).toBe('table'); + expect(result.properties).toMatchObject({ + layout: 'fixed', + width: 5000, + alignment: 'center', + }); + }); + + it('maps tableRow with empty properties', () => { + const result = mapNodeInfo(makeBlockCandidate('tableRow', 'tr1')); + + expect(result.nodeType).toBe('tableRow'); + expect(result.properties).toEqual({}); + }); + + it('maps tableCell with width and shading', () => { + const result = mapNodeInfo( + makeBlockCandidate('tableCell', 'tc1', { + tableCellProperties: { cellWidth: 2500, shading: { fill: '#FF0000' } }, + }), + ); + + expect(result.nodeType).toBe('tableCell'); + expect(result.properties).toMatchObject({ width: 2500, shading: '#FF0000' }); + }); +}); + +// --------------------------------------------------------------------------- +// overrideType behavior +// --------------------------------------------------------------------------- + +describe('mapNodeInfo — overrideType', () => { + it('uses overrideType="sdt" for sdt block candidates', () => { + const candidate = makeBlockCandidate('sdt', 'sdt2', { tag: 'MyTag' }, 'structuredContentBlock'); + const result = mapNodeInfo(candidate, 'sdt'); + + expect(result.nodeType).toBe('sdt'); + expect(result.properties).toMatchObject({ tag: 'MyTag' }); + }); +}); + +// --------------------------------------------------------------------------- +// Inline node mapping +// --------------------------------------------------------------------------- + +describe('mapNodeInfo — inline nodes', () => { + it('maps hyperlink from mark attrs', () => { + const result = mapNodeInfo( + makeInlineCandidate('hyperlink', { + markAttrs: { href: 'https://example.com', tooltip: 'Click me' }, + markName: 'link', + }), + ); + + expect(result.nodeType).toBe('hyperlink'); + expect(result.kind).toBe('inline'); + expect(result.properties).toMatchObject({ + href: 'https://example.com', + tooltip: 'Click me', + }); + }); + + it('maps comment from mark attrs', () => { + const result = mapNodeInfo( + makeInlineCandidate('comment', { + markAttrs: { commentId: 'c42' }, + markName: 'comment', + }), + ); + + expect(result.nodeType).toBe('comment'); + expect(result.properties).toMatchObject({ commentId: 'c42' }); + }); + + it('maps comment with importedId fallback', () => { + const result = mapNodeInfo( + makeInlineCandidate('comment', { + markAttrs: { importedId: 'imp1' }, + markName: 'comment', + }), + ); + + expect(result.properties).toMatchObject({ commentId: 'imp1' }); + }); + + it('maps bookmark from attrs', () => { + const result = mapNodeInfo( + makeInlineCandidate('bookmark', { + attrs: { name: 'Bookmark1', id: 'bk1' }, + }), + ); + + expect(result.nodeType).toBe('bookmark'); + expect(result.properties).toMatchObject({ name: 'Bookmark1', bookmarkId: 'bk1' }); + }); + + it('maps footnoteRef from node attrs', () => { + const result = mapNodeInfo( + makeInlineCandidate('footnoteRef', { + nodeAttrs: { id: 'fn1' }, + }), + ); + + expect(result.nodeType).toBe('footnoteRef'); + expect(result.properties).toMatchObject({ noteId: 'fn1' }); + }); + + it('maps tab with empty properties', () => { + const result = mapNodeInfo(makeInlineCandidate('tab')); + expect(result).toEqual({ nodeType: 'tab', kind: 'inline', properties: {} }); + }); + + it('maps lineBreak with empty properties', () => { + const result = mapNodeInfo(makeInlineCandidate('lineBreak')); + expect(result).toEqual({ nodeType: 'lineBreak', kind: 'inline', properties: {} }); + }); + + it('maps image with properties', () => { + const result = mapNodeInfo( + makeInlineCandidate('image', { + nodeAttrs: { src: 'pic.png', alt: 'A picture', size: { width: 100, height: 50 } }, + }), + ); + + expect(result.nodeType).toBe('image'); + expect(result.kind).toBe('inline'); + expect(result.properties).toMatchObject({ + src: 'pic.png', + alt: 'A picture', + size: { width: 100, height: 50 }, + }); + }); + + it('maps run with text-style properties', () => { + const result = mapNodeInfo( + makeInlineCandidate('run', { + nodeAttrs: { + runProperties: { + bold: true, + italic: true, + underline: { val: 'single' }, + rFonts: { ascii: 'Calibri' }, + sz: 24, + color: { val: 'FF0000' }, + highlight: 'yellow', + rStyle: 'Strong', + lang: { val: 'en-US' }, + }, + }, + }), + ); + + expect(result.nodeType).toBe('run'); + expect(result.kind).toBe('inline'); + expect(result.properties).toMatchObject({ + bold: true, + italic: true, + underline: true, + font: 'Calibri', + size: 24, + color: 'FF0000', + highlight: 'yellow', + styleId: 'Strong', + language: 'en-US', + }); + }); +}); + +// --------------------------------------------------------------------------- +// Kind mismatch errors +// --------------------------------------------------------------------------- + +describe('mapNodeInfo — kind mismatch errors', () => { + const blockOnlyTypes = ['paragraph', 'heading', 'listItem', 'table', 'tableRow', 'tableCell'] as const; + + for (const nodeType of blockOnlyTypes) { + it(`throws when ${nodeType} is mapped from an inline candidate`, () => { + const inlineCandidate = makeInlineCandidate('hyperlink'); + expect(() => mapNodeInfo(inlineCandidate, nodeType)).toThrow(); + }); + } + + const inlineOnlyTypes = ['hyperlink', 'comment', 'bookmark', 'footnoteRef'] as const; + + for (const nodeType of inlineOnlyTypes) { + it(`throws when ${nodeType} is mapped from a block candidate`, () => { + const blockCandidate = makeBlockCandidate('paragraph', 'p1'); + expect(() => mapNodeInfo(blockCandidate, nodeType)).toThrow(); + }); + } +}); + +// --------------------------------------------------------------------------- +// Unknown type +// --------------------------------------------------------------------------- + +describe('mapNodeInfo — unknown type', () => { + it('throws for unimplemented node type', () => { + const candidate = makeBlockCandidate('paragraph', 'p1'); + // Force an unknown type via overrideType + expect(() => mapNodeInfo(candidate, 'not-a-real-node-type' as unknown as NodeType)).toThrow( + 'Node type "not-a-real-node-type" is not implemented yet.', + ); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.ts b/packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.ts new file mode 100644 index 000000000..bcbfbebba --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.ts @@ -0,0 +1,422 @@ +import { getHeadingLevel, type BlockCandidate } from './node-address-resolver.js'; +import type { InlineCandidate } from './inline-address-resolver.js'; +import { resolveCommentIdFromAttrs, toFiniteNumber } from './value-utils.js'; +import { DocumentApiAdapterError } from '../errors.js'; +import type { + BookmarkNodeInfo, + CommentNodeInfo, + FootnoteRefNodeInfo, + HeadingNodeInfo, + HeadingProperties, + HyperlinkNodeInfo, + ImageNodeInfo, + LineBreakNodeInfo, + ListItemNodeInfo, + ListItemProperties, + ListNumbering, + NodeInfo, + NodeType, + ParagraphNodeInfo, + ParagraphProperties, + RunNodeInfo, + SdtNodeInfo, + TabNodeInfo, + TableCellNodeInfo, + TableNodeInfo, + TableRowNodeInfo, +} from '@superdoc/document-api'; +import type { + ImageAttrs, + ParagraphAttrs, + StructuredContentBlockAttrs, + TableAttrs, + TableCellAttrs, + TableMeasurement, +} from '../../extensions/types/node-attributes.js'; + +function resolveMeasurement(value: number | TableMeasurement | null | undefined): number | undefined { + if (typeof value === 'number') return value; + if (value && typeof value === 'object' && typeof value.value === 'number') return value.value; + return undefined; +} + +function mapTableAlignment( + justification: TableAttrs['tableProperties'] extends { justification?: infer J } ? J : never, +): TableNodeInfo['properties']['alignment'] { + switch (justification) { + case 'start': + return 'left'; + case 'end': + return 'right'; + case 'left': + case 'center': + case 'right': + return justification; + default: + return undefined; + } +} + +function mapParagraphProperties(attrs: ParagraphAttrs | null | undefined): ParagraphProperties { + const props = attrs?.paragraphProperties ?? undefined; + const indentation = props?.indentation + ? { + left: props.indentation.left, + right: props.indentation.right, + firstLine: props.indentation.firstLine, + hanging: props.indentation.hanging, + } + : undefined; + + const spacing = props?.spacing + ? { + before: props.spacing.before, + after: props.spacing.after, + line: props.spacing.line, + } + : undefined; + + const justification = props?.justification; + const alignment = justification === 'both' ? 'justify' : justification; + + const paragraphNumbering = props?.numberingProperties + ? { + numId: toFiniteNumber(props.numberingProperties.numId), + level: toFiniteNumber(props.numberingProperties.ilvl), + } + : undefined; + + return { + styleId: props?.styleId ?? undefined, + alignment: alignment ?? undefined, + indentation, + spacing, + keepWithNext: props?.keepNext ?? undefined, + outlineLevel: props?.outlineLevel ?? undefined, + paragraphNumbering, + }; +} + +function mapListNumbering(attrs: ParagraphAttrs | null | undefined): ListNumbering | undefined { + const listRendering = attrs?.listRendering ?? undefined; + if (!listRendering) return undefined; + + const listNumbering: ListNumbering = {}; + if (listRendering.markerText) listNumbering.marker = listRendering.markerText; + if (Array.isArray(listRendering.path)) listNumbering.path = listRendering.path; + if (Array.isArray(listRendering.path) && listRendering.path.length > 0) { + listNumbering.ordinal = listRendering.path[listRendering.path.length - 1]; + } + return Object.keys(listNumbering).length ? listNumbering : undefined; +} + +function mapParagraphNode(candidate: BlockCandidate): ParagraphNodeInfo { + const attrs = candidate.node.attrs as ParagraphAttrs | undefined; + const properties = mapParagraphProperties(attrs); + return { + nodeType: 'paragraph', + kind: 'block', + properties, + }; +} + +function mapHeadingNode(candidate: BlockCandidate): HeadingNodeInfo { + const attrs = candidate.node.attrs as ParagraphAttrs | undefined; + const baseProps = mapParagraphProperties(attrs); + const headingLevelCandidate = + getHeadingLevel(attrs?.paragraphProperties?.styleId) ?? + (baseProps.outlineLevel != null ? baseProps.outlineLevel + 1 : undefined); + + if (!headingLevelCandidate || headingLevelCandidate < 1 || headingLevelCandidate > 6) { + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + `Node "${candidate.nodeId}" does not have a valid heading level.`, + ); + } + + const properties: HeadingProperties = { + ...baseProps, + headingLevel: headingLevelCandidate as HeadingProperties['headingLevel'], + }; + + return { + nodeType: 'heading', + kind: 'block', + properties, + }; +} + +function mapListItemNode(candidate: BlockCandidate): ListItemNodeInfo { + const attrs = candidate.node.attrs as ParagraphAttrs | undefined; + const baseProps = mapParagraphProperties(attrs); + const properties: ListItemProperties = { + ...baseProps, + numbering: mapListNumbering(attrs), + }; + + return { + nodeType: 'listItem', + kind: 'block', + properties, + }; +} + +function mapTableNode(candidate: BlockCandidate): TableNodeInfo { + const attrs = candidate.node.attrs as TableAttrs | undefined; + const tableProps = attrs?.tableProperties ?? undefined; + const properties = { + layout: tableProps?.tableLayout ?? undefined, + width: resolveMeasurement(tableProps?.tableWidth ?? null) ?? undefined, + alignment: mapTableAlignment(tableProps?.justification), + }; + + return { + nodeType: 'table', + kind: 'block', + properties, + }; +} + +function mapTableRowNode(): TableRowNodeInfo { + return { + nodeType: 'tableRow', + kind: 'block', + properties: {}, + }; +} + +function mapTableCellNode(candidate: BlockCandidate): TableCellNodeInfo { + const attrs = candidate.node.attrs as TableCellAttrs | undefined; + const cellProps = attrs?.tableCellProperties ?? undefined; + const properties = { + width: + resolveMeasurement(cellProps?.cellWidth ?? null) ?? + (Array.isArray(attrs?.colwidth) && attrs.colwidth.length > 0 ? attrs.colwidth[0] : undefined), + shading: cellProps?.shading?.fill ?? attrs?.background?.color ?? undefined, + vMerge: + cellProps?.vMerge === 'continue' || cellProps?.vMerge === 'restart' + ? true + : attrs?.rowspan && attrs.rowspan > 1 + ? true + : undefined, + gridSpan: cellProps?.gridSpan ?? attrs?.colspan ?? undefined, + padding: + resolveMeasurement(cellProps?.cellMargins?.top ?? null) ?? resolveMeasurement(attrs?.cellMargins?.top ?? null), + }; + + return { + nodeType: 'tableCell', + kind: 'block', + properties, + }; +} + +function buildImageInfo(attrs: ImageAttrs | undefined, kind: 'block' | 'inline'): ImageNodeInfo { + const properties = { + src: attrs?.src ?? undefined, + alt: attrs?.alt ?? undefined, + size: attrs?.size + ? { + width: attrs.size.width, + height: attrs.size.height, + unit: undefined, + } + : undefined, + wrap: attrs?.wrap?.type ?? undefined, + }; + + return { + nodeType: 'image', + kind, + properties, + }; +} + +function buildSdtInfo(attrs: StructuredContentBlockAttrs | undefined, kind: 'block' | 'inline'): SdtNodeInfo { + const properties = { + tag: attrs?.tag ?? undefined, + alias: attrs?.alias ?? undefined, + }; + + return { + nodeType: 'sdt', + kind, + properties, + }; +} + +function mapHyperlinkNode(candidate: InlineCandidate): HyperlinkNodeInfo { + const attrs = (candidate.mark?.attrs ?? candidate.attrs ?? {}) as Record; + const properties = { + href: typeof attrs.href === 'string' ? attrs.href : undefined, + anchor: + typeof attrs.anchor === 'string' + ? attrs.anchor + : typeof attrs.docLocation === 'string' + ? attrs.docLocation + : undefined, + tooltip: typeof attrs.tooltip === 'string' ? attrs.tooltip : undefined, + }; + return { nodeType: 'hyperlink', kind: 'inline', properties }; +} + +function mapCommentNode(candidate: InlineCandidate): CommentNodeInfo { + const attrs = (candidate.mark?.attrs ?? candidate.attrs ?? {}) as Record; + const commentId = resolveCommentIdFromAttrs(attrs); + if (!commentId) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'Comment node is missing a commentId attribute.'); + } + const properties = { + commentId, + }; + return { nodeType: 'comment', kind: 'inline', properties }; +} + +function mapBookmarkNode(candidate: InlineCandidate): BookmarkNodeInfo { + const attrs = (candidate.attrs ?? candidate.node?.attrs ?? {}) as Record; + const properties = { + name: typeof attrs.name === 'string' ? attrs.name : undefined, + bookmarkId: typeof attrs.id === 'string' ? attrs.id : undefined, + }; + return { nodeType: 'bookmark', kind: 'inline', properties }; +} + +function mapFootnoteRefNode(candidate: InlineCandidate): FootnoteRefNodeInfo { + const attrs = (candidate.node?.attrs ?? candidate.attrs ?? {}) as Record; + const properties = { + noteId: typeof attrs.id === 'string' ? attrs.id : undefined, + }; + return { nodeType: 'footnoteRef', kind: 'inline', properties }; +} + +function mapRunNode(candidate: InlineCandidate): RunNodeInfo { + const attrs = (candidate.node?.attrs ?? candidate.attrs ?? {}) as { + runProperties?: { + bold?: boolean; + italic?: boolean; + underline?: { val?: string } | boolean; + rFonts?: { ascii?: string; hAnsi?: string; eastAsia?: string; cs?: string }; + sz?: number; + color?: { val?: string }; + highlight?: string; + rStyle?: string; + lang?: { val?: string }; + u?: { val?: string }; + } | null; + }; + const runProperties = attrs.runProperties ?? undefined; + const underline = Boolean( + runProperties?.underline === true || + runProperties?.u?.val === 'single' || + (typeof runProperties?.underline === 'object' && + typeof runProperties?.underline?.val === 'string' && + runProperties.underline.val !== 'none'), + ); + const font = + runProperties?.rFonts?.ascii ?? + runProperties?.rFonts?.hAnsi ?? + runProperties?.rFonts?.eastAsia ?? + runProperties?.rFonts?.cs; + + return { + nodeType: 'run', + kind: 'inline', + properties: { + bold: runProperties?.bold ?? undefined, + italic: runProperties?.italic ?? undefined, + underline: underline || undefined, + font: typeof font === 'string' ? font : undefined, + size: typeof runProperties?.sz === 'number' ? runProperties.sz : undefined, + color: runProperties?.color?.val ?? undefined, + highlight: runProperties?.highlight ?? undefined, + styleId: runProperties?.rStyle ?? undefined, + language: runProperties?.lang?.val ?? undefined, + }, + }; +} + +function mapTabNode(): TabNodeInfo { + return { nodeType: 'tab', kind: 'inline', properties: {} }; +} + +function mapLineBreakNode(): LineBreakNodeInfo { + return { nodeType: 'lineBreak', kind: 'inline', properties: {} }; +} + +function isInlineCandidate(candidate: BlockCandidate | InlineCandidate): candidate is InlineCandidate { + return 'anchor' in candidate; +} + +/** + * Maps a block or inline candidate to its typed {@link NodeInfo} representation. + * + * @param candidate - The block or inline candidate to map. + * @param overrideType - Optional node type override. + * @returns Typed node information with properties populated from node attributes. + * @throws {Error} If the node type is not implemented or the candidate kind mismatches. + */ +export function mapNodeInfo(candidate: BlockCandidate | InlineCandidate, overrideType?: NodeType): NodeInfo { + const nodeType: NodeType = overrideType ?? candidate.nodeType; + const kind = isInlineCandidate(candidate) ? 'inline' : 'block'; + + switch (nodeType) { + case 'paragraph': + if (kind !== 'block') + throw new DocumentApiAdapterError('INVALID_TARGET', 'Paragraph nodes can only be resolved as blocks.'); + return mapParagraphNode(candidate as BlockCandidate); + case 'heading': + if (kind !== 'block') + throw new DocumentApiAdapterError('INVALID_TARGET', 'Heading nodes can only be resolved as blocks.'); + return mapHeadingNode(candidate as BlockCandidate); + case 'listItem': + if (kind !== 'block') + throw new DocumentApiAdapterError('INVALID_TARGET', 'ListItem nodes can only be resolved as blocks.'); + return mapListItemNode(candidate as BlockCandidate); + case 'table': + if (kind !== 'block') + throw new DocumentApiAdapterError('INVALID_TARGET', 'Table nodes can only be resolved as blocks.'); + return mapTableNode(candidate as BlockCandidate); + case 'tableRow': + if (kind !== 'block') + throw new DocumentApiAdapterError('INVALID_TARGET', 'TableRow nodes can only be resolved as blocks.'); + return mapTableRowNode(); + case 'tableCell': + if (kind !== 'block') + throw new DocumentApiAdapterError('INVALID_TARGET', 'TableCell nodes can only be resolved as blocks.'); + return mapTableCellNode(candidate as BlockCandidate); + case 'image': { + const attrs = candidate.node?.attrs as ImageAttrs | undefined; + return buildImageInfo(attrs, kind); + } + case 'sdt': { + const attrs = candidate.node?.attrs as StructuredContentBlockAttrs | undefined; + return buildSdtInfo(attrs, kind); + } + case 'hyperlink': + if (!isInlineCandidate(candidate)) + throw new DocumentApiAdapterError('INVALID_TARGET', 'Hyperlink nodes can only be resolved inline.'); + return mapHyperlinkNode(candidate); + case 'comment': + if (!isInlineCandidate(candidate)) + throw new DocumentApiAdapterError('INVALID_TARGET', 'Comment nodes can only be resolved inline.'); + return mapCommentNode(candidate); + case 'run': + if (!isInlineCandidate(candidate)) + throw new DocumentApiAdapterError('INVALID_TARGET', 'Run nodes can only be resolved inline.'); + return mapRunNode(candidate); + case 'bookmark': + if (!isInlineCandidate(candidate)) + throw new DocumentApiAdapterError('INVALID_TARGET', 'Bookmark nodes can only be resolved inline.'); + return mapBookmarkNode(candidate); + case 'footnoteRef': + if (!isInlineCandidate(candidate)) + throw new DocumentApiAdapterError('INVALID_TARGET', 'Footnote references can only be resolved inline.'); + return mapFootnoteRefNode(candidate); + case 'tab': + return mapTabNode(); + case 'lineBreak': + return mapLineBreakNode(); + default: + throw new DocumentApiAdapterError('INVALID_TARGET', `Node type "${nodeType}" is not implemented yet.`); + } +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/node-info-resolver.ts b/packages/super-editor/src/document-api-adapters/helpers/node-info-resolver.ts new file mode 100644 index 000000000..76ce6b349 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/node-info-resolver.ts @@ -0,0 +1,61 @@ +import type { Editor } from '../../core/Editor.js'; +import type { NodeAddress, NodeInfo } from '@superdoc/document-api'; +import { findInlineByAnchor, type InlineIndex } from './inline-address-resolver.js'; +import { getInlineIndex } from './index-cache.js'; +import { findBlockById, type BlockIndex } from './node-address-resolver.js'; +import { mapNodeInfo } from './node-info-mapper.js'; + +/** + * Resolves a single {@link NodeAddress} to its {@link NodeInfo} representation. + * + * @param editor - The editor instance. + * @param index - Pre-built block index. + * @param address - The address to resolve. + * @param inlineIndex - Optional pre-built inline index (built lazily if omitted). + * @returns The resolved node info, or `undefined` if the address cannot be found. + */ +export function resolveNodeInfoForAddress( + editor: Editor, + index: BlockIndex, + address: NodeAddress, + inlineIndex?: InlineIndex, +): NodeInfo | undefined { + if (address.kind === 'block') { + const candidate = findBlockById(index, address); + if (!candidate) return undefined; + return mapNodeInfo(candidate, address.nodeType); + } + + const resolvedInlineIndex = inlineIndex ?? getInlineIndex(editor); + const candidate = findInlineByAnchor(resolvedInlineIndex, address); + if (!candidate) return undefined; + return mapNodeInfo(candidate, address.nodeType); +} + +/** + * Batch-resolves an array of addresses to their {@link NodeInfo} representations. + * Unresolvable addresses are silently skipped. + * + * @param editor - The editor instance. + * @param index - Pre-built block index. + * @param addresses - The addresses to resolve. + * @returns Array of resolved node infos (may be shorter than input if some addresses are missing). + */ +export function resolveIncludedNodes(editor: Editor, index: BlockIndex, addresses: NodeAddress[]): NodeInfo[] { + const included: NodeInfo[] = []; + let inlineIndex: InlineIndex | undefined; + + for (const address of addresses) { + if (address.kind === 'inline') { + inlineIndex ??= getInlineIndex(editor); + const info = resolveNodeInfoForAddress(editor, index, address, inlineIndex); + if (info) included.push(info); + continue; + } + + const info = resolveNodeInfoForAddress(editor, index, address); + if (info) included.push(info); + } + + return included; +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/text-offset-resolver.test.ts b/packages/super-editor/src/document-api-adapters/helpers/text-offset-resolver.test.ts new file mode 100644 index 000000000..17f3dcaa4 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/text-offset-resolver.test.ts @@ -0,0 +1,103 @@ +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import { resolveTextRangeInBlock } from './text-offset-resolver.js'; + +type NodeOptions = { + text?: string; + isInline?: boolean; + isBlock?: boolean; + isLeaf?: boolean; + inlineContent?: boolean; + nodeSize?: number; +}; + +function createNode(typeName: string, children: ProseMirrorNode[] = [], options: NodeOptions = {}): ProseMirrorNode { + const text = options.text ?? ''; + const isText = typeName === 'text'; + const isInline = options.isInline ?? isText; + const isBlock = options.isBlock ?? (!isInline && typeName !== 'doc'); + const inlineContent = options.inlineContent ?? isBlock; + const isLeaf = options.isLeaf ?? (isInline && !isText && children.length === 0); + + const contentSize = children.reduce((sum, child) => sum + child.nodeSize, 0); + const nodeSize = isText ? text.length : options.nodeSize != null ? options.nodeSize : isLeaf ? 1 : contentSize + 2; + + return { + type: { name: typeName }, + text: isText ? text : undefined, + nodeSize, + isText, + isInline, + isBlock, + inlineContent, + isTextblock: inlineContent, + isLeaf, + childCount: children.length, + child(index: number) { + return children[index]!; + }, + } as unknown as ProseMirrorNode; +} + +describe('resolveTextRangeInBlock', () => { + it('resolves plain text offsets to absolute positions', () => { + const textNode = createNode('text', [], { text: 'Hello' }); + const paragraph = createNode('paragraph', [textNode], { isBlock: true, inlineContent: true }); + + const result = resolveTextRangeInBlock(paragraph, 0, { start: 0, end: 5 }); + + expect(result).toEqual({ from: 1, to: 6 }); + }); + + it('resolves offsets that target leaf atoms with nodeSize > 1', () => { + const textNode = createNode('text', [], { text: 'A' }); + const imageNode = createNode('image', [], { isInline: true, isLeaf: true, nodeSize: 3 }); + const paragraph = createNode('paragraph', [textNode, imageNode], { isBlock: true, inlineContent: true }); + + const result = resolveTextRangeInBlock(paragraph, 0, { start: 1, end: 2 }); + + expect(result).toEqual({ from: 2, to: 5 }); + }); + + it('treats inline wrappers as transparent', () => { + const textNode = createNode('text', [], { text: 'Hi' }); + const runNode = createNode('run', [textNode], { isInline: true, isLeaf: false }); + const paragraph = createNode('paragraph', [runNode], { isBlock: true, inlineContent: true }); + + const result = resolveTextRangeInBlock(paragraph, 0, { start: 0, end: 2 }); + + expect(result).toEqual({ from: 2, to: 4 }); + }); + + it('returns null for out-of-range offsets', () => { + const textNode = createNode('text', [], { text: 'Hi' }); + const paragraph = createNode('paragraph', [textNode], { isBlock: true, inlineContent: true }); + + const result = resolveTextRangeInBlock(paragraph, 0, { start: 0, end: 5 }); + + expect(result).toBeNull(); + }); + + it('resolves collapsed zero-offset ranges in empty text blocks', () => { + const paragraph = createNode('paragraph', [], { isBlock: true, inlineContent: true }); + + const result = resolveTextRangeInBlock(paragraph, 10, { start: 0, end: 0 }); + + expect(result).toEqual({ from: 11, to: 11 }); + }); + + it('accounts for block separators inside container blocks', () => { + const paraA = createNode('paragraph', [createNode('text', [], { text: 'A' })], { + isBlock: true, + inlineContent: true, + }); + const paraB = createNode('paragraph', [createNode('text', [], { text: 'B' })], { + isBlock: true, + inlineContent: true, + }); + const cell = createNode('tableCell', [paraA, paraB], { isBlock: true, inlineContent: false }); + + const result = resolveTextRangeInBlock(cell, 0, { start: 2, end: 3 }); + + expect(result).toEqual({ from: 5, to: 6 }); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/text-offset-resolver.ts b/packages/super-editor/src/document-api-adapters/helpers/text-offset-resolver.ts new file mode 100644 index 000000000..1d677981c --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/text-offset-resolver.ts @@ -0,0 +1,107 @@ +import type { Node as ProseMirrorNode } from 'prosemirror-model'; + +export type TextOffsetRange = { + start: number; + end: number; +}; + +export type ResolvedTextRange = { + from: number; + to: number; +}; + +function resolveSegmentPosition( + targetOffset: number, + segmentStart: number, + segmentLength: number, + docFrom: number, + docTo: number, +): number { + if (segmentLength <= 1) { + return targetOffset <= segmentStart ? docFrom : docTo; + } + return docFrom + (targetOffset - segmentStart); +} + +/** + * Resolves block-relative text offsets into absolute ProseMirror positions. + * + * Uses the same flattened text model as search: + * - Text contributes its length. + * - Leaf atoms contribute 1. + * - Inline wrappers contribute only their inner text. + * - Block separators contribute 1 between block children. + */ +export function resolveTextRangeInBlock( + blockNode: ProseMirrorNode, + blockPos: number, + range: TextOffsetRange, +): ResolvedTextRange | null { + if (range.start < 0 || range.end < range.start) return null; + + let offset = 0; + let fromPos: number | undefined; + let toPos: number | undefined; + + const advanceSegment = (segmentLength: number, docFrom: number, docTo: number) => { + const segmentStart = offset; + const segmentEnd = offset + segmentLength; + + if (fromPos == null && range.start <= segmentEnd) { + fromPos = resolveSegmentPosition(range.start, segmentStart, segmentLength, docFrom, docTo); + } + if (toPos == null && range.end <= segmentEnd) { + toPos = resolveSegmentPosition(range.end, segmentStart, segmentLength, docFrom, docTo); + } + + offset = segmentEnd; + }; + + const walkNodeContent = (node: ProseMirrorNode, contentStart: number) => { + let isFirstChild = true; + let childOffset = 0; + + for (let i = 0; i < node.childCount; i += 1) { + const child = node.child(i); + const childPos = contentStart + childOffset; + + if (child.isBlock && !isFirstChild) { + advanceSegment(1, childPos, childPos + 1); + } + + walkNode(child, childPos); + childOffset += child.nodeSize; + isFirstChild = false; + } + }; + + const walkNode = (node: ProseMirrorNode, docPos: number) => { + if (node.isText) { + const text = node.text ?? ''; + if (text.length > 0) { + advanceSegment(text.length, docPos, docPos + text.length); + } + return; + } + + if (node.isLeaf) { + advanceSegment(1, docPos, docPos + node.nodeSize); + return; + } + + walkNodeContent(node, docPos + 1); + }; + + walkNodeContent(blockNode, blockPos + 1); + + // Empty text blocks have no traversable segments. A collapsed 0..0 range + // should still resolve to the block start so inserts can target blank docs. + if (offset === 0 && range.start === 0 && range.end === 0) { + const anchor = blockPos + 1; + return { from: anchor, to: anchor }; + } + + if (range.end > offset) return null; + if (fromPos == null || toPos == null) return null; + return { from: fromPos, to: toPos }; +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/value-utils.test.ts b/packages/super-editor/src/document-api-adapters/helpers/value-utils.test.ts new file mode 100644 index 000000000..3c7bb5b2b --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/value-utils.test.ts @@ -0,0 +1,123 @@ +import { toNonEmptyString, toFiniteNumber, toId, resolveCommentIdFromAttrs, normalizeExcerpt } from './value-utils.js'; + +describe('toNonEmptyString', () => { + it('returns a non-empty string as-is', () => { + expect(toNonEmptyString('hello')).toBe('hello'); + }); + + it('returns undefined for an empty string', () => { + expect(toNonEmptyString('')).toBeUndefined(); + }); + + it('returns undefined for non-string values', () => { + expect(toNonEmptyString(null)).toBeUndefined(); + expect(toNonEmptyString(undefined)).toBeUndefined(); + expect(toNonEmptyString(42)).toBeUndefined(); + expect(toNonEmptyString(true)).toBeUndefined(); + expect(toNonEmptyString({})).toBeUndefined(); + }); +}); + +describe('toFiniteNumber', () => { + it('returns a finite number as-is', () => { + expect(toFiniteNumber(42)).toBe(42); + expect(toFiniteNumber(0)).toBe(0); + expect(toFiniteNumber(-3.14)).toBe(-3.14); + }); + + it('returns undefined for non-finite numbers', () => { + expect(toFiniteNumber(Infinity)).toBeUndefined(); + expect(toFiniteNumber(-Infinity)).toBeUndefined(); + expect(toFiniteNumber(NaN)).toBeUndefined(); + }); + + it('parses numeric strings', () => { + expect(toFiniteNumber('42')).toBe(42); + expect(toFiniteNumber('3.14')).toBe(3.14); + expect(toFiniteNumber(' 7 ')).toBe(7); + }); + + it('returns undefined for non-numeric strings', () => { + expect(toFiniteNumber('abc')).toBeUndefined(); + expect(toFiniteNumber('')).toBeUndefined(); + expect(toFiniteNumber(' ')).toBeUndefined(); + }); + + it('returns undefined for non-number/string values', () => { + expect(toFiniteNumber(null)).toBeUndefined(); + expect(toFiniteNumber(undefined)).toBeUndefined(); + expect(toFiniteNumber(true)).toBeUndefined(); + expect(toFiniteNumber({})).toBeUndefined(); + }); +}); + +describe('toId', () => { + it('returns a non-empty string as-is', () => { + expect(toId('abc')).toBe('abc'); + }); + + it('returns undefined for an empty string', () => { + expect(toId('')).toBeUndefined(); + }); + + it('converts a finite number to a string', () => { + expect(toId(42)).toBe('42'); + expect(toId(0)).toBe('0'); + }); + + it('returns undefined for non-finite numbers', () => { + expect(toId(NaN)).toBeUndefined(); + expect(toId(Infinity)).toBeUndefined(); + }); + + it('returns undefined for other types', () => { + expect(toId(null)).toBeUndefined(); + expect(toId(undefined)).toBeUndefined(); + expect(toId(true)).toBeUndefined(); + expect(toId({})).toBeUndefined(); + }); +}); + +describe('resolveCommentIdFromAttrs', () => { + it('prefers commentId over importedId and w:id', () => { + expect(resolveCommentIdFromAttrs({ commentId: 'c1', importedId: 'i1', 'w:id': 'w1' })).toBe('c1'); + }); + + it('falls back to importedId when commentId is absent', () => { + expect(resolveCommentIdFromAttrs({ importedId: 'i1', 'w:id': 'w1' })).toBe('i1'); + }); + + it('falls back to w:id when commentId and importedId are absent', () => { + expect(resolveCommentIdFromAttrs({ 'w:id': 'w1' })).toBe('w1'); + }); + + it('returns undefined when no id attribute is present', () => { + expect(resolveCommentIdFromAttrs({})).toBeUndefined(); + }); + + it('skips empty string values', () => { + expect(resolveCommentIdFromAttrs({ commentId: '', importedId: 'i1' })).toBe('i1'); + }); +}); + +describe('normalizeExcerpt', () => { + it('collapses multiple whitespace characters', () => { + expect(normalizeExcerpt('hello world')).toBe('hello world'); + }); + + it('trims leading and trailing whitespace', () => { + expect(normalizeExcerpt(' hello ')).toBe('hello'); + }); + + it('normalizes newlines and tabs', () => { + expect(normalizeExcerpt('hello\n\tworld')).toBe('hello world'); + }); + + it('returns undefined for empty string', () => { + expect(normalizeExcerpt('')).toBeUndefined(); + }); + + it('returns undefined for whitespace-only string', () => { + expect(normalizeExcerpt(' \n\t ')).toBeUndefined(); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/value-utils.ts b/packages/super-editor/src/document-api-adapters/helpers/value-utils.ts new file mode 100644 index 000000000..499bf0e58 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/value-utils.ts @@ -0,0 +1,60 @@ +/** + * Shared scalar utility functions for document-api adapters. + */ + +/** + * Returns the value as a string if it is a non-empty string, otherwise `undefined`. + * + * @param value - The value to test. + */ +export function toNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** + * Coerces a value to a finite number. Accepts numbers and numeric strings. + * + * @param value - The value to coerce. + * @returns A finite number, or `undefined` if the value cannot be coerced. + */ +export function toFiniteNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +/** + * Coerces a value to a stable ID string. Accepts non-empty strings and finite numbers. + * + * @param value - The value to coerce. + * @returns A string ID, or `undefined` if the value is not a valid identifier. + */ +export function toId(value: unknown): string | undefined { + if (typeof value === 'string' && value.length > 0) return value; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return undefined; +} + +/** + * Extracts a comment ID from node attributes, checking `commentId`, `importedId`, and `w:id` in order. + * + * @param attrs - The attributes record to search. + * @returns The first non-empty comment ID found, or `undefined`. + */ +export function resolveCommentIdFromAttrs(attrs: Record): string | undefined { + return toNonEmptyString(attrs.commentId) ?? toNonEmptyString(attrs.importedId) ?? toNonEmptyString(attrs['w:id']); +} + +/** + * Normalizes whitespace in a text excerpt and returns `undefined` for empty results. + * + * @param text - The raw text to normalize. + * @returns Trimmed text with collapsed whitespace, or `undefined` if empty. + */ +export function normalizeExcerpt(text: string): string | undefined { + const trimmed = text.replace(/\s+/g, ' ').trim(); + return trimmed.length ? trimmed : undefined; +} diff --git a/packages/super-editor/src/document-api-adapters/info-adapter.test.ts b/packages/super-editor/src/document-api-adapters/info-adapter.test.ts new file mode 100644 index 000000000..10da54fc4 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/info-adapter.test.ts @@ -0,0 +1,133 @@ +import type { Query, QueryResult } from '@superdoc/document-api'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Editor } from '../core/Editor.js'; +import { findAdapter } from './find-adapter.js'; +import { getTextAdapter } from './get-text-adapter.js'; +import { infoAdapter } from './info-adapter.js'; + +vi.mock('./find-adapter.js', () => ({ + findAdapter: vi.fn(), +})); + +vi.mock('./get-text-adapter.js', () => ({ + getTextAdapter: vi.fn(), +})); + +const findAdapterMock = vi.mocked(findAdapter); +const getTextAdapterMock = vi.mocked(getTextAdapter); + +function makeResult(result: Partial): QueryResult { + return { + matches: [], + total: 0, + ...result, + }; +} + +function resolveFindResult(query: Query): QueryResult { + if (query.select.type === 'text') { + throw new Error('infoAdapter should only perform node-type queries.'); + } + + switch (query.select.nodeType) { + case 'paragraph': + return makeResult({ total: 5 }); + case 'heading': + return makeResult({ + total: 2, + matches: [ + { kind: 'block', nodeType: 'heading', nodeId: 'H1' }, + { kind: 'block', nodeType: 'heading', nodeId: 'H2' }, + ], + nodes: [ + { + nodeType: 'heading', + kind: 'block', + properties: { headingLevel: 2 }, + text: 'Overview', + }, + { + nodeType: 'heading', + kind: 'block', + properties: { headingLevel: 6 }, + summary: { text: 'Details' }, + }, + ], + }); + case 'table': + return makeResult({ total: 1 }); + case 'image': + return makeResult({ total: 3 }); + case 'comment': + return makeResult({ + total: 4, + nodes: [ + { + nodeType: 'comment', + kind: 'inline', + properties: { commentId: 'c-1' }, + }, + { + nodeType: 'comment', + kind: 'inline', + properties: { commentId: 'c-1' }, + }, + { + nodeType: 'comment', + kind: 'inline', + properties: { commentId: 'c-2' }, + }, + ], + }); + default: + return makeResult({}); + } +} + +describe('infoAdapter', () => { + beforeEach(() => { + findAdapterMock.mockReset(); + getTextAdapterMock.mockReset(); + }); + + it('computes counts and outline from find/get-text adapters', () => { + getTextAdapterMock.mockReturnValue('hello world from info adapter'); + findAdapterMock.mockImplementation((editor: Editor, query: Query) => resolveFindResult(query)); + + const result = infoAdapter({} as Editor, {}); + + expect(result.counts).toEqual({ + words: 5, + paragraphs: 5, + headings: 2, + tables: 1, + images: 3, + comments: 2, + }); + expect(result.outline).toEqual([ + { level: 2, text: 'Overview', nodeId: 'H1' }, + { level: 6, text: 'Details', nodeId: 'H2' }, + ]); + expect(result.capabilities).toEqual({ + canFind: true, + canGetNode: true, + canComment: true, + canReplace: true, + }); + }); + + it('falls back to total comment count when includeNodes does not return comment nodes', () => { + getTextAdapterMock.mockReturnValue(''); + findAdapterMock.mockImplementation((editor: Editor, query: Query) => { + if (query.select.type === 'text') return makeResult({}); + if (query.select.nodeType === 'comment') { + return makeResult({ total: 7, nodes: [] }); + } + return makeResult({}); + }); + + const result = infoAdapter({} as Editor, {}); + + expect(result.counts.comments).toBe(7); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/info-adapter.ts b/packages/super-editor/src/document-api-adapters/info-adapter.ts new file mode 100644 index 000000000..d11ce2bbc --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/info-adapter.ts @@ -0,0 +1,108 @@ +import type { DocumentInfo, InfoInput, NodeInfo, NodeType, QueryResult } from '@superdoc/document-api'; +import type { Editor } from '../core/Editor.js'; +import { findAdapter } from './find-adapter.js'; +import { getTextAdapter } from './get-text-adapter.js'; + +type HeadingNodeInfo = Extract; +type CommentNodeInfo = Extract; + +function countWords(text: string): number { + const matches = text.trim().match(/\S+/g); + return matches ? matches.length : 0; +} + +function clampHeadingLevel(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return 1; + const rounded = Math.floor(value); + if (rounded < 1) return 1; + if (rounded > 6) return 6; + return rounded; +} + +function isHeadingNodeInfo(node: NodeInfo | undefined): node is HeadingNodeInfo { + return node?.kind === 'block' && node.nodeType === 'heading'; +} + +function isCommentNodeInfo(node: NodeInfo | undefined): node is CommentNodeInfo { + return node?.kind === 'inline' && node.nodeType === 'comment'; +} + +function getHeadingText(node: HeadingNodeInfo | undefined): string { + if (!node) return ''; + if (typeof node.text === 'string' && node.text.length > 0) return node.text; + if (typeof node.summary?.text === 'string' && node.summary.text.length > 0) return node.summary.text; + return ''; +} + +function buildOutline(result: QueryResult): DocumentInfo['outline'] { + const outline: DocumentInfo['outline'] = []; + + for (const [index, match] of result.matches.entries()) { + if (match.kind !== 'block') continue; + + const maybeHeading = isHeadingNodeInfo(result.nodes?.[index]) ? result.nodes[index] : undefined; + outline.push({ + level: clampHeadingLevel(maybeHeading?.properties.headingLevel), + text: getHeadingText(maybeHeading), + nodeId: match.nodeId, + }); + } + + return outline; +} + +function countDistinctCommentIds(result: QueryResult): number { + const commentIds = new Set(); + for (const node of result.nodes ?? []) { + if (!isCommentNodeInfo(node)) continue; + if (typeof node.properties.commentId !== 'string' || node.properties.commentId.length === 0) continue; + commentIds.add(node.properties.commentId); + } + + // When node data is available, deduplicate by commentId. Otherwise fall + // back to the query total (e.g. when includeNodes was not requested). + if (commentIds.size > 0) { + return commentIds.size; + } + return result.total; +} + +function findByNodeType(editor: Editor, nodeType: NodeType, includeNodes = false): QueryResult { + return findAdapter(editor, { + select: { type: 'node', nodeType }, + includeNodes, + }); +} + +/** + * Build `doc.info` payload from engine-backed find/getText adapters. + * + * This keeps `document-api` engine-agnostic while centralizing composition + * logic in the super-editor adapter layer. + */ +export function infoAdapter(editor: Editor, _input: InfoInput): DocumentInfo { + const text = getTextAdapter(editor, {}); + const paragraphResult = findByNodeType(editor, 'paragraph'); + const headingResult = findByNodeType(editor, 'heading', true); + const tableResult = findByNodeType(editor, 'table'); + const imageResult = findByNodeType(editor, 'image'); + const commentResult = findByNodeType(editor, 'comment', true); + + return { + counts: { + words: countWords(text), + paragraphs: paragraphResult.total, + headings: headingResult.total, + tables: tableResult.total, + images: imageResult.total, + comments: countDistinctCommentIds(commentResult), + }, + outline: buildOutline(headingResult), + capabilities: { + canFind: true, + canGetNode: true, + canComment: true, + canReplace: true, + }, + }; +} diff --git a/packages/super-editor/src/extensions/search/search.js b/packages/super-editor/src/extensions/search/search.js index 869b9235b..d77592d61 100644 --- a/packages/super-editor/src/extensions/search/search.js +++ b/packages/super-editor/src/extensions/search/search.js @@ -136,6 +136,8 @@ const getPositionTracker = (editor) => { * @property {boolean} [highlight=true] - Whether to apply CSS classes for visual highlighting of search matches. * When true, matches are styled with 'ProseMirror-search-match' or 'ProseMirror-active-search-match' classes. * When false, matches are tracked without visual styling, useful for programmatic search without UI changes. + * @property {number} [maxMatches=1000] - Maximum number of matches to return. + * @property {boolean} [caseSensitive=false] - Whether the search should be case-sensitive. */ /** diff --git a/packages/super-editor/src/extensions/types/specialized-commands.ts b/packages/super-editor/src/extensions/types/specialized-commands.ts index ed4b5afa9..568946250 100644 --- a/packages/super-editor/src/extensions/types/specialized-commands.ts +++ b/packages/super-editor/src/extensions/types/specialized-commands.ts @@ -15,6 +15,8 @@ type SearchMatch = { export type SearchCommandOptions = { highlight?: boolean; + maxMatches?: number; + caseSensitive?: boolean; }; type DocumentSectionCreateOptions = { diff --git a/packages/super-editor/tsconfig.types.json b/packages/super-editor/tsconfig.types.json index e1215386a..f33a52768 100644 --- a/packages/super-editor/tsconfig.types.json +++ b/packages/super-editor/tsconfig.types.json @@ -12,6 +12,7 @@ { "path": "../layout-engine/painters/dom/tsconfig.json" }, { "path": "../layout-engine/style-engine/tsconfig.json" }, { "path": "../word-layout/tsconfig.json" }, - { "path": "../../shared/common/tsconfig.json" } + { "path": "../../shared/common/tsconfig.json" }, + { "path": "../document-api/tsconfig.json" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 147eaf60e..0b99f5a79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -432,7 +432,7 @@ importers: version: 14.0.3 mintlify: specifier: ^4.2.331 - version: 4.2.331(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3) + version: 4.2.331(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.8)(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3) remark-mdx: specifier: ^3.1.1 version: 3.1.1 @@ -630,6 +630,8 @@ importers: specifier: 'catalog:' version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.2)(esbuild@0.27.2)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.0))(tsx@4.21.0)(yaml@2.8.2) + packages/document-api: {} + packages/esign: devDependencies: '@testing-library/jest-dom': @@ -1072,6 +1074,9 @@ importers: '@superdoc/contracts': specifier: workspace:* version: link:../layout-engine/contracts + '@superdoc/document-api': + specifier: workspace:* + version: link:../document-api '@superdoc/layout-bridge': specifier: workspace:* version: link:../layout-engine/layout-bridge @@ -12771,128 +12776,128 @@ snapshots: '@inquirer/ansi@1.0.2': {} - '@inquirer/checkbox@4.3.2(@types/node@22.19.2)': + '@inquirer/checkbox@4.3.2(@types/node@22.19.8)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@22.19.8) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.8) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/confirm@5.1.21(@types/node@22.19.2)': + '@inquirer/confirm@5.1.21(@types/node@22.19.8)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@22.19.8) + '@inquirer/type': 3.0.10(@types/node@22.19.8) optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/core@10.3.2(@types/node@22.19.2)': + '@inquirer/core@10.3.2(@types/node@22.19.8)': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.8) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/editor@4.2.23(@types/node@22.19.2)': + '@inquirer/editor@4.2.23(@types/node@22.19.8)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/external-editor': 1.0.3(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@22.19.8) + '@inquirer/external-editor': 1.0.3(@types/node@22.19.8) + '@inquirer/type': 3.0.10(@types/node@22.19.8) optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/expand@4.0.23(@types/node@22.19.2)': + '@inquirer/expand@4.0.23(@types/node@22.19.8)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@22.19.8) + '@inquirer/type': 3.0.10(@types/node@22.19.8) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/external-editor@1.0.3(@types/node@22.19.2)': + '@inquirer/external-editor@1.0.3(@types/node@22.19.8)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 '@inquirer/figures@1.0.15': {} - '@inquirer/input@4.3.1(@types/node@22.19.2)': + '@inquirer/input@4.3.1(@types/node@22.19.8)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@22.19.8) + '@inquirer/type': 3.0.10(@types/node@22.19.8) optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/number@3.0.23(@types/node@22.19.2)': + '@inquirer/number@3.0.23(@types/node@22.19.8)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@22.19.8) + '@inquirer/type': 3.0.10(@types/node@22.19.8) optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/password@4.0.23(@types/node@22.19.2)': + '@inquirer/password@4.0.23(@types/node@22.19.8)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@22.19.8) + '@inquirer/type': 3.0.10(@types/node@22.19.8) optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/prompts@7.9.0(@types/node@22.19.2)': - dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@22.19.2) - '@inquirer/confirm': 5.1.21(@types/node@22.19.2) - '@inquirer/editor': 4.2.23(@types/node@22.19.2) - '@inquirer/expand': 4.0.23(@types/node@22.19.2) - '@inquirer/input': 4.3.1(@types/node@22.19.2) - '@inquirer/number': 3.0.23(@types/node@22.19.2) - '@inquirer/password': 4.0.23(@types/node@22.19.2) - '@inquirer/rawlist': 4.1.11(@types/node@22.19.2) - '@inquirer/search': 3.2.2(@types/node@22.19.2) - '@inquirer/select': 4.4.2(@types/node@22.19.2) + '@inquirer/prompts@7.9.0(@types/node@22.19.8)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@22.19.8) + '@inquirer/confirm': 5.1.21(@types/node@22.19.8) + '@inquirer/editor': 4.2.23(@types/node@22.19.8) + '@inquirer/expand': 4.0.23(@types/node@22.19.8) + '@inquirer/input': 4.3.1(@types/node@22.19.8) + '@inquirer/number': 3.0.23(@types/node@22.19.8) + '@inquirer/password': 4.0.23(@types/node@22.19.8) + '@inquirer/rawlist': 4.1.11(@types/node@22.19.8) + '@inquirer/search': 3.2.2(@types/node@22.19.8) + '@inquirer/select': 4.4.2(@types/node@22.19.8) optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/rawlist@4.1.11(@types/node@22.19.2)': + '@inquirer/rawlist@4.1.11(@types/node@22.19.8)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@22.19.8) + '@inquirer/type': 3.0.10(@types/node@22.19.8) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/search@3.2.2(@types/node@22.19.2)': + '@inquirer/search@3.2.2(@types/node@22.19.8)': dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@22.19.8) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.8) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/select@4.4.2(@types/node@22.19.2)': + '@inquirer/select@4.4.2(@types/node@22.19.8)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/core': 10.3.2(@types/node@22.19.8) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.8) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 - '@inquirer/type@3.0.10(@types/node@22.19.2)': + '@inquirer/type@3.0.10(@types/node@22.19.8)': optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 22.19.8 '@isaacs/balanced-match@4.0.1': {} @@ -13244,9 +13249,9 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} - '@mintlify/cli@4.0.935(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3)': + '@mintlify/cli@4.0.935(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.8)(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3)': dependencies: - '@inquirer/prompts': 7.9.0(@types/node@22.19.2) + '@inquirer/prompts': 7.9.0(@types/node@22.19.8) '@mintlify/common': 1.0.713(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@5.9.3) '@mintlify/link-rot': 3.0.872(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@5.9.3) '@mintlify/models': 0.0.268 @@ -13260,7 +13265,7 @@ snapshots: front-matter: 4.0.2 fs-extra: 11.2.0 ink: 6.3.0(@types/react@19.2.11)(react@19.2.3) - inquirer: 12.3.0(@types/node@22.19.2) + inquirer: 12.3.0(@types/node@22.19.8) js-yaml: 4.1.0 mdast-util-mdx-jsx: 3.2.0 react: 19.2.3 @@ -18787,12 +18792,12 @@ snapshots: inline-style-parser@0.2.7: {} - inquirer@12.3.0(@types/node@22.19.2): + inquirer@12.3.0(@types/node@22.19.8): dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/prompts': 7.9.0(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - '@types/node': 22.19.2 + '@inquirer/core': 10.3.2(@types/node@22.19.8) + '@inquirer/prompts': 7.9.0(@types/node@22.19.8) + '@inquirer/type': 3.0.10(@types/node@22.19.8) + '@types/node': 22.19.8 ansi-escapes: 4.3.2 mute-stream: 2.0.0 run-async: 3.0.0 @@ -20557,9 +20562,9 @@ snapshots: minipass: 3.3.6 yallist: 4.0.0 - mintlify@4.2.331(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3): + mintlify@4.2.331(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.8)(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3): dependencies: - '@mintlify/cli': 4.0.935(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3) + '@mintlify/cli': 4.0.935(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.8)(@types/react@19.2.11)(react-dom@19.2.4(react@19.2.3))(typescript@5.9.3) transitivePeerDependencies: - '@radix-ui/react-popover' - '@types/node' diff --git a/tsconfig.references.json b/tsconfig.references.json index 474f31802..83d8bd29f 100644 --- a/tsconfig.references.json +++ b/tsconfig.references.json @@ -9,6 +9,7 @@ { "path": "packages/layout-engine/painters/dom/tsconfig.json" }, { "path": "packages/layout-engine/style-engine/tsconfig.json" }, { "path": "packages/layout-engine/layout-bridge/tsconfig.types.json" }, + { "path": "packages/document-api/tsconfig.json" }, { "path": "packages/super-editor/tsconfig.types.json" }, { "path": "packages/superdoc/tsconfig.types.json" } ] From 5ae2e83e1912c4ef3d079c52802486c3ff8c350a Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Mon, 16 Feb 2026 19:30:38 -0800 Subject: [PATCH 04/25] feat(super-editor): add mutation document-api adapters and command plumbing --- .../src/core/commands/core-command-map.d.ts | 4 + .../src/core/commands/exitListItemAt.js | 29 + .../src/core/commands/exitListItemAt.test.js | 105 +++ .../super-editor/src/core/commands/index.js | 4 + .../src/core/commands/insertListItemAt.js | 69 ++ .../core/commands/insertListItemAt.test.js | 182 +++++ .../src/core/commands/insertParagraphAt.js | 44 + .../core/commands/insertParagraphAt.test.js | 159 ++++ .../src/core/commands/setListTypeAt.js | 55 ++ .../src/core/commands/setListTypeAt.test.js | 153 ++++ .../comments-adapter.test.ts | 619 ++++++++++++++ .../document-api-adapters/comments-adapter.ts | 757 ++++++++++++++++++ .../create-adapter.test.ts | 362 +++++++++ .../document-api-adapters/create-adapter.ts | 169 ++++ .../format-adapter.test.ts | 245 ++++++ .../document-api-adapters/format-adapter.ts | 67 ++ .../helpers/comment-entity-store.test.ts | 243 ++++++ .../helpers/comment-entity-store.ts | 212 +++++ .../helpers/comment-target-resolver.ts | 83 ++ .../helpers/list-item-resolver.test.ts | 169 ++++ .../helpers/list-item-resolver.ts | 233 ++++++ .../helpers/text-mutation-resolution.test.ts | 82 ++ .../helpers/text-mutation-resolution.ts | 40 + .../helpers/tracked-change-refs.ts | 41 + .../helpers/tracked-change-resolver.test.ts | 230 ++++++ .../helpers/tracked-change-resolver.ts | 177 ++++ .../lists-adapter.test.ts | 353 ++++++++ .../document-api-adapters/lists-adapter.ts | 379 +++++++++ .../track-changes-adapter.test.ts | 354 ++++++++ .../track-changes-adapter.ts | 133 +++ .../write-adapter.test.ts | 565 +++++++++++++ .../document-api-adapters/write-adapter.ts | 216 +++++ .../extensions/comment/comments-helpers.js | 74 +- .../src/extensions/comment/comments-plugin.js | 194 ++++- .../src/extensions/comment/comments.test.js | 10 +- .../src/extensions/types/comment-commands.ts | 46 +- .../types/track-changes-commands.ts | 2 + 37 files changed, 6794 insertions(+), 65 deletions(-) create mode 100644 packages/super-editor/src/core/commands/exitListItemAt.js create mode 100644 packages/super-editor/src/core/commands/exitListItemAt.test.js create mode 100644 packages/super-editor/src/core/commands/insertListItemAt.js create mode 100644 packages/super-editor/src/core/commands/insertListItemAt.test.js create mode 100644 packages/super-editor/src/core/commands/insertParagraphAt.js create mode 100644 packages/super-editor/src/core/commands/insertParagraphAt.test.js create mode 100644 packages/super-editor/src/core/commands/setListTypeAt.js create mode 100644 packages/super-editor/src/core/commands/setListTypeAt.test.js create mode 100644 packages/super-editor/src/document-api-adapters/comments-adapter.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/comments-adapter.ts create mode 100644 packages/super-editor/src/document-api-adapters/create-adapter.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/create-adapter.ts create mode 100644 packages/super-editor/src/document-api-adapters/format-adapter.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/format-adapter.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/comment-entity-store.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/comment-entity-store.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/comment-target-resolver.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/list-item-resolver.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/list-item-resolver.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/text-mutation-resolution.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/text-mutation-resolution.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/tracked-change-refs.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/tracked-change-resolver.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/tracked-change-resolver.ts create mode 100644 packages/super-editor/src/document-api-adapters/lists-adapter.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/lists-adapter.ts create mode 100644 packages/super-editor/src/document-api-adapters/track-changes-adapter.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/track-changes-adapter.ts create mode 100644 packages/super-editor/src/document-api-adapters/write-adapter.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/write-adapter.ts diff --git a/packages/super-editor/src/core/commands/core-command-map.d.ts b/packages/super-editor/src/core/commands/core-command-map.d.ts index 6c7618dec..b71184b19 100644 --- a/packages/super-editor/src/core/commands/core-command-map.d.ts +++ b/packages/super-editor/src/core/commands/core-command-map.d.ts @@ -38,6 +38,7 @@ type CoreCommandNames = | 'selectTextblockEnd' | 'insertContent' | 'insertContentAt' + | 'insertParagraphAt' | 'undoInputRule' | 'setSectionPageMarginsAtSelection' | 'toggleList' @@ -45,6 +46,9 @@ type CoreCommandNames = | 'decreaseListIndent' | 'changeListLevel' | 'removeNumberingProperties' + | 'insertListItemAt' + | 'setListTypeAt' + | 'exitListItemAt' | 'restoreSelection' | 'setTextSelection' | 'getSelectionMarks'; diff --git a/packages/super-editor/src/core/commands/exitListItemAt.js b/packages/super-editor/src/core/commands/exitListItemAt.js new file mode 100644 index 000000000..66a011d57 --- /dev/null +++ b/packages/super-editor/src/core/commands/exitListItemAt.js @@ -0,0 +1,29 @@ +import { updateNumberingProperties } from './changeListLevel.js'; +import { getResolvedParagraphProperties } from '@extensions/paragraph/resolvedPropertiesCache.js'; + +/** + * Remove list numbering from the paragraph at a specific position. + * + * Unlike cursor-driven removeNumbering commands, this operation is explicit + * and ignores caret/empty-line guards. + * + * @param {{ pos: number }} options + * @returns {import('./types/index.js').Command} + */ +export const exitListItemAt = + ({ pos }) => + ({ state, tr, editor, dispatch }) => { + if (!Number.isInteger(pos) || pos < 0 || pos > state.doc.content.size) return false; + + const paragraph = state.doc.nodeAt(pos); + if (!paragraph || paragraph.type.name !== 'paragraph') return false; + + const resolvedProps = getResolvedParagraphProperties(paragraph); + const numberingProperties = + resolvedProps?.numberingProperties ?? paragraph.attrs?.paragraphProperties?.numberingProperties; + if (!numberingProperties) return false; + + updateNumberingProperties(null, paragraph, pos, editor, tr); + if (dispatch) dispatch(tr); + return true; + }; diff --git a/packages/super-editor/src/core/commands/exitListItemAt.test.js b/packages/super-editor/src/core/commands/exitListItemAt.test.js new file mode 100644 index 000000000..712254ebe --- /dev/null +++ b/packages/super-editor/src/core/commands/exitListItemAt.test.js @@ -0,0 +1,105 @@ +// @ts-check +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('./changeListLevel.js', () => ({ + updateNumberingProperties: vi.fn(), +})); + +vi.mock('@extensions/paragraph/resolvedPropertiesCache.js', () => ({ + getResolvedParagraphProperties: vi.fn((node) => node.attrs?.paragraphProperties ?? {}), +})); + +import { exitListItemAt } from './exitListItemAt.js'; +import { updateNumberingProperties } from './changeListLevel.js'; + +function createListParagraph(numId = 1, ilvl = 0) { + return { + type: { name: 'paragraph' }, + attrs: { + paragraphProperties: { + numberingProperties: { numId, ilvl }, + }, + }, + nodeSize: 7, + }; +} + +function createMockState(nodeAtResult = createListParagraph()) { + return { + state: { + doc: { + content: { size: 100 }, + nodeAt: vi.fn(() => nodeAtResult), + }, + }, + tr: {}, + editor: {}, + dispatch: vi.fn(), + }; +} + +describe('exitListItemAt', () => { + it('returns false when pos is negative', () => { + const props = createMockState(); + const result = exitListItemAt({ pos: -1 })(props); + expect(result).toBe(false); + }); + + it('returns false when pos exceeds document size', () => { + const props = createMockState(); + props.state.doc.content.size = 10; + const result = exitListItemAt({ pos: 11 })(props); + expect(result).toBe(false); + }); + + it('returns false when pos is not an integer', () => { + const props = createMockState(); + const result = exitListItemAt({ pos: 2.5 })(props); + expect(result).toBe(false); + }); + + it('returns false when node at pos is null', () => { + const props = createMockState(); + props.state.doc.nodeAt.mockReturnValue(null); + const result = exitListItemAt({ pos: 0 })(props); + expect(result).toBe(false); + }); + + it('returns false when node at pos is not a paragraph', () => { + const props = createMockState({ type: { name: 'table' }, attrs: {} }); + const result = exitListItemAt({ pos: 0 })(props); + expect(result).toBe(false); + }); + + it('returns false when paragraph has no numbering properties', () => { + const plainParagraph = { + type: { name: 'paragraph' }, + attrs: { paragraphProperties: {} }, + }; + const props = createMockState(plainParagraph); + const result = exitListItemAt({ pos: 0 })(props); + expect(result).toBe(false); + }); + + it('calls updateNumberingProperties with null and dispatches on success', () => { + const node = createListParagraph(); + const props = createMockState(node); + + const result = exitListItemAt({ pos: 5 })(props); + + expect(result).toBe(true); + expect(updateNumberingProperties).toHaveBeenCalledWith(null, node, 5, props.editor, props.tr); + expect(props.dispatch).toHaveBeenCalledWith(props.tr); + }); + + it('does not dispatch when dispatch is not provided', () => { + const node = createListParagraph(); + const props = createMockState(node); + props.dispatch = undefined; + + const result = exitListItemAt({ pos: 5 })(props); + + expect(result).toBe(true); + expect(updateNumberingProperties).toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/core/commands/index.js b/packages/super-editor/src/core/commands/index.js index a3b3363fa..8b63ba863 100644 --- a/packages/super-editor/src/core/commands/index.js +++ b/packages/super-editor/src/core/commands/index.js @@ -33,6 +33,7 @@ export * from './selectTextblockStart.js'; export * from './selectTextblockEnd.js'; export * from './insertContent.js'; export * from './insertContentAt.js'; +export * from './insertParagraphAt.js'; export * from './undoInputRule.js'; export * from './setBodyHeaderFooter.js'; export * from './setSectionHeaderFooterAtSelection.js'; @@ -57,6 +58,9 @@ export * from './increaseListIndent.js'; export * from './decreaseListIndent.js'; export * from './changeListLevel.js'; export * from './removeNumberingProperties.js'; +export * from './insertListItemAt.js'; +export * from './setListTypeAt.js'; +export * from './exitListItemAt.js'; // Selection export * from './restoreSelection.js'; diff --git a/packages/super-editor/src/core/commands/insertListItemAt.js b/packages/super-editor/src/core/commands/insertListItemAt.js new file mode 100644 index 000000000..676e5593f --- /dev/null +++ b/packages/super-editor/src/core/commands/insertListItemAt.js @@ -0,0 +1,69 @@ +import { getResolvedParagraphProperties } from '@extensions/paragraph/resolvedPropertiesCache.js'; + +/** + * Insert a list-item paragraph before/after a target list paragraph position. + * + * This command preserves numbering metadata (numId/ilvl) from the target item, + * and always leaves marker rendering to the numbering plugin. + * + * @param {{ pos: number; position: 'before' | 'after'; text?: string; sdBlockId?: string; tracked?: boolean }} options + * @returns {import('./types/index.js').Command} + */ +export const insertListItemAt = + ({ pos, position, text = '', sdBlockId, tracked = false }) => + ({ state, dispatch }) => { + if (!Number.isInteger(pos) || pos < 0 || pos > state.doc.content.size) return false; + if (position !== 'before' && position !== 'after') return false; + + const targetNode = state.doc.nodeAt(pos); + if (!targetNode || targetNode.type.name !== 'paragraph') return false; + + const resolvedProps = getResolvedParagraphProperties(targetNode); + const paragraphProperties = targetNode.attrs?.paragraphProperties ?? {}; + const numberingProperties = resolvedProps?.numberingProperties ?? paragraphProperties?.numberingProperties; + if (!numberingProperties) return false; + + const paragraphType = state.schema.nodes.paragraph; + if (!paragraphType) return false; + + const newParagraphProperties = { + ...paragraphProperties, + numberingProperties: { ...numberingProperties }, + }; + + const attrs = { + ...(targetNode.attrs ?? {}), + ...(sdBlockId ? { sdBlockId } : {}), + paraId: null, + textId: null, + listRendering: null, + paragraphProperties: newParagraphProperties, + numberingProperties: newParagraphProperties.numberingProperties, + }; + + const normalizedText = typeof text === 'string' ? text : ''; + const textNode = normalizedText.length > 0 ? state.schema.text(normalizedText) : undefined; + + let paragraphNode; + try { + paragraphNode = + paragraphType.createAndFill(attrs, textNode) ?? paragraphType.create(attrs, textNode ? [textNode] : undefined); + } catch { + return false; + } + if (!paragraphNode) return false; + + const insertPos = position === 'before' ? pos : pos + targetNode.nodeSize; + if (!Number.isInteger(insertPos) || insertPos < 0 || insertPos > state.doc.content.size) return false; + + if (!dispatch) return true; + + try { + const tr = state.tr.insert(insertPos, paragraphNode).setMeta('inputType', 'programmatic'); + if (tracked) tr.setMeta('forceTrackChanges', true); + dispatch(tr); + return true; + } catch { + return false; + } + }; diff --git a/packages/super-editor/src/core/commands/insertListItemAt.test.js b/packages/super-editor/src/core/commands/insertListItemAt.test.js new file mode 100644 index 000000000..17eb94342 --- /dev/null +++ b/packages/super-editor/src/core/commands/insertListItemAt.test.js @@ -0,0 +1,182 @@ +// @ts-check +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@extensions/paragraph/resolvedPropertiesCache.js', () => ({ + getResolvedParagraphProperties: vi.fn((node) => node.attrs?.paragraphProperties ?? {}), +})); + +import { insertListItemAt } from './insertListItemAt.js'; + +const numberingProperties = { numId: 1, ilvl: 0 }; + +function createListParagraph(text = 'Hello') { + return { + type: { name: 'paragraph' }, + attrs: { + paragraphProperties: { numberingProperties }, + numberingProperties, + }, + nodeSize: text.length + 2, + }; +} + +function createMockState(targetNode = createListParagraph()) { + const paragraphType = { + createAndFill: vi.fn(() => ({ type: { name: 'paragraph' }, nodeSize: 2 })), + create: vi.fn(() => ({ type: { name: 'paragraph' }, nodeSize: 2 })), + }; + + const mockTr = { + insert: vi.fn().mockReturnThis(), + setMeta: vi.fn().mockReturnThis(), + }; + + return { + state: { + doc: { + content: { size: 100 }, + nodeAt: vi.fn((pos) => (pos === 0 ? targetNode : null)), + }, + schema: { + nodes: { paragraph: paragraphType }, + text: vi.fn((text) => ({ type: { name: 'text' }, text, nodeSize: text.length })), + }, + tr: mockTr, + }, + paragraphType, + dispatch: vi.fn(), + }; +} + +describe('insertListItemAt', () => { + it('returns false when pos is negative', () => { + const { state, dispatch } = createMockState(); + const result = insertListItemAt({ pos: -1, position: 'after' })({ state, dispatch }); + expect(result).toBe(false); + }); + + it('returns false when pos is not an integer', () => { + const { state, dispatch } = createMockState(); + const result = insertListItemAt({ pos: 1.5, position: 'after' })({ state, dispatch }); + expect(result).toBe(false); + }); + + it('returns false when position is invalid', () => { + const { state, dispatch } = createMockState(); + // @ts-expect-error - testing invalid input + const result = insertListItemAt({ pos: 0, position: 'middle' })({ state, dispatch }); + expect(result).toBe(false); + }); + + it('returns false when target node is not a paragraph', () => { + const nonParagraph = { type: { name: 'table' }, attrs: {}, nodeSize: 10 }; + const { state, dispatch } = createMockState(); + state.doc.nodeAt.mockReturnValue(nonParagraph); + const result = insertListItemAt({ pos: 0, position: 'after' })({ state, dispatch }); + expect(result).toBe(false); + }); + + it('returns false when target has no numbering properties', () => { + const plainParagraph = { + type: { name: 'paragraph' }, + attrs: { paragraphProperties: {} }, + nodeSize: 7, + }; + const { state, dispatch } = createMockState(); + state.doc.nodeAt.mockReturnValue(plainParagraph); + const result = insertListItemAt({ pos: 0, position: 'after' })({ state, dispatch }); + expect(result).toBe(false); + }); + + it('returns true without dispatching when dispatch is not provided', () => { + const { state } = createMockState(); + const result = insertListItemAt({ pos: 0, position: 'after' })({ state, dispatch: undefined }); + expect(result).toBe(true); + }); + + it('inserts after target when position is after', () => { + const target = createListParagraph('Hello'); + const { state, dispatch } = createMockState(target); + state.doc.nodeAt.mockReturnValue(target); + + const result = insertListItemAt({ pos: 0, position: 'after' })({ state, dispatch }); + + expect(result).toBe(true); + expect(state.tr.insert).toHaveBeenCalledWith( + target.nodeSize, // pos + nodeSize for 'after' + expect.any(Object), + ); + expect(dispatch).toHaveBeenCalled(); + }); + + it('inserts before target when position is before', () => { + const { state, dispatch } = createMockState(); + + const result = insertListItemAt({ pos: 0, position: 'before' })({ state, dispatch }); + + expect(result).toBe(true); + expect(state.tr.insert).toHaveBeenCalledWith(0, expect.any(Object)); + }); + + it('sets forceTrackChanges meta when tracked is true', () => { + const { state, dispatch } = createMockState(); + + insertListItemAt({ pos: 0, position: 'after', tracked: true })({ state, dispatch }); + + expect(state.tr.setMeta).toHaveBeenCalledWith('forceTrackChanges', true); + }); + + it('sets inputType programmatic meta', () => { + const { state, dispatch } = createMockState(); + + insertListItemAt({ pos: 0, position: 'after' })({ state, dispatch }); + + expect(state.tr.setMeta).toHaveBeenCalledWith('inputType', 'programmatic'); + }); + + it('passes sdBlockId into the created node attrs', () => { + const { state, dispatch, paragraphType } = createMockState(); + + insertListItemAt({ pos: 0, position: 'after', sdBlockId: 'custom-id' })({ + state, + dispatch, + }); + + const callArgs = paragraphType.createAndFill.mock.calls[0]; + expect(callArgs?.[0]).toMatchObject({ sdBlockId: 'custom-id' }); + }); + + it('preserves numbering properties from the target node', () => { + const { state, dispatch, paragraphType } = createMockState(); + + insertListItemAt({ pos: 0, position: 'after' })({ state, dispatch }); + + const callArgs = paragraphType.createAndFill.mock.calls[0]; + expect(callArgs?.[0]).toMatchObject({ + paragraphProperties: { + numberingProperties: { numId: 1, ilvl: 0 }, + }, + }); + }); + + it('creates text content when text is provided', () => { + const { state, dispatch } = createMockState(); + + insertListItemAt({ pos: 0, position: 'after', text: 'New item' })({ + state, + dispatch, + }); + + expect(state.schema.text).toHaveBeenCalledWith('New item'); + }); + + it('returns false when createAndFill throws', () => { + const { state, dispatch, paragraphType } = createMockState(); + paragraphType.createAndFill.mockImplementation(() => { + throw new Error('schema error'); + }); + + const result = insertListItemAt({ pos: 0, position: 'after' })({ state, dispatch }); + expect(result).toBe(false); + }); +}); diff --git a/packages/super-editor/src/core/commands/insertParagraphAt.js b/packages/super-editor/src/core/commands/insertParagraphAt.js new file mode 100644 index 000000000..90267b6aa --- /dev/null +++ b/packages/super-editor/src/core/commands/insertParagraphAt.js @@ -0,0 +1,44 @@ +/** + * Insert a paragraph node at an absolute document position. + * + * Supports optional seed text, deterministic block id assignment, and + * operation-scoped tracked-change conversion via transaction meta. + * + * @param {{ pos: number; text?: string; sdBlockId?: string; tracked?: boolean }} options + * @returns {import('./types/index.js').Command} + */ +export const insertParagraphAt = + ({ pos, text = '', sdBlockId, tracked = false }) => + ({ state, dispatch }) => { + const paragraphType = state.schema.nodes.paragraph; + if (!paragraphType) return false; + if (!Number.isInteger(pos) || pos < 0 || pos > state.doc.content.size) return false; + + const attrs = sdBlockId ? { sdBlockId } : undefined; + const normalizedText = typeof text === 'string' ? text : ''; + const textNode = normalizedText.length > 0 ? state.schema.text(normalizedText) : null; + + let paragraphNode; + try { + paragraphNode = + paragraphType.createAndFill(attrs, textNode ?? undefined) ?? + paragraphType.create(attrs, textNode ? [textNode] : undefined); + } catch { + return false; + } + + if (!paragraphNode) return false; + + if (!dispatch) return true; + + try { + const tr = state.tr.insert(pos, paragraphNode).setMeta('inputType', 'programmatic'); + if (tracked) { + tr.setMeta('forceTrackChanges', true); + } + dispatch(tr); + return true; + } catch { + return false; + } + }; diff --git a/packages/super-editor/src/core/commands/insertParagraphAt.test.js b/packages/super-editor/src/core/commands/insertParagraphAt.test.js new file mode 100644 index 000000000..870478807 --- /dev/null +++ b/packages/super-editor/src/core/commands/insertParagraphAt.test.js @@ -0,0 +1,159 @@ +// @ts-check +import { describe, it, expect, vi } from 'vitest'; +import { insertParagraphAt } from './insertParagraphAt.js'; + +/** + * @param {{ size?: number }} [options] + */ +function createMockState(options = {}) { + const { size = 100 } = options; + + const paragraphType = { + createAndFill: vi.fn(), + create: vi.fn(), + }; + + const schema = { + nodes: { paragraph: paragraphType }, + text: vi.fn((text) => ({ type: { name: 'text' }, text, nodeSize: text.length })), + }; + + const mockTr = { + insert: vi.fn().mockReturnThis(), + setMeta: vi.fn().mockReturnThis(), + }; + + return { + state: { + doc: { content: { size } }, + schema, + tr: mockTr, + }, + tr: mockTr, + paragraphType, + dispatch: vi.fn(), + }; +} + +describe('insertParagraphAt', () => { + it('returns false when pos is negative', () => { + const { state, dispatch } = createMockState(); + const result = insertParagraphAt({ pos: -1 })({ state, dispatch }); + expect(result).toBe(false); + }); + + it('returns false when pos exceeds document size', () => { + const { state, dispatch } = createMockState({ size: 10 }); + const result = insertParagraphAt({ pos: 11 })({ state, dispatch }); + expect(result).toBe(false); + }); + + it('returns false when pos is not an integer', () => { + const { state, dispatch } = createMockState(); + const result = insertParagraphAt({ pos: 1.5 })({ state, dispatch }); + expect(result).toBe(false); + }); + + it('returns false when paragraph type is not in schema', () => { + const { state, dispatch } = createMockState(); + state.schema.nodes.paragraph = undefined; + const result = insertParagraphAt({ pos: 0 })({ state, dispatch }); + expect(result).toBe(false); + }); + + it('returns true without dispatching when dispatch is not provided (dry run)', () => { + const { state, paragraphType } = createMockState(); + const mockNode = { type: { name: 'paragraph' } }; + paragraphType.createAndFill.mockReturnValue(mockNode); + + const result = insertParagraphAt({ pos: 0 })({ state, dispatch: undefined }); + expect(result).toBe(true); + }); + + it('inserts a paragraph at the given position', () => { + const { state, dispatch, paragraphType, tr } = createMockState(); + const mockNode = { type: { name: 'paragraph' } }; + paragraphType.createAndFill.mockReturnValue(mockNode); + + const result = insertParagraphAt({ pos: 5 })({ state, dispatch }); + + expect(result).toBe(true); + expect(tr.insert).toHaveBeenCalledWith(5, mockNode); + expect(tr.setMeta).toHaveBeenCalledWith('inputType', 'programmatic'); + expect(dispatch).toHaveBeenCalledWith(tr); + }); + + it('passes sdBlockId as attrs when provided', () => { + const { state, dispatch, paragraphType } = createMockState(); + const mockNode = { type: { name: 'paragraph' } }; + paragraphType.createAndFill.mockReturnValue(mockNode); + + insertParagraphAt({ pos: 0, sdBlockId: 'block-1' })({ state, dispatch }); + + expect(paragraphType.createAndFill).toHaveBeenCalledWith({ sdBlockId: 'block-1' }, undefined); + }); + + it('creates a text node when text is provided', () => { + const { state, dispatch, paragraphType } = createMockState(); + const mockNode = { type: { name: 'paragraph' } }; + paragraphType.createAndFill.mockReturnValue(mockNode); + + insertParagraphAt({ pos: 0, text: 'Hello' })({ state, dispatch }); + + expect(state.schema.text).toHaveBeenCalledWith('Hello'); + }); + + it('sets forceTrackChanges meta when tracked is true', () => { + const { state, dispatch, paragraphType, tr } = createMockState(); + const mockNode = { type: { name: 'paragraph' } }; + paragraphType.createAndFill.mockReturnValue(mockNode); + + insertParagraphAt({ pos: 0, tracked: true })({ state, dispatch }); + + expect(tr.setMeta).toHaveBeenCalledWith('forceTrackChanges', true); + }); + + it('does not set forceTrackChanges when tracked is false', () => { + const { state, dispatch, paragraphType, tr } = createMockState(); + const mockNode = { type: { name: 'paragraph' } }; + paragraphType.createAndFill.mockReturnValue(mockNode); + + insertParagraphAt({ pos: 0, tracked: false })({ state, dispatch }); + + const metaCalls = tr.setMeta.mock.calls.map((call) => call[0]); + expect(metaCalls).not.toContain('forceTrackChanges'); + }); + + it('falls back to paragraphType.create when createAndFill returns null', () => { + const { state, dispatch, paragraphType } = createMockState(); + const mockNode = { type: { name: 'paragraph' } }; + paragraphType.createAndFill.mockReturnValue(null); + paragraphType.create.mockReturnValue(mockNode); + + const result = insertParagraphAt({ pos: 0 })({ state, dispatch }); + expect(result).toBe(true); + expect(paragraphType.create).toHaveBeenCalled(); + }); + + it('returns false when both createAndFill and create throw', () => { + const { state, dispatch, paragraphType } = createMockState(); + paragraphType.createAndFill.mockImplementation(() => { + throw new Error('invalid'); + }); + + const result = insertParagraphAt({ pos: 0 })({ state, dispatch }); + expect(result).toBe(false); + }); + + it('returns false when tr.insert throws', () => { + const { state, dispatch, paragraphType, tr } = createMockState(); + const mockNode = { type: { name: 'paragraph' } }; + paragraphType.createAndFill.mockReturnValue(mockNode); + tr.insert.mockImplementation(() => { + throw new Error('Position out of range'); + }); + + const result = insertParagraphAt({ pos: 0 })({ state, dispatch }); + expect(result).toBe(false); + }); +}); diff --git a/packages/super-editor/src/core/commands/setListTypeAt.js b/packages/super-editor/src/core/commands/setListTypeAt.js new file mode 100644 index 000000000..975fe91f8 --- /dev/null +++ b/packages/super-editor/src/core/commands/setListTypeAt.js @@ -0,0 +1,55 @@ +import { ListHelpers } from '@helpers/list-numbering-helpers.js'; +import { updateNumberingProperties } from './changeListLevel.js'; +import { getResolvedParagraphProperties } from '@extensions/paragraph/resolvedPropertiesCache.js'; + +/** + * Set the list kind for a paragraph-based list item at a specific position. + * + * Uses deterministic semantics: + * - `kind: "ordered"` -> default ordered numbering definition + * - `kind: "bullet"` -> default bullet numbering definition + * + * @param {{ pos: number; kind: 'ordered' | 'bullet' }} options + * @returns {import('./types/index.js').Command} + */ +export const setListTypeAt = + ({ pos, kind }) => + ({ state, tr, editor, dispatch }) => { + if (!Number.isInteger(pos) || pos < 0 || pos > state.doc.content.size) return false; + if (kind !== 'ordered' && kind !== 'bullet') return false; + + const paragraph = state.doc.nodeAt(pos); + if (!paragraph || paragraph.type.name !== 'paragraph') return false; + + const resolvedProps = getResolvedParagraphProperties(paragraph); + const numberingProperties = + resolvedProps?.numberingProperties ?? paragraph.attrs?.paragraphProperties?.numberingProperties; + if (!numberingProperties) return false; + + const level = Number(numberingProperties.ilvl ?? 0) || 0; + + const listType = kind === 'bullet' ? 'bulletList' : 'orderedList'; + const newNumId = Number(ListHelpers.getNewListId(editor)); + if (!Number.isFinite(newNumId)) return false; + + ListHelpers.generateNewListDefinition({ + numId: newNumId, + listType, + editor, + }); + + updateNumberingProperties( + { + ...numberingProperties, + numId: newNumId, + ilvl: level, + }, + paragraph, + pos, + editor, + tr, + ); + + if (dispatch) dispatch(tr); + return true; + }; diff --git a/packages/super-editor/src/core/commands/setListTypeAt.test.js b/packages/super-editor/src/core/commands/setListTypeAt.test.js new file mode 100644 index 000000000..00e531000 --- /dev/null +++ b/packages/super-editor/src/core/commands/setListTypeAt.test.js @@ -0,0 +1,153 @@ +// @ts-check +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@helpers/list-numbering-helpers.js', () => ({ + ListHelpers: { + getNewListId: vi.fn(() => '42'), + generateNewListDefinition: vi.fn(), + }, +})); + +vi.mock('./changeListLevel.js', () => ({ + updateNumberingProperties: vi.fn(), +})); + +vi.mock('@extensions/paragraph/resolvedPropertiesCache.js', () => ({ + getResolvedParagraphProperties: vi.fn((node) => node.attrs?.paragraphProperties ?? {}), +})); + +import { setListTypeAt } from './setListTypeAt.js'; +import { ListHelpers } from '@helpers/list-numbering-helpers.js'; +import { updateNumberingProperties } from './changeListLevel.js'; + +function createListParagraph(numId = 1, ilvl = 0) { + return { + type: { name: 'paragraph' }, + attrs: { + paragraphProperties: { + numberingProperties: { numId, ilvl }, + }, + }, + nodeSize: 7, + }; +} + +function createMockProps(nodeAtResult = createListParagraph()) { + return { + state: { + doc: { + content: { size: 100 }, + nodeAt: vi.fn(() => nodeAtResult), + }, + }, + tr: {}, + editor: {}, + dispatch: vi.fn(), + }; +} + +describe('setListTypeAt', () => { + beforeEach(() => { + vi.clearAllMocks(); + ListHelpers.getNewListId.mockReturnValue('42'); + ListHelpers.generateNewListDefinition.mockReturnValue(undefined); + }); + + it('returns false when pos is negative', () => { + const props = createMockProps(); + const result = setListTypeAt({ pos: -1, kind: 'bullet' })(props); + expect(result).toBe(false); + }); + + it('returns false when pos exceeds document size', () => { + const props = createMockProps(); + props.state.doc.content.size = 10; + const result = setListTypeAt({ pos: 11, kind: 'bullet' })(props); + expect(result).toBe(false); + }); + + it('returns false when kind is invalid', () => { + const props = createMockProps(); + // @ts-expect-error - testing invalid input + const result = setListTypeAt({ pos: 0, kind: 'numbered' })(props); + expect(result).toBe(false); + }); + + it('returns false when node at pos is not a paragraph', () => { + const props = createMockProps({ type: { name: 'table' }, attrs: {} }); + const result = setListTypeAt({ pos: 0, kind: 'bullet' })(props); + expect(result).toBe(false); + }); + + it('returns false when paragraph has no numbering properties', () => { + const plainParagraph = { + type: { name: 'paragraph' }, + attrs: { paragraphProperties: {} }, + }; + const props = createMockProps(plainParagraph); + const result = setListTypeAt({ pos: 0, kind: 'bullet' })(props); + expect(result).toBe(false); + }); + + it('generates a bulletList definition for bullet kind', () => { + const props = createMockProps(); + + setListTypeAt({ pos: 0, kind: 'bullet' })(props); + + expect(ListHelpers.generateNewListDefinition).toHaveBeenCalledWith( + expect.objectContaining({ listType: 'bulletList' }), + ); + }); + + it('generates an orderedList definition for ordered kind', () => { + const props = createMockProps(); + + setListTypeAt({ pos: 0, kind: 'ordered' })(props); + + expect(ListHelpers.generateNewListDefinition).toHaveBeenCalledWith( + expect.objectContaining({ listType: 'orderedList' }), + ); + }); + + it('calls updateNumberingProperties with the new numId and existing level', () => { + const node = createListParagraph(1, 2); + const props = createMockProps(node); + + setListTypeAt({ pos: 5, kind: 'bullet' })(props); + + expect(updateNumberingProperties).toHaveBeenCalledWith( + expect.objectContaining({ numId: 42, ilvl: 2 }), + node, + 5, + props.editor, + props.tr, + ); + }); + + it('dispatches the transaction on success', () => { + const props = createMockProps(); + + const result = setListTypeAt({ pos: 0, kind: 'bullet' })(props); + + expect(result).toBe(true); + expect(props.dispatch).toHaveBeenCalledWith(props.tr); + }); + + it('returns false when getNewListId returns a non-finite number', () => { + ListHelpers.getNewListId.mockReturnValue('NaN'); + const props = createMockProps(); + + const result = setListTypeAt({ pos: 0, kind: 'bullet' })(props); + expect(result).toBe(false); + }); + + it('does not dispatch when dispatch is not provided', () => { + const props = createMockProps(); + props.dispatch = undefined; + + const result = setListTypeAt({ pos: 0, kind: 'bullet' })(props); + + expect(result).toBe(true); + expect(updateNumberingProperties).toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/comments-adapter.test.ts b/packages/super-editor/src/document-api-adapters/comments-adapter.test.ts new file mode 100644 index 000000000..ccf670a1b --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/comments-adapter.test.ts @@ -0,0 +1,619 @@ +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import { Schema } from 'prosemirror-model'; +import { EditorState } from 'prosemirror-state'; +import type { Editor } from '../core/Editor.js'; +import { CommentMarkName } from '../extensions/comment/comments-constants.js'; +import { createCommentsAdapter } from './comments-adapter.js'; + +type NodeOptions = { + attrs?: Record; + text?: string; + isInline?: boolean; + isBlock?: boolean; + isLeaf?: boolean; + inlineContent?: boolean; + nodeSize?: number; +}; + +function createNode(typeName: string, children: ProseMirrorNode[] = [], options: NodeOptions = {}): ProseMirrorNode { + const attrs = options.attrs ?? {}; + const text = options.text ?? ''; + const isText = typeName === 'text'; + const isInline = options.isInline ?? isText; + const isBlock = options.isBlock ?? (!isInline && typeName !== 'doc'); + const inlineContent = options.inlineContent ?? isBlock; + const isLeaf = options.isLeaf ?? (isInline && !isText && children.length === 0); + + const contentSize = children.reduce((sum, child) => sum + child.nodeSize, 0); + const nodeSize = isText ? text.length : options.nodeSize != null ? options.nodeSize : isLeaf ? 1 : contentSize + 2; + + return { + type: { name: typeName }, + attrs, + text: isText ? text : undefined, + nodeSize, + isText, + isInline, + isBlock, + inlineContent, + isTextblock: inlineContent, + isLeaf, + childCount: children.length, + child(index: number) { + return children[index]!; + }, + descendants(callback: (node: ProseMirrorNode, pos: number) => void) { + let offset = 0; + for (const child of children) { + callback(child, offset); + offset += child.nodeSize; + } + }, + } as unknown as ProseMirrorNode; +} + +function makeEditor(docNode: ProseMirrorNode, commands: Record): Editor { + return { + state: { doc: docNode }, + commands, + } as unknown as Editor; +} + +describe('addCommentAdapter', () => { + it('adds a comment when commands and range are valid', () => { + const textNode = createNode('text', [], { text: 'Hello' }); + const paragraph = createNode('paragraph', [textNode], { + attrs: { sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + + const setTextSelection = vi.fn(() => true); + const commands: Record = { setTextSelection }; + const editor = makeEditor(doc, commands); + const addComment = vi.fn(() => { + (editor as unknown as { converter?: { comments?: Array> } }).converter = { + comments: [ + { + commentId: 'new-comment-id', + commentText: 'Review this', + createdTime: Date.now(), + }, + ], + }; + return true; + }); + commands.addComment = addComment; + + const receipt = createCommentsAdapter(editor).add({ + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'Review this', + }); + + expect(setTextSelection).toHaveBeenCalledWith({ from: 1, to: 6 }); + expect(addComment).toHaveBeenCalledWith(expect.objectContaining({ content: 'Review this', isInternal: false })); + const passedCommentId = addComment.mock.calls[0]?.[0]?.commentId; + expect(typeof passedCommentId).toBe('string'); + expect(receipt.success).toBe(true); + expect(receipt.inserted?.[0]?.entityType).toBe('comment'); + expect(receipt.inserted?.[0]?.entityId).toBe(passedCommentId); + }); + + it('reads addComment from a fresh command snapshot after applying selection', () => { + const textNode = createNode('text', [], { text: 'Hello' }); + const paragraph = createNode('paragraph', [textNode], { + attrs: { sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + + const editor = { + state: { doc }, + converter: { + comments: [] as Array>, + }, + options: { + documentId: 'doc-1', + user: { + name: 'Test User', + email: 'test.user@example.com', + }, + }, + } as unknown as Editor & { + converter: { + comments: Array>; + }; + }; + + let activeSelection = { from: 0, to: 0 }; + const setTextSelection = vi.fn(({ from, to }: { from: number; to: number }) => { + activeSelection = { from, to }; + return true; + }); + const addCommentWithSnapshot = vi.fn( + ( + selectionSnapshot: { from: number; to: number }, + options: { content: string; isInternal: boolean; commentId?: string }, + ) => { + if (selectionSnapshot.from === selectionSnapshot.to) return false; + + editor.converter.comments.push({ + commentId: options.commentId ?? 'fresh-command-id', + commentText: options.content, + createdTime: Date.now(), + }); + return true; + }, + ); + + Object.defineProperty(editor, 'commands', { + configurable: true, + get() { + const selectionSnapshot = { ...activeSelection }; + return { + setTextSelection, + addComment: (options: { content: string; isInternal: boolean; commentId?: string }) => + addCommentWithSnapshot(selectionSnapshot, options), + }; + }, + }); + + const receipt = createCommentsAdapter(editor).add({ + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'Review this', + }); + + expect(setTextSelection).toHaveBeenCalledWith({ from: 1, to: 6 }); + expect(addCommentWithSnapshot).toHaveBeenCalledWith( + { from: 1, to: 6 }, + expect.objectContaining({ content: 'Review this', isInternal: false }), + ); + const passedId = addCommentWithSnapshot.mock.calls[0]?.[1]?.commentId; + expect(typeof passedId).toBe('string'); + expect(receipt.success).toBe(true); + expect(receipt.inserted?.[0]).toMatchObject({ entityType: 'comment', entityId: passedId }); + }); + + it('returns false when commands are missing', () => { + const doc = createNode('doc', [], { isBlock: false }); + const editor = makeEditor(doc, {}); + + expect(() => + createCommentsAdapter(editor).add({ + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 1 } }, + text: 'No commands', + }), + ).toThrow('Comment commands are not available on this editor instance.'); + }); + + it('returns false when blockId is not found', () => { + const textNode = createNode('text', [], { text: 'Hi' }); + const paragraph = createNode('paragraph', [textNode], { + attrs: { sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + + const setTextSelection = vi.fn(() => true); + const addComment = vi.fn(() => true); + const editor = makeEditor(doc, { setTextSelection, addComment }); + + expect(() => + createCommentsAdapter(editor).add({ + target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 1 } }, + text: 'Missing', + }), + ).toThrow('Comment target could not be resolved.'); + }); + + it('returns false for empty ranges', () => { + const textNode = createNode('text', [], { text: 'Hi' }); + const paragraph = createNode('paragraph', [textNode], { + attrs: { sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + + const setTextSelection = vi.fn(() => true); + const addComment = vi.fn(() => true); + const editor = makeEditor(doc, { setTextSelection, addComment }); + + const receipt = createCommentsAdapter(editor).add({ + target: { kind: 'text', blockId: 'p1', range: { start: 1, end: 1 } }, + text: 'Empty', + }); + + expect(receipt.success).toBe(false); + expect(receipt.failure).toMatchObject({ + code: 'INVALID_TARGET', + }); + }); + + it('returns false for out-of-range offsets', () => { + const textNode = createNode('text', [], { text: 'Hi' }); + const paragraph = createNode('paragraph', [textNode], { + attrs: { sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + + const setTextSelection = vi.fn(() => true); + const addComment = vi.fn(() => true); + const editor = makeEditor(doc, { setTextSelection, addComment }); + + expect(() => + createCommentsAdapter(editor).add({ + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'Out of range', + }), + ).toThrow('Comment target could not be resolved.'); + }); + + it('returns INVALID_TARGET when text selection cannot be applied', () => { + const textNode = createNode('text', [], { text: 'Hi' }); + const paragraph = createNode('paragraph', [textNode], { + attrs: { sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + + const setTextSelection = vi.fn(() => false); + const addComment = vi.fn(() => true); + const editor = makeEditor(doc, { setTextSelection, addComment }); + + const receipt = createCommentsAdapter(editor).add({ + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } }, + text: 'Selection failure', + }); + + expect(receipt.success).toBe(false); + expect(receipt.failure).toMatchObject({ + code: 'INVALID_TARGET', + }); + }); + + it('returns NO_OP when addComment does not apply a comment', () => { + const textNode = createNode('text', [], { text: 'Hi' }); + const paragraph = createNode('paragraph', [textNode], { + attrs: { sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + + const setTextSelection = vi.fn(() => true); + const addComment = vi.fn(() => false); + const editor = makeEditor(doc, { setTextSelection, addComment }); + + const receipt = createCommentsAdapter(editor).add({ + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } }, + text: 'Insert failure', + }); + + expect(receipt.success).toBe(false); + expect(receipt.failure).toMatchObject({ + code: 'NO_OP', + }); + }); +}); + +function createCommentSchema(): Schema { + return new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { + attrs: { paraId: { default: null }, sdBlockId: { default: null } }, + content: 'inline*', + group: 'block', + toDOM: () => ['p', 0], + parseDOM: [{ tag: 'p' }], + }, + text: { group: 'inline' }, + commentRangeStart: { + inline: true, + group: 'inline', + atom: true, + attrs: { 'w:id': {} }, + toDOM: () => ['commentRangeStart'], + parseDOM: [{ tag: 'commentRangeStart' }], + }, + commentRangeEnd: { + inline: true, + group: 'inline', + atom: true, + attrs: { 'w:id': {} }, + toDOM: () => ['commentRangeEnd'], + parseDOM: [{ tag: 'commentRangeEnd' }], + }, + }, + marks: { + [CommentMarkName]: { + attrs: { commentId: {}, importedId: { default: null }, internal: { default: false } }, + inclusive: false, + toDOM: () => [CommentMarkName], + parseDOM: [{ tag: CommentMarkName }], + }, + }, + }); +} + +function createPmEditor( + doc: ProseMirrorNode, + commands: Record = {}, + comments: Array> = [], +): Editor { + const state = EditorState.create({ + schema: doc.type.schema, + doc, + }); + + return { + state, + commands, + converter: { + comments, + }, + options: { + documentId: 'doc-1', + user: { + name: 'Test User', + email: 'test.user@example.com', + }, + }, + } as unknown as Editor; +} + +describe('commentsAdapter additional operations', () => { + it('edits a comment text and returns updated receipt', () => { + const schema = createCommentSchema(); + const mark = schema.marks[CommentMarkName].create({ commentId: 'c1', internal: false }); + const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello', [mark])]); + const doc = schema.node('doc', null, [paragraph]); + const editComment = vi.fn(() => true); + const editor = createPmEditor(doc, { editComment }, [{ commentId: 'c1', commentText: 'Before' }]); + + const receipt = createCommentsAdapter(editor).edit({ commentId: 'c1', text: 'After' }); + + expect(editComment).toHaveBeenCalledWith({ commentId: 'c1', importedId: undefined, content: 'After' }); + expect(receipt.success).toBe(true); + expect(receipt.updated?.[0]).toMatchObject({ entityType: 'comment', entityId: 'c1' }); + expect( + (editor as unknown as { converter: { comments: Array<{ commentText?: string }> } }).converter.comments[0] + ?.commentText, + ).toBe('After'); + }); + + it('replies to a comment and returns inserted receipt', () => { + const schema = createCommentSchema(); + const parentMark = schema.marks[CommentMarkName].create({ commentId: 'c1', internal: false }); + const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello', [parentMark])]); + const doc = schema.node('doc', null, [paragraph]); + const addCommentReply = vi.fn(() => true); + const editor = createPmEditor(doc, { addCommentReply }, [{ commentId: 'c1', commentText: 'Root comment' }]); + + const receipt = createCommentsAdapter(editor).reply({ parentCommentId: 'c1', text: 'Reply body' }); + + expect(addCommentReply).toHaveBeenCalledWith( + expect.objectContaining({ + parentId: 'c1', + content: 'Reply body', + }), + ); + expect(receipt.success).toBe(true); + expect(receipt.inserted?.[0]).toMatchObject({ entityType: 'comment' }); + }); + + it('throws TARGET_NOT_FOUND when replying to a missing parent comment', () => { + const schema = createCommentSchema(); + const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello')]); + const doc = schema.node('doc', null, [paragraph]); + const addCommentReply = vi.fn(() => true); + const editor = createPmEditor(doc, { addCommentReply }, []); + + expect(() => + createCommentsAdapter(editor).reply({ + parentCommentId: 'missing-parent', + text: 'Reply body', + }), + ).toThrow('Comment target could not be resolved.'); + expect(addCommentReply).not.toHaveBeenCalled(); + }); + + it('moves a comment to a new target range', () => { + const schema = createCommentSchema(); + const mark = schema.marks[CommentMarkName].create({ commentId: 'c1', internal: false }); + const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello', [mark])]); + const doc = schema.node('doc', null, [paragraph]); + const moveComment = vi.fn(() => true); + const editor = createPmEditor(doc, { moveComment }, [{ commentId: 'c1', commentText: 'Move me' }]); + + const receipt = createCommentsAdapter(editor).move({ + commentId: 'c1', + target: { kind: 'text', blockId: 'p1', range: { start: 1, end: 4 } }, + }); + + expect(moveComment).toHaveBeenCalledWith({ commentId: 'c1', from: 2, to: 5 }); + expect(receipt.success).toBe(true); + expect(receipt.updated?.[0]).toMatchObject({ entityType: 'comment', entityId: 'c1' }); + }); + + it('returns NO_OP when move command resolves but does not apply changes', () => { + const schema = createCommentSchema(); + const mark = schema.marks[CommentMarkName].create({ commentId: 'c1', internal: false }); + const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello', [mark])]); + const doc = schema.node('doc', null, [paragraph]); + const moveComment = vi.fn(() => false); + const editor = createPmEditor(doc, { moveComment }, [{ commentId: 'c1', commentText: 'Move me' }]); + + const receipt = createCommentsAdapter(editor).move({ + commentId: 'c1', + target: { kind: 'text', blockId: 'p1', range: { start: 1, end: 4 } }, + }); + + expect(moveComment).toHaveBeenCalledWith({ commentId: 'c1', from: 2, to: 5 }); + expect(receipt.success).toBe(false); + expect(receipt.failure).toMatchObject({ code: 'NO_OP' }); + }); + + it('resolves and removes comments, including replies', () => { + const schema = createCommentSchema(); + const mark = schema.marks[CommentMarkName].create({ commentId: 'c1', internal: false }); + const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello', [mark])]); + const doc = schema.node('doc', null, [paragraph]); + const resolveComment = vi.fn(() => true); + const removeComment = vi.fn(() => true); + const editor = createPmEditor(doc, { resolveComment, removeComment }, [ + { commentId: 'c1', commentText: 'Root', isDone: false }, + { commentId: 'c2', parentCommentId: 'c1', commentText: 'Child' }, + ]); + + const api = createCommentsAdapter(editor); + const resolveReceipt = api.resolve({ commentId: 'c1' }); + const removeReceipt = api.remove({ commentId: 'c1' }); + + expect(resolveComment).toHaveBeenCalledWith({ commentId: 'c1', importedId: undefined }); + expect(resolveReceipt.success).toBe(true); + expect(resolveReceipt.updated?.[0]).toMatchObject({ entityId: 'c1' }); + + expect(removeComment).toHaveBeenCalledWith({ commentId: 'c1', importedId: undefined }); + expect(removeReceipt.success).toBe(true); + const removedIds = (removeReceipt.removed ?? []).map((entry) => entry.entityId).sort(); + expect(removedIds).toEqual(['c1', 'c2']); + }); + + it('returns NO_OP when resolve command resolves but does not apply changes', () => { + const schema = createCommentSchema(); + const mark = schema.marks[CommentMarkName].create({ commentId: 'c1', internal: false }); + const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello', [mark])]); + const doc = schema.node('doc', null, [paragraph]); + const resolveComment = vi.fn(() => false); + const editor = createPmEditor(doc, { resolveComment }, [{ commentId: 'c1', commentText: 'Root', isDone: false }]); + + const receipt = createCommentsAdapter(editor).resolve({ commentId: 'c1' }); + + expect(resolveComment).toHaveBeenCalledWith({ commentId: 'c1', importedId: undefined }); + expect(receipt.success).toBe(false); + expect(receipt.failure).toMatchObject({ code: 'NO_OP' }); + }); + + it('returns NO_OP when remove command does not apply and no records are removed', () => { + const schema = createCommentSchema(); + const mark = schema.marks[CommentMarkName].create({ commentId: 'c1', internal: false }); + const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello', [mark])]); + const doc = schema.node('doc', null, [paragraph]); + const removeComment = vi.fn(() => false); + const editor = createPmEditor(doc, { removeComment }, []); + + const receipt = createCommentsAdapter(editor).remove({ commentId: 'c1' }); + + expect(removeComment).toHaveBeenCalledWith({ commentId: 'c1', importedId: undefined }); + expect(receipt.success).toBe(false); + expect(receipt.failure).toMatchObject({ code: 'NO_OP' }); + }); + + it('removes anchorless reply records even when remove command is not applied', () => { + const schema = createCommentSchema(); + const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello')]); + const doc = schema.node('doc', null, [paragraph]); + const removeComment = vi.fn(() => false); + const editor = createPmEditor(doc, { removeComment }, [ + { commentId: 'reply-1', parentCommentId: 'c1', commentText: 'Reply' }, + ]); + + const receipt = createCommentsAdapter(editor).remove({ commentId: 'reply-1' }); + + expect(removeComment).toHaveBeenCalledWith({ commentId: 'reply-1', importedId: undefined }); + expect(receipt.success).toBe(true); + expect((receipt.removed ?? []).map((entry) => entry.entityId)).toEqual(['reply-1']); + }); + + it('updates internal metadata for anchorless comments via entity store mutation', () => { + const schema = createCommentSchema(); + const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello')]); + const doc = schema.node('doc', null, [paragraph]); + const setCommentInternal = vi.fn(() => false); + const editor = createPmEditor(doc, { setCommentInternal }, [ + { commentId: 'c1', commentText: 'Root', isInternal: false }, + ]); + + const receipt = createCommentsAdapter(editor).setInternal({ commentId: 'c1', isInternal: true }); + + expect(setCommentInternal).not.toHaveBeenCalled(); + expect(receipt.success).toBe(true); + const updated = ( + editor as unknown as { converter: { comments: Array<{ commentId: string; isInternal?: boolean }> } } + ).converter.comments.find((comment) => comment.commentId === 'c1'); + expect(updated?.isInternal).toBe(true); + }); + + it('sets internal, active, and cursor target comment operations', () => { + const schema = createCommentSchema(); + const mark = schema.marks[CommentMarkName].create({ commentId: 'c1', internal: false }); + const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello', [mark])]); + const doc = schema.node('doc', null, [paragraph]); + const setCommentInternal = vi.fn(() => true); + const setActiveComment = vi.fn(() => true); + const setCursorById = vi.fn(() => true); + const editor = createPmEditor( + doc, + { + setCommentInternal, + setActiveComment, + setCursorById, + }, + [{ commentId: 'c1', commentText: 'Root', isInternal: false }], + ); + + const api = createCommentsAdapter(editor); + + const internalReceipt = api.setInternal({ commentId: 'c1', isInternal: true }); + const activeReceipt = api.setActive({ commentId: 'c1' }); + const clearActiveReceipt = api.setActive({ commentId: null }); + const goToReceipt = api.goTo({ commentId: 'c1' }); + + expect(setCommentInternal).toHaveBeenCalledWith({ commentId: 'c1', importedId: undefined, isInternal: true }); + expect(internalReceipt.success).toBe(true); + expect(activeReceipt.success).toBe(true); + expect(clearActiveReceipt.success).toBe(true); + expect(goToReceipt.success).toBe(true); + expect(setActiveComment).toHaveBeenNthCalledWith(1, { commentId: 'c1' }); + expect(setActiveComment).toHaveBeenNthCalledWith(2, { commentId: null }); + expect(setCursorById).toHaveBeenCalledWith('c1'); + }); + + it('gets and lists comments across open and resolved anchors', () => { + const schema = createCommentSchema(); + const openMark = schema.marks[CommentMarkName].create({ commentId: 'c1', internal: true }); + const openParagraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Open comment', [openMark])]); + const resolvedParagraph = schema.node('paragraph', { paraId: 'p2' }, [ + schema.nodes.commentRangeStart.create({ 'w:id': 'c2' }), + schema.text('Resolved comment'), + schema.nodes.commentRangeEnd.create({ 'w:id': 'c2' }), + ]); + const doc = schema.node('doc', null, [openParagraph, resolvedParagraph]); + + const editor = createPmEditor(doc, {}, [ + { commentId: 'c1', commentText: 'Open body', isDone: false, isInternal: true }, + { commentId: 'c2', commentText: 'Resolved body', isDone: true }, + ]); + const api = createCommentsAdapter(editor); + + const open = api.get({ commentId: 'c1' }); + const resolved = api.get({ commentId: 'c2' }); + const openOnly = api.list({ includeResolved: false }); + const all = api.list(); + + expect(open.status).toBe('open'); + expect(open.commentId).toBe('c1'); + expect(resolved.status).toBe('resolved'); + expect(resolved.commentId).toBe('c2'); + expect(openOnly.matches.map((comment) => comment.commentId)).toEqual(['c1']); + expect(all.total).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/comments-adapter.ts b/packages/super-editor/src/document-api-adapters/comments-adapter.ts new file mode 100644 index 000000000..403acb239 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/comments-adapter.ts @@ -0,0 +1,757 @@ +import type { Editor } from '../core/Editor.js'; +import type { + AddCommentInput, + CommentInfo, + CommentsAdapter, + CommentsListQuery, + CommentsListResult, + EditCommentInput, + GetCommentInput, + GoToCommentInput, + MoveCommentInput, + Receipt, + RemoveCommentInput, + ReplyToCommentInput, + ResolveCommentInput, + SetCommentActiveInput, + SetCommentInternalInput, +} from '@superdoc/document-api'; +import { TextSelection } from 'prosemirror-state'; +import { v4 as uuidv4 } from 'uuid'; +import { DocumentApiAdapterError } from './errors.js'; +import { clearIndexCache } from './helpers/index-cache.js'; +import { resolveTextTarget } from './helpers/adapter-utils.js'; +import { + buildCommentJsonFromText, + extractCommentText, + findCommentEntity, + getCommentEntityStore, + isCommentResolved, + removeCommentEntityTree, + toCommentInfo, + upsertCommentEntity, +} from './helpers/comment-entity-store.js'; +import { listCommentAnchors, resolveCommentAnchorsById } from './helpers/comment-target-resolver.js'; +import { toNonEmptyString } from './helpers/value-utils.js'; + +type EditorUserIdentity = { + name?: string; + email?: string; + image?: string; +}; + +function toCommentAddress(commentId: string): { kind: 'entity'; entityType: 'comment'; entityId: string } { + return { + kind: 'entity', + entityType: 'comment', + entityId: commentId, + }; +} + +function toNotFoundError(input: unknown): DocumentApiAdapterError { + return new DocumentApiAdapterError('TARGET_NOT_FOUND', 'Comment target could not be resolved.', { + target: input, + }); +} + +function isSameTarget( + left: { blockId: string; range: { start: number; end: number } }, + right: { blockId: string; range: { start: number; end: number } }, +): boolean { + return left.blockId === right.blockId && left.range.start === right.range.start && left.range.end === right.range.end; +} + +function listCommentAnchorsSafe(editor: Editor): ReturnType { + try { + return listCommentAnchors(editor); + } catch { + return []; + } +} + +function applyTextSelection(editor: Editor, from: number, to: number): boolean { + const setTextSelection = editor.commands?.setTextSelection; + if (typeof setTextSelection === 'function') { + if (setTextSelection({ from, to }) === true) return true; + } + + if (editor.state?.tr && typeof editor.dispatch === 'function') { + try { + const tr = editor.state.tr + .setSelection(TextSelection.create(editor.state.doc, from, to)) + .setMeta('inputType', 'programmatic'); + editor.dispatch(tr); + return true; + } catch { + return false; + } + } + + return false; +} + +function resolveCommentIdentity( + editor: Editor, + commentId: string, +): { + commentId: string; + importedId?: string; + anchors: ReturnType; +} { + const store = getCommentEntityStore(editor); + const record = findCommentEntity(store, commentId); + const canonicalCommentIdFromRecord = toNonEmptyString(record?.commentId); + const importedIdFromRecord = toNonEmptyString(record?.importedId); + + const anchorCandidates = [ + ...resolveCommentAnchorsById(editor, commentId), + ...(canonicalCommentIdFromRecord && canonicalCommentIdFromRecord !== commentId + ? resolveCommentAnchorsById(editor, canonicalCommentIdFromRecord) + : []), + ...(importedIdFromRecord && + importedIdFromRecord !== commentId && + importedIdFromRecord !== canonicalCommentIdFromRecord + ? resolveCommentAnchorsById(editor, importedIdFromRecord) + : []), + ]; + + const seen = new Set(); + const anchors = anchorCandidates.filter((anchor) => { + const key = `${anchor.commentId}|${anchor.importedId ?? ''}|${anchor.pos}|${anchor.end}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + const canonicalCommentId = canonicalCommentIdFromRecord ?? anchors[0]?.commentId; + + if (!canonicalCommentId) { + throw toNotFoundError({ commentId }); + } + + const importedId = importedIdFromRecord ?? anchors[0]?.importedId; + + return { + commentId: canonicalCommentId, + importedId, + anchors, + }; +} + +function buildCommentInfos(editor: Editor): CommentInfo[] { + const store = getCommentEntityStore(editor); + const infosById = new Map(); + + for (const entry of store) { + const commentId = toNonEmptyString(entry.commentId) ?? toNonEmptyString(entry.importedId) ?? null; + if (!commentId) continue; + infosById.set(commentId, toCommentInfo({ ...entry, commentId })); + } + + const anchors = listCommentAnchorsSafe(editor); + const grouped = new Map(); + for (const anchor of anchors) { + const group = grouped.get(anchor.commentId) ?? []; + group.push(anchor); + grouped.set(anchor.commentId, group); + } + + for (const [commentId, commentAnchors] of grouped.entries()) { + const sorted = [...commentAnchors].sort((a, b) => (a.pos === b.pos ? a.end - b.end : a.pos - b.pos)); + const primary = sorted[0]; + const status = sorted.every((anchor) => anchor.status === 'resolved') ? 'resolved' : 'open'; + const existing = infosById.get(commentId); + + if (existing) { + if (!existing.target) existing.target = primary.target; + if (!existing.importedId && primary.importedId) existing.importedId = primary.importedId; + if (existing.isInternal == null && primary.isInternal != null) existing.isInternal = primary.isInternal; + if (status === 'open') existing.status = 'open'; + continue; + } + + infosById.set( + commentId, + toCommentInfo( + { + commentId, + importedId: primary.importedId, + isInternal: primary.isInternal, + isDone: status === 'resolved', + }, + { + target: primary.target, + status, + }, + ), + ); + } + + const infos = Array.from(infosById.values()); + infos.sort((left, right) => { + const leftCreated = left.createdTime ?? 0; + const rightCreated = right.createdTime ?? 0; + if (leftCreated !== rightCreated) return leftCreated - rightCreated; + + const leftStart = left.target?.range.start ?? Number.MAX_SAFE_INTEGER; + const rightStart = right.target?.range.start ?? Number.MAX_SAFE_INTEGER; + if (leftStart !== rightStart) return leftStart - rightStart; + + return left.commentId.localeCompare(right.commentId); + }); + + return infos; +} + +/** + * Adds a comment to the document at the specified text range. + * + * @param editor - The editor instance. + * @param input - The comment target and text. + * @returns A receipt indicating success and the created entity address. + */ +function addCommentHandler(editor: Editor, input: AddCommentInput): Receipt { + if (typeof editor.commands?.addComment !== 'function') { + throw new DocumentApiAdapterError( + 'COMMAND_UNAVAILABLE', + 'Comment commands are not available on this editor instance.', + ); + } + + if (input.target.range.start === input.target.range.end) { + return { + success: false, + failure: { + code: 'INVALID_TARGET', + message: 'Comment target range must be non-collapsed.', + }, + }; + } + + const resolved = resolveTextTarget(editor, input.target); + if (!resolved) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'Comment target could not be resolved.', { + target: input.target, + }); + } + if (resolved.from === resolved.to) { + return { + success: false, + failure: { + code: 'INVALID_TARGET', + message: 'Comment target range must be non-collapsed.', + }, + }; + } + + const commentId = uuidv4(); + + if (!applyTextSelection(editor, resolved.from, resolved.to)) { + return { + success: false, + failure: { + code: 'INVALID_TARGET', + message: 'Comment target selection could not be applied.', + details: { target: input.target }, + }, + }; + } + + // Re-read after selection so the command closure captures the updated selection snapshot. + const addComment = editor.commands?.addComment; + if (typeof addComment !== 'function') { + throw new DocumentApiAdapterError( + 'COMMAND_UNAVAILABLE', + 'Comment commands are not available on this editor instance.', + ); + } + + const didInsert = + addComment({ + content: input.text, + isInternal: false, + commentId, + }) === true; + + if (!didInsert) { + return { + success: false, + failure: { + code: 'NO_OP', + message: 'Comment insertion produced no change.', + }, + }; + } + + clearIndexCache(editor); + + const store = getCommentEntityStore(editor); + const now = Date.now(); + const user = (editor.options?.user ?? {}) as EditorUserIdentity; + upsertCommentEntity(store, commentId, { + commentId, + commentText: input.text, + commentJSON: buildCommentJsonFromText(input.text), + parentCommentId: undefined, + createdTime: now, + creatorName: user.name, + creatorEmail: user.email, + creatorImage: user.image, + isDone: false, + isInternal: false, + fileId: editor.options?.documentId, + documentId: editor.options?.documentId, + }); + + return { + success: true, + inserted: [toCommentAddress(commentId)], + }; +} + +function editCommentHandler(editor: Editor, input: EditCommentInput): Receipt { + const editComment = editor.commands?.editComment; + if (!editComment) { + throw new DocumentApiAdapterError( + 'COMMAND_UNAVAILABLE', + 'Edit comment command is not available on this editor instance.', + ); + } + + const store = getCommentEntityStore(editor); + const identity = resolveCommentIdentity(editor, input.commentId); + const existing = findCommentEntity(store, identity.commentId); + const existingText = existing ? extractCommentText(existing) : undefined; + if (existingText === input.text) { + return { + success: false, + failure: { + code: 'NO_OP', + message: 'Comment edit produced no change.', + }, + }; + } + + const didEdit = editComment({ + commentId: identity.commentId, + importedId: identity.importedId, + content: input.text, + }); + if (!didEdit) { + return { + success: false, + failure: { + code: 'NO_OP', + message: 'Comment edit produced no change.', + }, + }; + } + + upsertCommentEntity(store, identity.commentId, { + commentText: input.text, + commentJSON: buildCommentJsonFromText(input.text), + importedId: identity.importedId, + }); + + return { + success: true, + updated: [toCommentAddress(identity.commentId)], + }; +} + +function replyToCommentHandler(editor: Editor, input: ReplyToCommentInput): Receipt { + const addCommentReply = editor.commands?.addCommentReply; + if (!addCommentReply) { + throw new DocumentApiAdapterError( + 'COMMAND_UNAVAILABLE', + 'Reply-to-comment command is not available on this editor instance.', + ); + } + + if (!input.parentCommentId) { + return { + success: false, + failure: { + code: 'INVALID_TARGET', + message: 'Reply target requires a non-empty parent comment id.', + }, + }; + } + + const parentIdentity = resolveCommentIdentity(editor, input.parentCommentId); + const replyId = uuidv4(); + const didReply = addCommentReply({ + parentId: parentIdentity.commentId, + content: input.text, + commentId: replyId, + }); + if (!didReply) { + return { + success: false, + failure: { + code: 'INVALID_TARGET', + message: 'Comment reply could not be applied.', + }, + }; + } + + const now = Date.now(); + const user = (editor.options?.user ?? {}) as EditorUserIdentity; + const store = getCommentEntityStore(editor); + upsertCommentEntity(store, replyId, { + commentId: replyId, + parentCommentId: parentIdentity.commentId, + commentText: input.text, + commentJSON: buildCommentJsonFromText(input.text), + createdTime: now, + creatorName: user.name, + creatorEmail: user.email, + creatorImage: user.image, + isDone: false, + isInternal: false, + fileId: editor.options?.documentId, + documentId: editor.options?.documentId, + }); + + return { + success: true, + inserted: [toCommentAddress(replyId)], + }; +} + +function moveCommentHandler(editor: Editor, input: MoveCommentInput): Receipt { + const moveComment = editor.commands?.moveComment; + if (!moveComment) { + throw new DocumentApiAdapterError( + 'COMMAND_UNAVAILABLE', + 'Move comment command is not available on this editor instance.', + ); + } + + if (input.target.range.start === input.target.range.end) { + return { + success: false, + failure: { + code: 'INVALID_TARGET', + message: 'Comment target range must be non-collapsed.', + }, + }; + } + + const resolved = resolveTextTarget(editor, input.target); + if (!resolved) { + throw toNotFoundError(input.target); + } + if (resolved.from === resolved.to) { + return { + success: false, + failure: { + code: 'INVALID_TARGET', + message: 'Comment target range must be non-collapsed.', + }, + }; + } + + const identity = resolveCommentIdentity(editor, input.commentId); + if (!identity.anchors.length) { + return { + success: false, + failure: { + code: 'INVALID_TARGET', + message: 'Comment cannot be moved because it has no resolvable anchor.', + }, + }; + } + + if (identity.anchors.length > 1) { + return { + success: false, + failure: { + code: 'INVALID_TARGET', + message: 'Comment move target is ambiguous for comments with multiple anchors.', + }, + }; + } + + const currentTarget = identity.anchors[0]?.target; + if (currentTarget && isSameTarget(currentTarget, input.target)) { + return { + success: false, + failure: { + code: 'NO_OP', + message: 'Comment move produced no change.', + }, + }; + } + + const didMove = moveComment({ + commentId: identity.commentId, + from: resolved.from, + to: resolved.to, + }); + + if (!didMove) { + return { + success: false, + failure: { + code: 'NO_OP', + message: 'Comment move produced no change.', + }, + }; + } + + return { + success: true, + updated: [toCommentAddress(identity.commentId)], + }; +} + +function resolveCommentHandler(editor: Editor, input: ResolveCommentInput): Receipt { + const resolveComment = editor.commands?.resolveComment; + if (!resolveComment) { + throw new DocumentApiAdapterError( + 'COMMAND_UNAVAILABLE', + 'Resolve comment command is not available on this editor instance.', + ); + } + + const store = getCommentEntityStore(editor); + const identity = resolveCommentIdentity(editor, input.commentId); + const existing = findCommentEntity(store, identity.commentId); + const alreadyResolved = + (existing ? isCommentResolved(existing) : false) || + (identity.anchors.length > 0 && identity.anchors.every((a) => a.status === 'resolved')); + if (alreadyResolved) { + return { + success: false, + failure: { + code: 'NO_OP', + message: 'Comment is already resolved.', + }, + }; + } + + const didResolve = resolveComment({ + commentId: identity.commentId, + importedId: identity.importedId, + }); + if (!didResolve) { + return { + success: false, + failure: { + code: 'NO_OP', + message: 'Comment resolve produced no change.', + }, + }; + } + + upsertCommentEntity(store, identity.commentId, { + importedId: identity.importedId, + isDone: true, + resolvedTime: Date.now(), + }); + + return { + success: true, + updated: [toCommentAddress(identity.commentId)], + }; +} + +function removeCommentHandler(editor: Editor, input: RemoveCommentInput): Receipt { + const removeComment = editor.commands?.removeComment; + if (!removeComment) { + throw new DocumentApiAdapterError( + 'COMMAND_UNAVAILABLE', + 'Remove comment command is not available on this editor instance.', + ); + } + + const store = getCommentEntityStore(editor); + const identity = resolveCommentIdentity(editor, input.commentId); + + const didRemove = + removeComment({ + commentId: identity.commentId, + importedId: identity.importedId, + }) === true; + + const removedRecords = removeCommentEntityTree(store, identity.commentId); + if (!didRemove && removedRecords.length === 0) { + return { + success: false, + failure: { + code: 'NO_OP', + message: 'Comment remove produced no change.', + }, + }; + } + + const removedIds = new Set(); + for (const record of removedRecords) { + const removedId = toNonEmptyString(record.commentId); + if (removedId) { + removedIds.add(removedId); + } + } + if (!removedIds.size && didRemove) { + removedIds.add(identity.commentId); + } + + return { + success: true, + removed: Array.from(removedIds).map((id) => toCommentAddress(id)), + }; +} + +function setCommentInternalHandler(editor: Editor, input: SetCommentInternalInput): Receipt { + const setCommentInternal = editor.commands?.setCommentInternal; + if (!setCommentInternal) { + throw new DocumentApiAdapterError( + 'COMMAND_UNAVAILABLE', + 'Set-comment-internal command is not available on this editor instance.', + ); + } + + const store = getCommentEntityStore(editor); + const identity = resolveCommentIdentity(editor, input.commentId); + const existing = findCommentEntity(store, identity.commentId); + const currentInternal = + (typeof existing?.isInternal === 'boolean' ? existing.isInternal : undefined) ?? identity.anchors[0]?.isInternal; + + if (typeof currentInternal === 'boolean' && currentInternal === input.isInternal) { + return { + success: false, + failure: { + code: 'NO_OP', + message: 'Comment internal state is already set to the requested value.', + }, + }; + } + + const hasOpenAnchor = identity.anchors.some((anchor) => anchor.status === 'open'); + if (hasOpenAnchor) { + const didApply = setCommentInternal({ + commentId: identity.commentId, + importedId: identity.importedId, + isInternal: input.isInternal, + }); + if (!didApply) { + return { + success: false, + failure: { + code: 'INVALID_TARGET', + message: 'Comment internal state could not be updated on the current anchor.', + }, + }; + } + } + + upsertCommentEntity(store, identity.commentId, { + importedId: identity.importedId, + isInternal: input.isInternal, + }); + + return { + success: true, + updated: [toCommentAddress(identity.commentId)], + }; +} + +function setCommentActiveHandler(editor: Editor, input: SetCommentActiveInput): Receipt { + const setActiveComment = editor.commands?.setActiveComment; + if (!setActiveComment) { + throw new DocumentApiAdapterError( + 'COMMAND_UNAVAILABLE', + 'Set-active-comment command is not available on this editor instance.', + ); + } + + let resolvedCommentId: string | null = null; + if (input.commentId != null) { + resolvedCommentId = resolveCommentIdentity(editor, input.commentId).commentId; + } + + const didSet = setActiveComment({ commentId: resolvedCommentId }); + if (!didSet) { + return { + success: false, + failure: { + code: 'INVALID_TARGET', + message: 'Active comment could not be updated.', + }, + }; + } + + return { + success: true, + updated: resolvedCommentId ? [toCommentAddress(resolvedCommentId)] : undefined, + }; +} + +function goToCommentHandler(editor: Editor, input: GoToCommentInput): Receipt { + const setCursorById = editor.commands?.setCursorById; + if (!setCursorById) { + throw new DocumentApiAdapterError( + 'COMMAND_UNAVAILABLE', + 'Go-to-comment command is not available on this editor instance.', + ); + } + + const identity = resolveCommentIdentity(editor, input.commentId); + let didSetCursor = setCursorById(identity.commentId); + if (!didSetCursor && identity.importedId && identity.importedId !== identity.commentId) { + didSetCursor = setCursorById(identity.importedId); + } + if (!didSetCursor) { + throw toNotFoundError({ commentId: identity.commentId }); + } + + return { + success: true, + updated: [toCommentAddress(identity.commentId)], + }; +} + +function getCommentHandler(editor: Editor, input: GetCommentInput): CommentInfo { + const comments = buildCommentInfos(editor); + const found = comments.find( + (comment) => comment.commentId === input.commentId || comment.importedId === input.commentId, + ); + if (!found) { + throw toNotFoundError({ commentId: input.commentId }); + } + return found; +} + +function listCommentsHandler(editor: Editor, query?: CommentsListQuery): CommentsListResult { + const comments = buildCommentInfos(editor); + const includeResolved = query?.includeResolved ?? true; + const matches = includeResolved ? comments : comments.filter((comment) => comment.status !== 'resolved'); + + return { + matches, + total: matches.length, + }; +} + +/** + * Creates the comments adapter namespace for the Document API. + * + * @param editor - The editor instance to bind comment operations to. + * @returns A {@link CommentsAdapter} that delegates to editor commands. + */ +export function createCommentsAdapter(editor: Editor): CommentsAdapter { + return { + add: (input: AddCommentInput) => addCommentHandler(editor, input), + edit: (input: EditCommentInput) => editCommentHandler(editor, input), + reply: (input: ReplyToCommentInput) => replyToCommentHandler(editor, input), + move: (input: MoveCommentInput) => moveCommentHandler(editor, input), + resolve: (input: ResolveCommentInput) => resolveCommentHandler(editor, input), + remove: (input: RemoveCommentInput) => removeCommentHandler(editor, input), + setInternal: (input: SetCommentInternalInput) => setCommentInternalHandler(editor, input), + setActive: (input: SetCommentActiveInput) => setCommentActiveHandler(editor, input), + goTo: (input: GoToCommentInput) => goToCommentHandler(editor, input), + get: (input: GetCommentInput) => getCommentHandler(editor, input), + list: (query?: CommentsListQuery) => listCommentsHandler(editor, query), + }; +} diff --git a/packages/super-editor/src/document-api-adapters/create-adapter.test.ts b/packages/super-editor/src/document-api-adapters/create-adapter.test.ts new file mode 100644 index 000000000..07ef63577 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/create-adapter.test.ts @@ -0,0 +1,362 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import type { Editor } from '../core/Editor.js'; +import { createParagraphAdapter } from './create-adapter.js'; +import * as trackedChangeResolver from './helpers/tracked-change-resolver.js'; + +type MockNode = ProseMirrorNode & { + _children?: MockNode[]; + marks?: Array<{ type: { name: string }; attrs?: Record }>; +}; + +function createTextNode(text: string, marks: MockNode['marks'] = []): MockNode { + return { + type: { name: 'text' }, + text, + marks, + nodeSize: text.length, + isText: true, + isInline: true, + isBlock: false, + isLeaf: false, + inlineContent: false, + isTextblock: false, + childCount: 0, + child() { + throw new Error('text node has no children'); + }, + descendants() { + return undefined; + }, + } as unknown as MockNode; +} + +function createParagraphNode( + id: string, + text = '', + tracked = false, + extraAttrs: Record = {}, +): MockNode { + const marks = + tracked && text.length > 0 + ? [ + { + type: { name: 'trackInsert' }, + attrs: { id: `tc-${id}` }, + }, + ] + : []; + const children = text.length > 0 ? [createTextNode(text, marks)] : []; + const contentSize = children.reduce((sum, child) => sum + child.nodeSize, 0); + + return { + type: { name: 'paragraph' }, + attrs: { sdBlockId: id, ...extraAttrs }, + _children: children, + nodeSize: contentSize + 2, + isText: false, + isInline: false, + isBlock: true, + isLeaf: false, + inlineContent: true, + isTextblock: true, + childCount: children.length, + child(index: number) { + return children[index] as unknown as ProseMirrorNode; + }, + descendants(callback: (node: ProseMirrorNode, pos: number) => void) { + let offset = 1; + for (const child of children) { + callback(child as unknown as ProseMirrorNode, offset); + offset += child.nodeSize; + } + return undefined; + }, + } as unknown as MockNode; +} + +function createDocNode(children: MockNode[]): MockNode { + const node = { + type: { name: 'doc' }, + _children: children, + isText: false, + isInline: false, + isBlock: false, + isLeaf: false, + inlineContent: false, + isTextblock: false, + childCount: children.length, + child(index: number) { + return children[index] as unknown as ProseMirrorNode; + }, + get nodeSize() { + return this.content.size + 2; + }, + get content() { + return { + size: children.reduce((sum, child) => sum + child.nodeSize, 0), + }; + }, + descendants(callback: (node: ProseMirrorNode, pos: number) => void) { + let pos = 0; + for (const child of children) { + callback(child as unknown as ProseMirrorNode, pos); + let offset = 1; + for (const grandChild of child._children ?? []) { + callback(grandChild as unknown as ProseMirrorNode, pos + offset); + offset += grandChild.nodeSize; + } + pos += child.nodeSize; + } + return undefined; + }, + nodesBetween(this: MockNode, from: number, to: number, callback: (node: ProseMirrorNode) => void) { + const size = this.content.size; + if (!Number.isFinite(size)) { + throw new Error('nodesBetween called without document context'); + } + let pos = 0; + for (const child of children) { + const childStart = pos; + const childEnd = pos + child.nodeSize; + if (childEnd < from || childStart > to) { + pos += child.nodeSize; + continue; + } + + callback(child as unknown as ProseMirrorNode); + for (const grandChild of child._children ?? []) { + callback(grandChild as unknown as ProseMirrorNode); + } + pos += child.nodeSize; + } + }, + } as unknown as MockNode; + + return node; +} + +function insertChildAtPos(doc: MockNode, child: MockNode, pos: number): boolean { + const children = doc._children ?? []; + let cursor = 0; + + for (let index = 0; index <= children.length; index += 1) { + if (cursor === pos) { + children.splice(index, 0, child); + doc.childCount = children.length; + return true; + } + + if (index < children.length) { + cursor += children[index]!.nodeSize; + } + } + + return false; +} + +function makeEditor({ + withTrackedCommand = true, + insertReturns = true, + insertedParagraphAttrs, +}: { + withTrackedCommand?: boolean; + insertReturns?: boolean; + insertedParagraphAttrs?: Record; +} = {}): { + editor: Editor; + insertParagraphAt: ReturnType; +} { + const doc = createDocNode([createParagraphNode('p1', 'Hello')]); + + const insertParagraphAt = vi.fn((options: { pos: number; text?: string; sdBlockId?: string; tracked?: boolean }) => { + if (!insertReturns) return false; + const nodeId = options.sdBlockId ?? 'new-paragraph'; + const paragraph = createParagraphNode(nodeId, options.text ?? '', options.tracked === true, insertedParagraphAttrs); + return insertChildAtPos(doc, paragraph, options.pos); + }); + + const editor = { + state: { + doc, + }, + commands: { + insertParagraphAt, + insertTrackedChange: withTrackedCommand ? vi.fn(() => true) : undefined, + }, + } as unknown as Editor; + + return { editor, insertParagraphAt }; +} + +describe('createParagraphAdapter', () => { + it('creates a paragraph at the document end by default', () => { + const { editor, insertParagraphAt } = makeEditor(); + + const result = createParagraphAdapter(editor, { text: 'New paragraph' }, { changeMode: 'direct' }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.paragraph.kind).toBe('block'); + expect(result.paragraph.nodeType).toBe('paragraph'); + expect(result.insertionPoint.kind).toBe('text'); + expect(result.insertionPoint.range).toEqual({ start: 0, end: 0 }); + } + + expect(insertParagraphAt).toHaveBeenCalledTimes(1); + expect(insertParagraphAt.mock.calls[0]?.[0]).toMatchObject({ + text: 'New paragraph', + tracked: false, + }); + }); + + it('creates a paragraph before a target block', () => { + const { editor, insertParagraphAt } = makeEditor(); + + const result = createParagraphAdapter( + editor, + { + at: { + kind: 'before', + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + }, + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + expect(insertParagraphAt.mock.calls[0]?.[0]?.pos).toBe(0); + }); + + it('throws TARGET_NOT_FOUND when a before/after target cannot be resolved', () => { + const { editor } = makeEditor(); + + expect(() => + createParagraphAdapter( + editor, + { + at: { + kind: 'after', + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'missing' }, + }, + }, + { changeMode: 'direct' }, + ), + ).toThrow('target block was not found'); + }); + + it('throws TRACK_CHANGE_COMMAND_UNAVAILABLE when tracked create is requested without tracked capability', () => { + const { editor } = makeEditor({ withTrackedCommand: false }); + + expect(() => createParagraphAdapter(editor, { text: 'Tracked' }, { changeMode: 'tracked' })).toThrow( + 'Tracked paragraph creation is not available', + ); + }); + + it('creates tracked paragraphs without losing nodesBetween context', () => { + const resolverSpy = vi.spyOn(trackedChangeResolver, 'buildTrackedChangeCanonicalIdMap').mockReturnValue(new Map()); + + const { editor } = makeEditor(); + + const result = createParagraphAdapter(editor, { text: 'Tracked paragraph' }, { changeMode: 'tracked' }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.trackedChangeRefs?.length).toBeGreaterThan(0); + expect(result.trackedChangeRefs?.[0]).toMatchObject({ + kind: 'entity', + entityType: 'trackedChange', + }); + expect(resolverSpy).toHaveBeenCalledTimes(1); + resolverSpy.mockRestore(); + }); + + it('returns INVALID_TARGET failure when command cannot apply the insertion', () => { + const { editor } = makeEditor({ insertReturns: false }); + + const result = createParagraphAdapter(editor, { text: 'No-op' }, { changeMode: 'direct' }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.failure.code).toBe('INVALID_TARGET'); + } + }); + + it('dry-run returns placeholder success without mutating the document', () => { + const { editor, insertParagraphAt } = makeEditor(); + + const result = createParagraphAdapter(editor, { text: 'Dry run text' }, { changeMode: 'direct', dryRun: true }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.paragraph).toEqual({ kind: 'block', nodeType: 'paragraph', nodeId: '(dry-run)' }); + expect(result.insertionPoint).toEqual({ kind: 'text', blockId: '(dry-run)', range: { start: 0, end: 0 } }); + expect(insertParagraphAt).not.toHaveBeenCalled(); + }); + + it('dry-run still throws TARGET_NOT_FOUND when target block does not exist', () => { + const { editor } = makeEditor(); + + expect(() => + createParagraphAdapter( + editor, + { + at: { + kind: 'before', + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'missing' }, + }, + }, + { changeMode: 'direct', dryRun: true }, + ), + ).toThrow('target block was not found'); + }); + + it('dry-run still throws TRACK_CHANGE_COMMAND_UNAVAILABLE when tracked capability is missing', () => { + const { editor } = makeEditor({ withTrackedCommand: false }); + + expect(() => + createParagraphAdapter(editor, { text: 'Tracked dry run' }, { changeMode: 'tracked', dryRun: true }), + ).toThrow('Tracked paragraph creation is not available'); + }); + + it('resolves created paragraph when block index identity prefers paraId over sdBlockId', () => { + const { editor } = makeEditor({ + insertedParagraphAttrs: { + paraId: 'pm-para-id', + }, + }); + + const result = createParagraphAdapter(editor, { text: 'Inserted paragraph' }, { changeMode: 'direct' }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.paragraph.nodeType).toBe('paragraph'); + expect(result.paragraph.nodeId).toBe('pm-para-id'); + expect(result.insertionPoint.blockId).toBe('pm-para-id'); + }); + + it('returns success with generated sdBlockId when post-apply paragraph resolution fails', () => { + const { editor, insertParagraphAt } = makeEditor({ + insertedParagraphAttrs: { + sdBlockId: undefined, + }, + }); + + const result = createParagraphAdapter(editor, { text: 'Inserted paragraph' }, { changeMode: 'direct' }); + + expect(result.success).toBe(true); + if (!result.success) return; + const generatedId = insertParagraphAt.mock.calls[0]?.[0]?.sdBlockId; + expect(generatedId).toBeTypeOf('string'); + expect(result.paragraph).toEqual({ + kind: 'block', + nodeType: 'paragraph', + nodeId: generatedId, + }); + expect(result.insertionPoint).toEqual({ + kind: 'text', + blockId: generatedId, + range: { start: 0, end: 0 }, + }); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/create-adapter.ts b/packages/super-editor/src/document-api-adapters/create-adapter.ts new file mode 100644 index 000000000..1bc693103 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/create-adapter.ts @@ -0,0 +1,169 @@ +import { v4 as uuidv4 } from 'uuid'; +import type { Editor } from '../core/Editor.js'; +import type { + CreateParagraphInput, + CreateParagraphResult, + CreateParagraphSuccessResult, + MutationOptions, +} from '@superdoc/document-api'; +import { clearIndexCache, getBlockIndex } from './helpers/index-cache.js'; +import { findBlockById, type BlockCandidate } from './helpers/node-address-resolver.js'; +import { collectTrackInsertRefsInRange } from './helpers/tracked-change-refs.js'; +import { DocumentApiAdapterError } from './errors.js'; + +type InsertParagraphAtCommandOptions = { + pos: number; + text?: string; + sdBlockId?: string; + tracked?: boolean; +}; + +type InsertParagraphAtCommand = (options: InsertParagraphAtCommandOptions) => boolean; + +function resolveParagraphInsertPosition(editor: Editor, input: CreateParagraphInput): number { + const location = input.at ?? { kind: 'documentEnd' }; + + if (location.kind === 'documentStart') return 0; + if (location.kind === 'documentEnd') return editor.state.doc.content.size; + + const index = getBlockIndex(editor); + const target = findBlockById(index, location.target); + if (!target) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'Create paragraph target block was not found.', { + target: location.target, + }); + } + + return location.kind === 'before' ? target.pos : target.end; +} + +function getInsertParagraphAtCommand(editor: Editor): InsertParagraphAtCommand { + const command = editor.commands?.insertParagraphAt; + if (!command) { + throw new DocumentApiAdapterError( + 'COMMAND_UNAVAILABLE', + 'Create paragraph command is not available on this editor instance.', + ); + } + return command as InsertParagraphAtCommand; +} + +function ensureTrackedCreateCapability(editor: Editor): void { + const hasTrackedInsertCommand = typeof editor.commands?.insertTrackedChange === 'function'; + if (!hasTrackedInsertCommand) { + throw new DocumentApiAdapterError( + 'TRACK_CHANGE_COMMAND_UNAVAILABLE', + 'Tracked paragraph creation is not available on this editor instance.', + ); + } +} + +function resolveCreatedParagraph(editor: Editor, paragraphId: string): BlockCandidate { + const index = getBlockIndex(editor); + const resolved = index.byId.get(`paragraph:${paragraphId}`); + + if (resolved) return resolved; + + // Paragraph addresses may currently prefer imported paraId over sdBlockId. + // After insertion, resolve by sdBlockId as a deterministic fallback so a + // successful insert cannot be reported as a failure solely due to ID + // projection differences. + const bySdBlockId = index.candidates.find((candidate) => { + if (candidate.nodeType !== 'paragraph') return false; + const attrs = (candidate.node as { attrs?: { sdBlockId?: unknown } }).attrs; + return typeof attrs?.sdBlockId === 'string' && attrs.sdBlockId === paragraphId; + }); + if (bySdBlockId) return bySdBlockId; + + const fallback = index.candidates.find( + (candidate) => candidate.nodeType === 'paragraph' && candidate.nodeId === paragraphId, + ); + if (fallback) return fallback; + + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'Created paragraph could not be resolved after insertion.', { + paragraphId, + }); +} + +function buildParagraphCreateSuccess( + paragraphNodeId: string, + trackedChangeRefs?: CreateParagraphSuccessResult['trackedChangeRefs'], +): CreateParagraphSuccessResult { + return { + success: true, + paragraph: { + kind: 'block', + nodeType: 'paragraph', + nodeId: paragraphNodeId, + }, + insertionPoint: { + kind: 'text', + blockId: paragraphNodeId, + range: { start: 0, end: 0 }, + }, + trackedChangeRefs, + }; +} + +export function createParagraphAdapter( + editor: Editor, + input: CreateParagraphInput, + options?: MutationOptions, +): CreateParagraphResult { + const insertParagraphAt = getInsertParagraphAtCommand(editor); + const mode = options?.changeMode ?? 'direct'; + + if (mode === 'tracked') { + ensureTrackedCreateCapability(editor); + } + + const insertAt = resolveParagraphInsertPosition(editor, input); + + if (options?.dryRun) { + return { + success: true, + paragraph: { + kind: 'block', + nodeType: 'paragraph', + nodeId: '(dry-run)', + }, + insertionPoint: { + kind: 'text', + blockId: '(dry-run)', + range: { start: 0, end: 0 }, + }, + }; + } + + const paragraphId = uuidv4(); + + const didApply = insertParagraphAt({ + pos: insertAt, + text: input.text, + sdBlockId: paragraphId, + tracked: mode === 'tracked', + }); + + if (!didApply) { + return { + success: false, + failure: { + code: 'INVALID_TARGET', + message: 'Paragraph creation could not be applied at the requested location.', + }, + }; + } + + clearIndexCache(editor); + try { + const paragraph = resolveCreatedParagraph(editor, paragraphId); + const trackedChangeRefs = + mode === 'tracked' ? collectTrackInsertRefsInRange(editor, paragraph.pos, paragraph.end) : undefined; + + return buildParagraphCreateSuccess(paragraph.nodeId, trackedChangeRefs); + } catch { + // Mutation already applied. Preserve success semantics with the generated + // block ID even if post-apply paragraph enrichment cannot be resolved. + return buildParagraphCreateSuccess(paragraphId); + } +} diff --git a/packages/super-editor/src/document-api-adapters/format-adapter.test.ts b/packages/super-editor/src/document-api-adapters/format-adapter.test.ts new file mode 100644 index 000000000..5c945486f --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/format-adapter.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import type { Editor } from '../core/Editor.js'; +import { TrackFormatMarkName } from '../extensions/track-changes/constants.js'; +import { formatBoldAdapter } from './format-adapter.js'; + +type NodeOptions = { + attrs?: Record; + text?: string; + isInline?: boolean; + isBlock?: boolean; + isLeaf?: boolean; + inlineContent?: boolean; + nodeSize?: number; +}; + +function createNode(typeName: string, children: ProseMirrorNode[] = [], options: NodeOptions = {}): ProseMirrorNode { + const attrs = options.attrs ?? {}; + const text = options.text ?? ''; + const isText = typeName === 'text'; + const isInline = options.isInline ?? isText; + const isBlock = options.isBlock ?? (!isInline && typeName !== 'doc'); + const inlineContent = options.inlineContent ?? isBlock; + const isLeaf = options.isLeaf ?? (isInline && !isText && children.length === 0); + + const contentSize = children.reduce((sum, child) => sum + child.nodeSize, 0); + const nodeSize = isText ? text.length : options.nodeSize != null ? options.nodeSize : isLeaf ? 1 : contentSize + 2; + + return { + type: { name: typeName }, + attrs, + text: isText ? text : undefined, + nodeSize, + isText, + isInline, + isBlock, + inlineContent, + isTextblock: inlineContent, + isLeaf, + childCount: children.length, + child(index: number) { + return children[index]!; + }, + descendants(callback: (node: ProseMirrorNode, pos: number) => void) { + let offset = 0; + for (const child of children) { + callback(child, offset); + offset += child.nodeSize; + } + }, + } as unknown as ProseMirrorNode; +} + +function makeEditor(text = 'Hello'): { + editor: Editor; + dispatch: ReturnType; + insertTrackedChange: ReturnType; + textBetween: ReturnType; + tr: { + addMark: ReturnType; + setMeta: ReturnType; + }; +} { + const textNode = createNode('text', [], { text }); + const paragraph = createNode('paragraph', [textNode], { + attrs: { sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + + const tr = { + addMark: vi.fn(), + setMeta: vi.fn(), + }; + tr.addMark.mockReturnValue(tr); + tr.setMeta.mockReturnValue(tr); + + const dispatch = vi.fn(); + const insertTrackedChange = vi.fn(() => true); + const textBetween = vi.fn((from: number, to: number) => { + const start = Math.max(0, from - 1); + const end = Math.max(start, to - 1); + return text.slice(start, end); + }); + + const editor = { + state: { + doc: { + ...doc, + textBetween, + }, + tr, + }, + schema: { + marks: { + bold: { + create: vi.fn(() => ({ type: 'bold' })), + }, + [TrackFormatMarkName]: { + create: vi.fn(() => ({ type: TrackFormatMarkName })), + }, + }, + }, + commands: { + insertTrackedChange, + }, + dispatch, + } as unknown as Editor; + + return { editor, dispatch, insertTrackedChange, textBetween, tr }; +} + +describe('formatBoldAdapter', () => { + it('applies direct bold formatting', () => { + const { editor, dispatch, tr } = makeEditor(); + const receipt = formatBoldAdapter( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, + { changeMode: 'direct' }, + ); + + expect(receipt.success).toBe(true); + expect(receipt.resolution).toMatchObject({ + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + range: { from: 1, to: 6 }, + text: 'Hello', + }); + expect(tr.addMark).toHaveBeenCalledTimes(1); + expect(tr.setMeta).toHaveBeenCalledWith('inputType', 'programmatic'); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it('sets forceTrackChanges meta in tracked mode', () => { + const { editor, tr } = makeEditor(); + const receipt = formatBoldAdapter( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, + { changeMode: 'tracked' }, + ); + + expect(receipt.success).toBe(true); + expect(tr.setMeta).toHaveBeenCalledWith('forceTrackChanges', true); + }); + + it('throws when target cannot be resolved', () => { + const { editor } = makeEditor(); + expect(() => + formatBoldAdapter( + editor, + { target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 5 } } }, + { changeMode: 'direct' }, + ), + ).toThrow('Format target could not be resolved.'); + }); + + it('returns INVALID_TARGET for collapsed target ranges', () => { + const { editor } = makeEditor(); + const receipt = formatBoldAdapter( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 2, end: 2 } } }, + { changeMode: 'direct' }, + ); + + expect(receipt.success).toBe(false); + expect(receipt.failure).toMatchObject({ + code: 'INVALID_TARGET', + }); + expect(receipt.resolution.range).toEqual({ from: 3, to: 3 }); + }); + + it('throws when bold mark is unavailable', () => { + const { editor } = makeEditor(); + delete (editor.schema?.marks as Record)?.bold; + + expect(() => + formatBoldAdapter( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, + { changeMode: 'direct' }, + ), + ).toThrow('Bold mark is not available on this editor instance.'); + }); + + it('throws when tracked format capability is unavailable', () => { + const { editor } = makeEditor(); + delete (editor.commands as Record)?.insertTrackedChange; + + expect(() => + formatBoldAdapter( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, + { changeMode: 'tracked' }, + ), + ).toThrow('Tracked bold formatting is not available on this editor instance.'); + }); + + it('supports direct dry-run without building a transaction', () => { + const { editor, dispatch, tr } = makeEditor(); + const receipt = formatBoldAdapter( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, + { changeMode: 'direct', dryRun: true }, + ); + + expect(receipt.success).toBe(true); + expect(receipt.resolution.range).toEqual({ from: 1, to: 6 }); + expect(tr.addMark).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('supports tracked dry-run without building a transaction', () => { + const { editor, dispatch, tr } = makeEditor(); + const receipt = formatBoldAdapter( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, + { changeMode: 'tracked', dryRun: true }, + ); + + expect(receipt.success).toBe(true); + expect(receipt.resolution.range).toEqual({ from: 1, to: 6 }); + expect(tr.addMark).not.toHaveBeenCalled(); + expect(tr.setMeta).not.toHaveBeenCalledWith('forceTrackChanges', true); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('keeps direct and tracked bold operations deterministic for the same target', () => { + const { editor, tr } = makeEditor(); + + const direct = formatBoldAdapter( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, + { changeMode: 'direct' }, + ); + expect(direct.success).toBe(true); + + const tracked = formatBoldAdapter( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, + { changeMode: 'tracked' }, + ); + expect(tracked.success).toBe(true); + expect(tr.setMeta).toHaveBeenCalledWith('forceTrackChanges', true); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/format-adapter.ts b/packages/super-editor/src/document-api-adapters/format-adapter.ts new file mode 100644 index 000000000..45da85112 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/format-adapter.ts @@ -0,0 +1,67 @@ +import type { Editor } from '../core/Editor.js'; +import type { FormatBoldInput, MutationOptions, TextMutationReceipt } from '@superdoc/document-api'; +import { TrackFormatMarkName } from '../extensions/track-changes/constants.js'; +import { DocumentApiAdapterError } from './errors.js'; +import { resolveTextTarget } from './helpers/adapter-utils.js'; +import { buildTextMutationResolution, readTextAtResolvedRange } from './helpers/text-mutation-resolution.js'; + +function assertTrackedFormatCapability(editor: Editor): void { + const hasTrackedInsertCommand = typeof editor.commands?.insertTrackedChange === 'function'; + const hasTrackFormatMark = Boolean(editor.schema?.marks?.[TrackFormatMarkName]); + + if (hasTrackedInsertCommand && hasTrackFormatMark) return; + + throw new DocumentApiAdapterError( + 'TRACK_CHANGE_COMMAND_UNAVAILABLE', + 'Tracked bold formatting is not available on this editor instance.', + ); +} + +export function formatBoldAdapter( + editor: Editor, + input: FormatBoldInput, + options?: MutationOptions, +): TextMutationReceipt { + const range = resolveTextTarget(editor, input.target); + if (!range) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'Format target could not be resolved.', { + target: input.target, + }); + } + + const resolution = buildTextMutationResolution({ + requestedTarget: input.target, + target: input.target, + range, + text: readTextAtResolvedRange(editor, range), + }); + + if (range.from === range.to) { + return { + success: false, + resolution, + failure: { + code: 'INVALID_TARGET', + message: 'Bold formatting requires a non-collapsed target range.', + }, + }; + } + + const boldMark = editor.schema?.marks?.bold; + if (!boldMark) { + throw new DocumentApiAdapterError('COMMAND_UNAVAILABLE', 'Bold mark is not available on this editor instance.'); + } + + const mode = options?.changeMode ?? 'direct'; + if (mode === 'tracked') assertTrackedFormatCapability(editor); + + if (options?.dryRun) { + return { success: true, resolution }; + } + + const tr = editor.state.tr.addMark(range.from, range.to, boldMark.create()).setMeta('inputType', 'programmatic'); + if (mode === 'tracked') tr.setMeta('forceTrackChanges', true); + + editor.dispatch(tr); + return { success: true, resolution }; +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/comment-entity-store.test.ts b/packages/super-editor/src/document-api-adapters/helpers/comment-entity-store.test.ts new file mode 100644 index 000000000..48ce78752 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/comment-entity-store.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; +import { + buildCommentJsonFromText, + extractCommentText, + findCommentEntity, + getCommentEntityStore, + isCommentResolved, + removeCommentEntityTree, + toCommentInfo, + upsertCommentEntity, + type CommentEntityRecord, +} from './comment-entity-store.js'; + +function makeEditorWithConverter(comments: CommentEntityRecord[] = []): Editor { + return { converter: { comments } } as unknown as Editor; +} + +function makeEditorWithoutConverter(): Editor { + return {} as unknown as Editor; +} + +describe('getCommentEntityStore', () => { + it('returns converter.comments when converter exists', () => { + const comments: CommentEntityRecord[] = [{ commentId: 'c1' }]; + const editor = makeEditorWithConverter(comments); + expect(getCommentEntityStore(editor)).toBe(comments); + }); + + it('initializes converter.comments as empty array when undefined', () => { + const editor = { converter: {} } as unknown as Editor; + const store = getCommentEntityStore(editor); + expect(store).toEqual([]); + expect(Array.isArray(store)).toBe(true); + }); + + it('uses fallback storage when converter is missing', () => { + const editor = makeEditorWithoutConverter(); + const store = getCommentEntityStore(editor); + expect(store).toEqual([]); + // Subsequent calls return the same array + expect(getCommentEntityStore(editor)).toBe(store); + }); +}); + +describe('findCommentEntity', () => { + it('finds by commentId', () => { + const store: CommentEntityRecord[] = [{ commentId: 'c1', commentText: 'Hello' }]; + expect(findCommentEntity(store, 'c1')?.commentText).toBe('Hello'); + }); + + it('finds by importedId', () => { + const store: CommentEntityRecord[] = [{ commentId: 'c1', importedId: 'imp-1' }]; + expect(findCommentEntity(store, 'imp-1')?.commentId).toBe('c1'); + }); + + it('returns undefined when not found', () => { + const store: CommentEntityRecord[] = [{ commentId: 'c1' }]; + expect(findCommentEntity(store, 'missing')).toBeUndefined(); + }); +}); + +describe('upsertCommentEntity', () => { + it('creates a new entry when none exists', () => { + const store: CommentEntityRecord[] = []; + const result = upsertCommentEntity(store, 'c1', { commentText: 'New' }); + expect(result.commentId).toBe('c1'); + expect(result.commentText).toBe('New'); + expect(store).toHaveLength(1); + }); + + it('updates an existing entry preserving its commentId', () => { + const store: CommentEntityRecord[] = [{ commentId: 'c1', commentText: 'Old' }]; + const result = upsertCommentEntity(store, 'c1', { commentText: 'Updated' }); + expect(result.commentText).toBe('Updated'); + expect(result.commentId).toBe('c1'); + expect(store).toHaveLength(1); + }); + + it('resolves to the provided commentId when existing entry has no commentId', () => { + const store: CommentEntityRecord[] = [{ importedId: 'imp-1', commentText: 'Old' }]; + const result = upsertCommentEntity(store, 'imp-1', { commentText: 'Updated' }); + expect(result.commentId).toBe('imp-1'); + }); +}); + +describe('removeCommentEntityTree', () => { + it('removes a root comment and its children', () => { + const store: CommentEntityRecord[] = [ + { commentId: 'c1', commentText: 'Root' }, + { commentId: 'c2', parentCommentId: 'c1', commentText: 'Reply' }, + { commentId: 'c3', parentCommentId: 'c2', commentText: 'Nested reply' }, + ]; + + const removed = removeCommentEntityTree(store, 'c1'); + expect(removed.map((r) => r.commentId).sort()).toEqual(['c1', 'c2', 'c3']); + expect(store).toHaveLength(0); + }); + + it('returns empty array when comment is not found', () => { + const store: CommentEntityRecord[] = [{ commentId: 'c1' }]; + const removed = removeCommentEntityTree(store, 'missing'); + expect(removed).toEqual([]); + expect(store).toHaveLength(1); + }); + + it('preserves unrelated comments', () => { + const store: CommentEntityRecord[] = [ + { commentId: 'c1', commentText: 'Root' }, + { commentId: 'c2', parentCommentId: 'c1', commentText: 'Reply' }, + { commentId: 'c3', commentText: 'Unrelated' }, + ]; + + removeCommentEntityTree(store, 'c1'); + expect(store).toHaveLength(1); + expect(store[0]?.commentId).toBe('c3'); + }); + + it('returns empty array when root has empty commentId', () => { + const store: CommentEntityRecord[] = [{ commentId: '', importedId: 'imp-1' }]; + const removed = removeCommentEntityTree(store, 'imp-1'); + expect(removed).toEqual([]); + }); +}); + +describe('extractCommentText', () => { + it('returns commentText when available', () => { + expect(extractCommentText({ commentText: 'Hello' })).toBe('Hello'); + }); + + it('extracts text from commentJSON structure', () => { + const entry: CommentEntityRecord = { + commentJSON: [{ type: 'paragraph', content: [{ type: 'text', text: 'From JSON' }] }], + }; + expect(extractCommentText(entry)).toBe('From JSON'); + }); + + it('extracts text from elements structure', () => { + const entry: CommentEntityRecord = { + elements: [{ text: 'From elements' }], + }; + expect(extractCommentText(entry)).toBe('From elements'); + }); + + it('returns undefined when no text source exists', () => { + expect(extractCommentText({})).toBeUndefined(); + }); + + it('returns undefined for empty commentJSON', () => { + expect(extractCommentText({ commentJSON: [] })).toBeUndefined(); + }); +}); + +describe('buildCommentJsonFromText', () => { + it('creates paragraph/run/text structure from plain text', () => { + const result = buildCommentJsonFromText('Hello world'); + expect(result).toEqual([ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Hello world' }], + }, + ], + }, + ]); + }); + + it('strips HTML tags from input', () => { + const result = buildCommentJsonFromText('Bold text'); + expect(result[0]).toMatchObject({ + content: [{ content: [{ text: 'Bold text' }] }], + }); + }); + + it('replaces   with spaces', () => { + const result = buildCommentJsonFromText('Hello world'); + expect(result[0]).toMatchObject({ + content: [{ content: [{ text: 'Hello world' }] }], + }); + }); +}); + +describe('isCommentResolved', () => { + it('returns true when isDone is true', () => { + expect(isCommentResolved({ isDone: true })).toBe(true); + }); + + it('returns true when resolvedTime is set', () => { + expect(isCommentResolved({ resolvedTime: Date.now() })).toBe(true); + }); + + it('returns false when neither isDone nor resolvedTime is set', () => { + expect(isCommentResolved({})).toBe(false); + }); + + it('returns false when resolvedTime is null', () => { + expect(isCommentResolved({ resolvedTime: null })).toBe(false); + }); +}); + +describe('toCommentInfo', () => { + it('builds CommentInfo from a record', () => { + const info = toCommentInfo({ + commentId: 'c1', + importedId: 'imp-1', + commentText: 'Hello', + isInternal: true, + createdTime: 1000, + creatorName: 'Ada', + creatorEmail: 'ada@example.com', + }); + + expect(info.commentId).toBe('c1'); + expect(info.importedId).toBe('imp-1'); + expect(info.text).toBe('Hello'); + expect(info.isInternal).toBe(true); + expect(info.status).toBe('open'); + expect(info.createdTime).toBe(1000); + }); + + it('respects explicit status override', () => { + const info = toCommentInfo({ commentId: 'c1' }, { status: 'resolved' }); + expect(info.status).toBe('resolved'); + }); + + it('derives resolved status from isDone', () => { + const info = toCommentInfo({ commentId: 'c1', isDone: true }); + expect(info.status).toBe('resolved'); + }); + + it('falls back to importedId when commentId is missing', () => { + const info = toCommentInfo({ importedId: 'imp-1' }); + expect(info.commentId).toBe('imp-1'); + }); + + it('includes target when provided', () => { + const target = { kind: 'text' as const, blockId: 'p1', range: { start: 0, end: 5 } }; + const info = toCommentInfo({ commentId: 'c1' }, { target }); + expect(info.target).toBe(target); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/comment-entity-store.ts b/packages/super-editor/src/document-api-adapters/helpers/comment-entity-store.ts new file mode 100644 index 000000000..28109ac65 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/comment-entity-store.ts @@ -0,0 +1,212 @@ +import type { Editor } from '../../core/Editor.js'; +import type { CommentInfo, CommentStatus, TextAddress } from '@superdoc/document-api'; + +const FALLBACK_STORE_KEY = '__documentApiComments'; + +export interface CommentEntityRecord { + commentId?: string; + importedId?: string; + parentCommentId?: string; + commentText?: string; + commentJSON?: unknown; + elements?: unknown; + isInternal?: boolean; + isDone?: boolean; + resolvedTime?: number | null; + resolvedByEmail?: string | null; + resolvedByName?: string | null; + creatorName?: string; + creatorEmail?: string; + creatorImage?: string; + createdTime?: number; + [key: string]: unknown; +} + +type ConverterWithComments = { + comments?: CommentEntityRecord[]; +}; + +type EditorWithCommentStorage = Editor & { + converter?: ConverterWithComments; + storage?: Record; +}; + +function ensureFallbackStore(editor: EditorWithCommentStorage): CommentEntityRecord[] { + if (!editor.storage) { + (editor as unknown as Record).storage = {}; + } + const storage = editor.storage as Record; + + if (!Array.isArray(storage[FALLBACK_STORE_KEY])) { + storage[FALLBACK_STORE_KEY] = []; + } + + return storage[FALLBACK_STORE_KEY] as CommentEntityRecord[]; +} + +export function getCommentEntityStore(editor: Editor): CommentEntityRecord[] { + const mutableEditor = editor as EditorWithCommentStorage; + const converter = mutableEditor.converter as ConverterWithComments | undefined; + + if (converter) { + if (!Array.isArray(converter.comments)) { + converter.comments = []; + } + return converter.comments as CommentEntityRecord[]; + } + + return ensureFallbackStore(mutableEditor); +} + +export function findCommentEntity(store: CommentEntityRecord[], commentId: string): CommentEntityRecord | undefined { + return store.find((entry) => entry.commentId === commentId || entry.importedId === commentId); +} + +export function upsertCommentEntity( + store: CommentEntityRecord[], + commentId: string, + patch: Partial, +): CommentEntityRecord { + const existing = findCommentEntity(store, commentId); + if (existing) { + const resolvedId = + typeof existing.commentId === 'string' && existing.commentId.length > 0 ? existing.commentId : commentId; + Object.assign(existing, patch, { commentId: resolvedId }); + return existing; + } + + const created: CommentEntityRecord = { + ...patch, + commentId, + }; + store.push(created); + return created; +} + +export function removeCommentEntityTree(store: CommentEntityRecord[], commentId: string): CommentEntityRecord[] { + const root = findCommentEntity(store, commentId); + if (!root || typeof root.commentId !== 'string' || root.commentId.length === 0) return []; + + const removeIds = new Set([root.commentId]); + let changed = true; + + while (changed) { + changed = false; + for (const entry of store) { + if (typeof entry.commentId !== 'string' || entry.commentId.length === 0) continue; + if (typeof entry.parentCommentId !== 'string' || entry.parentCommentId.length === 0) continue; + if (removeIds.has(entry.parentCommentId) && !removeIds.has(entry.commentId)) { + removeIds.add(entry.commentId); + changed = true; + } + } + } + + const removed = store.filter((entry) => typeof entry.commentId === 'string' && removeIds.has(entry.commentId)); + const kept = store.filter((entry) => !(typeof entry.commentId === 'string' && removeIds.has(entry.commentId))); + + store.splice(0, store.length, ...kept); + return removed; +} + +/** + * Strips HTML tags from a comment text string using simple regex replacement. + * + * This is only intended for normalizing comment content that was already authored + * within the editor. It is NOT a security sanitizer and must not be used to + * neutralize untrusted or user-supplied HTML. + */ +function stripHtmlToText(value: string): string { + return value + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function collectTextFragments(value: unknown, sink: string[]): void { + if (!value) return; + + if (typeof value === 'string') { + if (value.length > 0) sink.push(value); + return; + } + + if (Array.isArray(value)) { + for (const item of value) collectTextFragments(item, sink); + return; + } + + if (typeof value !== 'object') return; + const record = value as Record; + if (typeof record.text === 'string' && record.text.length > 0) sink.push(record.text); + + if (record.content) collectTextFragments(record.content, sink); + if (record.elements) collectTextFragments(record.elements, sink); + if (record.nodes) collectTextFragments(record.nodes, sink); +} + +export function extractCommentText(entry: CommentEntityRecord): string | undefined { + if (typeof entry.commentText === 'string') return entry.commentText; + + const fragments: string[] = []; + if (entry.commentJSON) collectTextFragments(entry.commentJSON, fragments); + if (entry.elements) collectTextFragments(entry.elements, fragments); + + if (!fragments.length) return undefined; + return fragments.join('').trim(); +} + +export function buildCommentJsonFromText(text: string): unknown[] { + const normalized = stripHtmlToText(text); + + return [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [ + { + type: 'text', + text: normalized, + }, + ], + }, + ], + }, + ]; +} + +export function isCommentResolved(entry: CommentEntityRecord): boolean { + return Boolean(entry.isDone || entry.resolvedTime); +} + +export function toCommentInfo( + entry: CommentEntityRecord, + options: { + target?: TextAddress; + status?: CommentStatus; + } = {}, +): CommentInfo { + const resolvedId = typeof entry.commentId === 'string' ? entry.commentId : String(entry.importedId ?? ''); + const status = options.status ?? (isCommentResolved(entry) ? 'resolved' : 'open'); + + return { + address: { + kind: 'entity', + entityType: 'comment', + entityId: resolvedId, + }, + commentId: resolvedId, + importedId: typeof entry.importedId === 'string' ? entry.importedId : undefined, + parentCommentId: typeof entry.parentCommentId === 'string' ? entry.parentCommentId : undefined, + text: extractCommentText(entry), + isInternal: typeof entry.isInternal === 'boolean' ? entry.isInternal : undefined, + status, + target: options.target, + createdTime: typeof entry.createdTime === 'number' ? entry.createdTime : undefined, + creatorName: typeof entry.creatorName === 'string' ? entry.creatorName : undefined, + creatorEmail: typeof entry.creatorEmail === 'string' ? entry.creatorEmail : undefined, + }; +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/comment-target-resolver.ts b/packages/super-editor/src/document-api-adapters/helpers/comment-target-resolver.ts new file mode 100644 index 000000000..21581aa4d --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/comment-target-resolver.ts @@ -0,0 +1,83 @@ +import type { Editor } from '../../core/Editor.js'; +import type { TextAddress } from '@superdoc/document-api'; +import { getInlineIndex } from './index-cache.js'; +import type { InlineCandidate } from './inline-address-resolver.js'; +import { resolveCommentIdFromAttrs, toNonEmptyString } from './value-utils.js'; + +export type CommentAnchorStatus = 'open' | 'resolved'; + +export interface CommentAnchor { + commentId: string; + importedId?: string; + status: CommentAnchorStatus; + target: TextAddress; + isInternal?: boolean; + pos: number; + end: number; + attrs: Record; +} + +function resolveCommentId(candidate: InlineCandidate): string | undefined { + return resolveCommentIdFromAttrs(candidate.attrs ?? {}); +} + +function resolveImportedId(candidate: InlineCandidate): string | undefined { + const attrs = candidate.attrs ?? {}; + return toNonEmptyString(attrs.importedId); +} + +function toTextAddress(candidate: InlineCandidate): TextAddress | null { + const { start, end } = candidate.anchor; + if (start.blockId !== end.blockId) return null; + + return { + kind: 'text', + blockId: start.blockId, + range: { + start: start.offset, + end: end.offset, + }, + }; +} + +export function listCommentAnchors(editor: Editor): CommentAnchor[] { + const inlineIndex = getInlineIndex(editor); + const candidates = inlineIndex.byType.get('comment') ?? []; + const anchors: CommentAnchor[] = []; + const seen = new Set(); + + for (const candidate of candidates) { + const commentId = resolveCommentId(candidate); + if (!commentId) continue; + + const target = toTextAddress(candidate); + if (!target) continue; + + const dedupeKey = `${commentId}|${target.blockId}:${target.range.start}:${target.range.end}`; + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + + const attrs = (candidate.attrs ?? {}) as Record; + const isInternal = typeof attrs.internal === 'boolean' ? attrs.internal : undefined; + const status: CommentAnchorStatus = candidate.mark ? 'open' : 'resolved'; + + anchors.push({ + commentId, + importedId: resolveImportedId(candidate), + status, + target, + isInternal, + pos: candidate.pos, + end: candidate.end, + attrs, + }); + } + + return anchors; +} + +export function resolveCommentAnchorsById(editor: Editor, commentId: string): CommentAnchor[] { + return listCommentAnchors(editor).filter( + (anchor) => anchor.commentId === commentId || anchor.importedId === commentId, + ); +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/list-item-resolver.test.ts b/packages/super-editor/src/document-api-adapters/helpers/list-item-resolver.test.ts new file mode 100644 index 000000000..9736d81d8 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/list-item-resolver.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; +import { listListItems, resolveListItem } from './list-item-resolver.js'; + +type MockParagraphOptions = { + id: string; + text?: string; + numId?: number; + ilvl?: number; + markerText?: string; + path?: number[]; + numberingType?: string; +}; + +type MockNode = { + type: { name: string }; + attrs: Record; + nodeSize: number; + isBlock: boolean; + textContent: string; +}; + +function makeParagraph(options: MockParagraphOptions): MockNode { + const text = options.text ?? ''; + const numberingProperties = + options.numId != null + ? { + numId: options.numId, + ilvl: options.ilvl ?? 0, + } + : undefined; + + return { + type: { name: 'paragraph' }, + attrs: { + paraId: options.id, + paragraphProperties: numberingProperties ? { numberingProperties } : {}, + listRendering: + options.numId != null + ? { + markerText: options.markerText ?? '', + path: options.path ?? [], + numberingType: options.numberingType, + } + : null, + }, + nodeSize: Math.max(2, text.length + 2), + isBlock: true, + textContent: text, + }; +} + +function makeDoc(children: MockNode[]) { + return { + content: { + size: children.reduce((sum, child) => sum + child.nodeSize, 0), + }, + descendants(callback: (node: MockNode, pos: number) => void) { + let pos = 0; + for (const child of children) { + callback(child, pos); + pos += child.nodeSize; + } + return undefined; + }, + }; +} + +function makeEditor(children: MockNode[]): Editor { + return { + state: { + doc: makeDoc(children), + }, + converter: { + numbering: { definitions: {}, abstracts: {} }, + }, + } as unknown as Editor; +} + +describe('list-item-resolver', () => { + it('lists paragraph-based list items with paragraph node ids', () => { + const editor = makeEditor([ + makeParagraph({ + id: 'li-1', + text: 'First', + numId: 1, + ilvl: 0, + markerText: '1.', + path: [1], + numberingType: 'decimal', + }), + makeParagraph({ + id: 'li-2', + text: 'Second', + numId: 1, + ilvl: 0, + markerText: '2.', + path: [2], + numberingType: 'decimal', + }), + makeParagraph({ id: 'p-3', text: 'Plain paragraph' }), + ]); + + const result = listListItems(editor); + expect(result.total).toBe(2); + expect(result.matches.map((match) => match.nodeId)).toEqual(['li-1', 'li-2']); + expect(result.items[0]?.kind).toBe('ordered'); + expect(result.items[0]?.ordinal).toBe(1); + expect(result.items[1]?.ordinal).toBe(2); + }); + + it('applies inclusive within scope when within itself is a list item', () => { + const editor = makeEditor([ + makeParagraph({ + id: 'li-1', + text: 'First', + numId: 1, + ilvl: 0, + markerText: '1.', + path: [1], + numberingType: 'decimal', + }), + makeParagraph({ + id: 'li-2', + text: 'Second', + numId: 1, + ilvl: 0, + markerText: '2.', + path: [2], + numberingType: 'decimal', + }), + ]); + + const result = listListItems(editor, { + within: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }); + + expect(result.total).toBe(1); + expect(result.matches[0]?.nodeId).toBe('li-1'); + }); + + it('throws TARGET_NOT_FOUND when resolving a stale list address', () => { + const editor = makeEditor([ + makeParagraph({ id: 'li-1', numId: 1, markerText: '1.', path: [1], numberingType: 'decimal' }), + ]); + + expect(() => + resolveListItem(editor, { + kind: 'block', + nodeType: 'listItem', + nodeId: 'missing', + }), + ).toThrow('List item target was not found'); + }); + + it('throws INVALID_TARGET for ambiguous list ids', () => { + const editor = makeEditor([ + makeParagraph({ id: 'dup', numId: 1, markerText: '1.', path: [1], numberingType: 'decimal' }), + makeParagraph({ id: 'dup', numId: 2, markerText: '1.', path: [1], numberingType: 'decimal' }), + ]); + + try { + resolveListItem(editor, { kind: 'block', nodeType: 'listItem', nodeId: 'dup' }); + throw new Error('expected resolver to throw'); + } catch (error) { + expect((error as { code?: string }).code).toBe('INVALID_TARGET'); + } + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/list-item-resolver.ts b/packages/super-editor/src/document-api-adapters/helpers/list-item-resolver.ts new file mode 100644 index 000000000..43b8bcbfb --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/list-item-resolver.ts @@ -0,0 +1,233 @@ +import { ListHelpers } from '@helpers/list-numbering-helpers.js'; +import type { Editor } from '../../core/Editor.js'; +import type { BlockNodeAddress, ListItemAddress, ListItemInfo, ListKind, ListsListQuery } from '@superdoc/document-api'; +import { DocumentApiAdapterError } from '../errors.js'; +import { getBlockIndex } from './index-cache.js'; +import type { BlockCandidate, BlockIndex } from './node-address-resolver.js'; +import { toFiniteNumber } from './value-utils.js'; + +export type ListItemProjection = { + candidate: BlockCandidate; + address: ListItemAddress; + numId?: number; + level?: number; + marker?: string; + path?: number[]; + ordinal?: number; + kind?: ListKind; + text?: string; +}; + +function toPath(value: unknown): number[] | undefined { + if (!Array.isArray(value)) return undefined; + const parsed = value.map((entry) => toFiniteNumber(entry)).filter((entry): entry is number => entry != null); + return parsed.length > 0 ? parsed : undefined; +} + +function getNumberingProperties(node: BlockCandidate['node']): { numId?: number; level?: number } { + const attrs = (node.attrs ?? {}) as { + paragraphProperties?: { numberingProperties?: { numId?: unknown; ilvl?: unknown } | null } | null; + numberingProperties?: { numId?: unknown; ilvl?: unknown } | null; + }; + + const paragraphNumbering = attrs.paragraphProperties?.numberingProperties ?? undefined; + const fallbackNumbering = attrs.numberingProperties ?? undefined; + const numId = toFiniteNumber(paragraphNumbering?.numId ?? fallbackNumbering?.numId); + const level = toFiniteNumber(paragraphNumbering?.ilvl ?? fallbackNumbering?.ilvl); + return { numId, level }; +} + +function deriveListKindFromDefinitions(editor: Editor, numId?: number, level?: number): ListKind | undefined { + if (numId == null || level == null || !editor.converter) return undefined; + try { + const details = ListHelpers.getListDefinitionDetails({ numId, level, editor }); + const numberingType = typeof details?.listNumberingType === 'string' ? details.listNumberingType : undefined; + if (numberingType === 'bullet') return 'bullet'; + if (typeof numberingType === 'string' && numberingType.length > 0) return 'ordered'; + return undefined; + } catch { + return undefined; + } +} + +function deriveListKind( + editor: Editor, + candidate: BlockCandidate, + numId?: number, + level?: number, +): ListKind | undefined { + const listRendering = (candidate.node.attrs ?? {}) as { + listRendering?: { + numberingType?: unknown; + } | null; + }; + const numberingType = listRendering.listRendering?.numberingType; + if (numberingType === 'bullet') return 'bullet'; + if (typeof numberingType === 'string' && numberingType.length > 0) return 'ordered'; + return deriveListKindFromDefinitions(editor, numId, level); +} + +function getListText(candidate: BlockCandidate): string | undefined { + const text = (candidate.node as { textContent?: unknown }).textContent; + return typeof text === 'string' ? text : undefined; +} + +export function projectListItemCandidate(editor: Editor, candidate: BlockCandidate): ListItemProjection { + const attrs = (candidate.node.attrs ?? {}) as { + listRendering?: { + markerText?: unknown; + path?: unknown; + } | null; + }; + + const { numId, level } = getNumberingProperties(candidate.node); + const path = toPath(attrs.listRendering?.path); + const ordinal = path?.length ? path[path.length - 1] : undefined; + const marker = typeof attrs.listRendering?.markerText === 'string' ? attrs.listRendering.markerText : undefined; + + return { + candidate, + address: { + kind: 'block', + nodeType: 'listItem', + nodeId: candidate.nodeId, + }, + numId, + level, + kind: deriveListKind(editor, candidate, numId, level), + marker, + path, + ordinal, + text: getListText(candidate), + }; +} + +export function listItemProjectionToInfo(projection: ListItemProjection): ListItemInfo { + return { + address: projection.address, + marker: projection.marker, + ordinal: projection.ordinal, + path: projection.path, + level: projection.level, + kind: projection.kind, + text: projection.text, + }; +} + +function matchesListQuery(projection: ListItemProjection, query?: ListsListQuery): boolean { + if (!query) return true; + if (query.kind && projection.kind !== query.kind) return false; + if (query.level != null && projection.level !== query.level) return false; + if (query.ordinal != null && projection.ordinal !== query.ordinal) return false; + return true; +} + +export function resolveBlockScopeRange( + index: BlockIndex, + within?: BlockNodeAddress, +): { start: number; end: number } | undefined { + if (!within) return undefined; + + const matches = index.candidates.filter( + (candidate) => candidate.nodeType === within.nodeType && candidate.nodeId === within.nodeId, + ); + if (matches.length === 0) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'List scope block was not found.', { + within, + }); + } + if (matches.length > 1) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'List scope block id is ambiguous.', { + within, + count: matches.length, + }); + } + + return { + start: matches[0]!.pos, + end: matches[0]!.end, + }; +} + +function isWithinScope(candidate: BlockCandidate, scope: { start: number; end: number } | undefined): boolean { + if (!scope) return true; + return candidate.pos >= scope.start && candidate.end <= scope.end; +} + +function listItemCandidatesInScope( + index: BlockIndex, + scope: { start: number; end: number } | undefined, +): BlockCandidate[] { + return index.candidates.filter((candidate) => candidate.nodeType === 'listItem' && isWithinScope(candidate, scope)); +} + +export function buildListItemIndex(editor: Editor): { index: BlockIndex; items: ListItemProjection[] } { + const index = getBlockIndex(editor); + const items = index.candidates + .filter((candidate) => candidate.nodeType === 'listItem') + .map((candidate) => projectListItemCandidate(editor, candidate)); + return { index, items }; +} + +export function listListItems( + editor: Editor, + query?: ListsListQuery, +): { matches: ListItemAddress[]; total: number; items: ListItemInfo[] } { + if (query?.within && query.within.kind !== 'block') { + throw new DocumentApiAdapterError('INVALID_TARGET', 'lists.list only supports block within scopes.', { + within: query.within, + }); + } + + const index = getBlockIndex(editor); + const scope = resolveBlockScopeRange(index, query?.within as BlockNodeAddress | undefined); + const candidates = listItemCandidatesInScope(index, scope); + const safeOffset = Math.max(0, query?.offset ?? 0); + const safeLimit = Math.max(0, query?.limit ?? Number.POSITIVE_INFINITY); + const pageEnd = safeOffset + safeLimit; + + let total = 0; + const infos: ListItemInfo[] = []; + const matches: ListItemAddress[] = []; + + for (const candidate of candidates) { + const projection = projectListItemCandidate(editor, candidate); + if (!matchesListQuery(projection, query)) continue; + + const currentIndex = total; + total += 1; + if (currentIndex < safeOffset || currentIndex >= pageEnd) continue; + + const info = listItemProjectionToInfo(projection); + infos.push(info); + matches.push(info.address); + } + + return { + matches, + total, + items: infos, + }; +} + +export function resolveListItem(editor: Editor, address: ListItemAddress): ListItemProjection { + const index = getBlockIndex(editor); + const matches = index.candidates.filter( + (candidate) => candidate.nodeType === 'listItem' && candidate.nodeId === address.nodeId, + ); + + if (matches.length === 0) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'List item target was not found.', { + target: address, + }); + } + + if (matches.length > 1) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'List item target id is ambiguous.', { + target: address, + count: matches.length, + }); + } + + return projectListItemCandidate(editor, matches[0]!); +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/text-mutation-resolution.test.ts b/packages/super-editor/src/document-api-adapters/helpers/text-mutation-resolution.test.ts new file mode 100644 index 000000000..66c3caf49 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/text-mutation-resolution.test.ts @@ -0,0 +1,82 @@ +import type { TextAddress } from '@superdoc/document-api'; +import { buildTextMutationResolution, readTextAtResolvedRange } from './text-mutation-resolution.js'; +import type { Editor } from '../../core/Editor.js'; + +function makeEditor(text: string): Editor { + return { + state: { + doc: { + textBetween: vi.fn((_from: number, _to: number, _blockSep: string, _leafChar: string) => text), + }, + }, + } as unknown as Editor; +} + +describe('readTextAtResolvedRange', () => { + it('delegates to textBetween with canonical separators', () => { + const editor = makeEditor('Hello'); + const result = readTextAtResolvedRange(editor, { from: 1, to: 6 }); + + expect(result).toBe('Hello'); + expect(editor.state.doc.textBetween).toHaveBeenCalledWith(1, 6, '\n', '\ufffc'); + }); + + it('returns empty string for collapsed ranges', () => { + const editor = makeEditor(''); + const result = readTextAtResolvedRange(editor, { from: 1, to: 1 }); + + expect(result).toBe(''); + expect(editor.state.doc.textBetween).toHaveBeenCalledWith(1, 1, '\n', '\ufffc'); + }); +}); + +describe('buildTextMutationResolution', () => { + const target: TextAddress = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }; + + it('builds resolution with all fields', () => { + const requestedTarget: TextAddress = { kind: 'text', blockId: 'p1', range: { start: 0, end: 10 } }; + const result = buildTextMutationResolution({ + requestedTarget, + target, + range: { from: 1, to: 6 }, + text: 'Hello', + }); + + expect(result).toEqual({ + requestedTarget, + target, + range: { from: 1, to: 6 }, + text: 'Hello', + }); + }); + + it('omits requestedTarget when not provided', () => { + const result = buildTextMutationResolution({ + target, + range: { from: 1, to: 6 }, + text: 'Hello', + }); + + expect(result).toEqual({ + target, + range: { from: 1, to: 6 }, + text: 'Hello', + }); + expect('requestedTarget' in result).toBe(false); + }); + + it('handles collapsed ranges with empty text', () => { + const collapsedTarget: TextAddress = { kind: 'text', blockId: 'p1', range: { start: 0, end: 0 } }; + const result = buildTextMutationResolution({ + target: collapsedTarget, + range: { from: 1, to: 1 }, + text: '', + }); + + expect(result).toEqual({ + target: collapsedTarget, + range: { from: 1, to: 1 }, + text: '', + }); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/text-mutation-resolution.ts b/packages/super-editor/src/document-api-adapters/helpers/text-mutation-resolution.ts new file mode 100644 index 000000000..0ea253b2c --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/text-mutation-resolution.ts @@ -0,0 +1,40 @@ +import type { TextAddress, TextMutationResolution } from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; +import type { ResolvedTextTarget } from './adapter-utils.js'; + +/** Unicode Object Replacement Character — used as placeholder for leaf inline nodes in textBetween(). */ +const OBJECT_REPLACEMENT_CHAR = '\ufffc'; + +/** + * Reads the canonical flattened text between two resolved document positions. + * + * Uses `\n` as the block separator and `\ufffc` (Object Replacement Character) as the + * leaf-inline placeholder, matching the offset model used by `TextAddress`. + * + * @param editor - The editor instance to read from. + * @param range - Resolved absolute document positions. + * @returns The text content between the resolved positions. + */ +export function readTextAtResolvedRange(editor: Editor, range: ResolvedTextTarget): string { + return editor.state.doc.textBetween(range.from, range.to, '\n', OBJECT_REPLACEMENT_CHAR); +} + +/** + * Builds a `TextMutationResolution` from already-resolved adapter data. + * + * @param input - The resolved target, range, and text snapshot. + * @returns A `TextMutationResolution` suitable for inclusion in a `TextMutationReceipt`. + */ +export function buildTextMutationResolution(input: { + requestedTarget?: TextAddress; + target: TextAddress; + range: ResolvedTextTarget; + text: string; +}): TextMutationResolution { + return { + ...(input.requestedTarget ? { requestedTarget: input.requestedTarget } : {}), + target: input.target, + range: { from: input.range.from, to: input.range.to }, + text: input.text, + }; +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/tracked-change-refs.ts b/packages/super-editor/src/document-api-adapters/helpers/tracked-change-refs.ts new file mode 100644 index 000000000..2d0cf1a8e --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/tracked-change-refs.ts @@ -0,0 +1,41 @@ +import type { Editor } from '../../core/Editor.js'; +import { TrackInsertMarkName } from '../../extensions/track-changes/constants.js'; +import { buildTrackedChangeCanonicalIdMap } from './tracked-change-resolver.js'; +import { toNonEmptyString } from './value-utils.js'; + +type ReceiptInsert = { kind: 'entity'; entityType: 'trackedChange'; entityId: string }; + +type PmMarkLike = { readonly type: { readonly name: string }; readonly attrs?: Readonly> }; + +/** + * Collects tracked-insert mark references within a document range. + * + * @param editor - The editor instance to query. + * @param from - Start position in the document. + * @param to - End position in the document. + * @returns Deduplicated tracked-change entity refs, or `undefined` if none found. + */ +export function collectTrackInsertRefsInRange(editor: Editor, from: number, to: number): ReceiptInsert[] | undefined { + if (to <= from) return undefined; + + // ProseMirror Node exposes nodesBetween but the Editor type doesn't surface it directly. + const doc = editor.state.doc as { + nodesBetween?: (from: number, to: number, callback: (node: { marks?: readonly PmMarkLike[] }) => void) => void; + }; + if (typeof doc.nodesBetween !== 'function') return undefined; + + const canonicalIdByAlias = buildTrackedChangeCanonicalIdMap(editor); + const ids = new Set(); + doc.nodesBetween(from, to, (node) => { + const marks = node.marks ?? []; + for (const mark of marks) { + if (mark.type.name !== TrackInsertMarkName) continue; + const id = toNonEmptyString(mark.attrs?.id); + if (!id) continue; + ids.add(canonicalIdByAlias.get(id) ?? id); + } + }); + + if (ids.size === 0) return undefined; + return Array.from(ids).map((id) => ({ kind: 'entity', entityType: 'trackedChange', entityId: id })); +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/tracked-change-resolver.test.ts b/packages/super-editor/src/document-api-adapters/helpers/tracked-change-resolver.test.ts new file mode 100644 index 000000000..6d46658e9 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/tracked-change-resolver.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; +import { + TrackDeleteMarkName, + TrackFormatMarkName, + TrackInsertMarkName, +} from '../../extensions/track-changes/constants.js'; +import { getTrackChanges } from '../../extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; +import { + buildTrackedChangeCanonicalIdMap, + groupTrackedChanges, + resolveTrackedChange, + resolveTrackedChangeType, + toCanonicalTrackedChangeId, +} from './tracked-change-resolver.js'; + +vi.mock('../../extensions/track-changes/trackChangesHelpers/getTrackChanges.js', () => ({ + getTrackChanges: vi.fn(), +})); + +function makeEditor(): Editor { + return { + state: { + doc: { + content: { size: 100 }, + textBetween: vi.fn((_from: number, _to: number) => 'excerpt'), + }, + }, + } as unknown as Editor; +} + +function makeTrackMark(typeName: string, id: string, attrs: Record = {}) { + return { + mark: { + type: { name: typeName }, + attrs: { id, ...attrs }, + }, + }; +} + +describe('resolveTrackedChangeType', () => { + it('returns insert when hasInsert is true', () => { + expect(resolveTrackedChangeType({ hasInsert: true, hasDelete: false, hasFormat: false })).toBe('insert'); + }); + + it('returns delete when only hasDelete is true', () => { + expect(resolveTrackedChangeType({ hasInsert: false, hasDelete: true, hasFormat: false })).toBe('delete'); + }); + + it('returns format when hasFormat is true', () => { + expect(resolveTrackedChangeType({ hasInsert: false, hasDelete: false, hasFormat: true })).toBe('format'); + }); + + it('returns format over insert/delete when hasFormat is true', () => { + expect(resolveTrackedChangeType({ hasInsert: true, hasDelete: true, hasFormat: true })).toBe('format'); + }); + + it('returns insert when both hasInsert and hasDelete are true (no format)', () => { + expect(resolveTrackedChangeType({ hasInsert: true, hasDelete: true, hasFormat: false })).toBe('insert'); + }); +}); + +describe('groupTrackedChanges', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('groups marks by raw id', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackInsertMarkName, 'tc-1'), from: 1, to: 5 }, + { ...makeTrackMark(TrackDeleteMarkName, 'tc-1'), from: 5, to: 10 }, + ] as never); + + const editor = makeEditor(); + const grouped = groupTrackedChanges(editor); + + expect(grouped).toHaveLength(1); + expect(grouped[0]?.rawId).toBe('tc-1'); + expect(grouped[0]?.from).toBe(1); + expect(grouped[0]?.to).toBe(10); + expect(grouped[0]?.hasInsert).toBe(true); + expect(grouped[0]?.hasDelete).toBe(true); + }); + + it('keeps separate entries for different raw ids', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackInsertMarkName, 'tc-1'), from: 1, to: 5 }, + { ...makeTrackMark(TrackDeleteMarkName, 'tc-2'), from: 6, to: 10 }, + ] as never); + + const grouped = groupTrackedChanges(makeEditor()); + expect(grouped).toHaveLength(2); + }); + + it('generates deterministic stable ids', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackInsertMarkName, 'tc-1', { author: 'Ada' }), from: 2, to: 5 }, + ] as never); + + const editor = makeEditor(); + const first = groupTrackedChanges(editor); + // Force cache invalidation by changing doc reference + (editor.state as { doc: unknown }).doc = { + ...editor.state.doc, + textBetween: vi.fn(() => 'excerpt'), + }; + const second = groupTrackedChanges(editor); + + expect(first[0]?.id).toBe(second[0]?.id); + }); + + it('caches results by document reference', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackInsertMarkName, 'tc-1'), from: 1, to: 5 }, + ] as never); + + const editor = makeEditor(); + const first = groupTrackedChanges(editor); + const second = groupTrackedChanges(editor); + + expect(first).toBe(second); + expect(vi.mocked(getTrackChanges)).toHaveBeenCalledTimes(1); + }); + + it('returns empty array when no tracked marks exist', () => { + vi.mocked(getTrackChanges).mockReturnValue([] as never); + expect(groupTrackedChanges(makeEditor())).toEqual([]); + }); + + it('skips marks without an id', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { mark: { type: { name: TrackInsertMarkName }, attrs: {} }, from: 1, to: 5 }, + ] as never); + + expect(groupTrackedChanges(makeEditor())).toEqual([]); + }); + + it('detects format marks', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackFormatMarkName, 'tc-1'), from: 1, to: 5 }, + ] as never); + + const grouped = groupTrackedChanges(makeEditor()); + expect(grouped[0]?.hasFormat).toBe(true); + expect(grouped[0]?.hasInsert).toBe(false); + expect(grouped[0]?.hasDelete).toBe(false); + }); + + it('sorts results by from position', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackInsertMarkName, 'tc-2'), from: 10, to: 15 }, + { ...makeTrackMark(TrackDeleteMarkName, 'tc-1'), from: 1, to: 5 }, + ] as never); + + const grouped = groupTrackedChanges(makeEditor()); + expect(grouped[0]?.from).toBeLessThan(grouped[1]?.from ?? 0); + }); +}); + +describe('resolveTrackedChange', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('finds a grouped change by derived id', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackInsertMarkName, 'tc-1'), from: 1, to: 5 }, + ] as never); + + const editor = makeEditor(); + const grouped = groupTrackedChanges(editor); + const id = grouped[0]?.id; + expect(id).toBeDefined(); + + const resolved = resolveTrackedChange(editor, id!); + expect(resolved?.rawId).toBe('tc-1'); + }); + + it('returns null for unknown ids', () => { + vi.mocked(getTrackChanges).mockReturnValue([] as never); + expect(resolveTrackedChange(makeEditor(), 'unknown')).toBeNull(); + }); +}); + +describe('toCanonicalTrackedChangeId', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('maps a raw id to its canonical derived id', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackInsertMarkName, 'tc-1'), from: 1, to: 5 }, + ] as never); + + const editor = makeEditor(); + const canonical = toCanonicalTrackedChangeId(editor, 'tc-1'); + expect(typeof canonical).toBe('string'); + expect(canonical).not.toBe('tc-1'); + }); + + it('returns null for unknown raw ids', () => { + vi.mocked(getTrackChanges).mockReturnValue([] as never); + expect(toCanonicalTrackedChangeId(makeEditor(), 'missing')).toBeNull(); + }); +}); + +describe('buildTrackedChangeCanonicalIdMap', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('maps both raw id and canonical id to canonical id', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackInsertMarkName, 'tc-1'), from: 1, to: 5 }, + ] as never); + + const editor = makeEditor(); + const map = buildTrackedChangeCanonicalIdMap(editor); + const grouped = groupTrackedChanges(editor); + const canonicalId = grouped[0]?.id; + + expect(map.get('tc-1')).toBe(canonicalId); + expect(map.get(canonicalId!)).toBe(canonicalId); + }); + + it('returns empty map when no tracked changes exist', () => { + vi.mocked(getTrackChanges).mockReturnValue([] as never); + expect(buildTrackedChangeCanonicalIdMap(makeEditor()).size).toBe(0); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/tracked-change-resolver.ts b/packages/super-editor/src/document-api-adapters/helpers/tracked-change-resolver.ts new file mode 100644 index 000000000..77eaef8ff --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/tracked-change-resolver.ts @@ -0,0 +1,177 @@ +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import type { Editor } from '../../core/Editor.js'; +import type { TrackChangeType } from '@superdoc/document-api'; +import { + TrackDeleteMarkName, + TrackFormatMarkName, + TrackInsertMarkName, +} from '../../extensions/track-changes/constants.js'; +import { getTrackChanges } from '../../extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; +import { normalizeExcerpt, toNonEmptyString } from './value-utils.js'; + +const DERIVED_ID_LENGTH = 24; + +type RawTrackedMark = { + mark: { + type: { name: string }; + attrs?: Record; + }; + from: number; + to: number; +}; + +export type GroupedTrackedChange = { + rawId: string; + id: string; + from: number; + to: number; + hasInsert: boolean; + hasDelete: boolean; + hasFormat: boolean; + attrs: Record; +}; + +type ChangeTypeInput = Pick; + +function getRawTrackedMarks(editor: Editor): RawTrackedMark[] { + try { + const marks = getTrackChanges(editor.state) as RawTrackedMark[]; + return Array.isArray(marks) ? marks : []; + } catch { + return []; + } +} + +/** + * Browser-safe hash producing a {@link DERIVED_ID_LENGTH}-char hex string. + * + * Uses FNV-1a-inspired mixing across three independent accumulators to produce + * a 96-bit (24-hex-char) digest. This is NOT cryptographic — it only needs to + * be deterministic with low collision probability for tracked-change IDs. + */ +function portableHash(input: string): string { + let h1 = 0x811c9dc5; + let h2 = 0x01000193; + let h3 = 0xdeadbeef; + + for (let i = 0; i < input.length; i++) { + const c = input.charCodeAt(i); + h1 = Math.imul(h1 ^ c, 0x01000193); + h2 = Math.imul(h2 ^ c, 0x5bd1e995); + h3 = Math.imul(h3 ^ c, 0x1b873593); + } + + h1 = Math.imul(h1 ^ (h1 >>> 16), 0x85ebca6b); + h2 = Math.imul(h2 ^ (h2 >>> 16), 0xcc9e2d51); + h3 = Math.imul(h3 ^ (h3 >>> 16), 0x1b873593); + + return ( + (h1 >>> 0).toString(16).padStart(8, '0') + + (h2 >>> 0).toString(16).padStart(8, '0') + + (h3 >>> 0).toString(16).padStart(8, '0') + ).slice(0, DERIVED_ID_LENGTH); +} + +/** + * Derives a deterministic ID for a tracked change from the current document state. + * + * The ID is computed from the change type, ProseMirror positions, author, + * date, and a text excerpt. It is stable for a given document state but will + * change if the document is edited, since positions shift. These are NOT + * persistent identifiers — they are ephemeral keys valid only for the + * current transaction snapshot. + */ +function deriveTrackedChangeId(editor: Editor, change: Omit): string { + const type = resolveTrackedChangeType(change); + const excerpt = normalizeExcerpt(editor.state.doc.textBetween(change.from, change.to, ' ', '\ufffc')) ?? ''; + const author = toNonEmptyString(change.attrs.author) ?? ''; + const authorEmail = toNonEmptyString(change.attrs.authorEmail) ?? ''; + const date = toNonEmptyString(change.attrs.date) ?? ''; + const signature = `${type}|${change.from}|${change.to}|${author}|${authorEmail}|${date}|${excerpt}`; + + return portableHash(signature); +} + +export function resolveTrackedChangeType(change: ChangeTypeInput): TrackChangeType { + if (change.hasFormat) return 'format'; + if (change.hasDelete && !change.hasInsert) return 'delete'; + return 'insert'; +} + +const groupedCache = new WeakMap(); + +export function groupTrackedChanges(editor: Editor): GroupedTrackedChange[] { + const currentDoc = editor.state.doc; + const cached = groupedCache.get(editor); + if (cached && cached.doc === currentDoc) return cached.grouped; + + const marks = getRawTrackedMarks(editor); + const byRawId = new Map>(); + + for (const item of marks) { + const attrs = item.mark?.attrs ?? {}; + const id = toNonEmptyString(attrs.id); + if (!id) continue; + + const existing = byRawId.get(id); + const markType = item.mark.type.name; + const nextHasInsert = markType === TrackInsertMarkName; + const nextHasDelete = markType === TrackDeleteMarkName; + const nextHasFormat = markType === TrackFormatMarkName; + + if (!existing) { + byRawId.set(id, { + rawId: id, + from: item.from, + to: item.to, + hasInsert: nextHasInsert, + hasDelete: nextHasDelete, + hasFormat: nextHasFormat, + attrs: { ...attrs }, + }); + continue; + } + + existing.from = Math.min(existing.from, item.from); + existing.to = Math.max(existing.to, item.to); + existing.hasInsert = existing.hasInsert || nextHasInsert; + existing.hasDelete = existing.hasDelete || nextHasDelete; + existing.hasFormat = existing.hasFormat || nextHasFormat; + if (Object.keys(existing.attrs).length === 0 && Object.keys(attrs).length > 0) { + existing.attrs = { ...attrs }; + } + } + + const grouped = Array.from(byRawId.values()) + .map((change) => ({ + ...change, + id: deriveTrackedChangeId(editor, change), + })) + .sort((a, b) => { + if (a.from !== b.from) return a.from - b.from; + return a.id.localeCompare(b.id); + }); + + groupedCache.set(editor, { doc: currentDoc, grouped }); + return grouped; +} + +export function resolveTrackedChange(editor: Editor, id: string): GroupedTrackedChange | null { + const grouped = groupTrackedChanges(editor); + return grouped.find((item) => item.id === id) ?? null; +} + +export function toCanonicalTrackedChangeId(editor: Editor, rawId: string): string | null { + const grouped = groupTrackedChanges(editor); + return grouped.find((item) => item.rawId === rawId)?.id ?? null; +} + +export function buildTrackedChangeCanonicalIdMap(editor: Editor): Map { + const grouped = groupTrackedChanges(editor); + const map = new Map(); + for (const change of grouped) { + map.set(change.rawId, change.id); + map.set(change.id, change.id); + } + return map; +} diff --git a/packages/super-editor/src/document-api-adapters/lists-adapter.test.ts b/packages/super-editor/src/document-api-adapters/lists-adapter.test.ts new file mode 100644 index 000000000..53e15dced --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/lists-adapter.test.ts @@ -0,0 +1,353 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { Editor } from '../core/Editor.js'; +import { + listsExitAdapter, + listsIndentAdapter, + listsInsertAdapter, + listsListAdapter, + listsOutdentAdapter, + listsRestartAdapter, + listsSetTypeAdapter, +} from './lists-adapter.js'; +import { ListHelpers } from '../core/helpers/list-numbering-helpers.js'; + +type MockTextNode = { + type: { name: 'text' }; + marks?: Array<{ type: { name: string }; attrs?: Record }>; +}; + +type MockParagraphNode = { + type: { name: 'paragraph' }; + attrs: Record; + nodeSize: number; + isBlock: true; + textContent: string; + _textNode?: MockTextNode; +}; + +function makeListParagraph(options: { + id: string; + text?: string; + numId?: number; + ilvl?: number; + markerText?: string; + path?: number[]; + numberingType?: string; + sdBlockId?: string; + trackedMarkId?: string; +}): MockParagraphNode { + const text = options.text ?? ''; + const numberingProperties = + options.numId != null + ? { + numId: options.numId, + ilvl: options.ilvl ?? 0, + } + : undefined; + + return { + type: { name: 'paragraph' }, + attrs: { + paraId: options.id, + sdBlockId: options.sdBlockId ?? options.id, + paragraphProperties: numberingProperties ? { numberingProperties } : {}, + listRendering: + options.numId != null + ? { + markerText: options.markerText ?? '', + path: options.path ?? [], + numberingType: options.numberingType ?? 'decimal', + } + : null, + }, + nodeSize: Math.max(2, text.length + 2), + isBlock: true, + textContent: text, + _textNode: + options.trackedMarkId != null + ? { + type: { name: 'text' }, + marks: [{ type: { name: 'trackInsert' }, attrs: { id: options.trackedMarkId } }], + } + : undefined, + }; +} + +function makeDoc(children: MockParagraphNode[]) { + return { + get content() { + return { + size: children.reduce((sum, child) => sum + child.nodeSize, 0), + }; + }, + nodeAt(pos: number) { + let cursor = 0; + for (const child of children) { + if (cursor === pos) return child; + cursor += child.nodeSize; + } + return null; + }, + descendants(callback: (node: MockParagraphNode, pos: number) => void) { + let pos = 0; + for (const child of children) { + callback(child, pos); + pos += child.nodeSize; + } + return undefined; + }, + nodesBetween(from: number, to: number, callback: (node: unknown) => void) { + let pos = 0; + for (const child of children) { + const end = pos + child.nodeSize; + if (end < from || pos > to) { + pos = end; + continue; + } + callback(child); + if (child._textNode) callback(child._textNode); + pos = end; + } + return undefined; + }, + }; +} + +function makeEditor(children: MockParagraphNode[], commandOverrides: Record = {}): Editor { + const doc = makeDoc(children); + const baseCommands = { + insertListItemAt: vi.fn( + (options: { + pos: number; + position: 'before' | 'after'; + sdBlockId?: string; + text?: string; + tracked?: boolean; + }) => { + const insertionId = options.sdBlockId ?? `inserted-${Date.now()}`; + let targetIndex = -1; + let cursor = 0; + for (let i = 0; i < children.length; i += 1) { + if (cursor === options.pos) { + targetIndex = i; + break; + } + cursor += children[i]!.nodeSize; + } + if (targetIndex < 0) return false; + + const target = children[targetIndex]!; + const numbering = ( + target.attrs.paragraphProperties as { numberingProperties?: { numId?: number; ilvl?: number } } + )?.numberingProperties; + if (!numbering) return false; + + const inserted = makeListParagraph({ + id: insertionId, + sdBlockId: insertionId, + text: options.text ?? '', + numId: numbering.numId, + ilvl: numbering.ilvl, + markerText: '', + path: [1], + numberingType: target.attrs?.listRendering?.numberingType as string | undefined, + trackedMarkId: options.tracked ? `tc-${insertionId}` : undefined, + }); + const at = options.position === 'before' ? targetIndex : targetIndex + 1; + children.splice(at, 0, inserted); + return true; + }, + ), + setListTypeAt: vi.fn(() => true), + setTextSelection: vi.fn(() => true), + increaseListIndent: vi.fn(() => true), + decreaseListIndent: vi.fn(() => true), + restartNumbering: vi.fn(() => true), + exitListItemAt: vi.fn(() => true), + insertTrackedChange: vi.fn(() => true), + }; + + return { + state: { + doc, + }, + commands: { + ...baseCommands, + ...commandOverrides, + }, + converter: { + numbering: { definitions: {}, abstracts: {} }, + }, + } as unknown as Editor; +} + +describe('lists adapter', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('lists projected list items', () => { + const editor = makeEditor([ + makeListParagraph({ + id: 'li-1', + text: 'One', + numId: 1, + ilvl: 0, + markerText: '1.', + path: [1], + numberingType: 'decimal', + }), + makeListParagraph({ + id: 'li-2', + text: 'Two', + numId: 1, + ilvl: 0, + markerText: '2.', + path: [2], + numberingType: 'decimal', + }), + ]); + + const result = listsListAdapter(editor); + expect(result.total).toBe(2); + expect(result.matches.map((match) => match.nodeId)).toEqual(['li-1', 'li-2']); + }); + + it('inserts a list item with deterministic insertionPoint at offset 0', () => { + const editor = makeEditor([ + makeListParagraph({ + id: 'li-1', + text: 'One', + numId: 1, + ilvl: 0, + markerText: '1.', + path: [1], + numberingType: 'decimal', + }), + ]); + + const result = listsInsertAdapter( + editor, + { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + position: 'after', + text: 'Inserted', + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.insertionPoint.range).toEqual({ start: 0, end: 0 }); + }); + + it('throws TRACK_CHANGE_COMMAND_UNAVAILABLE for direct-only tracked requests', () => { + const editor = makeEditor([ + makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'decimal' }), + ]); + + expect(() => + listsSetTypeAdapter( + editor, + { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + kind: 'bullet', + }, + { changeMode: 'tracked' }, + ), + ).toThrow('does not support tracked mode'); + }); + + it('returns NO_OP when setType already matches requested kind', () => { + const editor = makeEditor([ + makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'bullet' }), + ]); + + const result = listsSetTypeAdapter(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + kind: 'bullet', + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.failure.code).toBe('NO_OP'); + }); + + it('returns NO_OP for outdent at level 0', () => { + const editor = makeEditor([ + makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'decimal' }), + ]); + + const result = listsOutdentAdapter(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.failure.code).toBe('NO_OP'); + }); + + it('returns NO_OP for indent when list definition does not support next level', () => { + const hasDefinitionSpy = vi.spyOn(ListHelpers, 'hasListDefinition').mockReturnValue(false); + const editor = makeEditor([ + makeListParagraph({ + id: 'li-1', + numId: 1, + ilvl: 2, + markerText: 'iii.', + path: [1, 1, 3], + numberingType: 'lowerRoman', + }), + ]); + + const result = listsIndentAdapter(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.failure.code).toBe('NO_OP'); + expect(hasDefinitionSpy).toHaveBeenCalled(); + }); + + it('returns NO_OP for restart when target is already effective start at 1 and run start', () => { + const editor = makeEditor([ + makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'decimal' }), + ]); + + const result = listsRestartAdapter(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.failure.code).toBe('NO_OP'); + }); + + it('throws TARGET_NOT_FOUND for stale list targets', () => { + const editor = makeEditor([ + makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'decimal' }), + ]); + + expect(() => + listsExitAdapter(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'missing' }, + }), + ).toThrow('List item target was not found'); + }); + + it('maps explicit non-applied exit command to INVALID_TARGET', () => { + const editor = makeEditor( + [makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'decimal' })], + { exitListItemAt: vi.fn(() => false) }, + ); + + const result = listsExitAdapter(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.failure.code).toBe('INVALID_TARGET'); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/lists-adapter.ts b/packages/super-editor/src/document-api-adapters/lists-adapter.ts new file mode 100644 index 000000000..a2eb8ee07 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/lists-adapter.ts @@ -0,0 +1,379 @@ +import { v4 as uuidv4 } from 'uuid'; +import type { Editor } from '../core/Editor.js'; +import type { + ListInsertInput, + ListItemInfo, + ListSetTypeInput, + ListsExitResult, + ListsGetInput, + ListsInsertResult, + ListsListQuery, + ListsListResult, + ListsMutateItemResult, + ListTargetInput, + MutationOptions, +} from '@superdoc/document-api'; +import { DocumentApiAdapterError } from './errors.js'; +import { clearIndexCache, getBlockIndex } from './helpers/index-cache.js'; +import { collectTrackInsertRefsInRange } from './helpers/tracked-change-refs.js'; +import { + listItemProjectionToInfo, + listListItems, + resolveListItem, + type ListItemProjection, +} from './helpers/list-item-resolver.js'; +import { ListHelpers } from '../core/helpers/list-numbering-helpers.js'; + +type InsertListItemAtCommand = (options: { + pos: number; + position: 'before' | 'after'; + text?: string; + sdBlockId?: string; + tracked?: boolean; +}) => boolean; + +type SetListTypeAtCommand = (options: { pos: number; kind: 'ordered' | 'bullet' }) => boolean; +type ExitListItemAtCommand = (options: { pos: number }) => boolean; +type SetTextSelectionCommand = (options: { from: number; to?: number }) => boolean; + +function requireCommand(command: T | undefined, message: string): T { + if (command) return command; + throw new DocumentApiAdapterError('COMMAND_UNAVAILABLE', message); +} + +function toListsFailure(code: 'NO_OP' | 'INVALID_TARGET', message: string, details?: unknown) { + return { success: false as const, failure: { code, message, details } }; +} + +function ensureDirectOnly(operation: string, options?: MutationOptions): void { + const mode = options?.changeMode ?? 'direct'; + if (mode === 'direct') return; + throw new DocumentApiAdapterError( + 'TRACK_CHANGE_COMMAND_UNAVAILABLE', + `${operation} does not support tracked mode in v1.`, + ); +} + +function ensureTrackedInsertCapability(editor: Editor, mode: 'direct' | 'tracked'): void { + if (mode !== 'tracked') return; + const hasTrackedInsertCommand = typeof editor.commands?.insertTrackedChange === 'function'; + if (!hasTrackedInsertCommand) { + throw new DocumentApiAdapterError( + 'TRACK_CHANGE_COMMAND_UNAVAILABLE', + 'lists.insert tracked mode is not available on this editor instance.', + ); + } +} + +function resolveInsertedListItem(editor: Editor, sdBlockId: string): ListItemProjection { + const index = getBlockIndex(editor); + const byNodeId = index.candidates.find( + (candidate) => candidate.nodeType === 'listItem' && candidate.nodeId === sdBlockId, + ); + if (byNodeId) return resolveListItem(editor, { kind: 'block', nodeType: 'listItem', nodeId: byNodeId.nodeId }); + + const bySdBlockId = index.candidates.find((candidate) => { + if (candidate.nodeType !== 'listItem') return false; + const attrs = (candidate.node as { attrs?: { sdBlockId?: unknown } }).attrs; + return typeof attrs?.sdBlockId === 'string' && attrs.sdBlockId === sdBlockId; + }); + + if (bySdBlockId) { + return resolveListItem(editor, { kind: 'block', nodeType: 'listItem', nodeId: bySdBlockId.nodeId }); + } + + throw new DocumentApiAdapterError( + 'TARGET_NOT_FOUND', + `Inserted list item with sdBlockId "${sdBlockId}" could not be resolved after insertion.`, + ); +} + +function selectionAnchorPos(item: ListItemProjection): number { + return item.candidate.pos + 1; +} + +function setSelectionToListItem(editor: Editor, item: ListItemProjection): boolean { + const setTextSelection = requireCommand( + editor.commands?.setTextSelection as SetTextSelectionCommand | undefined, + 'List selection command is not available on this editor instance.', + ); + const anchor = selectionAnchorPos(item); + return Boolean(setTextSelection({ from: anchor, to: anchor })); +} + +function isAtMaximumLevel(editor: Editor, item: ListItemProjection): boolean { + if (item.numId == null || item.level == null) return false; + return !ListHelpers.hasListDefinition(editor, item.numId, item.level + 1); +} + +function isRestartNoOp(editor: Editor, item: ListItemProjection): boolean { + if (item.ordinal !== 1) return false; + if (item.numId == null) return false; + + const index = getBlockIndex(editor); + const currentIndex = index.candidates.findIndex( + (candidate) => candidate.nodeType === 'listItem' && candidate.nodeId === item.address.nodeId, + ); + if (currentIndex <= 0) return true; + + for (let cursor = currentIndex - 1; cursor >= 0; cursor -= 1) { + const previous = index.candidates[cursor]!; + if (previous.node.type.name !== 'paragraph') { + return true; + } + if (previous.nodeType !== 'listItem') { + return true; + } + + const previousProjection = resolveListItem(editor, { + kind: 'block', + nodeType: 'listItem', + nodeId: previous.nodeId, + }); + + return previousProjection.numId !== item.numId; + } + + return true; +} + +function withListTarget(editor: Editor, input: ListTargetInput): ListItemProjection { + return resolveListItem(editor, input.target); +} + +export function listsListAdapter(editor: Editor, query?: ListsListQuery): ListsListResult { + return listListItems(editor, query); +} + +export function listsGetAdapter(editor: Editor, input: ListsGetInput): ListItemInfo { + const item = resolveListItem(editor, input.address); + return listItemProjectionToInfo(item); +} + +export function listsInsertAdapter( + editor: Editor, + input: ListInsertInput, + options?: MutationOptions, +): ListsInsertResult { + const target = withListTarget(editor, { target: input.target }); + const changeMode = options?.changeMode ?? 'direct'; + const mode = changeMode === 'tracked' ? 'tracked' : 'direct'; + ensureTrackedInsertCapability(editor, mode); + + const insertListItemAt = requireCommand( + editor.commands?.insertListItemAt as InsertListItemAtCommand | undefined, + 'Insert list item command is not available on this editor instance.', + ); + + const createdId = uuidv4(); + const didApply = insertListItemAt({ + pos: target.candidate.pos, + position: input.position, + text: input.text ?? '', + sdBlockId: createdId, + tracked: mode === 'tracked', + }); + + if (!didApply) { + return toListsFailure('INVALID_TARGET', 'List item insertion could not be applied at the requested target.', { + target: input.target, + position: input.position, + }); + } + + clearIndexCache(editor); + + let created: ListItemProjection; + try { + created = resolveInsertedListItem(editor, createdId); + } catch { + // Insertion succeeded but the new item could not be located in the index. + // Return a degraded success with the generated sdBlockId as the address. + return { + success: true, + item: { kind: 'block', nodeType: 'listItem', nodeId: createdId }, + insertionPoint: { + kind: 'text', + blockId: createdId, + range: { start: 0, end: 0 }, + }, + }; + } + + return { + success: true, + item: created.address, + insertionPoint: { + kind: 'text', + blockId: created.address.nodeId, + range: { start: 0, end: 0 }, + }, + trackedChangeRefs: + mode === 'tracked' + ? collectTrackInsertRefsInRange(editor, created.candidate.pos, created.candidate.end) + : undefined, + }; +} + +export function listsSetTypeAdapter( + editor: Editor, + input: ListSetTypeInput, + options?: MutationOptions, +): ListsMutateItemResult { + ensureDirectOnly('lists.setType', options); + const target = withListTarget(editor, { target: input.target }); + if (target.kind === input.kind) { + return toListsFailure('NO_OP', 'List item already has the requested list kind.', { + target: input.target, + kind: input.kind, + }); + } + + const setListTypeAt = requireCommand( + editor.commands?.setListTypeAt as SetListTypeAtCommand | undefined, + 'Set list type command is not available on this editor instance.', + ); + + const didApply = setListTypeAt({ + pos: target.candidate.pos, + kind: input.kind, + }); + + if (!didApply) { + return toListsFailure('INVALID_TARGET', 'List type conversion could not be applied.', { + target: input.target, + kind: input.kind, + }); + } + + return { + success: true, + item: target.address, + }; +} + +export function listsIndentAdapter( + editor: Editor, + input: ListTargetInput, + options?: MutationOptions, +): ListsMutateItemResult { + ensureDirectOnly('lists.indent', options); + const target = withListTarget(editor, input); + if (isAtMaximumLevel(editor, target)) { + return toListsFailure('NO_OP', 'List item is already at the maximum supported level.', { target: input.target }); + } + + if (!setSelectionToListItem(editor, target)) { + return toListsFailure('INVALID_TARGET', 'List item target could not be selected for indentation.', { + target: input.target, + }); + } + + const increaseListIndent = requireCommand<() => boolean>( + editor.commands?.increaseListIndent as (() => boolean) | undefined, + 'Increase list indent command is not available on this editor instance.', + ); + const didApply = increaseListIndent(); + if (!didApply) { + return toListsFailure('INVALID_TARGET', 'List indentation could not be applied.', { target: input.target }); + } + + return { + success: true, + item: target.address, + }; +} + +export function listsOutdentAdapter( + editor: Editor, + input: ListTargetInput, + options?: MutationOptions, +): ListsMutateItemResult { + ensureDirectOnly('lists.outdent', options); + const target = withListTarget(editor, input); + if ((target.level ?? 0) <= 0) { + return toListsFailure('NO_OP', 'List item is already at level 0.', { target: input.target }); + } + + if (!setSelectionToListItem(editor, target)) { + return toListsFailure('INVALID_TARGET', 'List item target could not be selected for outdent.', { + target: input.target, + }); + } + + const decreaseListIndent = requireCommand<() => boolean>( + editor.commands?.decreaseListIndent as (() => boolean) | undefined, + 'Decrease list indent command is not available on this editor instance.', + ); + const didApply = decreaseListIndent(); + if (!didApply) { + return toListsFailure('INVALID_TARGET', 'List outdent could not be applied.', { target: input.target }); + } + + return { + success: true, + item: target.address, + }; +} + +export function listsRestartAdapter( + editor: Editor, + input: ListTargetInput, + options?: MutationOptions, +): ListsMutateItemResult { + ensureDirectOnly('lists.restart', options); + const target = withListTarget(editor, input); + if (target.numId == null) { + return toListsFailure('INVALID_TARGET', 'List restart requires numbering metadata on the target item.', { + target: input.target, + }); + } + if (isRestartNoOp(editor, target)) { + return toListsFailure('NO_OP', 'List item is already the start of a sequence that effectively starts at 1.', { + target: input.target, + }); + } + + if (!setSelectionToListItem(editor, target)) { + return toListsFailure('INVALID_TARGET', 'List item target could not be selected for restart.', { + target: input.target, + }); + } + + const restartNumbering = requireCommand<() => boolean>( + editor.commands?.restartNumbering as (() => boolean) | undefined, + 'Restart numbering command is not available on this editor instance.', + ); + const didApply = restartNumbering(); + if (!didApply) { + return toListsFailure('INVALID_TARGET', 'List restart could not be applied.', { target: input.target }); + } + + return { + success: true, + item: target.address, + }; +} + +export function listsExitAdapter(editor: Editor, input: ListTargetInput, options?: MutationOptions): ListsExitResult { + ensureDirectOnly('lists.exit', options); + const target = withListTarget(editor, input); + + const exitListItemAt = requireCommand( + editor.commands?.exitListItemAt as ExitListItemAtCommand | undefined, + 'Exit list item command is not available on this editor instance.', + ); + const didApply = exitListItemAt({ pos: target.candidate.pos }); + if (!didApply) { + return toListsFailure('INVALID_TARGET', 'List exit could not be applied.', { target: input.target }); + } + + return { + success: true, + paragraph: { + kind: 'block', + nodeType: 'paragraph', + nodeId: target.address.nodeId, + }, + }; +} diff --git a/packages/super-editor/src/document-api-adapters/track-changes-adapter.test.ts b/packages/super-editor/src/document-api-adapters/track-changes-adapter.test.ts new file mode 100644 index 000000000..88b42ca6f --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/track-changes-adapter.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Editor } from '../core/Editor.js'; +import { + trackChangesAcceptAdapter, + trackChangesAcceptAllAdapter, + trackChangesGetAdapter, + trackChangesListAdapter, + trackChangesRejectAdapter, + trackChangesRejectAllAdapter, +} from './track-changes-adapter.js'; +import { TrackDeleteMarkName, TrackInsertMarkName } from '../extensions/track-changes/constants.js'; +import { getTrackChanges } from '../extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; + +vi.mock('../extensions/track-changes/trackChangesHelpers/getTrackChanges.js', () => ({ + getTrackChanges: vi.fn(), +})); + +function makeEditor(overrides: Partial = {}): Editor { + return { + state: { + doc: { + content: { size: 100 }, + textBetween(from: number, to: number) { + return `excerpt-${from}-${to}`; + }, + }, + }, + commands: { + acceptTrackedChangeById: vi.fn(() => true), + rejectTrackedChangeById: vi.fn(() => true), + acceptAllTrackedChanges: vi.fn(() => true), + rejectAllTrackedChanges: vi.fn(() => true), + }, + ...overrides, + } as unknown as Editor; +} + +describe('track-changes adapters', () => { + it('lists tracked changes with stable trackedChange entity addresses', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { + mark: { + type: { name: TrackInsertMarkName }, + attrs: { id: 'tc-1', author: 'Ada', authorEmail: 'ada@example.com' }, + }, + from: 2, + to: 5, + }, + { + mark: { + type: { name: TrackDeleteMarkName }, + attrs: { id: 'tc-1' }, + }, + from: 5, + to: 8, + }, + ] as never); + + const result = trackChangesListAdapter(makeEditor()); + expect(result.total).toBe(1); + expect(result.matches[0]).toMatchObject({ + kind: 'entity', + entityType: 'trackedChange', + }); + expect(typeof result.matches[0]?.entityId).toBe('string'); + expect(result.changes?.[0]?.id).toBe(result.matches[0]?.entityId); + expect(result.changes?.[0]?.type).toBe('insert'); + expect(result.changes?.[0]?.excerpt).toContain('excerpt-2-8'); + }); + + it('respects list type filters and pagination', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { + mark: { + type: { name: TrackInsertMarkName }, + attrs: { id: 'tc-1' }, + }, + from: 1, + to: 2, + }, + { + mark: { + type: { name: TrackDeleteMarkName }, + attrs: { id: 'tc-2' }, + }, + from: 3, + to: 4, + }, + ] as never); + + const result = trackChangesListAdapter(makeEditor(), { type: 'delete', limit: 1, offset: 0 }); + expect(result.total).toBe(1); + expect(result.matches).toHaveLength(1); + expect(result.changes?.[0]?.type).toBe('delete'); + }); + + it('gets a tracked change by id', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { + mark: { + type: { name: TrackInsertMarkName }, + attrs: { id: 'tc-1' }, + }, + from: 2, + to: 5, + }, + ] as never); + + const editor = makeEditor(); + const listed = trackChangesListAdapter(editor, { limit: 1 }); + const id = listed.matches[0]?.entityId; + expect(typeof id).toBe('string'); + const change = trackChangesGetAdapter(editor, { id: id as string }); + expect(change.id).toBe(id); + }); + + it('throws for unknown tracked change ids', () => { + vi.mocked(getTrackChanges).mockReturnValue([] as never); + expect(() => trackChangesGetAdapter(makeEditor(), { id: 'missing' })).toThrow('was not found'); + + try { + trackChangesGetAdapter(makeEditor(), { id: 'missing' }); + } catch (error) { + expect((error as { code?: string }).code).toBe('TARGET_NOT_FOUND'); + } + }); + + it('maps accept/reject commands to receipts', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { + mark: { + type: { name: TrackInsertMarkName }, + attrs: { id: 'tc-1' }, + }, + from: 1, + to: 2, + }, + ] as never); + + const acceptTrackedChangeById = vi.fn(() => true); + const rejectTrackedChangeById = vi.fn(() => true); + const acceptAllTrackedChanges = vi.fn(() => true); + const rejectAllTrackedChanges = vi.fn(() => true); + const editor = makeEditor({ + commands: { + acceptTrackedChangeById, + rejectTrackedChangeById, + acceptAllTrackedChanges, + rejectAllTrackedChanges, + } as never, + }); + + const listed = trackChangesListAdapter(editor, { limit: 1 }); + const id = listed.matches[0]?.entityId as string; + expect(typeof id).toBe('string'); + + expect(trackChangesAcceptAdapter(editor, { id }).success).toBe(true); + expect(trackChangesRejectAdapter(editor, { id }).success).toBe(true); + expect(trackChangesAcceptAllAdapter(editor, {}).success).toBe(true); + expect(trackChangesRejectAllAdapter(editor, {}).success).toBe(true); + expect(acceptTrackedChangeById).toHaveBeenCalledWith('tc-1'); + expect(rejectTrackedChangeById).toHaveBeenCalledWith('tc-1'); + }); + + it('throws TARGET_NOT_FOUND when accepting/rejecting an unknown id', () => { + vi.mocked(getTrackChanges).mockReturnValue([] as never); + + expect(() => trackChangesAcceptAdapter(makeEditor(), { id: 'missing' })).toThrow('was not found'); + expect(() => trackChangesRejectAdapter(makeEditor(), { id: 'missing' })).toThrow('was not found'); + + try { + trackChangesAcceptAdapter(makeEditor(), { id: 'missing' }); + } catch (error) { + expect((error as { code?: string }).code).toBe('TARGET_NOT_FOUND'); + } + }); + + it('throws COMMAND_UNAVAILABLE when accept/reject commands are missing', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { + mark: { + type: { name: TrackInsertMarkName }, + attrs: { id: 'tc-1' }, + }, + from: 1, + to: 2, + }, + ] as never); + + const editor = makeEditor({ + commands: { + acceptTrackedChangeById: undefined, + rejectTrackedChangeById: undefined, + acceptAllTrackedChanges: vi.fn(() => true), + rejectAllTrackedChanges: vi.fn(() => true), + } as never, + }); + + const listed = trackChangesListAdapter(editor, { limit: 1 }); + const id = listed.matches[0]?.entityId as string; + + expect(() => trackChangesAcceptAdapter(editor, { id })).toThrow('Accept tracked change command is not available'); + expect(() => trackChangesRejectAdapter(editor, { id })).toThrow('Reject tracked change command is not available'); + }); + + it('returns NO_OP failure when accept/reject commands do not apply', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { + mark: { + type: { name: TrackInsertMarkName }, + attrs: { id: 'tc-1' }, + }, + from: 1, + to: 2, + }, + ] as never); + + const editor = makeEditor({ + commands: { + acceptTrackedChangeById: vi.fn(() => false), + rejectTrackedChangeById: vi.fn(() => false), + acceptAllTrackedChanges: vi.fn(() => true), + rejectAllTrackedChanges: vi.fn(() => true), + } as never, + }); + + const listed = trackChangesListAdapter(editor, { limit: 1 }); + const id = listed.matches[0]?.entityId as string; + + const acceptReceipt = trackChangesAcceptAdapter(editor, { id }); + const rejectReceipt = trackChangesRejectAdapter(editor, { id }); + expect(acceptReceipt.success).toBe(false); + expect(acceptReceipt.failure?.code).toBe('NO_OP'); + expect(rejectReceipt.success).toBe(false); + expect(rejectReceipt.failure?.code).toBe('NO_OP'); + }); + + it('throws COMMAND_UNAVAILABLE for missing accept-all/reject-all commands', () => { + vi.mocked(getTrackChanges).mockReturnValue([] as never); + + const editor = makeEditor({ + commands: { + acceptTrackedChangeById: vi.fn(() => true), + rejectTrackedChangeById: vi.fn(() => true), + acceptAllTrackedChanges: undefined, + rejectAllTrackedChanges: undefined, + } as never, + }); + + expect(() => trackChangesAcceptAllAdapter(editor, {})).toThrow( + 'Accept all tracked changes command is not available', + ); + expect(() => trackChangesRejectAllAdapter(editor, {})).toThrow( + 'Reject all tracked changes command is not available', + ); + }); + + it('returns NO_OP failure when accept-all/reject-all do not apply', () => { + vi.mocked(getTrackChanges).mockReturnValue([] as never); + + const editor = makeEditor({ + commands: { + acceptTrackedChangeById: vi.fn(() => true), + rejectTrackedChangeById: vi.fn(() => true), + acceptAllTrackedChanges: vi.fn(() => false), + rejectAllTrackedChanges: vi.fn(() => false), + } as never, + }); + + const acceptAllReceipt = trackChangesAcceptAllAdapter(editor, {}); + const rejectAllReceipt = trackChangesRejectAllAdapter(editor, {}); + expect(acceptAllReceipt.success).toBe(false); + expect(acceptAllReceipt.failure?.code).toBe('NO_OP'); + expect(rejectAllReceipt.success).toBe(false); + expect(rejectAllReceipt.failure?.code).toBe('NO_OP'); + }); + + it('resolves stable ids across calls when raw ids differ', () => { + const marks = [ + { + mark: { + type: { name: TrackInsertMarkName }, + attrs: { id: 'raw-1', date: '2026-02-11T00:00:00.000Z' }, + }, + from: 2, + to: 5, + }, + ]; + + vi.mocked(getTrackChanges).mockImplementation(() => marks as never); + const editor = makeEditor(); + + const listed = trackChangesListAdapter(editor, { limit: 1 }); + const stableId = listed.matches[0]?.entityId; + expect(typeof stableId).toBe('string'); + + marks[0] = { + ...marks[0], + mark: { + ...marks[0].mark, + attrs: { ...marks[0].mark.attrs, id: 'raw-2' }, + }, + }; + + const resolved = trackChangesGetAdapter(editor, { id: stableId as string }); + expect(resolved.id).toBe(stableId); + }); + + it('throws TARGET_NOT_FOUND when accepting an id that was already processed', () => { + const marks = [ + { + mark: { + type: { name: TrackInsertMarkName }, + attrs: { id: 'raw-1' }, + }, + from: 2, + to: 5, + }, + ]; + + vi.mocked(getTrackChanges).mockImplementation(() => marks as never); + + const state = { + doc: { + content: { size: 100 }, + textBetween(from: number, to: number) { + return `excerpt-${from}-${to}`; + }, + }, + }; + const acceptTrackedChangeById = vi.fn(() => { + marks.splice(0, marks.length); + // Simulate ProseMirror creating a new doc reference after mutation + state.doc = { ...state.doc }; + return true; + }); + + const editor = makeEditor({ + state: state as never, + commands: { + acceptTrackedChangeById, + rejectTrackedChangeById: vi.fn(() => true), + acceptAllTrackedChanges: vi.fn(() => true), + rejectAllTrackedChanges: vi.fn(() => true), + } as never, + }); + + const listed = trackChangesListAdapter(editor, { limit: 1 }); + const stableId = listed.matches[0]?.entityId as string; + + expect(trackChangesAcceptAdapter(editor, { id: stableId }).success).toBe(true); + expect(() => trackChangesAcceptAdapter(editor, { id: stableId })).toThrow('was not found'); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/track-changes-adapter.ts b/packages/super-editor/src/document-api-adapters/track-changes-adapter.ts new file mode 100644 index 000000000..3048198d1 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/track-changes-adapter.ts @@ -0,0 +1,133 @@ +import type { Editor } from '../core/Editor.js'; +import type { + Receipt, + TrackChangeInfo, + TrackChangesAcceptAllInput, + TrackChangesAcceptInput, + TrackChangesGetInput, + TrackChangesListInput, + TrackChangesRejectAllInput, + TrackChangesRejectInput, + TrackChangeType, + TrackChangesListResult, +} from '@superdoc/document-api'; +import { DocumentApiAdapterError } from './errors.js'; +import { paginate } from './helpers/adapter-utils.js'; +import { + groupTrackedChanges, + resolveTrackedChange, + resolveTrackedChangeType, + type GroupedTrackedChange, +} from './helpers/tracked-change-resolver.js'; +import { normalizeExcerpt, toNonEmptyString } from './helpers/value-utils.js'; + +function buildTrackChangeInfo(editor: Editor, change: GroupedTrackedChange): TrackChangeInfo { + const excerpt = normalizeExcerpt(editor.state.doc.textBetween(change.from, change.to, ' ', '\ufffc')); + const type = resolveTrackedChangeType(change); + + return { + address: { + kind: 'entity', + entityType: 'trackedChange', + entityId: change.id, + }, + id: change.id, + type, + author: toNonEmptyString(change.attrs.author), + authorEmail: toNonEmptyString(change.attrs.authorEmail), + authorImage: toNonEmptyString(change.attrs.authorImage), + date: toNonEmptyString(change.attrs.date), + excerpt, + }; +} + +function filterByType(changes: GroupedTrackedChange[], requestedType?: TrackChangeType): GroupedTrackedChange[] { + if (!requestedType) return changes; + return changes.filter((change) => resolveTrackedChangeType(change) === requestedType); +} + +function requireTrackChangeById(editor: Editor, id: string): GroupedTrackedChange { + const change = resolveTrackedChange(editor, id); + if (change) return change; + + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', `Tracked change "${id}" was not found.`, { + id, + }); +} + +function requireTrackChangesCommand(command: T | undefined, operation: string): T { + if (command) return command; + + throw new DocumentApiAdapterError( + 'COMMAND_UNAVAILABLE', + `${operation} command is not available on this editor instance.`, + ); +} + +function toNoOpReceipt(message: string, details?: unknown): Receipt { + return { + success: false, + failure: { + code: 'NO_OP', + message, + details, + }, + }; +} + +export function trackChangesListAdapter(editor: Editor, input?: TrackChangesListInput): TrackChangesListResult { + const query = input; + const grouped = filterByType(groupTrackedChanges(editor), query?.type); + const paged = paginate(grouped, query?.offset, query?.limit); + const changes = paged.items.map((item) => buildTrackChangeInfo(editor, item)); + const matches = changes.map((change) => change.address); + + return { + matches, + total: paged.total, + changes: changes.length ? changes : undefined, + }; +} + +export function trackChangesGetAdapter(editor: Editor, input: TrackChangesGetInput): TrackChangeInfo { + const { id } = input; + return buildTrackChangeInfo(editor, requireTrackChangeById(editor, id)); +} + +export function trackChangesAcceptAdapter(editor: Editor, input: TrackChangesAcceptInput): Receipt { + const { id } = input; + const change = requireTrackChangeById(editor, id); + + const acceptById = requireTrackChangesCommand(editor.commands?.acceptTrackedChangeById, 'Accept tracked change'); + const didAccept = Boolean(acceptById(change.rawId)); + if (didAccept) return { success: true }; + + return toNoOpReceipt(`Accept tracked change "${id}" produced no change.`, { id }); +} + +export function trackChangesRejectAdapter(editor: Editor, input: TrackChangesRejectInput): Receipt { + const { id } = input; + const change = requireTrackChangeById(editor, id); + + const rejectById = requireTrackChangesCommand(editor.commands?.rejectTrackedChangeById, 'Reject tracked change'); + const didReject = Boolean(rejectById(change.rawId)); + if (didReject) return { success: true }; + + return toNoOpReceipt(`Reject tracked change "${id}" produced no change.`, { id }); +} + +export function trackChangesAcceptAllAdapter(editor: Editor, _input: TrackChangesAcceptAllInput): Receipt { + const acceptAll = requireTrackChangesCommand(editor.commands?.acceptAllTrackedChanges, 'Accept all tracked changes'); + const didAcceptAll = Boolean(acceptAll()); + if (didAcceptAll) return { success: true }; + + return toNoOpReceipt('Accept all tracked changes produced no change.'); +} + +export function trackChangesRejectAllAdapter(editor: Editor, _input: TrackChangesRejectAllInput): Receipt { + const rejectAll = requireTrackChangesCommand(editor.commands?.rejectAllTrackedChanges, 'Reject all tracked changes'); + const didRejectAll = Boolean(rejectAll()); + if (didRejectAll) return { success: true }; + + return toNoOpReceipt('Reject all tracked changes produced no change.'); +} diff --git a/packages/super-editor/src/document-api-adapters/write-adapter.test.ts b/packages/super-editor/src/document-api-adapters/write-adapter.test.ts new file mode 100644 index 000000000..223639bd6 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/write-adapter.test.ts @@ -0,0 +1,565 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import type { Editor } from '../core/Editor.js'; +import { writeAdapter } from './write-adapter.js'; +import * as trackedChangeResolver from './helpers/tracked-change-resolver.js'; + +type NodeOptions = { + attrs?: Record; + text?: string; + isInline?: boolean; + isBlock?: boolean; + isLeaf?: boolean; + inlineContent?: boolean; + nodeSize?: number; +}; + +function createNode(typeName: string, children: ProseMirrorNode[] = [], options: NodeOptions = {}): ProseMirrorNode { + const attrs = options.attrs ?? {}; + const text = options.text ?? ''; + const isText = typeName === 'text'; + const isInline = options.isInline ?? isText; + const isBlock = options.isBlock ?? (!isInline && typeName !== 'doc'); + const inlineContent = options.inlineContent ?? isBlock; + const isLeaf = options.isLeaf ?? (isInline && !isText && children.length === 0); + + const contentSize = children.reduce((sum, child) => sum + child.nodeSize, 0); + const nodeSize = isText ? text.length : options.nodeSize != null ? options.nodeSize : isLeaf ? 1 : contentSize + 2; + + return { + type: { name: typeName }, + attrs, + text: isText ? text : undefined, + nodeSize, + isText, + isInline, + isBlock, + inlineContent, + isTextblock: inlineContent, + isLeaf, + childCount: children.length, + child(index: number) { + return children[index]!; + }, + descendants(callback: (node: ProseMirrorNode, pos: number) => void) { + let offset = 0; + for (const child of children) { + callback(child, offset); + offset += child.nodeSize; + } + }, + } as unknown as ProseMirrorNode; +} + +function makeEditor(text = 'Hello'): { + editor: Editor; + dispatch: ReturnType; + insertTrackedChange: ReturnType; + textBetween: ReturnType; + tr: { + insertText: ReturnType; + delete: ReturnType; + setMeta: ReturnType; + addMark: ReturnType; + }; +} { + const textNode = createNode('text', [], { text }); + const paragraph = createNode('paragraph', [textNode], { + attrs: { sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + + const tr = { + insertText: vi.fn(), + delete: vi.fn(), + setMeta: vi.fn(), + addMark: vi.fn(), + }; + tr.insertText.mockReturnValue(tr); + tr.delete.mockReturnValue(tr); + tr.setMeta.mockReturnValue(tr); + tr.addMark.mockReturnValue(tr); + + const dispatch = vi.fn(); + const insertTrackedChange = vi.fn(() => true); + const textBetween = vi.fn((from: number, to: number) => { + const start = Math.max(0, from - 1); + const end = Math.max(start, to - 1); + return text.slice(start, end); + }); + + const editor = { + state: { + doc: { + ...doc, + textBetween, + }, + tr, + }, + commands: { + insertTrackedChange, + }, + dispatch, + } as unknown as Editor; + + return { editor, dispatch, insertTrackedChange, textBetween, tr }; +} + +function makeEditorWithoutEditableTextBlock(): { + editor: Editor; +} { + const table = createNode('table', [], { + attrs: { sdBlockId: 't1' }, + isBlock: true, + inlineContent: false, + }); + const doc = createNode('doc', [table], { isBlock: false, inlineContent: false }); + + const tr = { + insertText: vi.fn(), + delete: vi.fn(), + setMeta: vi.fn(), + addMark: vi.fn(), + }; + tr.insertText.mockReturnValue(tr); + tr.delete.mockReturnValue(tr); + tr.setMeta.mockReturnValue(tr); + tr.addMark.mockReturnValue(tr); + + const editor = { + state: { + doc: { + ...doc, + textBetween: vi.fn(() => ''), + }, + tr, + }, + commands: { + insertTrackedChange: vi.fn(() => true), + }, + dispatch: vi.fn(), + } as unknown as Editor; + + return { editor }; +} + +function makeEditorWithBlankParagraph(): { + editor: Editor; + dispatch: ReturnType; + insertTrackedChange: ReturnType; + tr: { + insertText: ReturnType; + delete: ReturnType; + setMeta: ReturnType; + addMark: ReturnType; + }; +} { + const paragraph = createNode('paragraph', [], { + attrs: { sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + + const tr = { + insertText: vi.fn(), + delete: vi.fn(), + setMeta: vi.fn(), + addMark: vi.fn(), + }; + tr.insertText.mockReturnValue(tr); + tr.delete.mockReturnValue(tr); + tr.setMeta.mockReturnValue(tr); + tr.addMark.mockReturnValue(tr); + + const dispatch = vi.fn(); + const insertTrackedChange = vi.fn(() => true); + + const editor = { + state: { + doc: { + ...doc, + textBetween: vi.fn(() => ''), + }, + tr, + }, + commands: { + insertTrackedChange, + }, + dispatch, + } as unknown as Editor; + + return { editor, dispatch, insertTrackedChange, tr }; +} + +describe('writeAdapter', () => { + it('applies direct replace mutations', () => { + const { editor, dispatch, tr } = makeEditor('Hello'); + + const receipt = writeAdapter( + editor, + { + kind: 'replace', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'World', + }, + { changeMode: 'direct' }, + ); + + expect(receipt.success).toBe(true); + expect(receipt.resolution).toMatchObject({ + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + range: { from: 1, to: 6 }, + text: 'Hello', + }); + expect(tr.insertText).toHaveBeenCalledWith('World', 1, 6); + expect(tr.setMeta).toHaveBeenCalledWith('inputType', 'programmatic'); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it('creates tracked changes for tracked writes', () => { + const { editor, insertTrackedChange } = makeEditor('Hello'); + + const receipt = writeAdapter( + editor, + { + kind: 'replace', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'World', + }, + { changeMode: 'tracked' }, + ); + + expect(receipt.success).toBe(true); + expect(receipt.inserted?.[0]?.entityType).toBe('trackedChange'); + expect(insertTrackedChange).toHaveBeenCalledTimes(1); + expect(insertTrackedChange.mock.calls[0]?.[0]).toMatchObject({ + from: 1, + to: 6, + text: 'World', + }); + expect(typeof insertTrackedChange.mock.calls[0]?.[0]?.id).toBe('string'); + }); + + it('returns canonical tracked-change entity ids when resolver can map raw ids', () => { + const resolverSpy = vi + .spyOn(trackedChangeResolver, 'toCanonicalTrackedChangeId') + .mockReturnValue('stable-change-id'); + const { editor } = makeEditor('Hello'); + + const receipt = writeAdapter( + editor, + { + kind: 'replace', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'World', + }, + { changeMode: 'tracked' }, + ); + + expect(receipt.success).toBe(true); + expect(receipt.inserted?.[0]?.entityId).toBe('stable-change-id'); + resolverSpy.mockRestore(); + }); + + it('returns failure when target cannot be resolved', () => { + const { editor } = makeEditor('Hello'); + + expect(() => + writeAdapter( + editor, + { + kind: 'replace', + target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 5 } }, + text: 'World', + }, + { changeMode: 'direct' }, + ), + ).toThrow('Mutation target could not be resolved.'); + }); + + it('requires collapsed targets for insert', () => { + const { editor } = makeEditor('Hello'); + + const receipt = writeAdapter( + editor, + { + kind: 'insert', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'X', + }, + { changeMode: 'direct' }, + ); + + expect(receipt.success).toBe(false); + expect(receipt.failure).toMatchObject({ + code: 'INVALID_TARGET', + }); + }); + + it('defaults insert-without-target to the first paragraph at offset 0', () => { + const { editor, dispatch, tr } = makeEditor('Hello'); + + const receipt = writeAdapter( + editor, + { + kind: 'insert', + text: 'X', + }, + { changeMode: 'direct' }, + ); + + expect(receipt.success).toBe(true); + expect(receipt.resolution.target.range).toEqual({ start: 0, end: 0 }); + expect(receipt.resolution.range).toEqual({ from: 1, to: 1 }); + expect(tr.insertText).toHaveBeenCalledWith('X', 1, 1); + expect(tr.setMeta).toHaveBeenCalledWith('inputType', 'programmatic'); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it('supports insert-without-target for blank text blocks', () => { + const { editor, dispatch, tr } = makeEditorWithBlankParagraph(); + + const receipt = writeAdapter( + editor, + { + kind: 'insert', + text: 'X', + }, + { changeMode: 'direct' }, + ); + + expect(receipt.success).toBe(true); + expect(receipt.resolution.target).toEqual({ + kind: 'text', + blockId: 'p1', + range: { start: 0, end: 0 }, + }); + expect(receipt.resolution.range).toEqual({ from: 1, to: 1 }); + expect(receipt.resolution.text).toBe(''); + expect(tr.insertText).toHaveBeenCalledWith('X', 1, 1); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it('supports tracked insert-without-target using the default insertion point', () => { + const { editor, insertTrackedChange } = makeEditor('Hello'); + + const receipt = writeAdapter( + editor, + { + kind: 'insert', + text: 'X', + }, + { changeMode: 'tracked' }, + ); + + expect(receipt.success).toBe(true); + expect(insertTrackedChange).toHaveBeenCalledTimes(1); + expect(insertTrackedChange.mock.calls[0]?.[0]).toMatchObject({ + from: 1, + to: 1, + text: 'X', + }); + expect(typeof insertTrackedChange.mock.calls[0]?.[0]?.id).toBe('string'); + }); + + it('throws TARGET_NOT_FOUND for insert-without-target when no editable text block exists', () => { + const { editor } = makeEditorWithoutEditableTextBlock(); + + expect(() => + writeAdapter( + editor, + { + kind: 'insert', + text: 'X', + }, + { changeMode: 'direct' }, + ), + ).toThrow('Mutation target could not be resolved.'); + }); + + it('throws when tracked writes are unavailable', () => { + const { editor } = makeEditor('Hello'); + (editor.commands as { insertTrackedChange?: unknown }).insertTrackedChange = undefined; + + expect(() => + writeAdapter( + editor, + { + kind: 'replace', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'World', + }, + { changeMode: 'tracked' }, + ), + ).toThrow('Tracked write command is not available on this editor instance.'); + }); + + it('throws when tracked dry-run capability is unavailable', () => { + const { editor } = makeEditor('Hello'); + (editor.commands as { insertTrackedChange?: unknown }).insertTrackedChange = undefined; + + expect(() => + writeAdapter( + editor, + { + kind: 'replace', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'World', + }, + { changeMode: 'tracked', dryRun: true }, + ), + ).toThrow('Tracked write command is not available on this editor instance.'); + }); + + it('returns explicit NO_OP when replacement text is unchanged', () => { + const { editor, textBetween } = makeEditor('Hello'); + + const receipt = writeAdapter( + editor, + { + kind: 'replace', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'Hello', + }, + { changeMode: 'direct' }, + ); + + expect(receipt.success).toBe(false); + expect(receipt.failure).toMatchObject({ + code: 'NO_OP', + }); + expect(textBetween).toHaveBeenCalledWith(1, 6, '\n', '\ufffc'); + }); + + it('returns INVALID_TARGET for replace with empty text', () => { + const { editor } = makeEditor('Hello'); + + const receipt = writeAdapter( + editor, + { + kind: 'replace', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: '', + }, + { changeMode: 'direct' }, + ); + + expect(receipt.success).toBe(false); + expect(receipt.failure).toMatchObject({ + code: 'INVALID_TARGET', + }); + }); + + it('applies the same NO_OP rule for tracked replace as direct replace', () => { + const { editor, insertTrackedChange } = makeEditor('Hello'); + + const receipt = writeAdapter( + editor, + { + kind: 'replace', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'Hello', + }, + { changeMode: 'tracked' }, + ); + + expect(receipt.success).toBe(false); + expect(receipt.failure).toMatchObject({ + code: 'NO_OP', + }); + expect(insertTrackedChange).not.toHaveBeenCalled(); + }); + + it('returns NO_OP when tracked write command does not apply', () => { + const { editor } = makeEditor('Hello'); + (editor.commands as { insertTrackedChange?: ReturnType }).insertTrackedChange = vi.fn(() => false); + + const receipt = writeAdapter( + editor, + { + kind: 'replace', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'World', + }, + { changeMode: 'tracked' }, + ); + + expect(receipt.success).toBe(false); + expect(receipt.failure).toMatchObject({ + code: 'NO_OP', + }); + }); + + it('supports direct dry-run without mutating editor state', () => { + const { editor, dispatch, tr } = makeEditor('Hello'); + + const receipt = writeAdapter( + editor, + { + kind: 'replace', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'World', + }, + { changeMode: 'direct', dryRun: true }, + ); + + expect(receipt.success).toBe(true); + expect(receipt.resolution).toMatchObject({ + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + range: { from: 1, to: 6 }, + text: 'Hello', + }); + expect(tr.insertText).not.toHaveBeenCalled(); + expect(tr.delete).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('supports tracked dry-run without applying tracked changes', () => { + const { editor, insertTrackedChange, dispatch } = makeEditor('Hello'); + + const receipt = writeAdapter( + editor, + { + kind: 'replace', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'World', + }, + { changeMode: 'tracked', dryRun: true }, + ); + + expect(receipt.success).toBe(true); + expect(receipt.resolution.range).toEqual({ from: 1, to: 6 }); + expect(insertTrackedChange).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('keeps direct and tracked writes deterministic on the same target window', () => { + const { editor, insertTrackedChange } = makeEditor('Hello'); + + const directReceipt = writeAdapter( + editor, + { + kind: 'replace', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'World', + }, + { changeMode: 'direct' }, + ); + expect(directReceipt.success).toBe(true); + + const trackedReceipt = writeAdapter( + editor, + { + kind: 'replace', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'Again', + }, + { changeMode: 'tracked' }, + ); + expect(trackedReceipt.success).toBe(true); + expect(insertTrackedChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/write-adapter.ts b/packages/super-editor/src/document-api-adapters/write-adapter.ts new file mode 100644 index 000000000..c8f8b76c0 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/write-adapter.ts @@ -0,0 +1,216 @@ +import { v4 as uuidv4 } from 'uuid'; +import type { Editor } from '../core/Editor.js'; +import type { + MutationOptions, + ReceiptFailure, + TextAddress, + TextMutationReceipt, + WriteRequest, +} from '@superdoc/document-api'; +import { DocumentApiAdapterError } from './errors.js'; +import { resolveDefaultInsertTarget, resolveTextTarget, type ResolvedTextTarget } from './helpers/adapter-utils.js'; +import { buildTextMutationResolution, readTextAtResolvedRange } from './helpers/text-mutation-resolution.js'; +import { toCanonicalTrackedChangeId } from './helpers/tracked-change-resolver.js'; + +function validateWriteRequest(request: WriteRequest, resolvedTarget: ResolvedWriteTarget): ReceiptFailure | null { + if (request.kind === 'insert') { + if (!request.text) { + return { + code: 'INVALID_TARGET', + message: 'Insert operations require non-empty text.', + }; + } + + if (resolvedTarget.range.from !== resolvedTarget.range.to) { + return { + code: 'INVALID_TARGET', + message: 'Insert operations require a collapsed target range.', + }; + } + + return null; + } + + if (request.kind === 'replace') { + if (request.text == null || request.text.length === 0) { + return { + code: 'INVALID_TARGET', + message: 'Replace operations require non-empty text. Use delete for removals.', + }; + } + + if (resolvedTarget.resolution.text === request.text) { + return { + code: 'NO_OP', + message: 'Replace operation produced no change.', + }; + } + + return null; + } + + if (resolvedTarget.range.from === resolvedTarget.range.to) { + return { + code: 'NO_OP', + message: 'Delete operation produced no change for a collapsed range.', + }; + } + + return null; +} + +type ResolvedWriteTarget = { + requestedTarget?: TextAddress; + effectiveTarget: TextAddress; + range: ResolvedTextTarget; + resolution: ReturnType; +}; + +function resolveWriteTarget(editor: Editor, request: WriteRequest): ResolvedWriteTarget | null { + const requestedTarget = request.target; + + if (request.kind === 'insert' && !request.target) { + const fallback = resolveDefaultInsertTarget(editor); + if (!fallback) return null; + + const text = readTextAtResolvedRange(editor, fallback.range); + return { + requestedTarget, + effectiveTarget: fallback.target, + range: fallback.range, + resolution: buildTextMutationResolution({ + requestedTarget, + target: fallback.target, + range: fallback.range, + text, + }), + }; + } + + const target = request.target; + if (!target) return null; + + const range = resolveTextTarget(editor, target); + if (!range) return null; + + const text = readTextAtResolvedRange(editor, range); + return { + requestedTarget, + effectiveTarget: target, + range, + resolution: buildTextMutationResolution({ + requestedTarget, + target, + range, + text, + }), + }; +} + +function applyDirectWrite( + editor: Editor, + request: WriteRequest, + resolvedTarget: ResolvedWriteTarget, +): TextMutationReceipt { + if (request.kind === 'delete') { + const tr = editor.state.tr + .delete(resolvedTarget.range.from, resolvedTarget.range.to) + .setMeta('inputType', 'programmatic'); + editor.dispatch(tr); + return { success: true, resolution: resolvedTarget.resolution }; + } + + // text is guaranteed non-empty for insert/replace after validateWriteRequest + const tr = editor.state.tr + .insertText(request.text ?? '', resolvedTarget.range.from, resolvedTarget.range.to) + .setMeta('inputType', 'programmatic'); + editor.dispatch(tr); + return { success: true, resolution: resolvedTarget.resolution }; +} + +function assertTrackedWriteCapability(editor: Editor): NonNullable['insertTrackedChange'] { + const insertTrackedChange = editor.commands?.insertTrackedChange; + if (!insertTrackedChange) { + throw new DocumentApiAdapterError( + 'TRACK_CHANGE_COMMAND_UNAVAILABLE', + 'Tracked write command is not available on this editor instance.', + ); + } + + return insertTrackedChange; +} + +function applyTrackedWrite( + editor: Editor, + request: WriteRequest, + resolvedTarget: ResolvedWriteTarget, +): TextMutationReceipt { + const insertTrackedChange = assertTrackedWriteCapability(editor); + const text = request.kind === 'delete' ? '' : (request.text ?? ''); + + const changeId = uuidv4(); + const didApply = insertTrackedChange({ + from: resolvedTarget.range.from, + to: request.kind === 'insert' ? resolvedTarget.range.from : resolvedTarget.range.to, + text, + id: changeId, + }); + + if (!didApply) { + return { + success: false, + resolution: resolvedTarget.resolution, + failure: { + code: 'NO_OP', + message: 'Tracked write command did not apply a change.', + }, + }; + } + const publicChangeId = toCanonicalTrackedChangeId(editor, changeId) ?? changeId; + + return { + success: true, + resolution: resolvedTarget.resolution, + inserted: [ + { + kind: 'entity', + entityType: 'trackedChange', + entityId: publicChangeId, + }, + ], + }; +} + +function toFailureReceipt(failure: ReceiptFailure, resolvedTarget: ResolvedWriteTarget): TextMutationReceipt { + return { + success: false, + resolution: resolvedTarget.resolution, + failure, + }; +} + +export function writeAdapter(editor: Editor, request: WriteRequest, options?: MutationOptions): TextMutationReceipt { + const resolvedTarget = resolveWriteTarget(editor, request); + if (!resolvedTarget) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'Mutation target could not be resolved.', { + target: request.target, + }); + } + + const validationFailure = validateWriteRequest(request, resolvedTarget); + if (validationFailure) { + return toFailureReceipt(validationFailure, resolvedTarget); + } + + const mode = options?.changeMode ?? 'direct'; + if (options?.dryRun) { + if (mode === 'tracked') assertTrackedWriteCapability(editor); + return { success: true, resolution: resolvedTarget.resolution }; + } + + if (mode === 'tracked') { + return applyTrackedWrite(editor, request, resolvedTarget); + } + + return applyDirectWrite(editor, request, resolvedTarget); +} diff --git a/packages/super-editor/src/extensions/comment/comments-helpers.js b/packages/super-editor/src/extensions/comment/comments-helpers.js index 33d12a748..4bd1e1452 100644 --- a/packages/super-editor/src/extensions/comment/comments-helpers.js +++ b/packages/super-editor/src/extensions/comment/comments-helpers.js @@ -10,41 +10,66 @@ const TRACK_CHANGE_MARKS = [TrackInsertMarkName, TrackDeleteMarkName, TrackForma * * @param {Object} param0 * @param {string} param0.commentId The comment ID + * @param {string} [param0.importedId] The imported comment ID * @param {import('prosemirror-state').EditorState} state The current editor state * @param {import('prosemirror-state').Transaction} tr The current transaction * @param {Function} param0.dispatch The dispatch function - * @returns {void} + * @returns {boolean} True if any comment marks were removed */ -export const removeCommentsById = ({ commentId, state, tr, dispatch }) => { - const positions = getCommentPositionsById(commentId, state.doc); +export const removeCommentsById = ({ commentId, importedId, state, tr, dispatch }) => { + const positions = getCommentPositionsById(commentId, state.doc, importedId); + const anchorNodePositions = []; + + state.doc.descendants((node, pos) => { + const nodeTypeName = node.type?.name; + if (nodeTypeName !== 'commentRangeStart' && nodeTypeName !== 'commentRangeEnd') return; + const wid = node.attrs?.['w:id']; + if (wid === commentId || (importedId && wid === importedId)) { + anchorNodePositions.push(pos); + } + }); + + if (!positions.length && !anchorNodePositions.length) return false; // Remove the mark - positions.forEach(({ from, to }) => { - tr.removeMark(from, to, state.schema.marks[CommentMarkName]); + positions.forEach(({ from, to, mark }) => { + tr.removeMark(from, to, mark); }); + + // Remove resolved-comment anchors (commentRangeStart/commentRangeEnd) when present. + anchorNodePositions + .slice() + .sort((a, b) => b - a) + .forEach((pos) => { + tr.delete(pos, pos + 1); + }); + dispatch(tr); + return true; }; /** * Get the positions of a comment by ID * * @param {String} commentId The comment ID + * @param {String} [importedId] The imported comment ID * @param {import('prosemirror-model').Node} doc The prosemirror document - * @returns {Array} The positions of the comment + * @returns {Array<{from:number,to:number,mark:Object}>} The positions and exact mark instances for the comment */ -export const getCommentPositionsById = (commentId, doc) => { +export const getCommentPositionsById = (commentId, doc, importedId) => { const positions = []; doc.descendants((node, pos) => { const { marks } = node; - const commentMark = marks.find((mark) => mark.type.name === CommentMarkName); - - if (commentMark) { - const { attrs } = commentMark; - const { commentId: currentCommentId } = attrs; - if (commentId === currentCommentId) { - positions.push({ from: pos, to: pos + node.nodeSize }); - } - } + marks + .filter((mark) => mark.type.name === CommentMarkName) + .forEach((commentMark) => { + const { attrs } = commentMark; + const currentCommentId = attrs?.commentId; + const currentImportedId = attrs?.importedId; + if (commentId === currentCommentId || (importedId && importedId === currentImportedId)) { + positions.push({ from: pos, to: pos + node.nodeSize, mark: commentMark }); + } + }); }); return positions; }; @@ -54,16 +79,19 @@ export const getCommentPositionsById = (commentId, doc) => { * This returns the raw segments (per inline node) rather than merged contiguous ranges. * * @param {string} commentId The comment ID to match + * @param {string} [importedId] The imported comment ID to match * @param {import('prosemirror-model').Node} doc The ProseMirror document * @returns {Array<{from:number,to:number,attrs:Object}>} Segments containing mark attrs */ -const getCommentMarkSegmentsById = (commentId, doc) => { +const getCommentMarkSegmentsById = (commentId, doc, importedId) => { const segments = []; doc.descendants((node, pos) => { if (!node.isInline) return; const commentMark = node.marks?.find( - (mark) => mark.type.name === CommentMarkName && mark.attrs?.commentId === commentId, + (mark) => + mark.type.name === CommentMarkName && + (mark.attrs?.commentId === commentId || (importedId && mark.attrs?.importedId === importedId)), ); if (!commentMark) return; @@ -83,11 +111,12 @@ const getCommentMarkSegmentsById = (commentId, doc) => { * so this returns both the raw segments and the merged ranges. * * @param {string} commentId The comment ID to match + * @param {string} [importedId] The imported comment ID to match * @param {import('prosemirror-model').Node} doc The ProseMirror document * @returns {{segments:Array<{from:number,to:number,attrs:Object}>,ranges:Array<{from:number,to:number,internal:boolean}>}} */ -const getCommentMarkRangesById = (commentId, doc) => { - const segments = getCommentMarkSegmentsById(commentId, doc); +const getCommentMarkRangesById = (commentId, doc, importedId) => { + const segments = getCommentMarkSegmentsById(commentId, doc, importedId); if (!segments.length) return { segments, ranges: [] }; const ranges = []; @@ -127,17 +156,18 @@ const getCommentMarkRangesById = (commentId, doc) => { * * @param {Object} param0 * @param {string} param0.commentId The comment ID + * @param {string} [param0.importedId] The imported comment ID * @param {import('prosemirror-state').EditorState} param0.state The current editor state * @param {import('prosemirror-state').Transaction} param0.tr The current transaction * @param {Function} param0.dispatch The dispatch function * @returns {boolean} True if the comment mark existed and was processed */ -export const resolveCommentById = ({ commentId, state, tr, dispatch }) => { +export const resolveCommentById = ({ commentId, importedId, state, tr, dispatch }) => { const { schema } = state; const markType = schema.marks?.[CommentMarkName]; if (!markType) return false; - const { segments, ranges } = getCommentMarkRangesById(commentId, state.doc); + const { segments, ranges } = getCommentMarkRangesById(commentId, state.doc, importedId); if (!segments.length) return false; segments.forEach(({ from, to, attrs }) => { diff --git a/packages/super-editor/src/extensions/comment/comments-plugin.js b/packages/super-editor/src/extensions/comment/comments-plugin.js index 32eb99bca..624bf71dc 100644 --- a/packages/super-editor/src/extensions/comment/comments-plugin.js +++ b/packages/super-editor/src/extensions/comment/comments-plugin.js @@ -31,6 +31,7 @@ export const CommentsPlugin = Extension.create({ * @category Command * @param {string|Object} contentOrOptions - Comment content as a string, or an options object * @param {string} [contentOrOptions.content] - The comment content (text or HTML) + * @param {string} [contentOrOptions.commentId] - Explicit comment ID (defaults to a new UUID) * @param {string} [contentOrOptions.author] - Author name (defaults to user from editor config) * @param {string} [contentOrOptions.authorEmail] - Author email (defaults to user from editor config) * @param {string} [contentOrOptions.authorImage] - Author image URL (defaults to user from editor config) @@ -67,12 +68,13 @@ export const CommentsPlugin = Extension.create({ } // Handle string or options object - let content, author, authorEmail, authorImage, isInternal; + let content, explicitCommentId, author, authorEmail, authorImage, isInternal; if (typeof contentOrOptions === 'string') { content = contentOrOptions; } else if (contentOrOptions && typeof contentOrOptions === 'object') { content = contentOrOptions.content; + explicitCommentId = contentOrOptions.commentId; author = contentOrOptions.author; authorEmail = contentOrOptions.authorEmail; authorImage = contentOrOptions.authorImage; @@ -80,7 +82,7 @@ export const CommentsPlugin = Extension.create({ } // Generate a unique comment ID - const commentId = uuidv4(); + const commentId = explicitCommentId ?? uuidv4(); const resolvedInternal = isInternal ?? false; // Get user defaults from editor config @@ -143,14 +145,14 @@ export const CommentsPlugin = Extension.create({ addCommentReply: (options = {}) => ({ editor }) => { - const { parentId, content, author, authorEmail, authorImage } = options; + const { parentId, content, author, authorEmail, authorImage, commentId: explicitCommentId } = options; if (!parentId) { console.warn('addCommentReply requires a parentId'); return false; } - const commentId = uuidv4(); + const commentId = explicitCommentId ?? uuidv4(); const configUser = editor.options?.user || {}; const commentPayload = normalizeCommentEventPayload({ @@ -230,7 +232,7 @@ export const CommentsPlugin = Extension.create({ ({ commentId, importedId }) => ({ tr, dispatch, state }) => { tr.setMeta(CommentsPluginKey, { event: 'deleted' }); - removeCommentsById({ commentId, importedId, state, tr, dispatch }); + return removeCommentsById({ commentId, importedId, state, tr, dispatch }); }, setActiveComment: @@ -241,42 +243,49 @@ export const CommentsPlugin = Extension.create({ }, setCommentInternal: - ({ commentId, isInternal }) => + ({ commentId, importedId, isInternal }) => ({ tr, dispatch, state }) => { const { doc } = state; - let foundStartNode; - let foundPos; + const commentMarkType = this.editor.schema.marks[CommentMarkName]; + if (!commentMarkType) return false; + const matchedSegments = []; - // Find the commentRangeStart node that matches the comment ID tr.setMeta(CommentsPluginKey, { event: 'update' }); doc.descendants((node, pos) => { - if (foundStartNode) return; - + if (!node.isInline) return; const { marks = [] } = node; - const commentMark = marks.find((mark) => mark.type.name === CommentMarkName); - - if (commentMark) { - const { attrs } = commentMark; - const wid = attrs.commentId; - if (wid === commentId) { - foundStartNode = node; - foundPos = pos; - } - } + marks + .filter((mark) => mark.type.name === CommentMarkName) + .forEach((commentMark) => { + const { attrs } = commentMark; + const wid = attrs.commentId; + const importedWid = attrs.importedId; + if (wid === commentId || (importedId && importedWid === importedId)) { + matchedSegments.push({ + from: pos, + to: pos + node.nodeSize, + attrs, + mark: commentMark, + }); + } + }); }); - // If no matching node, return false - if (!foundStartNode) return false; - - // Update the mark itself - tr.addMark( - foundPos, - foundPos + foundStartNode.nodeSize, - this.editor.schema.marks[CommentMarkName].create({ - commentId, - internal: isInternal, - }), - ); + if (!matchedSegments.length) return false; + + matchedSegments.forEach(({ from, to, attrs, mark }) => { + tr.removeMark(from, to, mark); + tr.addMark( + from, + to, + commentMarkType.create({ + ...attrs, + commentId: attrs?.commentId ?? commentId, + importedId: attrs?.importedId ?? importedId, + internal: isInternal, + }), + ); + }); tr.setMeta(CommentsPluginKey, { type: 'setCommentInternal' }); dispatch(tr); @@ -284,10 +293,114 @@ export const CommentsPlugin = Extension.create({ }, resolveComment: - ({ commentId }) => + ({ commentId, importedId }) => ({ tr, dispatch, state }) => { tr.setMeta(CommentsPluginKey, { event: 'update' }); - return resolveCommentById({ commentId, state, tr, dispatch }); + return resolveCommentById({ commentId, importedId, state, tr, dispatch }); + }, + editComment: + ({ commentId, importedId, content, text }) => + ({ editor }) => { + const nextCommentId = commentId ?? importedId; + if (!nextCommentId) return false; + + const normalizedText = content ?? text ?? ''; + const payload = normalizeCommentEventPayload({ + conversation: { + commentId: nextCommentId, + importedId, + commentText: normalizedText, + updatedTime: Date.now(), + }, + editorOptions: editor.options, + fallbackCommentId: nextCommentId, + fallbackInternal: false, + }); + + editor.emit('commentsUpdate', { + type: comments_module_events.UPDATE, + comment: payload, + activeCommentId: nextCommentId, + }); + + return true; + }, + moveComment: + ({ commentId, from, to }) => + ({ tr, dispatch, state, editor }) => { + if (!commentId) return false; + if (!Number.isFinite(from) || !Number.isFinite(to)) return false; + if (from >= to) return false; + + const { doc } = state; + const resolved = findRangeById(doc, commentId); + if (!resolved) return false; + + const markType = editor.schema?.marks?.[CommentMarkName]; + if (!markType) return false; + + tr.setMeta(CommentsPluginKey, { event: 'update' }); + + const segments = []; + doc.descendants((node, pos) => { + if (!node.isInline) return; + const commentMark = node.marks?.find( + (mark) => + mark.type.name === CommentMarkName && + (mark.attrs?.commentId === commentId || mark.attrs?.importedId === commentId), + ); + if (!commentMark) return; + segments.push({ + from: pos, + to: pos + node.nodeSize, + attrs: commentMark.attrs, + mark: commentMark, + }); + }); + + if (segments.length > 0) { + segments.forEach((segment) => { + tr.removeMark(segment.from, segment.to, segment.mark); + }); + + const attrs = segments[0]?.attrs ?? { commentId }; + const mappedFrom = tr.mapping.map(from); + const mappedTo = tr.mapping.map(to); + tr.addMark(mappedFrom, mappedTo, markType.create(attrs)); + dispatch(tr); + return true; + } + + const startType = editor.schema?.nodes?.commentRangeStart; + const endType = editor.schema?.nodes?.commentRangeEnd; + if (!startType || !endType) return false; + + let startPos = null; + let endPos = null; + let startAttrs = { 'w:id': commentId }; + doc.descendants((node, pos) => { + if (node.type.name === 'commentRangeStart' && node.attrs?.['w:id'] === commentId) { + startPos = pos; + startAttrs = { ...node.attrs }; + } + if (node.type.name === 'commentRangeEnd' && node.attrs?.['w:id'] === commentId) { + endPos = pos; + } + }); + + if (startPos == null || endPos == null) return false; + + const toDelete = [startPos, endPos].sort((a, b) => b - a); + toDelete.forEach((pos) => { + tr.delete(pos, pos + 1); + }); + + const mappedFrom = tr.mapping.map(from); + const mappedTo = tr.mapping.map(to); + tr.insert(mappedTo, endType.create({ 'w:id': commentId })); + tr.insert(mappedFrom, startType.create({ ...startAttrs, 'w:id': commentId })); + dispatch(tr); + return true; }, setCursorById: (id) => @@ -295,7 +408,9 @@ export const CommentsPlugin = Extension.create({ const { from } = findRangeById(state.doc, id) || {}; if (from != null) { state.tr.setSelection(TextSelection.create(state.doc, from)); - editor.view.focus(); + if (editor.view && typeof editor.view.focus === 'function') { + editor.view.focus(); + } return true; } return false; @@ -782,7 +897,7 @@ const handleTrackedChangeTransaction = (trackedChangeMeta, trackedChanges, newEd return newTrackedChanges; }; -const getTrackedChangeText = ({ nodes, mark, trackedChangeType, isDeletionInsertion, marks }) => { +const getTrackedChangeText = ({ nodes, mark, trackedChangeType, isDeletionInsertion }) => { let trackedChangeText = ''; let deletionText = ''; @@ -847,8 +962,8 @@ const createOrUpdateTrackedChangeComment = ({ event, marks, deletionNodes, nodes // Collect nodes from the tracked changes found // We need to get the actual nodes at those positions let nodesWithMark = []; - trackedChangesWithId.forEach(({ from, to, mark }) => { - newEditorState.doc.nodesBetween(from, to, (node, pos) => { + trackedChangesWithId.forEach(({ from, to }) => { + newEditorState.doc.nodesBetween(from, to, (node) => { // Only collect inline text nodes if (node.isText) { // Check if this node has the mark (it should, since getTrackChanges found it) @@ -897,7 +1012,6 @@ const createOrUpdateTrackedChangeComment = ({ event, marks, deletionNodes, nodes state: newEditorState, nodes: nodesToUse, mark: trackedMark, - marks, trackedChangeType, isDeletionInsertion, deletionNodes, diff --git a/packages/super-editor/src/extensions/comment/comments.test.js b/packages/super-editor/src/extensions/comment/comments.test.js index 9282fde88..03a45e4c1 100644 --- a/packages/super-editor/src/extensions/comment/comments.test.js +++ b/packages/super-editor/src/extensions/comment/comments.test.js @@ -115,7 +115,9 @@ describe('comment helpers', () => { const positions = getCommentPositionsById('comment-123', state.doc); - expect(positions).toEqual([{ from: 1, to: 6 }]); + expect(positions).toEqual([expect.objectContaining({ from: 1, to: 6 })]); + expect(positions[0].mark).toBeDefined(); + expect(positions[0].mark.type.name).toBe(CommentMarkName); }); it('removes comments by id and dispatches transaction', () => { @@ -127,7 +129,11 @@ describe('comment helpers', () => { removeCommentsById({ commentId: 'comment-123', state, tr, dispatch }); - expect(removeSpy).toHaveBeenCalledWith(1, 6, schema.marks[CommentMarkName]); + expect(removeSpy).toHaveBeenCalledWith( + 1, + 6, + expect.objectContaining({ type: expect.objectContaining({ name: CommentMarkName }) }), + ); expect(dispatch).toHaveBeenCalledWith(tr); }); diff --git a/packages/super-editor/src/extensions/types/comment-commands.ts b/packages/super-editor/src/extensions/types/comment-commands.ts index 411825cab..e24f51677 100644 --- a/packages/super-editor/src/extensions/types/comment-commands.ts +++ b/packages/super-editor/src/extensions/types/comment-commands.ts @@ -8,6 +8,8 @@ export type AddCommentOptions = { /** The comment content (text or HTML) */ content?: string; + /** Explicit comment ID (defaults to a new UUID) */ + commentId?: string; /** Author name (defaults to user from editor config) */ author?: string; /** Author email (defaults to user from editor config) */ @@ -59,13 +61,17 @@ export type RemoveCommentOptions = { /** Options for setActiveComment command */ export type SetActiveCommentOptions = { /** The comment ID to set as active */ - commentId: string; + commentId: string | null; + /** The imported comment ID */ + importedId?: string; }; /** Options for setCommentInternal command */ export type SetCommentInternalOptions = { /** The comment ID to update */ commentId: string; + /** The imported comment ID */ + importedId?: string; /** Whether the comment should be internal */ isInternal: boolean; }; @@ -74,12 +80,38 @@ export type SetCommentInternalOptions = { export type ResolveCommentOptions = { /** The comment ID to resolve */ commentId: string; + /** The imported comment ID */ + importedId?: string; +}; + +/** Options for editComment command */ +export type EditCommentOptions = { + /** The comment ID to edit */ + commentId: string; + /** The imported comment ID */ + importedId?: string; + /** Updated content (text or HTML) */ + content?: string; + /** Updated content alias */ + text?: string; +}; + +/** Options for moveComment command */ +export type MoveCommentOptions = { + /** The comment ID to move */ + commentId: string; + /** Absolute ProseMirror start position */ + from: number; + /** Absolute ProseMirror end position */ + to: number; }; /** Options for addCommentReply command */ export type AddCommentReplyOptions = { /** The ID of the parent comment or tracked change to reply to */ parentId: string; + /** Optional explicit comment ID for deterministic callers */ + commentId?: string; /** The reply content (text or HTML) */ content?: string; /** Author name (defaults to user from editor config) */ @@ -156,6 +188,18 @@ export interface CommentCommands { */ resolveComment: (options: ResolveCommentOptions) => boolean; + /** + * Edit an existing comment payload. + * @param options - Object containing comment id and updated content + */ + editComment: (options: EditCommentOptions) => boolean; + + /** + * Move a comment anchor to a new document range. + * @param options - Object containing comment id and absolute target positions + */ + moveComment: (options: MoveCommentOptions) => boolean; + /** * Set cursor position to a comment by ID * @param id - The comment ID to navigate to diff --git a/packages/super-editor/src/extensions/types/track-changes-commands.ts b/packages/super-editor/src/extensions/types/track-changes-commands.ts index 2e3c03f43..d28bb2be2 100644 --- a/packages/super-editor/src/extensions/types/track-changes-commands.ts +++ b/packages/super-editor/src/extensions/types/track-changes-commands.ts @@ -32,6 +32,8 @@ export type InsertTrackedChangeOptions = { to?: number; /** Replacement text */ text?: string; + /** Explicit change ID for deterministic callers (defaults to a new UUID) */ + id?: string; /** Author override for the tracked change (defaults to editor user if not provided) */ user?: Partial; /** Optional comment reply to attach to the tracked change */ From 7da5112387f283b6cadb49f834bb669f7bbfc044 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Mon, 16 Feb 2026 20:54:21 -0800 Subject: [PATCH 05/25] fix(super-editor): correct document-api tracked IDs, list dry-run behavior, and text-find totals --- .../find-adapter.test.ts | 27 ++++ .../find/text-strategy.ts | 12 +- .../lists-adapter.test.ts | 118 ++++++++++++++++++ .../document-api-adapters/lists-adapter.ts | 67 ++++++++-- .../write-adapter.test.ts | 23 ++++ .../document-api-adapters/write-adapter.ts | 20 +-- .../extensions/track-changes/track-changes.js | 3 +- 7 files changed, 240 insertions(+), 30 deletions(-) diff --git a/packages/super-editor/src/document-api-adapters/find-adapter.test.ts b/packages/super-editor/src/document-api-adapters/find-adapter.test.ts index 6c92b3fb3..438e96aac 100644 --- a/packages/super-editor/src/document-api-adapters/find-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/find-adapter.test.ts @@ -651,6 +651,33 @@ describe('findAdapter — text selectors', () => { expect(result.context![0].snippet).toBeDefined(); }); + it('reports true total for paginated text queries (not capped by page window)', () => { + // Build a search that respects maxMatches to expose the capping bug + const allMatches = [ + { from: 5, to: 8, text: 'a' }, + { from: 15, to: 18, text: 'b' }, + { from: 25, to: 28, text: 'c' }, + { from: 35, to: 38, text: 'd' }, + { from: 45, to: 48, text: 'e' }, + ]; + const doc = buildDoc( + 'a'.repeat(102), + { typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, nodeSize: 50, offset: 0 }, + { typeName: 'paragraph', attrs: { sdBlockId: 'p2' }, nodeSize: 50, offset: 52 }, + ); + const search: SearchFn = (_pattern, opts) => { + const max = (opts as { maxMatches?: number })?.maxMatches ?? Infinity; + return allMatches.slice(0, max); + }; + const editor = makeEditor(doc, search); + const query: Query = { select: { type: 'text', pattern: 'test' }, offset: 0, limit: 2 }; + + const result = findAdapter(editor, query); + + expect(result.matches).toHaveLength(2); + expect(result.total).toBe(5); // must be 5, not 2 + }); + it('filters text matches by within scope', () => { const doc = buildDoc( 'a'.repeat(70), diff --git a/packages/super-editor/src/document-api-adapters/find/text-strategy.ts b/packages/super-editor/src/document-api-adapters/find/text-strategy.ts index 96e7b7f47..28e1a4c1d 100644 --- a/packages/super-editor/src/document-api-adapters/find/text-strategy.ts +++ b/packages/super-editor/src/document-api-adapters/find/text-strategy.ts @@ -87,17 +87,11 @@ export function executeTextSelector( ); } - // When there is no within scope, we can limit the search engine to only - // produce the matches we need (offset + limit). With a scope, post-search - // filtering makes the needed count unpredictable, so fetch all. - const effectiveMaxMatches = - !scope.range && query.limit != null && Number.isFinite(query.limit) - ? (query.offset ?? 0) + query.limit - : Number.MAX_SAFE_INTEGER; - + // Fetch all matches so `total` reflects the true document-wide count. + // Pagination is applied after filtering via paginate(). const rawResult = search(pattern, { highlight: false, - maxMatches: effectiveMaxMatches, + maxMatches: Number.MAX_SAFE_INTEGER, caseSensitive: selector.caseSensitive ?? false, }); diff --git a/packages/super-editor/src/document-api-adapters/lists-adapter.test.ts b/packages/super-editor/src/document-api-adapters/lists-adapter.test.ts index 53e15dced..db9f8e893 100644 --- a/packages/super-editor/src/document-api-adapters/lists-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/lists-adapter.test.ts @@ -350,4 +350,122 @@ describe('lists adapter', () => { if (result.success) return; expect(result.failure.code).toBe('INVALID_TARGET'); }); + + describe('dryRun', () => { + function makeListEditor() { + return makeEditor([ + makeListParagraph({ + id: 'li-1', + text: 'One', + numId: 1, + ilvl: 1, + markerText: '1.', + path: [1], + numberingType: 'decimal', + }), + makeListParagraph({ + id: 'li-2', + text: 'Two', + numId: 1, + ilvl: 1, + markerText: '2.', + path: [2], + numberingType: 'decimal', + }), + ]); + } + + it('insert: returns placeholder success without mutating the document', () => { + const editor = makeListEditor(); + const insertListItemAt = editor.commands!.insertListItemAt as ReturnType; + + const result = listsInsertAdapter( + editor, + { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + position: 'after', + }, + { dryRun: true }, + ); + + expect(result.success).toBe(true); + expect(insertListItemAt).not.toHaveBeenCalled(); + }); + + it('setType: returns success without dispatching command', () => { + const editor = makeListEditor(); + const setListTypeAt = editor.commands!.setListTypeAt as ReturnType; + + const result = listsSetTypeAdapter( + editor, + { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + kind: 'bullet', + }, + { dryRun: true }, + ); + + expect(result.success).toBe(true); + expect(setListTypeAt).not.toHaveBeenCalled(); + }); + + it('indent: returns success without dispatching command', () => { + vi.spyOn(ListHelpers, 'hasListDefinition').mockReturnValue(true); + const editor = makeListEditor(); + const increaseListIndent = editor.commands!.increaseListIndent as ReturnType; + + const result = listsIndentAdapter( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, + { dryRun: true }, + ); + + expect(result.success).toBe(true); + expect(increaseListIndent).not.toHaveBeenCalled(); + }); + + it('outdent: returns success without dispatching command', () => { + const editor = makeListEditor(); + const decreaseListIndent = editor.commands!.decreaseListIndent as ReturnType; + + const result = listsOutdentAdapter( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, + { dryRun: true }, + ); + + expect(result.success).toBe(true); + expect(decreaseListIndent).not.toHaveBeenCalled(); + }); + + it('restart: returns success without dispatching command', () => { + const editor = makeListEditor(); + const restartNumbering = editor.commands!.restartNumbering as ReturnType; + + const result = listsRestartAdapter( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-2' } }, + { dryRun: true }, + ); + + expect(result.success).toBe(true); + expect(restartNumbering).not.toHaveBeenCalled(); + }); + + it('exit: returns placeholder success without dispatching command', () => { + const editor = makeListEditor(); + const exitListItemAt = editor.commands!.exitListItemAt as ReturnType; + + const result = listsExitAdapter( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, + { dryRun: true }, + ); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.paragraph.nodeId).toBe('(dry-run)'); + expect(exitListItemAt).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/super-editor/src/document-api-adapters/lists-adapter.ts b/packages/super-editor/src/document-api-adapters/lists-adapter.ts index a2eb8ee07..0e03a1a62 100644 --- a/packages/super-editor/src/document-api-adapters/lists-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/lists-adapter.ts @@ -165,6 +165,18 @@ export function listsInsertAdapter( 'Insert list item command is not available on this editor instance.', ); + if (options?.dryRun) { + return { + success: true, + item: { kind: 'block', nodeType: 'listItem', nodeId: '(dry-run)' }, + insertionPoint: { + kind: 'text', + blockId: '(dry-run)', + range: { start: 0, end: 0 }, + }, + }; + } + const createdId = uuidv4(); const didApply = insertListItemAt({ pos: target.candidate.pos, @@ -234,6 +246,10 @@ export function listsSetTypeAdapter( 'Set list type command is not available on this editor instance.', ); + if (options?.dryRun) { + return { success: true, item: target.address }; + } + const didApply = setListTypeAt({ pos: target.candidate.pos, kind: input.kind, @@ -263,16 +279,21 @@ export function listsIndentAdapter( return toListsFailure('NO_OP', 'List item is already at the maximum supported level.', { target: input.target }); } + const increaseListIndent = requireCommand<() => boolean>( + editor.commands?.increaseListIndent as (() => boolean) | undefined, + 'Increase list indent command is not available on this editor instance.', + ); + + if (options?.dryRun) { + return { success: true, item: target.address }; + } + if (!setSelectionToListItem(editor, target)) { return toListsFailure('INVALID_TARGET', 'List item target could not be selected for indentation.', { target: input.target, }); } - const increaseListIndent = requireCommand<() => boolean>( - editor.commands?.increaseListIndent as (() => boolean) | undefined, - 'Increase list indent command is not available on this editor instance.', - ); const didApply = increaseListIndent(); if (!didApply) { return toListsFailure('INVALID_TARGET', 'List indentation could not be applied.', { target: input.target }); @@ -295,16 +316,21 @@ export function listsOutdentAdapter( return toListsFailure('NO_OP', 'List item is already at level 0.', { target: input.target }); } + const decreaseListIndent = requireCommand<() => boolean>( + editor.commands?.decreaseListIndent as (() => boolean) | undefined, + 'Decrease list indent command is not available on this editor instance.', + ); + + if (options?.dryRun) { + return { success: true, item: target.address }; + } + if (!setSelectionToListItem(editor, target)) { return toListsFailure('INVALID_TARGET', 'List item target could not be selected for outdent.', { target: input.target, }); } - const decreaseListIndent = requireCommand<() => boolean>( - editor.commands?.decreaseListIndent as (() => boolean) | undefined, - 'Decrease list indent command is not available on this editor instance.', - ); const didApply = decreaseListIndent(); if (!didApply) { return toListsFailure('INVALID_TARGET', 'List outdent could not be applied.', { target: input.target }); @@ -334,16 +360,21 @@ export function listsRestartAdapter( }); } + const restartNumbering = requireCommand<() => boolean>( + editor.commands?.restartNumbering as (() => boolean) | undefined, + 'Restart numbering command is not available on this editor instance.', + ); + + if (options?.dryRun) { + return { success: true, item: target.address }; + } + if (!setSelectionToListItem(editor, target)) { return toListsFailure('INVALID_TARGET', 'List item target could not be selected for restart.', { target: input.target, }); } - const restartNumbering = requireCommand<() => boolean>( - editor.commands?.restartNumbering as (() => boolean) | undefined, - 'Restart numbering command is not available on this editor instance.', - ); const didApply = restartNumbering(); if (!didApply) { return toListsFailure('INVALID_TARGET', 'List restart could not be applied.', { target: input.target }); @@ -363,6 +394,18 @@ export function listsExitAdapter(editor: Editor, input: ListTargetInput, options editor.commands?.exitListItemAt as ExitListItemAtCommand | undefined, 'Exit list item command is not available on this editor instance.', ); + + if (options?.dryRun) { + return { + success: true, + paragraph: { + kind: 'block', + nodeType: 'paragraph', + nodeId: '(dry-run)', + }, + }; + } + const didApply = exitListItemAt({ pos: target.candidate.pos }); if (!didApply) { return toListsFailure('INVALID_TARGET', 'List exit could not be applied.', { target: input.target }); diff --git a/packages/super-editor/src/document-api-adapters/write-adapter.test.ts b/packages/super-editor/src/document-api-adapters/write-adapter.test.ts index 223639bd6..ccffa8836 100644 --- a/packages/super-editor/src/document-api-adapters/write-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/write-adapter.test.ts @@ -220,6 +220,9 @@ describe('writeAdapter', () => { }); it('creates tracked changes for tracked writes', () => { + const resolverSpy = vi + .spyOn(trackedChangeResolver, 'toCanonicalTrackedChangeId') + .mockReturnValue('resolved-change-id'); const { editor, insertTrackedChange } = makeEditor('Hello'); const receipt = writeAdapter( @@ -241,6 +244,7 @@ describe('writeAdapter', () => { text: 'World', }); expect(typeof insertTrackedChange.mock.calls[0]?.[0]?.id).toBe('string'); + resolverSpy.mockRestore(); }); it('returns canonical tracked-change entity ids when resolver can map raw ids', () => { @@ -264,6 +268,25 @@ describe('writeAdapter', () => { resolverSpy.mockRestore(); }); + it('returns degraded success without inserted ref when canonical resolution fails', () => { + const resolverSpy = vi.spyOn(trackedChangeResolver, 'toCanonicalTrackedChangeId').mockReturnValue(null); + const { editor } = makeEditor('Hello'); + + const receipt = writeAdapter( + editor, + { + kind: 'replace', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'World', + }, + { changeMode: 'tracked' }, + ); + + expect(receipt.success).toBe(true); + expect(receipt.inserted).toBeUndefined(); + resolverSpy.mockRestore(); + }); + it('returns failure when target cannot be resolved', () => { const { editor } = makeEditor('Hello'); diff --git a/packages/super-editor/src/document-api-adapters/write-adapter.ts b/packages/super-editor/src/document-api-adapters/write-adapter.ts index c8f8b76c0..4272920ab 100644 --- a/packages/super-editor/src/document-api-adapters/write-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/write-adapter.ts @@ -166,18 +166,22 @@ function applyTrackedWrite( }, }; } - const publicChangeId = toCanonicalTrackedChangeId(editor, changeId) ?? changeId; + const publicChangeId = toCanonicalTrackedChangeId(editor, changeId); return { success: true, resolution: resolvedTarget.resolution, - inserted: [ - { - kind: 'entity', - entityType: 'trackedChange', - entityId: publicChangeId, - }, - ], + ...(publicChangeId + ? { + inserted: [ + { + kind: 'entity', + entityType: 'trackedChange', + entityId: publicChangeId, + }, + ], + } + : {}), }; } diff --git a/packages/super-editor/src/extensions/track-changes/track-changes.js b/packages/super-editor/src/extensions/track-changes/track-changes.js index c5729e74a..c09c1becc 100644 --- a/packages/super-editor/src/extensions/track-changes/track-changes.js +++ b/packages/super-editor/src/extensions/track-changes/track-changes.js @@ -258,6 +258,7 @@ export const TrackChanges = Extension.create({ from = state.selection.from, to = state.selection.to, text = '', + id, user, comment, addToHistory = true, @@ -296,7 +297,7 @@ export const TrackChanges = Extension.create({ // For replacements (both deletion and insertion), generate a shared ID upfront // so the deletion and insertion marks are linked together const isReplacement = from !== to && text; - const sharedId = isReplacement ? uuidv4() : null; + const sharedId = id ?? (isReplacement ? uuidv4() : null); let changeId = sharedId; let insertPos = to; // Default insert position is after the selection From c5a4860d8668242d3c7542f8d745413f6eedb0af Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Mon, 16 Feb 2026 21:35:46 -0800 Subject: [PATCH 06/25] fix(super-editor): make list commands can()-safe and prevent sdBlockId inheritance --- .../src/core/commands/insertListItemAt.js | 2 +- .../core/commands/insertListItemAt.test.js | 19 ++++++ .../src/core/commands/setListTypeAt.js | 6 +- .../src/core/commands/setListTypeAt.test.js | 6 +- .../src/core/commands/toggleList.js | 7 +- .../src/core/commands/toggleList.test.js | 64 ++++++++++++++++++- 6 files changed, 95 insertions(+), 9 deletions(-) diff --git a/packages/super-editor/src/core/commands/insertListItemAt.js b/packages/super-editor/src/core/commands/insertListItemAt.js index 676e5593f..3fe9b94b4 100644 --- a/packages/super-editor/src/core/commands/insertListItemAt.js +++ b/packages/super-editor/src/core/commands/insertListItemAt.js @@ -33,7 +33,7 @@ export const insertListItemAt = const attrs = { ...(targetNode.attrs ?? {}), - ...(sdBlockId ? { sdBlockId } : {}), + sdBlockId: sdBlockId ?? null, paraId: null, textId: null, listRendering: null, diff --git a/packages/super-editor/src/core/commands/insertListItemAt.test.js b/packages/super-editor/src/core/commands/insertListItemAt.test.js index 17eb94342..b9e3ed0de 100644 --- a/packages/super-editor/src/core/commands/insertListItemAt.test.js +++ b/packages/super-editor/src/core/commands/insertListItemAt.test.js @@ -170,6 +170,25 @@ describe('insertListItemAt', () => { expect(state.schema.text).toHaveBeenCalledWith('New item'); }); + it('does not inherit sdBlockId from the target node when omitted', () => { + const targetWithBlockId = { + type: { name: 'paragraph' }, + attrs: { + sdBlockId: 'source-block-id', + paragraphProperties: { numberingProperties }, + numberingProperties, + }, + nodeSize: 7, + }; + const { state, dispatch, paragraphType } = createMockState(targetWithBlockId); + state.doc.nodeAt.mockReturnValue(targetWithBlockId); + + insertListItemAt({ pos: 0, position: 'after' })({ state, dispatch }); + + const callArgs = paragraphType.createAndFill.mock.calls[0]; + expect(callArgs?.[0]?.sdBlockId).toBeNull(); + }); + it('returns false when createAndFill throws', () => { const { state, dispatch, paragraphType } = createMockState(); paragraphType.createAndFill.mockImplementation(() => { diff --git a/packages/super-editor/src/core/commands/setListTypeAt.js b/packages/super-editor/src/core/commands/setListTypeAt.js index 975fe91f8..2f7117188 100644 --- a/packages/super-editor/src/core/commands/setListTypeAt.js +++ b/packages/super-editor/src/core/commands/setListTypeAt.js @@ -27,8 +27,10 @@ export const setListTypeAt = if (!numberingProperties) return false; const level = Number(numberingProperties.ilvl ?? 0) || 0; - const listType = kind === 'bullet' ? 'bulletList' : 'orderedList'; + + if (!dispatch) return true; + const newNumId = Number(ListHelpers.getNewListId(editor)); if (!Number.isFinite(newNumId)) return false; @@ -50,6 +52,6 @@ export const setListTypeAt = tr, ); - if (dispatch) dispatch(tr); + dispatch(tr); return true; }; diff --git a/packages/super-editor/src/core/commands/setListTypeAt.test.js b/packages/super-editor/src/core/commands/setListTypeAt.test.js index 00e531000..be1197c76 100644 --- a/packages/super-editor/src/core/commands/setListTypeAt.test.js +++ b/packages/super-editor/src/core/commands/setListTypeAt.test.js @@ -141,13 +141,15 @@ describe('setListTypeAt', () => { expect(result).toBe(false); }); - it('does not dispatch when dispatch is not provided', () => { + it('is side-effect-free when dispatch is not provided', () => { const props = createMockProps(); props.dispatch = undefined; const result = setListTypeAt({ pos: 0, kind: 'bullet' })(props); expect(result).toBe(true); - expect(updateNumberingProperties).toHaveBeenCalled(); + expect(ListHelpers.getNewListId).not.toHaveBeenCalled(); + expect(ListHelpers.generateNewListDefinition).not.toHaveBeenCalled(); + expect(updateNumberingProperties).not.toHaveBeenCalled(); }); }); diff --git a/packages/super-editor/src/core/commands/toggleList.js b/packages/super-editor/src/core/commands/toggleList.js index 341e0787b..bd88d425e 100644 --- a/packages/super-editor/src/core/commands/toggleList.js +++ b/packages/super-editor/src/core/commands/toggleList.js @@ -86,6 +86,11 @@ export const toggleList = } else { // If list paragraph was not found, create a new list definition and apply it to all paragraphs in selection mode = 'create'; + } + + if (!dispatch) return true; + + if (mode === 'create') { const numId = ListHelpers.getNewListId(editor); ListHelpers.generateNewListDefinition({ numId: Number(numId), listType, editor }); sharedNumberingProperties = { @@ -141,6 +146,6 @@ export const toggleList = } } } - if (dispatch) dispatch(tr); + dispatch(tr); return true; }; diff --git a/packages/super-editor/src/core/commands/toggleList.test.js b/packages/super-editor/src/core/commands/toggleList.test.js index d8cf56ed9..17a0f34c5 100644 --- a/packages/super-editor/src/core/commands/toggleList.test.js +++ b/packages/super-editor/src/core/commands/toggleList.test.js @@ -140,7 +140,7 @@ describe('toggleList', () => { const state = createState(paragraphs); const handler = toggleList('orderedList'); - const result = handler({ editor, state, tr, dispatch: undefined }); + const result = handler({ editor, state, tr, dispatch }); expect(result).toBe(true); expect(updateNumberingProperties).toHaveBeenCalledTimes(1); @@ -153,6 +153,7 @@ describe('toggleList', () => { editor, tr, ); + expect(dispatch).toHaveBeenCalledWith(tr); }); it('creates a new list definition when no matching list exists in or before the selection', () => { @@ -164,7 +165,7 @@ describe('toggleList', () => { const state = createState(paragraphs); const handler = toggleList('orderedList'); - const result = handler({ editor, state, tr, dispatch: undefined }); + const result = handler({ editor, state, tr, dispatch }); expect(result).toBe(true); expect(ListHelpers.getNewListId).toHaveBeenCalledWith(editor); @@ -177,6 +178,7 @@ describe('toggleList', () => { for (const [index, { node, pos }] of paragraphs.entries()) { expect(updateNumberingProperties).toHaveBeenNthCalledWith(index + 1, expectedNumbering, node, pos, editor, tr); } + expect(dispatch).toHaveBeenCalledWith(tr); }); it('borrows numbering from the previous list paragraph when selection lacks one', () => { @@ -195,7 +197,7 @@ describe('toggleList', () => { const state = createState(paragraphs, { beforeNode, parentIndex: 1 }); const handler = toggleList('orderedList'); - const result = handler({ editor, state, tr, dispatch: undefined }); + const result = handler({ editor, state, tr, dispatch }); expect(result).toBe(true); expect(ListHelpers.getNewListId).not.toHaveBeenCalled(); @@ -204,5 +206,61 @@ describe('toggleList', () => { for (const [index, { node, pos }] of paragraphs.entries()) { expect(updateNumberingProperties).toHaveBeenNthCalledWith(index + 1, expectedNumbering, node, pos, editor, tr); } + expect(dispatch).toHaveBeenCalledWith(tr); + }); + + it('is side-effect-free when dispatch is not provided (create mode)', () => { + ListHelpers.getNewListId.mockReturnValue('42'); + const paragraphs = [createParagraph({ paragraphProperties: {} }, 3)]; + const state = createState(paragraphs); + const handler = toggleList('orderedList'); + + const result = handler({ editor, state, tr, dispatch: undefined }); + + expect(result).toBe(true); + expect(ListHelpers.getNewListId).not.toHaveBeenCalled(); + expect(ListHelpers.generateNewListDefinition).not.toHaveBeenCalled(); + expect(updateNumberingProperties).not.toHaveBeenCalled(); + }); + + it('is side-effect-free when dispatch is not provided (remove mode)', () => { + const paragraphs = [ + createParagraph( + { + paragraphProperties: { numberingProperties: { numId: 5, ilvl: 0 } }, + listRendering: { numberingType: 'bullet' }, + }, + 1, + ), + ]; + const state = createState(paragraphs); + const handler = toggleList('bulletList'); + + const result = handler({ editor, state, tr, dispatch: undefined }); + + expect(result).toBe(true); + expect(updateNumberingProperties).not.toHaveBeenCalled(); + expect(ListHelpers.generateNewListDefinition).not.toHaveBeenCalled(); + }); + + it('is side-effect-free when dispatch is not provided (reuse mode)', () => { + const paragraphs = [ + createParagraph( + { + paragraphProperties: { numberingProperties: { numId: 12, ilvl: 0 } }, + listRendering: { numberingType: 'decimal' }, + }, + 2, + ), + createParagraph({ paragraphProperties: {} }, 6), + ]; + const state = createState(paragraphs); + const handler = toggleList('orderedList'); + + const result = handler({ editor, state, tr, dispatch: undefined }); + + expect(result).toBe(true); + expect(updateNumberingProperties).not.toHaveBeenCalled(); + expect(ListHelpers.generateNewListDefinition).not.toHaveBeenCalled(); }); }); From 4a4f8d4e8bd32e77435e6b567a6be4738040f0a1 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Mon, 16 Feb 2026 21:52:25 -0800 Subject: [PATCH 07/25] fix(search): honor caseSensitive for plain-string queries --- .../find-adapter.test.ts | 16 ++++++++++++ .../src/extensions/search/search.js | 1 + .../search/search-find-replace.test.js | 25 +++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/packages/super-editor/src/document-api-adapters/find-adapter.test.ts b/packages/super-editor/src/document-api-adapters/find-adapter.test.ts index 438e96aac..101d9d51c 100644 --- a/packages/super-editor/src/document-api-adapters/find-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/find-adapter.test.ts @@ -626,6 +626,22 @@ describe('findAdapter — text selectors', () => { expect(capturedPattern).toBe('Hello'); }); + it('forwards caseSensitive option to search command for contains mode', () => { + let capturedOptions: Record | undefined; + const doc = buildDoc({ typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, nodeSize: 50, offset: 0 }); + const search: SearchFn = (_pattern, options) => { + capturedOptions = options as Record; + return []; + }; + const editor = makeEditor(doc, search); + const query: Query = { select: { type: 'text', pattern: 'Hello', caseSensitive: true } }; + + findAdapter(editor, query); + + expect(capturedOptions).toBeDefined(); + expect(capturedOptions!.caseSensitive).toBe(true); + }); + it('throws when editor has no search command', () => { const doc = buildDoc({ typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, nodeSize: 50, offset: 0 }); const editor = makeEditor(doc); // no search command diff --git a/packages/super-editor/src/extensions/search/search.js b/packages/super-editor/src/extensions/search/search.js index d77592d61..571c46e55 100644 --- a/packages/super-editor/src/extensions/search/search.js +++ b/packages/super-editor/src/extensions/search/search.js @@ -350,6 +350,7 @@ export const Search = Extension.create({ searchPattern = new RegExp(body, flags.includes('g') ? flags : flags + 'g'); } else { searchPattern = String(patternInput); + caseSensitive = typeof options?.caseSensitive === 'boolean' ? options.caseSensitive : false; } // Ensure search index is valid diff --git a/packages/super-editor/src/tests/extensions/search/search-find-replace.test.js b/packages/super-editor/src/tests/extensions/search/search-find-replace.test.js index 4f5bc883c..d4ca3efe8 100644 --- a/packages/super-editor/src/tests/extensions/search/search-find-replace.test.js +++ b/packages/super-editor/src/tests/extensions/search/search-find-replace.test.js @@ -887,6 +887,31 @@ describe('Search find/replace commands', () => { editor.destroy(); } }); + + it('should respect caseSensitive option for string patterns', () => { + const editor = createDocxTestEditor(); + + try { + const { doc, paragraph, run } = editor.schema.nodes; + const testDoc = doc.create(null, [ + paragraph.create(null, [run.create(null, [editor.schema.text('Test TEST test TeSt')])]), + ]); + + const baseState = EditorState.create({ + schema: editor.schema, + doc: testDoc, + plugins: editor.state.plugins, + }); + editor.setState(baseState); + + const matches = editor.commands.search('test', { caseSensitive: true }); + + expect(matches).toHaveLength(1); + expect(matches[0].text).toBe('test'); + } finally { + editor.destroy(); + } + }); }); describe('Whole word matching', () => { From bd6e1adbd7d678071ce5bbaf7e2b46e22cbb50b5 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Mon, 16 Feb 2026 22:25:36 -0800 Subject: [PATCH 08/25] fix(super-editor): honor forceTrackChanges in dispatch and reject ambiguous text targets --- .../Editor.track-changes-dispatch.test.js | 107 ++++++++++++++++++ packages/super-editor/src/core/Editor.ts | 22 ++-- .../helpers/adapter-utils.ts | 29 ++++- .../write-adapter.test.ts | 81 +++++++++++++ 4 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 packages/super-editor/src/core/Editor.track-changes-dispatch.test.js diff --git a/packages/super-editor/src/core/Editor.track-changes-dispatch.test.js b/packages/super-editor/src/core/Editor.track-changes-dispatch.test.js new file mode 100644 index 000000000..33e391570 --- /dev/null +++ b/packages/super-editor/src/core/Editor.track-changes-dispatch.test.js @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { initTestEditor } from '@tests/helpers/helpers.js'; +import { getTrackChanges } from '@extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; +import { TrackInsertMarkName } from '@extensions/track-changes/constants.js'; +import { TrackChangesBasePluginKey } from '@extensions/track-changes/plugins/trackChangesBasePlugin.js'; + +describe('Editor dispatch tracked-change meta', () => { + let editor; + + afterEach(() => { + if (editor && !editor.isDestroyed) { + editor.destroy(); + editor = null; + } + }); + + it('treats forceTrackChanges programmatic transactions as tracked even when global mode is off', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

Hello

', + user: { name: 'Test', email: 'test@example.com' }, + useImmediateSetTimeout: false, + })); + + const trackInsertMark = editor.schema?.marks?.[TrackInsertMarkName]; + expect(trackInsertMark).toBeDefined(); + + const trackState = TrackChangesBasePluginKey.getState(editor.state); + expect(trackState?.isTrackChangesActive ?? false).toBe(false); + expect(getTrackChanges(editor.state)).toHaveLength(0); + + const tr = editor.state.tr + .insertText('X', 1, 1) + .setMeta('inputType', 'programmatic') + .setMeta('forceTrackChanges', true); + + editor.dispatch(tr); + + const tracked = getTrackChanges(editor.state); + expect(tracked.some((entry) => entry.mark.type.name === TrackInsertMarkName)).toBe(true); + }); + + it('skipTrackChanges overrides forceTrackChanges — no tracking applied', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

Hello

', + user: { name: 'Test', email: 'test@example.com' }, + useImmediateSetTimeout: false, + })); + + const trackState = TrackChangesBasePluginKey.getState(editor.state); + expect(trackState?.isTrackChangesActive ?? false).toBe(false); + + const tr = editor.state.tr + .insertText('X', 1, 1) + .setMeta('inputType', 'programmatic') + .setMeta('forceTrackChanges', true) + .setMeta('skipTrackChanges', true); + + editor.dispatch(tr); + + const tracked = getTrackChanges(editor.state); + expect(tracked).toHaveLength(0); + }); + + it('throws a clear error when forceTrackChanges is used without a configured user', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

Hello

', + useImmediateSetTimeout: false, + })); + + const tr = editor.state.tr + .insertText('X', 1, 1) + .setMeta('inputType', 'programmatic') + .setMeta('forceTrackChanges', true); + + expect(() => editor.dispatch(tr)).toThrow( + 'forceTrackChanges requires a user to be configured on the editor instance.', + ); + }); + + it('global track-changes mode still produces tracked entities without forceTrackChanges', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

Hello

', + user: { name: 'Test', email: 'test@example.com' }, + useImmediateSetTimeout: false, + })); + + const enableTr = editor.state.tr.setMeta(TrackChangesBasePluginKey, { + type: 'TRACK_CHANGES_ENABLE', + value: true, + }); + editor.dispatch(enableTr); + + const trackState = TrackChangesBasePluginKey.getState(editor.state); + expect(trackState?.isTrackChangesActive).toBe(true); + + const tr = editor.state.tr.insertText('Y', 1, 1).setMeta('inputType', 'programmatic'); + + editor.dispatch(tr); + + const tracked = getTrackChanges(editor.state); + expect(tracked.some((entry) => entry.mark.type.name === TrackInsertMarkName)).toBe(true); + }); +}); diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index b5b1cc8fa..9e62a20d2 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -2089,23 +2089,29 @@ export class Editor extends EventEmitter { const prevState = this.state; let nextState: EditorState; let transactionToApply = transaction; + const forceTrackChanges = transactionToApply.getMeta('forceTrackChanges') === true; try { const trackChangesState = TrackChangesBasePluginKey.getState(prevState); const isTrackChangesActive = trackChangesState?.isTrackChangesActive ?? false; const skipTrackChanges = transactionToApply.getMeta('skipTrackChanges') === true; - transactionToApply = - isTrackChangesActive && !skipTrackChanges - ? trackedTransaction({ - tr: transactionToApply, - state: prevState, - user: this.options.user!, - }) - : transactionToApply; + const shouldTrack = (isTrackChangesActive || forceTrackChanges) && !skipTrackChanges; + if (shouldTrack && forceTrackChanges && !this.options.user) { + throw new Error('forceTrackChanges requires a user to be configured on the editor instance.'); + } + + transactionToApply = shouldTrack + ? trackedTransaction({ + tr: transactionToApply, + state: prevState, + user: this.options.user!, + }) + : transactionToApply; const { state: appliedState } = prevState.applyTransaction(transactionToApply); nextState = appliedState; } catch (error) { + if (forceTrackChanges) throw error; // just in case nextState = prevState.apply(transactionToApply); console.log(error); diff --git a/packages/super-editor/src/document-api-adapters/helpers/adapter-utils.ts b/packages/super-editor/src/document-api-adapters/helpers/adapter-utils.ts index f243e6e77..e1c3ea43a 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/adapter-utils.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/adapter-utils.ts @@ -3,12 +3,32 @@ import { getBlockIndex } from './index-cache.js'; import { findBlockById, isTextBlockCandidate, type BlockCandidate, type BlockIndex } from './node-address-resolver.js'; import { resolveTextRangeInBlock } from './text-offset-resolver.js'; import type { Editor } from '../../core/Editor.js'; +import { DocumentApiAdapterError } from '../errors.js'; export type WithinResult = { ok: true; range: { start: number; end: number } | undefined } | { ok: false }; export type ResolvedTextTarget = { from: number; to: number }; +function findTextBlockCandidates(index: BlockIndex, blockId: string): BlockCandidate[] { + return index.candidates.filter((candidate) => candidate.nodeId === blockId && isTextBlockCandidate(candidate)); +} + +function assertUnambiguous(matches: BlockCandidate[], blockId: string): void { + if (matches.length > 1) { + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + `Block ID "${blockId}" is ambiguous: matched ${matches.length} text blocks.`, + { + blockId, + matchCount: matches.length, + }, + ); + } +} + function findInlineWithinTextBlock(index: BlockIndex, blockId: string): BlockCandidate | undefined { - return index.candidates.find((candidate) => candidate.nodeId === blockId && isTextBlockCandidate(candidate)); + const matches = findTextBlockCandidates(index, blockId); + assertUnambiguous(matches, blockId); + return matches[0]; } /** @@ -17,12 +37,13 @@ function findInlineWithinTextBlock(index: BlockIndex, blockId: string): BlockCan * @param editor - The editor instance. * @param target - The text address to resolve. * @returns Absolute `{ from, to }` positions, or `null` if the target block cannot be found. + * @throws {DocumentApiAdapterError} `INVALID_TARGET` when multiple text blocks share the same blockId. */ export function resolveTextTarget(editor: Editor, target: TextAddress): ResolvedTextTarget | null { const index = getBlockIndex(editor); - const block = index.candidates.find( - (candidate) => candidate.nodeId === target.blockId && isTextBlockCandidate(candidate), - ); + const matches = findTextBlockCandidates(index, target.blockId); + assertUnambiguous(matches, target.blockId); + const block = matches[0]; if (!block) return null; return resolveTextRangeInBlock(block.node, block.pos, target.range); } diff --git a/packages/super-editor/src/document-api-adapters/write-adapter.test.ts b/packages/super-editor/src/document-api-adapters/write-adapter.test.ts index ccffa8836..7beeb1984 100644 --- a/packages/super-editor/src/document-api-adapters/write-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/write-adapter.test.ts @@ -107,6 +107,65 @@ function makeEditor(text = 'Hello'): { return { editor, dispatch, insertTrackedChange, textBetween, tr }; } +function makeEditorWithDuplicateBlockIds(): { + editor: Editor; + dispatch: ReturnType; + tr: { + insertText: ReturnType; + delete: ReturnType; + setMeta: ReturnType; + addMark: ReturnType; + }; +} { + const firstTextNode = createNode('text', [], { text: 'Hello' }); + const secondTextNode = createNode('text', [], { text: 'World' }); + const firstParagraph = createNode('paragraph', [firstTextNode], { + attrs: { sdBlockId: 'dup' }, + isBlock: true, + inlineContent: true, + }); + const secondParagraph = createNode('paragraph', [secondTextNode], { + attrs: { sdBlockId: 'dup' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [firstParagraph, secondParagraph], { isBlock: false }); + + const tr = { + insertText: vi.fn(), + delete: vi.fn(), + setMeta: vi.fn(), + addMark: vi.fn(), + }; + tr.insertText.mockReturnValue(tr); + tr.delete.mockReturnValue(tr); + tr.setMeta.mockReturnValue(tr); + tr.addMark.mockReturnValue(tr); + + const dispatch = vi.fn(); + + const editor = { + state: { + doc: { + ...doc, + textBetween: vi.fn((from: number, to: number) => { + const docText = 'Hello\nWorld'; + const start = Math.max(0, from - 1); + const end = Math.max(start, to - 1); + return docText.slice(start, end); + }), + }, + tr, + }, + commands: { + insertTrackedChange: vi.fn(() => true), + }, + dispatch, + } as unknown as Editor; + + return { editor, dispatch, tr }; +} + function makeEditorWithoutEditableTextBlock(): { editor: Editor; } { @@ -303,6 +362,28 @@ describe('writeAdapter', () => { ).toThrow('Mutation target could not be resolved.'); }); + it('throws INVALID_TARGET when target block id is ambiguous across multiple text blocks', () => { + const { editor, dispatch, tr } = makeEditorWithDuplicateBlockIds(); + + try { + writeAdapter( + editor, + { + kind: 'replace', + target: { kind: 'text', blockId: 'dup', range: { start: 0, end: 1 } }, + text: 'X', + }, + { changeMode: 'direct' }, + ); + throw new Error('Expected writeAdapter to throw for ambiguous blockId target.'); + } catch (error) { + expect((error as { code?: string }).code).toBe('INVALID_TARGET'); + } + + expect(tr.insertText).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + it('requires collapsed targets for insert', () => { const { editor } = makeEditor('Hello'); From a152f78b8b963f94b4d7b17812882fddc11104c3 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Feb 2026 09:48:05 -0800 Subject: [PATCH 09/25] fix(super-editor): enforce tracked-mode user preconditions in dry-run adapters --- .../create-adapter.test.ts | 21 +++++++++- .../document-api-adapters/create-adapter.ts | 2 + .../format-adapter.test.ts | 36 ++++++++++++++-- .../document-api-adapters/format-adapter.ts | 14 ++++--- .../helpers/tracked-mode-guards.ts | 19 +++++++++ .../lists-adapter.test.ts | 41 ++++++++++++++++++- .../document-api-adapters/lists-adapter.ts | 2 + 7 files changed, 123 insertions(+), 12 deletions(-) create mode 100644 packages/super-editor/src/document-api-adapters/helpers/tracked-mode-guards.ts diff --git a/packages/super-editor/src/document-api-adapters/create-adapter.test.ts b/packages/super-editor/src/document-api-adapters/create-adapter.test.ts index 07ef63577..e0a62c8bf 100644 --- a/packages/super-editor/src/document-api-adapters/create-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/create-adapter.test.ts @@ -159,10 +159,12 @@ function makeEditor({ withTrackedCommand = true, insertReturns = true, insertedParagraphAttrs, + user, }: { withTrackedCommand?: boolean; insertReturns?: boolean; insertedParagraphAttrs?: Record; + user?: { name: string }; } = {}): { editor: Editor; insertParagraphAt: ReturnType; @@ -184,6 +186,7 @@ function makeEditor({ insertParagraphAt, insertTrackedChange: withTrackedCommand ? vi.fn(() => true) : undefined, }, + options: { user }, } as unknown as Editor; return { editor, insertParagraphAt }; @@ -256,7 +259,7 @@ describe('createParagraphAdapter', () => { it('creates tracked paragraphs without losing nodesBetween context', () => { const resolverSpy = vi.spyOn(trackedChangeResolver, 'buildTrackedChangeCanonicalIdMap').mockReturnValue(new Map()); - const { editor } = makeEditor(); + const { editor } = makeEditor({ user: { name: 'Test' } }); const result = createParagraphAdapter(editor, { text: 'Tracked paragraph' }, { changeMode: 'tracked' }); @@ -359,4 +362,20 @@ describe('createParagraphAdapter', () => { range: { start: 0, end: 0 }, }); }); + + it('throws TRACK_CHANGE_COMMAND_UNAVAILABLE for tracked dry-run without a configured user', () => { + const { editor } = makeEditor(); + + expect(() => createParagraphAdapter(editor, { text: 'Tracked' }, { changeMode: 'tracked', dryRun: true })).toThrow( + 'requires a user to be configured', + ); + }); + + it('throws same error for tracked non-dry-run without a configured user', () => { + const { editor } = makeEditor(); + + expect(() => createParagraphAdapter(editor, { text: 'Tracked' }, { changeMode: 'tracked' })).toThrow( + 'requires a user to be configured', + ); + }); }); diff --git a/packages/super-editor/src/document-api-adapters/create-adapter.ts b/packages/super-editor/src/document-api-adapters/create-adapter.ts index 1bc693103..ea5a01daf 100644 --- a/packages/super-editor/src/document-api-adapters/create-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/create-adapter.ts @@ -10,6 +10,7 @@ import { clearIndexCache, getBlockIndex } from './helpers/index-cache.js'; import { findBlockById, type BlockCandidate } from './helpers/node-address-resolver.js'; import { collectTrackInsertRefsInRange } from './helpers/tracked-change-refs.js'; import { DocumentApiAdapterError } from './errors.js'; +import { ensureTrackedUser } from './helpers/tracked-mode-guards.js'; type InsertParagraphAtCommandOptions = { pos: number; @@ -56,6 +57,7 @@ function ensureTrackedCreateCapability(editor: Editor): void { 'Tracked paragraph creation is not available on this editor instance.', ); } + ensureTrackedUser(editor, 'Tracked paragraph creation'); } function resolveCreatedParagraph(editor: Editor, paragraphId: string): BlockCandidate { diff --git a/packages/super-editor/src/document-api-adapters/format-adapter.test.ts b/packages/super-editor/src/document-api-adapters/format-adapter.test.ts index 5c945486f..5da983c0a 100644 --- a/packages/super-editor/src/document-api-adapters/format-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/format-adapter.test.ts @@ -51,7 +51,10 @@ function createNode(typeName: string, children: ProseMirrorNode[] = [], options: } as unknown as ProseMirrorNode; } -function makeEditor(text = 'Hello'): { +function makeEditor( + text = 'Hello', + options: { user?: { name: string } } = {}, +): { editor: Editor; dispatch: ReturnType; insertTrackedChange: ReturnType; @@ -105,6 +108,7 @@ function makeEditor(text = 'Hello'): { commands: { insertTrackedChange, }, + options: { user: options.user }, dispatch, } as unknown as Editor; @@ -132,7 +136,7 @@ describe('formatBoldAdapter', () => { }); it('sets forceTrackChanges meta in tracked mode', () => { - const { editor, tr } = makeEditor(); + const { editor, tr } = makeEditor('Hello', { user: { name: 'Test' } }); const receipt = formatBoldAdapter( editor, { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, @@ -210,7 +214,7 @@ describe('formatBoldAdapter', () => { }); it('supports tracked dry-run without building a transaction', () => { - const { editor, dispatch, tr } = makeEditor(); + const { editor, dispatch, tr } = makeEditor('Hello', { user: { name: 'Test' } }); const receipt = formatBoldAdapter( editor, { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, @@ -225,7 +229,7 @@ describe('formatBoldAdapter', () => { }); it('keeps direct and tracked bold operations deterministic for the same target', () => { - const { editor, tr } = makeEditor(); + const { editor, tr } = makeEditor('Hello', { user: { name: 'Test' } }); const direct = formatBoldAdapter( editor, @@ -242,4 +246,28 @@ describe('formatBoldAdapter', () => { expect(tracked.success).toBe(true); expect(tr.setMeta).toHaveBeenCalledWith('forceTrackChanges', true); }); + + it('throws TRACK_CHANGE_COMMAND_UNAVAILABLE for tracked dry-run without a configured user', () => { + const { editor } = makeEditor(); + + expect(() => + formatBoldAdapter( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, + { changeMode: 'tracked', dryRun: true }, + ), + ).toThrow('requires a user to be configured'); + }); + + it('throws same error for tracked non-dry-run without a configured user', () => { + const { editor } = makeEditor(); + + expect(() => + formatBoldAdapter( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, + { changeMode: 'tracked' }, + ), + ).toThrow('requires a user to be configured'); + }); }); diff --git a/packages/super-editor/src/document-api-adapters/format-adapter.ts b/packages/super-editor/src/document-api-adapters/format-adapter.ts index 45da85112..db12a5e95 100644 --- a/packages/super-editor/src/document-api-adapters/format-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/format-adapter.ts @@ -2,6 +2,7 @@ import type { Editor } from '../core/Editor.js'; import type { FormatBoldInput, MutationOptions, TextMutationReceipt } from '@superdoc/document-api'; import { TrackFormatMarkName } from '../extensions/track-changes/constants.js'; import { DocumentApiAdapterError } from './errors.js'; +import { ensureTrackedUser } from './helpers/tracked-mode-guards.js'; import { resolveTextTarget } from './helpers/adapter-utils.js'; import { buildTextMutationResolution, readTextAtResolvedRange } from './helpers/text-mutation-resolution.js'; @@ -9,12 +10,13 @@ function assertTrackedFormatCapability(editor: Editor): void { const hasTrackedInsertCommand = typeof editor.commands?.insertTrackedChange === 'function'; const hasTrackFormatMark = Boolean(editor.schema?.marks?.[TrackFormatMarkName]); - if (hasTrackedInsertCommand && hasTrackFormatMark) return; - - throw new DocumentApiAdapterError( - 'TRACK_CHANGE_COMMAND_UNAVAILABLE', - 'Tracked bold formatting is not available on this editor instance.', - ); + if (!hasTrackedInsertCommand || !hasTrackFormatMark) { + throw new DocumentApiAdapterError( + 'TRACK_CHANGE_COMMAND_UNAVAILABLE', + 'Tracked bold formatting is not available on this editor instance.', + ); + } + ensureTrackedUser(editor, 'Tracked bold formatting'); } export function formatBoldAdapter( diff --git a/packages/super-editor/src/document-api-adapters/helpers/tracked-mode-guards.ts b/packages/super-editor/src/document-api-adapters/helpers/tracked-mode-guards.ts new file mode 100644 index 000000000..d70fef49c --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/tracked-mode-guards.ts @@ -0,0 +1,19 @@ +import type { Editor } from '../../core/Editor.js'; +import { DocumentApiAdapterError } from '../errors.js'; + +/** + * Asserts that the editor has a configured user, which is required for + * force-tracked mutations that set `forceTrackChanges` on the transaction. + * + * @param editor - The editor instance to validate. + * @param operation - Human-readable operation name for the error message. + * @throws {DocumentApiAdapterError} `TRACK_CHANGE_COMMAND_UNAVAILABLE` when user is missing. + */ +export function ensureTrackedUser(editor: Editor, operation: string): void { + if (!editor.options.user) { + throw new DocumentApiAdapterError( + 'TRACK_CHANGE_COMMAND_UNAVAILABLE', + `${operation} requires a user to be configured on the editor instance.`, + ); + } +} diff --git a/packages/super-editor/src/document-api-adapters/lists-adapter.test.ts b/packages/super-editor/src/document-api-adapters/lists-adapter.test.ts index db9f8e893..b7600dc1b 100644 --- a/packages/super-editor/src/document-api-adapters/lists-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/lists-adapter.test.ts @@ -113,7 +113,11 @@ function makeDoc(children: MockParagraphNode[]) { }; } -function makeEditor(children: MockParagraphNode[], commandOverrides: Record = {}): Editor { +function makeEditor( + children: MockParagraphNode[], + commandOverrides: Record = {}, + editorOptions: { user?: { name: string } } = {}, +): Editor { const doc = makeDoc(children); const baseCommands = { insertListItemAt: vi.fn( @@ -175,6 +179,7 @@ function makeEditor(children: MockParagraphNode[], commandOverrides: Record { expect(exitListItemAt).not.toHaveBeenCalled(); }); }); + + it('throws TRACK_CHANGE_COMMAND_UNAVAILABLE for tracked insert dry-run without a configured user', () => { + const editor = makeEditor([ + makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'decimal' }), + ]); + + expect(() => + listsInsertAdapter( + editor, + { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + position: 'after', + }, + { changeMode: 'tracked', dryRun: true }, + ), + ).toThrow('requires a user to be configured'); + }); + + it('throws same error for tracked insert non-dry-run without a configured user', () => { + const editor = makeEditor([ + makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'decimal' }), + ]); + + expect(() => + listsInsertAdapter( + editor, + { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + position: 'after', + }, + { changeMode: 'tracked' }, + ), + ).toThrow('requires a user to be configured'); + }); }); diff --git a/packages/super-editor/src/document-api-adapters/lists-adapter.ts b/packages/super-editor/src/document-api-adapters/lists-adapter.ts index 0e03a1a62..c32ba36f0 100644 --- a/packages/super-editor/src/document-api-adapters/lists-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/lists-adapter.ts @@ -14,6 +14,7 @@ import type { MutationOptions, } from '@superdoc/document-api'; import { DocumentApiAdapterError } from './errors.js'; +import { ensureTrackedUser } from './helpers/tracked-mode-guards.js'; import { clearIndexCache, getBlockIndex } from './helpers/index-cache.js'; import { collectTrackInsertRefsInRange } from './helpers/tracked-change-refs.js'; import { @@ -63,6 +64,7 @@ function ensureTrackedInsertCapability(editor: Editor, mode: 'direct' | 'tracked 'lists.insert tracked mode is not available on this editor instance.', ); } + ensureTrackedUser(editor, 'lists.insert tracked mode'); } function resolveInsertedListItem(editor: Editor, sdBlockId: string): ListItemProjection { From d779e52dcb4886c8f72a92779c6f6de8d2f86bcb Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Feb 2026 11:34:01 -0800 Subject: [PATCH 10/25] refactor(document-api-adapters): unify adapter capability errors and extract mutation helpers --- .../src/core/commands/insertParagraphAt.js | 8 +- .../assemble-adapters.test.ts | 52 +++++++ .../assemble-adapters.ts | 80 +++++++++++ .../comments-adapter.test.ts | 2 +- .../document-api-adapters/comments-adapter.ts | 86 +++--------- .../create-adapter.test.ts | 44 +++--- .../document-api-adapters/create-adapter.ts | 51 ++++--- .../src/document-api-adapters/errors.test.ts | 9 +- .../src/document-api-adapters/errors.ts | 6 +- .../find-adapter.test.ts | 2 +- .../find/text-strategy.ts | 11 +- .../format-adapter.test.ts | 6 +- .../document-api-adapters/format-adapter.ts | 23 +--- .../get-text-adapter.test.ts | 24 +++- .../document-api-adapters/get-text-adapter.ts | 3 +- .../helpers/mutation-helpers.test.ts | 128 ++++++++++++++++++ .../helpers/mutation-helpers.ts | 79 +++++++++++ .../helpers/tracked-mode-guards.ts | 19 --- .../lists-adapter.test.ts | 79 ++++++++++- .../document-api-adapters/lists-adapter.ts | 88 +++++------- .../track-changes-adapter.test.ts | 24 +++- .../track-changes-adapter.ts | 28 ++-- .../write-adapter.test.ts | 31 ++++- .../document-api-adapters/write-adapter.ts | 19 +-- .../src/extensions/comment/comments-plugin.js | 4 +- .../comment/comments-plugin.test.js | 16 +++ packages/super-editor/src/index.js | 2 + vitest.config.mjs | 1 + 28 files changed, 647 insertions(+), 278 deletions(-) create mode 100644 packages/super-editor/src/document-api-adapters/assemble-adapters.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/assemble-adapters.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/mutation-helpers.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/mutation-helpers.ts delete mode 100644 packages/super-editor/src/document-api-adapters/helpers/tracked-mode-guards.ts diff --git a/packages/super-editor/src/core/commands/insertParagraphAt.js b/packages/super-editor/src/core/commands/insertParagraphAt.js index 90267b6aa..e35f8a239 100644 --- a/packages/super-editor/src/core/commands/insertParagraphAt.js +++ b/packages/super-editor/src/core/commands/insertParagraphAt.js @@ -29,10 +29,12 @@ export const insertParagraphAt = if (!paragraphNode) return false; - if (!dispatch) return true; - + // Validate the structural insertion before the dispatch guard so that + // editor.can().insertParagraphAt() accurately reflects feasibility. try { - const tr = state.tr.insert(pos, paragraphNode).setMeta('inputType', 'programmatic'); + const tr = state.tr.insert(pos, paragraphNode); + if (!dispatch) return true; + tr.setMeta('inputType', 'programmatic'); if (tracked) { tr.setMeta('forceTrackChanges', true); } diff --git a/packages/super-editor/src/document-api-adapters/assemble-adapters.test.ts b/packages/super-editor/src/document-api-adapters/assemble-adapters.test.ts new file mode 100644 index 000000000..273468043 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { assembleDocumentApiAdapters } from './assemble-adapters.js'; +import type { Editor } from '../core/Editor.js'; + +function makeEditor(): Editor { + return { + state: { doc: { content: { size: 0 } } }, + commands: {}, + schema: { marks: {} }, + options: {}, + } as unknown as Editor; +} + +describe('assembleDocumentApiAdapters', () => { + it('returns an object with all expected adapter namespaces', () => { + const adapters = assembleDocumentApiAdapters(makeEditor()); + + expect(adapters).toHaveProperty('find.find'); + expect(adapters).toHaveProperty('getNode.getNode'); + expect(adapters).toHaveProperty('getNode.getNodeById'); + expect(adapters).toHaveProperty('getText.getText'); + expect(adapters).toHaveProperty('info.info'); + expect(adapters).toHaveProperty('comments'); + expect(adapters).toHaveProperty('write.write'); + expect(adapters).toHaveProperty('format.bold'); + expect(adapters).toHaveProperty('trackChanges.list'); + expect(adapters).toHaveProperty('trackChanges.get'); + expect(adapters).toHaveProperty('trackChanges.accept'); + expect(adapters).toHaveProperty('trackChanges.reject'); + expect(adapters).toHaveProperty('trackChanges.acceptAll'); + expect(adapters).toHaveProperty('trackChanges.rejectAll'); + expect(adapters).toHaveProperty('create.paragraph'); + expect(adapters).toHaveProperty('lists.list'); + expect(adapters).toHaveProperty('lists.get'); + expect(adapters).toHaveProperty('lists.insert'); + expect(adapters).toHaveProperty('lists.setType'); + expect(adapters).toHaveProperty('lists.indent'); + expect(adapters).toHaveProperty('lists.outdent'); + expect(adapters).toHaveProperty('lists.restart'); + expect(adapters).toHaveProperty('lists.exit'); + }); + + it('returns functions for all adapter methods', () => { + const adapters = assembleDocumentApiAdapters(makeEditor()); + + expect(typeof adapters.find.find).toBe('function'); + expect(typeof adapters.write.write).toBe('function'); + expect(typeof adapters.format.bold).toBe('function'); + expect(typeof adapters.create.paragraph).toBe('function'); + expect(typeof adapters.lists.insert).toBe('function'); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts new file mode 100644 index 000000000..58456072d --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts @@ -0,0 +1,80 @@ +import type { DocumentApiAdapters } from '@superdoc/document-api'; +import type { Editor } from '../core/Editor.js'; +import { findAdapter } from './find-adapter.js'; +import { getNodeAdapter, getNodeByIdAdapter } from './get-node-adapter.js'; +import { getTextAdapter } from './get-text-adapter.js'; +import { infoAdapter } from './info-adapter.js'; +import { createCommentsAdapter } from './comments-adapter.js'; +import { writeAdapter } from './write-adapter.js'; +import { formatBoldAdapter } from './format-adapter.js'; +import { + trackChangesListAdapter, + trackChangesGetAdapter, + trackChangesAcceptAdapter, + trackChangesRejectAdapter, + trackChangesAcceptAllAdapter, + trackChangesRejectAllAdapter, +} from './track-changes-adapter.js'; +import { createParagraphAdapter } from './create-adapter.js'; +import { + listsListAdapter, + listsGetAdapter, + listsInsertAdapter, + listsSetTypeAdapter, + listsIndentAdapter, + listsOutdentAdapter, + listsRestartAdapter, + listsExitAdapter, +} from './lists-adapter.js'; + +/** + * Assembles all document-api adapters for the given editor instance. + * + * @param editor - The editor instance to bind adapters to. + * @returns A {@link DocumentApiAdapters} object ready to pass to `createDocumentApi()`. + */ +export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters { + return { + find: { + find: (query) => findAdapter(editor, query), + }, + getNode: { + getNode: (address) => getNodeAdapter(editor, address), + getNodeById: (input) => getNodeByIdAdapter(editor, input), + }, + getText: { + getText: (input) => getTextAdapter(editor, input), + }, + info: { + info: (input) => infoAdapter(editor, input), + }, + comments: createCommentsAdapter(editor), + write: { + write: (request, options) => writeAdapter(editor, request, options), + }, + format: { + bold: (input, options) => formatBoldAdapter(editor, input, options), + }, + trackChanges: { + list: (input) => trackChangesListAdapter(editor, input), + get: (input) => trackChangesGetAdapter(editor, input), + accept: (input) => trackChangesAcceptAdapter(editor, input), + reject: (input) => trackChangesRejectAdapter(editor, input), + acceptAll: (input) => trackChangesAcceptAllAdapter(editor, input), + rejectAll: (input) => trackChangesRejectAllAdapter(editor, input), + }, + create: { + paragraph: (input, options) => createParagraphAdapter(editor, input, options), + }, + lists: { + list: (query) => listsListAdapter(editor, query), + get: (input) => listsGetAdapter(editor, input), + insert: (input, options) => listsInsertAdapter(editor, input, options), + setType: (input, options) => listsSetTypeAdapter(editor, input, options), + indent: (input, options) => listsIndentAdapter(editor, input, options), + outdent: (input, options) => listsOutdentAdapter(editor, input, options), + restart: (input, options) => listsRestartAdapter(editor, input, options), + exit: (input, options) => listsExitAdapter(editor, input, options), + }, + }; +} diff --git a/packages/super-editor/src/document-api-adapters/comments-adapter.test.ts b/packages/super-editor/src/document-api-adapters/comments-adapter.test.ts index ccf670a1b..ff58b0f85 100644 --- a/packages/super-editor/src/document-api-adapters/comments-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/comments-adapter.test.ts @@ -185,7 +185,7 @@ describe('addCommentAdapter', () => { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 1 } }, text: 'No commands', }), - ).toThrow('Comment commands are not available on this editor instance.'); + ).toThrow('command is not available'); }); it('returns false when blockId is not found', () => { diff --git a/packages/super-editor/src/document-api-adapters/comments-adapter.ts b/packages/super-editor/src/document-api-adapters/comments-adapter.ts index 403acb239..d80852383 100644 --- a/packages/super-editor/src/document-api-adapters/comments-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/comments-adapter.ts @@ -19,6 +19,7 @@ import type { import { TextSelection } from 'prosemirror-state'; import { v4 as uuidv4 } from 'uuid'; import { DocumentApiAdapterError } from './errors.js'; +import { requireEditorCommand } from './helpers/mutation-helpers.js'; import { clearIndexCache } from './helpers/index-cache.js'; import { resolveTextTarget } from './helpers/adapter-utils.js'; import { @@ -211,12 +212,7 @@ function buildCommentInfos(editor: Editor): CommentInfo[] { * @returns A receipt indicating success and the created entity address. */ function addCommentHandler(editor: Editor, input: AddCommentInput): Receipt { - if (typeof editor.commands?.addComment !== 'function') { - throw new DocumentApiAdapterError( - 'COMMAND_UNAVAILABLE', - 'Comment commands are not available on this editor instance.', - ); - } + requireEditorCommand(editor.commands?.addComment, 'comments.add (addComment)'); if (input.target.range.start === input.target.range.end) { return { @@ -258,13 +254,7 @@ function addCommentHandler(editor: Editor, input: AddCommentInput): Receipt { } // Re-read after selection so the command closure captures the updated selection snapshot. - const addComment = editor.commands?.addComment; - if (typeof addComment !== 'function') { - throw new DocumentApiAdapterError( - 'COMMAND_UNAVAILABLE', - 'Comment commands are not available on this editor instance.', - ); - } + const addComment = requireEditorCommand(editor.commands?.addComment, 'comments.add (addComment)'); const didInsert = addComment({ @@ -310,13 +300,7 @@ function addCommentHandler(editor: Editor, input: AddCommentInput): Receipt { } function editCommentHandler(editor: Editor, input: EditCommentInput): Receipt { - const editComment = editor.commands?.editComment; - if (!editComment) { - throw new DocumentApiAdapterError( - 'COMMAND_UNAVAILABLE', - 'Edit comment command is not available on this editor instance.', - ); - } + const editComment = requireEditorCommand(editor.commands?.editComment, 'comments.edit (editComment)'); const store = getCommentEntityStore(editor); const identity = resolveCommentIdentity(editor, input.commentId); @@ -360,13 +344,7 @@ function editCommentHandler(editor: Editor, input: EditCommentInput): Receipt { } function replyToCommentHandler(editor: Editor, input: ReplyToCommentInput): Receipt { - const addCommentReply = editor.commands?.addCommentReply; - if (!addCommentReply) { - throw new DocumentApiAdapterError( - 'COMMAND_UNAVAILABLE', - 'Reply-to-comment command is not available on this editor instance.', - ); - } + const addCommentReply = requireEditorCommand(editor.commands?.addCommentReply, 'comments.reply (addCommentReply)'); if (!input.parentCommentId) { return { @@ -420,13 +398,7 @@ function replyToCommentHandler(editor: Editor, input: ReplyToCommentInput): Rece } function moveCommentHandler(editor: Editor, input: MoveCommentInput): Receipt { - const moveComment = editor.commands?.moveComment; - if (!moveComment) { - throw new DocumentApiAdapterError( - 'COMMAND_UNAVAILABLE', - 'Move comment command is not available on this editor instance.', - ); - } + const moveComment = requireEditorCommand(editor.commands?.moveComment, 'comments.move (moveComment)'); if (input.target.range.start === input.target.range.end) { return { @@ -507,13 +479,7 @@ function moveCommentHandler(editor: Editor, input: MoveCommentInput): Receipt { } function resolveCommentHandler(editor: Editor, input: ResolveCommentInput): Receipt { - const resolveComment = editor.commands?.resolveComment; - if (!resolveComment) { - throw new DocumentApiAdapterError( - 'COMMAND_UNAVAILABLE', - 'Resolve comment command is not available on this editor instance.', - ); - } + const resolveComment = requireEditorCommand(editor.commands?.resolveComment, 'comments.resolve (resolveComment)'); const store = getCommentEntityStore(editor); const identity = resolveCommentIdentity(editor, input.commentId); @@ -558,13 +524,7 @@ function resolveCommentHandler(editor: Editor, input: ResolveCommentInput): Rece } function removeCommentHandler(editor: Editor, input: RemoveCommentInput): Receipt { - const removeComment = editor.commands?.removeComment; - if (!removeComment) { - throw new DocumentApiAdapterError( - 'COMMAND_UNAVAILABLE', - 'Remove comment command is not available on this editor instance.', - ); - } + const removeComment = requireEditorCommand(editor.commands?.removeComment, 'comments.remove (removeComment)'); const store = getCommentEntityStore(editor); const identity = resolveCommentIdentity(editor, input.commentId); @@ -604,13 +564,10 @@ function removeCommentHandler(editor: Editor, input: RemoveCommentInput): Receip } function setCommentInternalHandler(editor: Editor, input: SetCommentInternalInput): Receipt { - const setCommentInternal = editor.commands?.setCommentInternal; - if (!setCommentInternal) { - throw new DocumentApiAdapterError( - 'COMMAND_UNAVAILABLE', - 'Set-comment-internal command is not available on this editor instance.', - ); - } + const setCommentInternal = requireEditorCommand( + editor.commands?.setCommentInternal, + 'comments.setInternal (setCommentInternal)', + ); const store = getCommentEntityStore(editor); const identity = resolveCommentIdentity(editor, input.commentId); @@ -658,13 +615,10 @@ function setCommentInternalHandler(editor: Editor, input: SetCommentInternalInpu } function setCommentActiveHandler(editor: Editor, input: SetCommentActiveInput): Receipt { - const setActiveComment = editor.commands?.setActiveComment; - if (!setActiveComment) { - throw new DocumentApiAdapterError( - 'COMMAND_UNAVAILABLE', - 'Set-active-comment command is not available on this editor instance.', - ); - } + const setActiveComment = requireEditorCommand( + editor.commands?.setActiveComment, + 'comments.setActive (setActiveComment)', + ); let resolvedCommentId: string | null = null; if (input.commentId != null) { @@ -689,13 +643,7 @@ function setCommentActiveHandler(editor: Editor, input: SetCommentActiveInput): } function goToCommentHandler(editor: Editor, input: GoToCommentInput): Receipt { - const setCursorById = editor.commands?.setCursorById; - if (!setCursorById) { - throw new DocumentApiAdapterError( - 'COMMAND_UNAVAILABLE', - 'Go-to-comment command is not available on this editor instance.', - ); - } + const setCursorById = requireEditorCommand(editor.commands?.setCursorById, 'comments.goTo (setCursorById)'); const identity = resolveCommentIdentity(editor, input.commentId); let didSetCursor = setCursorById(identity.commentId); diff --git a/packages/super-editor/src/document-api-adapters/create-adapter.test.ts b/packages/super-editor/src/document-api-adapters/create-adapter.test.ts index e0a62c8bf..0cfba9300 100644 --- a/packages/super-editor/src/document-api-adapters/create-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/create-adapter.test.ts @@ -186,6 +186,9 @@ function makeEditor({ insertParagraphAt, insertTrackedChange: withTrackedCommand ? vi.fn(() => true) : undefined, }, + can: () => ({ + insertParagraphAt: () => insertReturns, + }), options: { user }, } as unknown as Editor; @@ -248,11 +251,11 @@ describe('createParagraphAdapter', () => { ).toThrow('target block was not found'); }); - it('throws TRACK_CHANGE_COMMAND_UNAVAILABLE when tracked create is requested without tracked capability', () => { + it('throws CAPABILITY_UNAVAILABLE when tracked create is requested without tracked capability', () => { const { editor } = makeEditor({ withTrackedCommand: false }); expect(() => createParagraphAdapter(editor, { text: 'Tracked' }, { changeMode: 'tracked' })).toThrow( - 'Tracked paragraph creation is not available', + 'requires the insertTrackedChange command', ); }); @@ -297,6 +300,16 @@ describe('createParagraphAdapter', () => { expect(insertParagraphAt).not.toHaveBeenCalled(); }); + it('dry-run returns INVALID_TARGET when insertion cannot be applied', () => { + const { editor } = makeEditor({ insertReturns: false }); + + const result = createParagraphAdapter(editor, { text: 'Dry run text' }, { changeMode: 'direct', dryRun: true }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.failure.code).toBe('INVALID_TARGET'); + }); + it('dry-run still throws TARGET_NOT_FOUND when target block does not exist', () => { const { editor } = makeEditor(); @@ -314,12 +327,12 @@ describe('createParagraphAdapter', () => { ).toThrow('target block was not found'); }); - it('dry-run still throws TRACK_CHANGE_COMMAND_UNAVAILABLE when tracked capability is missing', () => { + it('dry-run still throws CAPABILITY_UNAVAILABLE when tracked capability is missing', () => { const { editor } = makeEditor({ withTrackedCommand: false }); expect(() => createParagraphAdapter(editor, { text: 'Tracked dry run' }, { changeMode: 'tracked', dryRun: true }), - ).toThrow('Tracked paragraph creation is not available'); + ).toThrow('requires the insertTrackedChange command'); }); it('resolves created paragraph when block index identity prefers paraId over sdBlockId', () => { @@ -338,8 +351,8 @@ describe('createParagraphAdapter', () => { expect(result.insertionPoint.blockId).toBe('pm-para-id'); }); - it('returns success with generated sdBlockId when post-apply paragraph resolution fails', () => { - const { editor, insertParagraphAt } = makeEditor({ + it('returns success with generated ID when post-apply paragraph resolution fails', () => { + const { editor } = makeEditor({ insertedParagraphAttrs: { sdBlockId: undefined, }, @@ -347,23 +360,16 @@ describe('createParagraphAdapter', () => { const result = createParagraphAdapter(editor, { text: 'Inserted paragraph' }, { changeMode: 'direct' }); + // Contract: success:false means no mutation was applied. + // The mutation DID apply, so we must return success with the generated ID. expect(result.success).toBe(true); if (!result.success) return; - const generatedId = insertParagraphAt.mock.calls[0]?.[0]?.sdBlockId; - expect(generatedId).toBeTypeOf('string'); - expect(result.paragraph).toEqual({ - kind: 'block', - nodeType: 'paragraph', - nodeId: generatedId, - }); - expect(result.insertionPoint).toEqual({ - kind: 'text', - blockId: generatedId, - range: { start: 0, end: 0 }, - }); + expect(result.paragraph.nodeType).toBe('paragraph'); + expect(typeof result.paragraph.nodeId).toBe('string'); + expect(result.paragraph.nodeId).not.toBe('(dry-run)'); }); - it('throws TRACK_CHANGE_COMMAND_UNAVAILABLE for tracked dry-run without a configured user', () => { + it('throws CAPABILITY_UNAVAILABLE for tracked dry-run without a configured user', () => { const { editor } = makeEditor(); expect(() => createParagraphAdapter(editor, { text: 'Tracked' }, { changeMode: 'tracked', dryRun: true })).toThrow( diff --git a/packages/super-editor/src/document-api-adapters/create-adapter.ts b/packages/super-editor/src/document-api-adapters/create-adapter.ts index ea5a01daf..ec081a0d0 100644 --- a/packages/super-editor/src/document-api-adapters/create-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/create-adapter.ts @@ -10,7 +10,7 @@ import { clearIndexCache, getBlockIndex } from './helpers/index-cache.js'; import { findBlockById, type BlockCandidate } from './helpers/node-address-resolver.js'; import { collectTrackInsertRefsInRange } from './helpers/tracked-change-refs.js'; import { DocumentApiAdapterError } from './errors.js'; -import { ensureTrackedUser } from './helpers/tracked-mode-guards.js'; +import { requireEditorCommand, ensureTrackedCapability } from './helpers/mutation-helpers.js'; type InsertParagraphAtCommandOptions = { pos: number; @@ -38,28 +38,6 @@ function resolveParagraphInsertPosition(editor: Editor, input: CreateParagraphIn return location.kind === 'before' ? target.pos : target.end; } -function getInsertParagraphAtCommand(editor: Editor): InsertParagraphAtCommand { - const command = editor.commands?.insertParagraphAt; - if (!command) { - throw new DocumentApiAdapterError( - 'COMMAND_UNAVAILABLE', - 'Create paragraph command is not available on this editor instance.', - ); - } - return command as InsertParagraphAtCommand; -} - -function ensureTrackedCreateCapability(editor: Editor): void { - const hasTrackedInsertCommand = typeof editor.commands?.insertTrackedChange === 'function'; - if (!hasTrackedInsertCommand) { - throw new DocumentApiAdapterError( - 'TRACK_CHANGE_COMMAND_UNAVAILABLE', - 'Tracked paragraph creation is not available on this editor instance.', - ); - } - ensureTrackedUser(editor, 'Tracked paragraph creation'); -} - function resolveCreatedParagraph(editor: Editor, paragraphId: string): BlockCandidate { const index = getBlockIndex(editor); const resolved = index.byId.get(`paragraph:${paragraphId}`); @@ -112,16 +90,35 @@ export function createParagraphAdapter( input: CreateParagraphInput, options?: MutationOptions, ): CreateParagraphResult { - const insertParagraphAt = getInsertParagraphAtCommand(editor); + const insertParagraphAt = requireEditorCommand( + editor.commands?.insertParagraphAt, + 'create.paragraph', + ) as InsertParagraphAtCommand; const mode = options?.changeMode ?? 'direct'; if (mode === 'tracked') { - ensureTrackedCreateCapability(editor); + ensureTrackedCapability(editor, { operation: 'create.paragraph' }); } const insertAt = resolveParagraphInsertPosition(editor, input); if (options?.dryRun) { + const canInsert = editor.can().insertParagraphAt?.({ + pos: insertAt, + text: input.text, + tracked: mode === 'tracked', + }); + + if (!canInsert) { + return { + success: false, + failure: { + code: 'INVALID_TARGET', + message: 'Paragraph creation could not be applied at the requested location.', + }, + }; + } + return { success: true, paragraph: { @@ -164,8 +161,8 @@ export function createParagraphAdapter( return buildParagraphCreateSuccess(paragraph.nodeId, trackedChangeRefs); } catch { - // Mutation already applied. Preserve success semantics with the generated - // block ID even if post-apply paragraph enrichment cannot be resolved. + // Mutation already applied — contract requires success: true. + // Fall back to the generated ID we assigned to the command. return buildParagraphCreateSuccess(paragraphId); } } diff --git a/packages/super-editor/src/document-api-adapters/errors.test.ts b/packages/super-editor/src/document-api-adapters/errors.test.ts index 9dbd9008a..2e8180081 100644 --- a/packages/super-editor/src/document-api-adapters/errors.test.ts +++ b/packages/super-editor/src/document-api-adapters/errors.test.ts @@ -20,12 +20,7 @@ describe('DocumentApiAdapterError', () => { }); it('supports all error codes', () => { - const codes = [ - 'TARGET_NOT_FOUND', - 'TRACK_CHANGE_COMMAND_UNAVAILABLE', - 'INVALID_TARGET', - 'COMMAND_UNAVAILABLE', - ] as const; + const codes = ['TARGET_NOT_FOUND', 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'] as const; for (const code of codes) { const error = new DocumentApiAdapterError(code, `Error: ${code}`); @@ -34,7 +29,7 @@ describe('DocumentApiAdapterError', () => { }); it('is caught by instanceof checks after setPrototypeOf', () => { - const error = new DocumentApiAdapterError('COMMAND_UNAVAILABLE', 'test'); + const error = new DocumentApiAdapterError('CAPABILITY_UNAVAILABLE', 'test'); try { throw error; diff --git a/packages/super-editor/src/document-api-adapters/errors.ts b/packages/super-editor/src/document-api-adapters/errors.ts index 1fd9eb158..20186c1bc 100644 --- a/packages/super-editor/src/document-api-adapters/errors.ts +++ b/packages/super-editor/src/document-api-adapters/errors.ts @@ -1,9 +1,5 @@ /** Error codes used by {@link DocumentApiAdapterError} to classify adapter failures. */ -export type DocumentApiAdapterErrorCode = - | 'TARGET_NOT_FOUND' - | 'TRACK_CHANGE_COMMAND_UNAVAILABLE' - | 'INVALID_TARGET' - | 'COMMAND_UNAVAILABLE'; +export type DocumentApiAdapterErrorCode = 'TARGET_NOT_FOUND' | 'INVALID_TARGET' | 'CAPABILITY_UNAVAILABLE'; /** * Structured error thrown by document-api adapter functions. diff --git a/packages/super-editor/src/document-api-adapters/find-adapter.test.ts b/packages/super-editor/src/document-api-adapters/find-adapter.test.ts index 101d9d51c..0a3fe7fff 100644 --- a/packages/super-editor/src/document-api-adapters/find-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/find-adapter.test.ts @@ -647,7 +647,7 @@ describe('findAdapter — text selectors', () => { const editor = makeEditor(doc); // no search command const query: Query = { select: { type: 'text', pattern: 'hello' } }; - expect(() => findAdapter(editor, query)).toThrow('search command is not available'); + expect(() => findAdapter(editor, query)).toThrow('command is not available'); }); it('paginates text results and contexts together', () => { diff --git a/packages/super-editor/src/document-api-adapters/find/text-strategy.ts b/packages/super-editor/src/document-api-adapters/find/text-strategy.ts index 28e1a4c1d..13928881c 100644 --- a/packages/super-editor/src/document-api-adapters/find/text-strategy.ts +++ b/packages/super-editor/src/document-api-adapters/find/text-strategy.ts @@ -18,6 +18,7 @@ import { import { addDiagnostic, findCandidateByPos, paginate, resolveWithinScope } from '../helpers/adapter-utils.js'; import { buildTextContext, toTextAddress } from './common.js'; import { DocumentApiAdapterError } from '../errors.js'; +import { requireEditorCommand } from '../helpers/mutation-helpers.js'; /** Shape returned by `editor.commands.search`. */ type SearchMatch = { @@ -79,13 +80,7 @@ export function executeTextSelector( const pattern = buildSearchPattern(selector, diagnostics); if (!pattern) return { matches: [], total: 0 }; - const search = editor.commands?.search; - if (!search) { - throw new DocumentApiAdapterError( - 'COMMAND_UNAVAILABLE', - 'Editor search command is not available on this editor instance.', - ); - } + const search = requireEditorCommand(editor.commands?.search, 'find (search)'); // Fetch all matches so `total` reflects the true document-wide count. // Pagination is applied after filtering via paginate(). @@ -97,7 +92,7 @@ export function executeTextSelector( if (!Array.isArray(rawResult)) { throw new DocumentApiAdapterError( - 'COMMAND_UNAVAILABLE', + 'CAPABILITY_UNAVAILABLE', 'Editor search command returned an unexpected result format.', ); } diff --git a/packages/super-editor/src/document-api-adapters/format-adapter.test.ts b/packages/super-editor/src/document-api-adapters/format-adapter.test.ts index 5da983c0a..85897c7f5 100644 --- a/packages/super-editor/src/document-api-adapters/format-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/format-adapter.test.ts @@ -183,7 +183,7 @@ describe('formatBoldAdapter', () => { { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, { changeMode: 'direct' }, ), - ).toThrow('Bold mark is not available on this editor instance.'); + ).toThrow('requires the "bold" mark'); }); it('throws when tracked format capability is unavailable', () => { @@ -196,7 +196,7 @@ describe('formatBoldAdapter', () => { { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, { changeMode: 'tracked' }, ), - ).toThrow('Tracked bold formatting is not available on this editor instance.'); + ).toThrow('requires the insertTrackedChange command'); }); it('supports direct dry-run without building a transaction', () => { @@ -247,7 +247,7 @@ describe('formatBoldAdapter', () => { expect(tr.setMeta).toHaveBeenCalledWith('forceTrackChanges', true); }); - it('throws TRACK_CHANGE_COMMAND_UNAVAILABLE for tracked dry-run without a configured user', () => { + it('throws CAPABILITY_UNAVAILABLE for tracked dry-run without a configured user', () => { const { editor } = makeEditor(); expect(() => diff --git a/packages/super-editor/src/document-api-adapters/format-adapter.ts b/packages/super-editor/src/document-api-adapters/format-adapter.ts index db12a5e95..54b6bc65d 100644 --- a/packages/super-editor/src/document-api-adapters/format-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/format-adapter.ts @@ -2,23 +2,10 @@ import type { Editor } from '../core/Editor.js'; import type { FormatBoldInput, MutationOptions, TextMutationReceipt } from '@superdoc/document-api'; import { TrackFormatMarkName } from '../extensions/track-changes/constants.js'; import { DocumentApiAdapterError } from './errors.js'; -import { ensureTrackedUser } from './helpers/tracked-mode-guards.js'; +import { requireSchemaMark, ensureTrackedCapability } from './helpers/mutation-helpers.js'; import { resolveTextTarget } from './helpers/adapter-utils.js'; import { buildTextMutationResolution, readTextAtResolvedRange } from './helpers/text-mutation-resolution.js'; -function assertTrackedFormatCapability(editor: Editor): void { - const hasTrackedInsertCommand = typeof editor.commands?.insertTrackedChange === 'function'; - const hasTrackFormatMark = Boolean(editor.schema?.marks?.[TrackFormatMarkName]); - - if (!hasTrackedInsertCommand || !hasTrackFormatMark) { - throw new DocumentApiAdapterError( - 'TRACK_CHANGE_COMMAND_UNAVAILABLE', - 'Tracked bold formatting is not available on this editor instance.', - ); - } - ensureTrackedUser(editor, 'Tracked bold formatting'); -} - export function formatBoldAdapter( editor: Editor, input: FormatBoldInput, @@ -49,13 +36,11 @@ export function formatBoldAdapter( }; } - const boldMark = editor.schema?.marks?.bold; - if (!boldMark) { - throw new DocumentApiAdapterError('COMMAND_UNAVAILABLE', 'Bold mark is not available on this editor instance.'); - } + const boldMark = requireSchemaMark(editor, 'bold', 'format.bold'); const mode = options?.changeMode ?? 'direct'; - if (mode === 'tracked') assertTrackedFormatCapability(editor); + if (mode === 'tracked') + ensureTrackedCapability(editor, { operation: 'format.bold', requireMarks: [TrackFormatMarkName] }); if (options?.dryRun) { return { success: true, resolution }; diff --git a/packages/super-editor/src/document-api-adapters/get-text-adapter.test.ts b/packages/super-editor/src/document-api-adapters/get-text-adapter.test.ts index 173fb0b46..499216c57 100644 --- a/packages/super-editor/src/document-api-adapters/get-text-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/get-text-adapter.test.ts @@ -1,11 +1,15 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { Editor } from '../core/Editor.js'; import { getTextAdapter } from './get-text-adapter.js'; function makeEditor(textContent: string): Editor { return { state: { - doc: { textContent }, + doc: { + textContent, + content: { size: textContent.length }, + textBetween: () => textContent, + }, }, } as unknown as Editor; } @@ -20,4 +24,20 @@ describe('getTextAdapter', () => { const editor = makeEditor(''); expect(getTextAdapter(editor, {})).toBe(''); }); + + it('preserves block separators when reading full document text', () => { + const textBetween = vi.fn(() => 'Hello\nworld'); + const editor = { + state: { + doc: { + textContent: 'Helloworld', + content: { size: 10 }, + textBetween, + }, + }, + } as unknown as Editor; + + expect(getTextAdapter(editor, {})).toBe('Hello\nworld'); + expect(textBetween).toHaveBeenCalledWith(0, 10, '\n', '\n'); + }); }); diff --git a/packages/super-editor/src/document-api-adapters/get-text-adapter.ts b/packages/super-editor/src/document-api-adapters/get-text-adapter.ts index a11a3b6b5..3fa957dcb 100644 --- a/packages/super-editor/src/document-api-adapters/get-text-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/get-text-adapter.ts @@ -8,5 +8,6 @@ import type { GetTextInput } from '@superdoc/document-api'; * @returns Plain text content of the document. */ export function getTextAdapter(editor: Editor, _input: GetTextInput): string { - return editor.state.doc.textContent; + const doc = editor.state.doc; + return doc.textBetween(0, doc.content.size, '\n', '\n'); } diff --git a/packages/super-editor/src/document-api-adapters/helpers/mutation-helpers.test.ts b/packages/super-editor/src/document-api-adapters/helpers/mutation-helpers.test.ts new file mode 100644 index 000000000..38589f81a --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/mutation-helpers.test.ts @@ -0,0 +1,128 @@ +import { DocumentApiAdapterError } from '../errors.js'; +import { + requireEditorCommand, + requireSchemaMark, + ensureTrackedCapability, + rejectTrackedMode, +} from './mutation-helpers.js'; + +function makeEditor(overrides: Record = {}): any { + return { + commands: {}, + schema: { marks: {} }, + options: {}, + ...overrides, + }; +} + +describe('requireEditorCommand', () => { + it('returns the command when present', () => { + const command = () => true; + expect(requireEditorCommand(command, 'test')).toBe(command); + }); + + it('throws CAPABILITY_UNAVAILABLE with reason: missing_command when absent', () => { + expect(() => requireEditorCommand(undefined, 'test.op')).toThrow(DocumentApiAdapterError); + try { + requireEditorCommand(undefined, 'test.op'); + } catch (error) { + const err = error as DocumentApiAdapterError; + expect(err.code).toBe('CAPABILITY_UNAVAILABLE'); + expect(err.details).toEqual({ reason: 'missing_command' }); + expect(err.message).toContain('test.op'); + } + }); +}); + +describe('requireSchemaMark', () => { + it('returns the mark type when present', () => { + const boldMark = { name: 'bold' }; + const editor = makeEditor({ schema: { marks: { bold: boldMark } } }); + expect(requireSchemaMark(editor, 'bold', 'format.bold')).toBe(boldMark); + }); + + it('throws CAPABILITY_UNAVAILABLE with reason: missing_mark when absent', () => { + const editor = makeEditor(); + expect(() => requireSchemaMark(editor, 'bold', 'format.bold')).toThrow(DocumentApiAdapterError); + try { + requireSchemaMark(editor, 'bold', 'format.bold'); + } catch (error) { + const err = error as DocumentApiAdapterError; + expect(err.code).toBe('CAPABILITY_UNAVAILABLE'); + expect(err.details).toEqual({ reason: 'missing_mark', markName: 'bold' }); + } + }); +}); + +describe('ensureTrackedCapability', () => { + it('does not throw when all prerequisites are met', () => { + const editor = makeEditor({ + commands: { insertTrackedChange: () => true }, + schema: { marks: { trackFormat: {} } }, + options: { user: { name: 'test' } }, + }); + expect(() => ensureTrackedCapability(editor, { operation: 'test', requireMarks: ['trackFormat'] })).not.toThrow(); + }); + + it('throws with reason: missing_command when insertTrackedChange is missing', () => { + const editor = makeEditor({ options: { user: { name: 'test' } } }); + try { + ensureTrackedCapability(editor, { operation: 'test.op' }); + throw new Error('expected throw'); + } catch (error) { + const err = error as DocumentApiAdapterError; + expect(err.code).toBe('CAPABILITY_UNAVAILABLE'); + expect(err.details).toEqual({ reason: 'missing_command' }); + } + }); + + it('throws with reason: missing_mark when a required mark is missing', () => { + const editor = makeEditor({ + commands: { insertTrackedChange: () => true }, + options: { user: { name: 'test' } }, + }); + try { + ensureTrackedCapability(editor, { operation: 'test.op', requireMarks: ['trackFormat'] }); + throw new Error('expected throw'); + } catch (error) { + const err = error as DocumentApiAdapterError; + expect(err.code).toBe('CAPABILITY_UNAVAILABLE'); + expect(err.details).toEqual({ reason: 'missing_mark', markName: 'trackFormat' }); + } + }); + + it('throws with reason: missing_user when user is not configured', () => { + const editor = makeEditor({ + commands: { insertTrackedChange: () => true }, + }); + try { + ensureTrackedCapability(editor, { operation: 'test.op' }); + throw new Error('expected throw'); + } catch (error) { + const err = error as DocumentApiAdapterError; + expect(err.code).toBe('CAPABILITY_UNAVAILABLE'); + expect(err.details).toEqual({ reason: 'missing_user' }); + } + }); +}); + +describe('rejectTrackedMode', () => { + it('does not throw for direct mode', () => { + expect(() => rejectTrackedMode('test.op', { changeMode: 'direct' })).not.toThrow(); + }); + + it('does not throw when options are undefined', () => { + expect(() => rejectTrackedMode('test.op')).not.toThrow(); + }); + + it('throws CAPABILITY_UNAVAILABLE for tracked mode', () => { + try { + rejectTrackedMode('test.op', { changeMode: 'tracked' }); + throw new Error('expected throw'); + } catch (error) { + const err = error as DocumentApiAdapterError; + expect(err.code).toBe('CAPABILITY_UNAVAILABLE'); + expect(err.details).toEqual({ reason: 'tracked_mode_unsupported' }); + } + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/mutation-helpers.ts b/packages/super-editor/src/document-api-adapters/helpers/mutation-helpers.ts new file mode 100644 index 000000000..f5786f223 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/mutation-helpers.ts @@ -0,0 +1,79 @@ +import type { MarkType } from 'prosemirror-model'; +import type { MutationOptions } from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; +import { DocumentApiAdapterError } from '../errors.js'; + +/** + * Validates that an editor command exists and returns it. + * + * @throws {DocumentApiAdapterError} `CAPABILITY_UNAVAILABLE` with `reason: 'missing_command'`. + */ +export function requireEditorCommand(command: T | undefined, operationName: string): NonNullable { + if (typeof command === 'function') return command as NonNullable; + throw new DocumentApiAdapterError('CAPABILITY_UNAVAILABLE', `${operationName} command is not available.`, { + reason: 'missing_command', + }); +} + +/** + * Validates that a schema mark exists and returns it. + * + * @throws {DocumentApiAdapterError} `CAPABILITY_UNAVAILABLE` with `reason: 'missing_mark'`. + */ +export function requireSchemaMark(editor: Editor, markName: string, operationName: string): MarkType { + const mark = editor.schema?.marks?.[markName]; + if (mark) return mark; + throw new DocumentApiAdapterError('CAPABILITY_UNAVAILABLE', `${operationName} requires the "${markName}" mark.`, { + reason: 'missing_mark', + markName, + }); +} + +/** + * Validates all tracked-mode prerequisites: insertTrackedChange command, + * optional required marks, and a configured user. + * + * @throws {DocumentApiAdapterError} `CAPABILITY_UNAVAILABLE` with a `reason` detail + * of `'missing_command'`, `'missing_mark'`, or `'missing_user'`. + */ +export function ensureTrackedCapability(editor: Editor, config: { operation: string; requireMarks?: string[] }): void { + if (typeof editor.commands?.insertTrackedChange !== 'function') { + throw new DocumentApiAdapterError( + 'CAPABILITY_UNAVAILABLE', + `${config.operation} requires the insertTrackedChange command.`, + { reason: 'missing_command' }, + ); + } + + if (config.requireMarks) { + for (const markName of config.requireMarks) { + if (!editor.schema?.marks?.[markName]) { + throw new DocumentApiAdapterError( + 'CAPABILITY_UNAVAILABLE', + `${config.operation} requires the "${markName}" mark in the schema.`, + { reason: 'missing_mark', markName }, + ); + } + } + } + + if (!editor.options.user) { + throw new DocumentApiAdapterError( + 'CAPABILITY_UNAVAILABLE', + `${config.operation} requires a user to be configured on the editor instance.`, + { reason: 'missing_user' }, + ); + } +} + +/** + * Rejects tracked mode for adapters that do not support it yet. + * + * @throws {DocumentApiAdapterError} `CAPABILITY_UNAVAILABLE` with `reason: 'tracked_mode_unsupported'`. + */ +export function rejectTrackedMode(operation: string, options?: MutationOptions): void { + if ((options?.changeMode ?? 'direct') === 'direct') return; + throw new DocumentApiAdapterError('CAPABILITY_UNAVAILABLE', `${operation} does not support tracked mode.`, { + reason: 'tracked_mode_unsupported', + }); +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/tracked-mode-guards.ts b/packages/super-editor/src/document-api-adapters/helpers/tracked-mode-guards.ts deleted file mode 100644 index d70fef49c..000000000 --- a/packages/super-editor/src/document-api-adapters/helpers/tracked-mode-guards.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Editor } from '../../core/Editor.js'; -import { DocumentApiAdapterError } from '../errors.js'; - -/** - * Asserts that the editor has a configured user, which is required for - * force-tracked mutations that set `forceTrackChanges` on the transaction. - * - * @param editor - The editor instance to validate. - * @param operation - Human-readable operation name for the error message. - * @throws {DocumentApiAdapterError} `TRACK_CHANGE_COMMAND_UNAVAILABLE` when user is missing. - */ -export function ensureTrackedUser(editor: Editor, operation: string): void { - if (!editor.options.user) { - throw new DocumentApiAdapterError( - 'TRACK_CHANGE_COMMAND_UNAVAILABLE', - `${operation} requires a user to be configured on the editor instance.`, - ); - } -} diff --git a/packages/super-editor/src/document-api-adapters/lists-adapter.test.ts b/packages/super-editor/src/document-api-adapters/lists-adapter.test.ts index b7600dc1b..4bd5ee26d 100644 --- a/packages/super-editor/src/document-api-adapters/lists-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/lists-adapter.test.ts @@ -246,7 +246,7 @@ describe('lists adapter', () => { expect(result.insertionPoint.range).toEqual({ start: 0, end: 0 }); }); - it('throws TRACK_CHANGE_COMMAND_UNAVAILABLE for direct-only tracked requests', () => { + it('throws CAPABILITY_UNAVAILABLE for direct-only tracked requests', () => { const editor = makeEditor([ makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'decimal' }), ]); @@ -329,6 +329,30 @@ describe('lists adapter', () => { expect(result.failure.code).toBe('NO_OP'); }); + it('returns NO_OP for restart when a level-1 item starts after a level-0 item with same numId', () => { + const editor = makeEditor([ + makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'decimal' }), + makeListParagraph({ + id: 'li-2', + numId: 1, + ilvl: 1, + markerText: 'a.', + path: [1, 1], + numberingType: 'lowerLetter', + }), + ]); + const restartNumbering = editor.commands!.restartNumbering as ReturnType; + + const result = listsRestartAdapter(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-2' }, + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.failure.code).toBe('NO_OP'); + expect(restartNumbering).not.toHaveBeenCalled(); + }); + it('throws TARGET_NOT_FOUND for stale list targets', () => { const editor = makeEditor([ makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'decimal' }), @@ -474,7 +498,7 @@ describe('lists adapter', () => { }); }); - it('throws TRACK_CHANGE_COMMAND_UNAVAILABLE for tracked insert dry-run without a configured user', () => { + it('throws CAPABILITY_UNAVAILABLE for tracked insert dry-run without a configured user', () => { const editor = makeEditor([ makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'decimal' }), ]); @@ -491,6 +515,57 @@ describe('lists adapter', () => { ).toThrow('requires a user to be configured'); }); + it('returns TARGET_NOT_FOUND failure when post-apply list item resolution fails', () => { + const children = [ + makeListParagraph({ + id: 'li-1', + text: 'One', + numId: 1, + ilvl: 0, + markerText: '1.', + path: [1], + numberingType: 'decimal', + }), + ]; + + // Custom insertListItemAt that returns true but inserts a node with a + // different sdBlockId/paraId than what was requested, making it + // unresolvable by resolveInsertedListItem. + const insertListItemAt = vi.fn((options: { pos: number; position: 'before' | 'after'; sdBlockId?: string }) => { + const inserted = makeListParagraph({ + id: 'unrelated-id', + sdBlockId: 'unrelated-sdBlockId', + numId: 1, + ilvl: 0, + markerText: '', + path: [1], + numberingType: 'decimal', + }); + const at = options.position === 'before' ? 0 : 1; + children.splice(at, 0, inserted); + return true; + }); + + const editor = makeEditor(children, { insertListItemAt }); + + const result = listsInsertAdapter( + editor, + { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + position: 'after', + }, + { changeMode: 'direct' }, + ); + + // Contract: success:false means no mutation was applied. + // The mutation DID apply, so we must return success with the generated ID. + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.item.nodeType).toBe('listItem'); + expect(typeof result.item.nodeId).toBe('string'); + expect(result.item.nodeId).not.toBe('(dry-run)'); + }); + it('throws same error for tracked insert non-dry-run without a configured user', () => { const editor = makeEditor([ makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'decimal' }), diff --git a/packages/super-editor/src/document-api-adapters/lists-adapter.ts b/packages/super-editor/src/document-api-adapters/lists-adapter.ts index c32ba36f0..99b5f0a1c 100644 --- a/packages/super-editor/src/document-api-adapters/lists-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/lists-adapter.ts @@ -14,7 +14,7 @@ import type { MutationOptions, } from '@superdoc/document-api'; import { DocumentApiAdapterError } from './errors.js'; -import { ensureTrackedUser } from './helpers/tracked-mode-guards.js'; +import { requireEditorCommand, ensureTrackedCapability, rejectTrackedMode } from './helpers/mutation-helpers.js'; import { clearIndexCache, getBlockIndex } from './helpers/index-cache.js'; import { collectTrackInsertRefsInRange } from './helpers/tracked-change-refs.js'; import { @@ -37,36 +37,10 @@ type SetListTypeAtCommand = (options: { pos: number; kind: 'ordered' | 'bullet' type ExitListItemAtCommand = (options: { pos: number }) => boolean; type SetTextSelectionCommand = (options: { from: number; to?: number }) => boolean; -function requireCommand(command: T | undefined, message: string): T { - if (command) return command; - throw new DocumentApiAdapterError('COMMAND_UNAVAILABLE', message); -} - function toListsFailure(code: 'NO_OP' | 'INVALID_TARGET', message: string, details?: unknown) { return { success: false as const, failure: { code, message, details } }; } -function ensureDirectOnly(operation: string, options?: MutationOptions): void { - const mode = options?.changeMode ?? 'direct'; - if (mode === 'direct') return; - throw new DocumentApiAdapterError( - 'TRACK_CHANGE_COMMAND_UNAVAILABLE', - `${operation} does not support tracked mode in v1.`, - ); -} - -function ensureTrackedInsertCapability(editor: Editor, mode: 'direct' | 'tracked'): void { - if (mode !== 'tracked') return; - const hasTrackedInsertCommand = typeof editor.commands?.insertTrackedChange === 'function'; - if (!hasTrackedInsertCommand) { - throw new DocumentApiAdapterError( - 'TRACK_CHANGE_COMMAND_UNAVAILABLE', - 'lists.insert tracked mode is not available on this editor instance.', - ); - } - ensureTrackedUser(editor, 'lists.insert tracked mode'); -} - function resolveInsertedListItem(editor: Editor, sdBlockId: string): ListItemProjection { const index = getBlockIndex(editor); const byNodeId = index.candidates.find( @@ -95,10 +69,10 @@ function selectionAnchorPos(item: ListItemProjection): number { } function setSelectionToListItem(editor: Editor, item: ListItemProjection): boolean { - const setTextSelection = requireCommand( + const setTextSelection = requireEditorCommand( editor.commands?.setTextSelection as SetTextSelectionCommand | undefined, - 'List selection command is not available on this editor instance.', - ); + 'lists (setTextSelection)', + ) as SetTextSelectionCommand; const anchor = selectionAnchorPos(item); return Boolean(setTextSelection({ from: anchor, to: anchor })); } @@ -133,7 +107,7 @@ function isRestartNoOp(editor: Editor, item: ListItemProjection): boolean { nodeId: previous.nodeId, }); - return previousProjection.numId !== item.numId; + return previousProjection.numId !== item.numId || previousProjection.level !== item.level; } return true; @@ -160,12 +134,12 @@ export function listsInsertAdapter( const target = withListTarget(editor, { target: input.target }); const changeMode = options?.changeMode ?? 'direct'; const mode = changeMode === 'tracked' ? 'tracked' : 'direct'; - ensureTrackedInsertCapability(editor, mode); + if (mode === 'tracked') ensureTrackedCapability(editor, { operation: 'lists.insert' }); - const insertListItemAt = requireCommand( + const insertListItemAt = requireEditorCommand( editor.commands?.insertListItemAt as InsertListItemAtCommand | undefined, - 'Insert list item command is not available on this editor instance.', - ); + 'lists.insert (insertListItemAt)', + ) as InsertListItemAtCommand; if (options?.dryRun) { return { @@ -201,8 +175,8 @@ export function listsInsertAdapter( try { created = resolveInsertedListItem(editor, createdId); } catch { - // Insertion succeeded but the new item could not be located in the index. - // Return a degraded success with the generated sdBlockId as the address. + // Mutation already applied — contract requires success: true. + // Fall back to the generated ID we assigned to the command. return { success: true, item: { kind: 'block', nodeType: 'listItem', nodeId: createdId }, @@ -234,7 +208,7 @@ export function listsSetTypeAdapter( input: ListSetTypeInput, options?: MutationOptions, ): ListsMutateItemResult { - ensureDirectOnly('lists.setType', options); + rejectTrackedMode('lists.setType', options); const target = withListTarget(editor, { target: input.target }); if (target.kind === input.kind) { return toListsFailure('NO_OP', 'List item already has the requested list kind.', { @@ -243,10 +217,10 @@ export function listsSetTypeAdapter( }); } - const setListTypeAt = requireCommand( + const setListTypeAt = requireEditorCommand( editor.commands?.setListTypeAt as SetListTypeAtCommand | undefined, - 'Set list type command is not available on this editor instance.', - ); + 'lists.setType (setListTypeAt)', + ) as SetListTypeAtCommand; if (options?.dryRun) { return { success: true, item: target.address }; @@ -275,16 +249,16 @@ export function listsIndentAdapter( input: ListTargetInput, options?: MutationOptions, ): ListsMutateItemResult { - ensureDirectOnly('lists.indent', options); + rejectTrackedMode('lists.indent', options); const target = withListTarget(editor, input); if (isAtMaximumLevel(editor, target)) { return toListsFailure('NO_OP', 'List item is already at the maximum supported level.', { target: input.target }); } - const increaseListIndent = requireCommand<() => boolean>( + const increaseListIndent = requireEditorCommand( editor.commands?.increaseListIndent as (() => boolean) | undefined, - 'Increase list indent command is not available on this editor instance.', - ); + 'lists.indent (increaseListIndent)', + ) as () => boolean; if (options?.dryRun) { return { success: true, item: target.address }; @@ -312,16 +286,16 @@ export function listsOutdentAdapter( input: ListTargetInput, options?: MutationOptions, ): ListsMutateItemResult { - ensureDirectOnly('lists.outdent', options); + rejectTrackedMode('lists.outdent', options); const target = withListTarget(editor, input); if ((target.level ?? 0) <= 0) { return toListsFailure('NO_OP', 'List item is already at level 0.', { target: input.target }); } - const decreaseListIndent = requireCommand<() => boolean>( + const decreaseListIndent = requireEditorCommand( editor.commands?.decreaseListIndent as (() => boolean) | undefined, - 'Decrease list indent command is not available on this editor instance.', - ); + 'lists.outdent (decreaseListIndent)', + ) as () => boolean; if (options?.dryRun) { return { success: true, item: target.address }; @@ -349,7 +323,7 @@ export function listsRestartAdapter( input: ListTargetInput, options?: MutationOptions, ): ListsMutateItemResult { - ensureDirectOnly('lists.restart', options); + rejectTrackedMode('lists.restart', options); const target = withListTarget(editor, input); if (target.numId == null) { return toListsFailure('INVALID_TARGET', 'List restart requires numbering metadata on the target item.', { @@ -362,10 +336,10 @@ export function listsRestartAdapter( }); } - const restartNumbering = requireCommand<() => boolean>( + const restartNumbering = requireEditorCommand( editor.commands?.restartNumbering as (() => boolean) | undefined, - 'Restart numbering command is not available on this editor instance.', - ); + 'lists.restart (restartNumbering)', + ) as () => boolean; if (options?.dryRun) { return { success: true, item: target.address }; @@ -389,13 +363,13 @@ export function listsRestartAdapter( } export function listsExitAdapter(editor: Editor, input: ListTargetInput, options?: MutationOptions): ListsExitResult { - ensureDirectOnly('lists.exit', options); + rejectTrackedMode('lists.exit', options); const target = withListTarget(editor, input); - const exitListItemAt = requireCommand( + const exitListItemAt = requireEditorCommand( editor.commands?.exitListItemAt as ExitListItemAtCommand | undefined, - 'Exit list item command is not available on this editor instance.', - ); + 'lists.exit (exitListItemAt)', + ) as ExitListItemAtCommand; if (options?.dryRun) { return { diff --git a/packages/super-editor/src/document-api-adapters/track-changes-adapter.test.ts b/packages/super-editor/src/document-api-adapters/track-changes-adapter.test.ts index 88b42ca6f..22cb40a30 100644 --- a/packages/super-editor/src/document-api-adapters/track-changes-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/track-changes-adapter.test.ts @@ -175,7 +175,7 @@ describe('track-changes adapters', () => { } }); - it('throws COMMAND_UNAVAILABLE when accept/reject commands are missing', () => { + it('throws CAPABILITY_UNAVAILABLE when accept/reject commands are missing', () => { vi.mocked(getTrackChanges).mockReturnValue([ { mark: { @@ -235,7 +235,7 @@ describe('track-changes adapters', () => { expect(rejectReceipt.failure?.code).toBe('NO_OP'); }); - it('throws COMMAND_UNAVAILABLE for missing accept-all/reject-all commands', () => { + it('throws CAPABILITY_UNAVAILABLE for missing accept-all/reject-all commands', () => { vi.mocked(getTrackChanges).mockReturnValue([] as never); const editor = makeEditor({ @@ -275,6 +275,26 @@ describe('track-changes adapters', () => { expect(rejectAllReceipt.failure?.code).toBe('NO_OP'); }); + it('returns NO_OP failure when accept-all/reject-all report true but no tracked changes exist', () => { + vi.mocked(getTrackChanges).mockReturnValue([] as never); + + const editor = makeEditor({ + commands: { + acceptTrackedChangeById: vi.fn(() => true), + rejectTrackedChangeById: vi.fn(() => true), + acceptAllTrackedChanges: vi.fn(() => true), + rejectAllTrackedChanges: vi.fn(() => true), + } as never, + }); + + const acceptAllReceipt = trackChangesAcceptAllAdapter(editor, {}); + const rejectAllReceipt = trackChangesRejectAllAdapter(editor, {}); + expect(acceptAllReceipt.success).toBe(false); + expect(acceptAllReceipt.failure?.code).toBe('NO_OP'); + expect(rejectAllReceipt.success).toBe(false); + expect(rejectAllReceipt.failure?.code).toBe('NO_OP'); + }); + it('resolves stable ids across calls when raw ids differ', () => { const marks = [ { diff --git a/packages/super-editor/src/document-api-adapters/track-changes-adapter.ts b/packages/super-editor/src/document-api-adapters/track-changes-adapter.ts index 3048198d1..3d4d3aa9f 100644 --- a/packages/super-editor/src/document-api-adapters/track-changes-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/track-changes-adapter.ts @@ -12,6 +12,7 @@ import type { TrackChangesListResult, } from '@superdoc/document-api'; import { DocumentApiAdapterError } from './errors.js'; +import { requireEditorCommand } from './helpers/mutation-helpers.js'; import { paginate } from './helpers/adapter-utils.js'; import { groupTrackedChanges, @@ -55,15 +56,6 @@ function requireTrackChangeById(editor: Editor, id: string): GroupedTrackedChang }); } -function requireTrackChangesCommand(command: T | undefined, operation: string): T { - if (command) return command; - - throw new DocumentApiAdapterError( - 'COMMAND_UNAVAILABLE', - `${operation} command is not available on this editor instance.`, - ); -} - function toNoOpReceipt(message: string, details?: unknown): Receipt { return { success: false, @@ -98,7 +90,7 @@ export function trackChangesAcceptAdapter(editor: Editor, input: TrackChangesAcc const { id } = input; const change = requireTrackChangeById(editor, id); - const acceptById = requireTrackChangesCommand(editor.commands?.acceptTrackedChangeById, 'Accept tracked change'); + const acceptById = requireEditorCommand(editor.commands?.acceptTrackedChangeById, 'Accept tracked change'); const didAccept = Boolean(acceptById(change.rawId)); if (didAccept) return { success: true }; @@ -109,7 +101,7 @@ export function trackChangesRejectAdapter(editor: Editor, input: TrackChangesRej const { id } = input; const change = requireTrackChangeById(editor, id); - const rejectById = requireTrackChangesCommand(editor.commands?.rejectTrackedChangeById, 'Reject tracked change'); + const rejectById = requireEditorCommand(editor.commands?.rejectTrackedChangeById, 'Reject tracked change'); const didReject = Boolean(rejectById(change.rawId)); if (didReject) return { success: true }; @@ -117,7 +109,12 @@ export function trackChangesRejectAdapter(editor: Editor, input: TrackChangesRej } export function trackChangesAcceptAllAdapter(editor: Editor, _input: TrackChangesAcceptAllInput): Receipt { - const acceptAll = requireTrackChangesCommand(editor.commands?.acceptAllTrackedChanges, 'Accept all tracked changes'); + const acceptAll = requireEditorCommand(editor.commands?.acceptAllTrackedChanges, 'Accept all tracked changes'); + + if (groupTrackedChanges(editor).length === 0) { + return toNoOpReceipt('Accept all tracked changes produced no change.'); + } + const didAcceptAll = Boolean(acceptAll()); if (didAcceptAll) return { success: true }; @@ -125,7 +122,12 @@ export function trackChangesAcceptAllAdapter(editor: Editor, _input: TrackChange } export function trackChangesRejectAllAdapter(editor: Editor, _input: TrackChangesRejectAllInput): Receipt { - const rejectAll = requireTrackChangesCommand(editor.commands?.rejectAllTrackedChanges, 'Reject all tracked changes'); + const rejectAll = requireEditorCommand(editor.commands?.rejectAllTrackedChanges, 'Reject all tracked changes'); + + if (groupTrackedChanges(editor).length === 0) { + return toNoOpReceipt('Reject all tracked changes produced no change.'); + } + const didRejectAll = Boolean(rejectAll()); if (didRejectAll) return { success: true }; diff --git a/packages/super-editor/src/document-api-adapters/write-adapter.test.ts b/packages/super-editor/src/document-api-adapters/write-adapter.test.ts index 7beeb1984..0f5232d71 100644 --- a/packages/super-editor/src/document-api-adapters/write-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/write-adapter.test.ts @@ -101,6 +101,9 @@ function makeEditor(text = 'Hello'): { commands: { insertTrackedChange, }, + options: { + user: { name: 'Test User' }, + }, dispatch, } as unknown as Editor; @@ -160,6 +163,9 @@ function makeEditorWithDuplicateBlockIds(): { commands: { insertTrackedChange: vi.fn(() => true), }, + options: { + user: { name: 'Test User' }, + }, dispatch, } as unknown as Editor; @@ -484,7 +490,7 @@ describe('writeAdapter', () => { ).toThrow('Mutation target could not be resolved.'); }); - it('throws when tracked writes are unavailable', () => { + it('throws CAPABILITY_UNAVAILABLE when tracked writes are unavailable', () => { const { editor } = makeEditor('Hello'); (editor.commands as { insertTrackedChange?: unknown }).insertTrackedChange = undefined; @@ -498,10 +504,10 @@ describe('writeAdapter', () => { }, { changeMode: 'tracked' }, ), - ).toThrow('Tracked write command is not available on this editor instance.'); + ).toThrow('requires the insertTrackedChange command'); }); - it('throws when tracked dry-run capability is unavailable', () => { + it('throws CAPABILITY_UNAVAILABLE when tracked dry-run capability is unavailable', () => { const { editor } = makeEditor('Hello'); (editor.commands as { insertTrackedChange?: unknown }).insertTrackedChange = undefined; @@ -515,7 +521,24 @@ describe('writeAdapter', () => { }, { changeMode: 'tracked', dryRun: true }, ), - ).toThrow('Tracked write command is not available on this editor instance.'); + ).toThrow('requires the insertTrackedChange command'); + }); + + it('throws CAPABILITY_UNAVAILABLE for tracked write without a configured user', () => { + const { editor } = makeEditor('Hello'); + (editor as { options: { user?: unknown } }).options.user = undefined; + + expect(() => + writeAdapter( + editor, + { + kind: 'replace', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'World', + }, + { changeMode: 'tracked' }, + ), + ).toThrow('requires a user to be configured'); }); it('returns explicit NO_OP when replacement text is unchanged', () => { diff --git a/packages/super-editor/src/document-api-adapters/write-adapter.ts b/packages/super-editor/src/document-api-adapters/write-adapter.ts index 4272920ab..afccba0c5 100644 --- a/packages/super-editor/src/document-api-adapters/write-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/write-adapter.ts @@ -8,6 +8,7 @@ import type { WriteRequest, } from '@superdoc/document-api'; import { DocumentApiAdapterError } from './errors.js'; +import { ensureTrackedCapability } from './helpers/mutation-helpers.js'; import { resolveDefaultInsertTarget, resolveTextTarget, type ResolvedTextTarget } from './helpers/adapter-utils.js'; import { buildTextMutationResolution, readTextAtResolvedRange } from './helpers/text-mutation-resolution.js'; import { toCanonicalTrackedChangeId } from './helpers/tracked-change-resolver.js'; @@ -128,24 +129,14 @@ function applyDirectWrite( return { success: true, resolution: resolvedTarget.resolution }; } -function assertTrackedWriteCapability(editor: Editor): NonNullable['insertTrackedChange'] { - const insertTrackedChange = editor.commands?.insertTrackedChange; - if (!insertTrackedChange) { - throw new DocumentApiAdapterError( - 'TRACK_CHANGE_COMMAND_UNAVAILABLE', - 'Tracked write command is not available on this editor instance.', - ); - } - - return insertTrackedChange; -} - function applyTrackedWrite( editor: Editor, request: WriteRequest, resolvedTarget: ResolvedWriteTarget, ): TextMutationReceipt { - const insertTrackedChange = assertTrackedWriteCapability(editor); + ensureTrackedCapability(editor, { operation: 'write' }); + // insertTrackedChange is guaranteed to exist after ensureTrackedCapability. + const insertTrackedChange = editor.commands!.insertTrackedChange!; const text = request.kind === 'delete' ? '' : (request.text ?? ''); const changeId = uuidv4(); @@ -208,7 +199,7 @@ export function writeAdapter(editor: Editor, request: WriteRequest, options?: Mu const mode = options?.changeMode ?? 'direct'; if (options?.dryRun) { - if (mode === 'tracked') assertTrackedWriteCapability(editor); + if (mode === 'tracked') ensureTrackedCapability(editor, { operation: 'write' }); return { success: true, resolution: resolvedTarget.resolution }; } diff --git a/packages/super-editor/src/extensions/comment/comments-plugin.js b/packages/super-editor/src/extensions/comment/comments-plugin.js index 624bf71dc..3f0577140 100644 --- a/packages/super-editor/src/extensions/comment/comments-plugin.js +++ b/packages/super-editor/src/extensions/comment/comments-plugin.js @@ -367,7 +367,7 @@ export const CommentsPlugin = Extension.create({ const mappedFrom = tr.mapping.map(from); const mappedTo = tr.mapping.map(to); tr.addMark(mappedFrom, mappedTo, markType.create(attrs)); - dispatch(tr); + if (dispatch) dispatch(tr); return true; } @@ -399,7 +399,7 @@ export const CommentsPlugin = Extension.create({ const mappedTo = tr.mapping.map(to); tr.insert(mappedTo, endType.create({ 'w:id': commentId })); tr.insert(mappedFrom, startType.create({ ...startAttrs, 'w:id': commentId })); - dispatch(tr); + if (dispatch) dispatch(tr); return true; }, setCursorById: diff --git a/packages/super-editor/src/extensions/comment/comments-plugin.test.js b/packages/super-editor/src/extensions/comment/comments-plugin.test.js index 76a185d79..a01dd9c8c 100644 --- a/packages/super-editor/src/extensions/comment/comments-plugin.test.js +++ b/packages/super-editor/src/extensions/comment/comments-plugin.test.js @@ -315,6 +315,22 @@ describe('CommentsPlugin commands', () => { expect(updatedMark?.attrs.internal).toBe(false); }); + it('supports moveComment capability checks when dispatch is undefined', () => { + const schema = createCommentSchema(); + const mark = schema.marks[CommentMarkName].create({ commentId: 'c-move', internal: true }); + const paragraph = schema.node('paragraph', null, [schema.text('Hello', [mark])]); + const doc = schema.node('doc', null, [paragraph]); + const { editor, commands } = createEditorEnvironment(schema, doc); + + const command = commands.moveComment({ commentId: 'c-move', from: 2, to: 4 }); + + let result; + expect(() => { + result = command({ tr: editor.state.tr, dispatch: undefined, state: editor.state, editor }); + }).not.toThrow(); + expect(result).toBe(true); + }); + it('focuses editor when moving the cursor to a comment by id', () => { const schema = createCommentSchema(); const mark = schema.marks[CommentMarkName].create({ commentId: 'c-10', internal: true }); diff --git a/packages/super-editor/src/index.js b/packages/super-editor/src/index.js index f0bdb0ad0..e08b51ace 100644 --- a/packages/super-editor/src/index.js +++ b/packages/super-editor/src/index.js @@ -114,3 +114,5 @@ export { defineNode, defineMark, }; + +export { assembleDocumentApiAdapters } from './document-api-adapters/assemble-adapters.js'; diff --git a/vitest.config.mjs b/vitest.config.mjs index 495d349ca..27dd4dd6d 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -12,6 +12,7 @@ export default defineConfig({ // Use package directories; Vitest will pick up each package's vite.config.js projects: [ './packages/super-editor', + './packages/document-api', './packages/superdoc', './packages/ai', './packages/collaboration-yjs', From bf4fb0c4500f85b27bea48eaa8bd90f9c3d6e10f Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Feb 2026 12:40:02 -0800 Subject: [PATCH 11/25] feat(document-api): add capability model and super-editor capability adapter --- .../src/capabilities/capabilities.ts | 61 ++++ .../src/contract/command-catalog.ts | 291 ++++++++++++++++++ packages/document-api/src/contract/index.ts | 2 + .../document-api/src/contract/types.test.ts | 73 +++++ packages/document-api/src/contract/types.ts | 119 +++++++ packages/document-api/src/index.ts | 19 ++ .../assemble-adapters.ts | 4 + .../capabilities-adapter.test.ts | 165 ++++++++++ .../capabilities-adapter.ts | 180 +++++++++++ .../src/document-api-adapters/index.ts | 97 ++++++ 10 files changed, 1011 insertions(+) create mode 100644 packages/document-api/src/capabilities/capabilities.ts create mode 100644 packages/document-api/src/contract/command-catalog.ts create mode 100644 packages/document-api/src/contract/index.ts create mode 100644 packages/document-api/src/contract/types.test.ts create mode 100644 packages/document-api/src/contract/types.ts create mode 100644 packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/capabilities-adapter.ts create mode 100644 packages/super-editor/src/document-api-adapters/index.ts diff --git a/packages/document-api/src/capabilities/capabilities.ts b/packages/document-api/src/capabilities/capabilities.ts new file mode 100644 index 000000000..2a446311f --- /dev/null +++ b/packages/document-api/src/capabilities/capabilities.ts @@ -0,0 +1,61 @@ +import type { OperationId } from '../contract/types.js'; + +export const CAPABILITY_REASON_CODES = [ + 'COMMAND_UNAVAILABLE', + 'OPERATION_UNAVAILABLE', + 'TRACKED_MODE_UNAVAILABLE', + 'DRY_RUN_UNAVAILABLE', + 'NAMESPACE_UNAVAILABLE', +] as const; + +export type CapabilityReasonCode = (typeof CAPABILITY_REASON_CODES)[number]; + +/** + * A boolean flag indicating whether a capability is active, with optional + * machine-readable reason codes explaining why it is disabled. + */ +export type CapabilityFlag = { + enabled: boolean; + reasons?: CapabilityReasonCode[]; +}; + +/** Per-operation runtime capability describing availability, tracked-mode, and dry-run support. */ +export interface OperationRuntimeCapability { + available: boolean; + tracked: boolean; + dryRun: boolean; + reasons?: CapabilityReasonCode[]; +} + +export type OperationCapabilities = Record; + +/** + * Complete runtime capability snapshot for a Document API editor instance. + * + * `global` contains namespace-level flags (track changes, comments, lists, dry-run). + * `operations` contains per-operation availability details keyed by {@link OperationId}. + */ +export interface DocumentApiCapabilities { + global: { + trackChanges: CapabilityFlag; + comments: CapabilityFlag; + lists: CapabilityFlag; + dryRun: CapabilityFlag; + }; + operations: OperationCapabilities; +} + +/** Engine-specific adapter that resolves runtime capabilities for the current editor instance. */ +export interface CapabilitiesAdapter { + get(): DocumentApiCapabilities; +} + +/** + * Delegates to the capabilities adapter to retrieve the current capability snapshot. + * + * @param adapter - The engine-specific capabilities adapter. + * @returns The resolved capabilities for this editor instance. + */ +export function executeCapabilities(adapter: CapabilitiesAdapter): DocumentApiCapabilities { + return adapter.get(); +} diff --git a/packages/document-api/src/contract/command-catalog.ts b/packages/document-api/src/contract/command-catalog.ts new file mode 100644 index 000000000..b9230228d --- /dev/null +++ b/packages/document-api/src/contract/command-catalog.ts @@ -0,0 +1,291 @@ +import type { ReceiptFailureCode } from '../types/receipt.js'; +import type { CommandCatalog, CommandStaticMetadata, OperationIdempotency, PreApplyThrowCode } from './types.js'; +import { OPERATION_IDS } from './types.js'; + +const NONE_FAILURES: readonly ReceiptFailureCode[] = []; +const NONE_THROWS: readonly PreApplyThrowCode[] = []; + +function readOperation( + options: { + idempotency?: OperationIdempotency; + throws?: readonly PreApplyThrowCode[]; + deterministicTargetResolution?: boolean; + remediationHints?: readonly string[]; + } = {}, +): CommandStaticMetadata { + return { + mutates: false, + idempotency: options.idempotency ?? 'idempotent', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: NONE_FAILURES, + throws: { + preApply: options.throws ?? NONE_THROWS, + postApplyForbidden: true, + }, + deterministicTargetResolution: options.deterministicTargetResolution ?? true, + remediationHints: options.remediationHints, + }; +} + +function mutationOperation(options: { + idempotency: OperationIdempotency; + supportsDryRun: boolean; + supportsTrackedMode: boolean; + possibleFailureCodes: readonly ReceiptFailureCode[]; + throws: readonly PreApplyThrowCode[]; + deterministicTargetResolution?: boolean; + remediationHints?: readonly string[]; +}): CommandStaticMetadata { + return { + mutates: true, + idempotency: options.idempotency, + supportsDryRun: options.supportsDryRun, + supportsTrackedMode: options.supportsTrackedMode, + possibleFailureCodes: options.possibleFailureCodes, + throws: { + preApply: options.throws, + postApplyForbidden: true, + }, + deterministicTargetResolution: options.deterministicTargetResolution ?? true, + remediationHints: options.remediationHints, + }; +} + +const T_NOT_FOUND = ['TARGET_NOT_FOUND'] as const; +const T_COMMAND = ['COMMAND_UNAVAILABLE'] as const; +const T_NOT_FOUND_COMMAND = ['TARGET_NOT_FOUND', 'COMMAND_UNAVAILABLE'] as const; +const T_NOT_FOUND_TRACKED = ['TARGET_NOT_FOUND', 'TRACK_CHANGE_COMMAND_UNAVAILABLE'] as const; +const T_NOT_FOUND_COMMAND_TRACKED = [ + 'TARGET_NOT_FOUND', + 'COMMAND_UNAVAILABLE', + 'TRACK_CHANGE_COMMAND_UNAVAILABLE', +] as const; + +export const COMMAND_CATALOG: CommandCatalog = { + find: readOperation({ + idempotency: 'idempotent', + deterministicTargetResolution: false, + }), + getNode: readOperation({ + idempotency: 'idempotent', + throws: T_NOT_FOUND, + }), + getNodeById: readOperation({ + idempotency: 'idempotent', + throws: T_NOT_FOUND, + }), + getText: readOperation(), + info: readOperation(), + + insert: mutationOperation({ + idempotency: 'non-idempotent', + supportsDryRun: true, + supportsTrackedMode: true, + possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], + throws: T_NOT_FOUND_TRACKED, + }), + replace: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: true, + possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], + throws: T_NOT_FOUND_TRACKED, + }), + delete: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: true, + possibleFailureCodes: ['NO_OP'], + throws: T_NOT_FOUND_TRACKED, + }), + + 'format.bold': mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: true, + possibleFailureCodes: ['INVALID_TARGET'], + throws: T_NOT_FOUND_COMMAND_TRACKED, + }), + + 'create.paragraph': mutationOperation({ + idempotency: 'non-idempotent', + supportsDryRun: true, + supportsTrackedMode: true, + possibleFailureCodes: ['INVALID_TARGET'], + throws: T_NOT_FOUND_COMMAND_TRACKED, + }), + + 'lists.list': readOperation({ + idempotency: 'idempotent', + throws: T_NOT_FOUND, + }), + 'lists.get': readOperation({ + idempotency: 'idempotent', + throws: T_NOT_FOUND, + }), + 'lists.insert': mutationOperation({ + idempotency: 'non-idempotent', + supportsDryRun: false, + supportsTrackedMode: true, + possibleFailureCodes: ['INVALID_TARGET'], + throws: T_NOT_FOUND_COMMAND_TRACKED, + }), + 'lists.setType': mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], + throws: T_NOT_FOUND_COMMAND_TRACKED, + }), + 'lists.indent': mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], + throws: T_NOT_FOUND_COMMAND_TRACKED, + }), + 'lists.outdent': mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], + throws: T_NOT_FOUND_COMMAND_TRACKED, + }), + 'lists.restart': mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], + throws: T_NOT_FOUND_COMMAND_TRACKED, + }), + 'lists.exit': mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET'], + throws: T_NOT_FOUND_COMMAND_TRACKED, + }), + + 'comments.add': mutationOperation({ + idempotency: 'non-idempotent', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], + throws: T_NOT_FOUND_COMMAND, + }), + 'comments.edit': mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_NOT_FOUND_COMMAND, + }), + 'comments.reply': mutationOperation({ + idempotency: 'non-idempotent', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET'], + throws: T_NOT_FOUND_COMMAND, + }), + 'comments.move': mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], + throws: T_NOT_FOUND_COMMAND, + }), + 'comments.resolve': mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_NOT_FOUND_COMMAND, + }), + 'comments.remove': mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_NOT_FOUND_COMMAND, + }), + 'comments.setInternal': mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], + throws: T_NOT_FOUND_COMMAND, + }), + 'comments.setActive': mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET'], + throws: T_NOT_FOUND_COMMAND, + }), + 'comments.goTo': readOperation({ + idempotency: 'conditional', + throws: T_NOT_FOUND_COMMAND, + }), + 'comments.get': readOperation({ + idempotency: 'idempotent', + throws: T_NOT_FOUND, + }), + 'comments.list': readOperation({ + idempotency: 'idempotent', + }), + + 'trackChanges.list': readOperation({ + idempotency: 'idempotent', + }), + 'trackChanges.get': readOperation({ + idempotency: 'idempotent', + throws: T_NOT_FOUND, + }), + 'trackChanges.accept': mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_NOT_FOUND_COMMAND, + }), + 'trackChanges.reject': mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_NOT_FOUND_COMMAND, + }), + 'trackChanges.acceptAll': mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_COMMAND, + }), + 'trackChanges.rejectAll': mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_COMMAND, + }), + + 'capabilities.get': readOperation({ + idempotency: 'idempotent', + throws: NONE_THROWS, + }), +} as const; + +/** Operation IDs whose catalog entry has `mutates: true`. */ +export const MUTATING_OPERATION_IDS = OPERATION_IDS.filter((operationId) => COMMAND_CATALOG[operationId].mutates); + +/** + * Returns the static metadata for a given operation. + * + * @param operationId - A known operation identifier from the command catalog. + * @returns The compile-time metadata describing idempotency, failure codes, throw policy, etc. + */ +export function getCommandMetadata(operationId: keyof typeof COMMAND_CATALOG): CommandStaticMetadata { + return COMMAND_CATALOG[operationId]; +} diff --git a/packages/document-api/src/contract/index.ts b/packages/document-api/src/contract/index.ts new file mode 100644 index 000000000..a541b4622 --- /dev/null +++ b/packages/document-api/src/contract/index.ts @@ -0,0 +1,2 @@ +export * from './types.js'; +export * from './command-catalog.js'; diff --git a/packages/document-api/src/contract/types.test.ts b/packages/document-api/src/contract/types.test.ts new file mode 100644 index 000000000..46bcd2034 --- /dev/null +++ b/packages/document-api/src/contract/types.test.ts @@ -0,0 +1,73 @@ +import { assertOperationId, isOperationId, isValidOperationIdFormat, OPERATION_IDS } from './types.js'; + +describe('isValidOperationIdFormat', () => { + it('accepts simple camelCase identifiers', () => { + expect(isValidOperationIdFormat('find')).toBe(true); + expect(isValidOperationIdFormat('getNode')).toBe(true); + expect(isValidOperationIdFormat('getText')).toBe(true); + }); + + it('accepts namespaced identifiers (namespace.camelCase)', () => { + expect(isValidOperationIdFormat('comments.add')).toBe(true); + expect(isValidOperationIdFormat('trackChanges.list')).toBe(true); + expect(isValidOperationIdFormat('lists.setType')).toBe(true); + }); + + it('rejects empty strings', () => { + expect(isValidOperationIdFormat('')).toBe(false); + }); + + it('rejects identifiers starting with uppercase', () => { + expect(isValidOperationIdFormat('Find')).toBe(false); + expect(isValidOperationIdFormat('Comments.add')).toBe(false); + }); + + it('rejects identifiers with multiple dots', () => { + expect(isValidOperationIdFormat('a.b.c')).toBe(false); + }); + + it('rejects identifiers with special characters', () => { + expect(isValidOperationIdFormat('find-all')).toBe(false); + expect(isValidOperationIdFormat('find_all')).toBe(false); + expect(isValidOperationIdFormat('find all')).toBe(false); + }); + + it('rejects trailing or leading dots', () => { + expect(isValidOperationIdFormat('.find')).toBe(false); + expect(isValidOperationIdFormat('find.')).toBe(false); + }); +}); + +describe('isOperationId', () => { + it('returns true for every known operation ID', () => { + for (const id of OPERATION_IDS) { + expect(isOperationId(id)).toBe(true); + } + }); + + it('returns false for unknown but validly formatted strings', () => { + expect(isOperationId('unknown')).toBe(false); + expect(isOperationId('comments.unknown')).toBe(false); + }); + + it('returns false for invalid format strings', () => { + expect(isOperationId('')).toBe(false); + expect(isOperationId('FIND')).toBe(false); + }); +}); + +describe('assertOperationId', () => { + it('does not throw for known operation IDs', () => { + for (const id of OPERATION_IDS) { + expect(() => assertOperationId(id)).not.toThrow(); + } + }); + + it('throws for unknown operation IDs', () => { + expect(() => assertOperationId('nonexistent')).toThrow(/Unknown operationId "nonexistent"/); + }); + + it('throws for invalid format strings', () => { + expect(() => assertOperationId('BAD FORMAT')).toThrow(/Unknown operationId/); + }); +}); diff --git a/packages/document-api/src/contract/types.ts b/packages/document-api/src/contract/types.ts new file mode 100644 index 000000000..f354870a6 --- /dev/null +++ b/packages/document-api/src/contract/types.ts @@ -0,0 +1,119 @@ +import type { ReceiptFailureCode } from '../types/receipt.js'; + +export const CONTRACT_VERSION = '0.1.0'; + +export const JSON_SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema'; + +export const SINGLETON_OPERATION_IDS = [ + 'find', + 'getNode', + 'getNodeById', + 'getText', + 'info', + 'insert', + 'replace', + 'delete', +] as const; + +export const NAMESPACED_OPERATION_IDS = [ + 'format.bold', + 'create.paragraph', + 'lists.list', + 'lists.get', + 'lists.insert', + 'lists.setType', + 'lists.indent', + 'lists.outdent', + 'lists.restart', + 'lists.exit', + 'comments.add', + 'comments.edit', + 'comments.reply', + 'comments.move', + 'comments.resolve', + 'comments.remove', + 'comments.setInternal', + 'comments.setActive', + 'comments.goTo', + 'comments.get', + 'comments.list', + 'trackChanges.list', + 'trackChanges.get', + 'trackChanges.accept', + 'trackChanges.reject', + 'trackChanges.acceptAll', + 'trackChanges.rejectAll', + 'capabilities.get', +] as const; + +export const OPERATION_IDS = [...SINGLETON_OPERATION_IDS, ...NAMESPACED_OPERATION_IDS] as const; + +export type OperationId = (typeof OPERATION_IDS)[number]; + +export const OPERATION_IDEMPOTENCY_VALUES = ['idempotent', 'conditional', 'non-idempotent'] as const; +export type OperationIdempotency = (typeof OPERATION_IDEMPOTENCY_VALUES)[number]; + +export const PRE_APPLY_THROW_CODES = [ + 'TARGET_NOT_FOUND', + 'COMMAND_UNAVAILABLE', + 'TRACK_CHANGE_COMMAND_UNAVAILABLE', + 'CAPABILITY_UNAVAILABLE', + 'INVALID_TARGET', +] as const; + +export type PreApplyThrowCode = (typeof PRE_APPLY_THROW_CODES)[number]; + +export interface CommandThrowPolicy { + preApply: readonly PreApplyThrowCode[]; + postApplyForbidden: true; +} + +export interface CommandStaticMetadata { + mutates: boolean; + idempotency: OperationIdempotency; + supportsDryRun: boolean; + supportsTrackedMode: boolean; + possibleFailureCodes: readonly ReceiptFailureCode[]; + throws: CommandThrowPolicy; + deterministicTargetResolution: boolean; + remediationHints?: readonly string[]; +} + +export type CommandCatalog = { + readonly [K in OperationId]: CommandStaticMetadata; +}; + +const OPERATION_ID_FORMAT = /^(?:[a-z][a-zA-Z0-9]*|[a-z][a-zA-Z0-9]*\.[a-z][a-zA-Z0-9]*)$/; + +/** + * Checks whether a string matches the syntactic format of an operation ID + * (`camelCase` or `namespace.camelCase`). + * + * @param operationId - The string to validate. + * @returns `true` if the string matches the expected format. + */ +export function isValidOperationIdFormat(operationId: string): boolean { + return OPERATION_ID_FORMAT.test(operationId); +} + +/** + * Type-guard that narrows a string to the {@link OperationId} union. + * + * @param operationId - The string to check. + * @returns `true` if the string is a known operation ID. + */ +export function isOperationId(operationId: string): operationId is OperationId { + return (OPERATION_IDS as readonly string[]).includes(operationId); +} + +/** + * Asserts that a string is a valid, known {@link OperationId}. + * + * @param operationId - The string to assert. + * @throws {Error} If the string is not a recognised operation ID. + */ +export function assertOperationId(operationId: string): asserts operationId is OperationId { + if (!isValidOperationIdFormat(operationId) || !isOperationId(operationId)) { + throw new Error(`Unknown operationId "${operationId}".`); + } +} diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index 32f7c2b8e..e849f1044 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -3,6 +3,8 @@ */ export * from './types/index.js'; +export * from './contract/index.js'; +export * from './capabilities/capabilities.js'; import type { CreateParagraphInput, @@ -102,6 +104,11 @@ import { executeTrackChangesRejectAll, } from './track-changes/track-changes.js'; import type { MutationOptions, WriteAdapter } from './write/write.js'; +import { + executeCapabilities, + type CapabilitiesAdapter, + type DocumentApiCapabilities, +} from './capabilities/capabilities.js'; export type { FindAdapter, FindOptions } from './find/find.js'; export type { GetNodeAdapter, GetNodeByIdInput } from './get-node/get-node.js'; @@ -220,6 +227,12 @@ export interface DocumentApi { * List item operations. */ lists: ListsApi; + /** + * Runtime capability introspection. + */ + capabilities: { + get(): DocumentApiCapabilities; + }; } export interface DocumentApiAdapters { @@ -227,6 +240,7 @@ export interface DocumentApiAdapters { getNode: GetNodeAdapter; getText: GetTextAdapter; info: InfoAdapter; + capabilities: CapabilitiesAdapter; comments: CommentsAdapter; write: WriteAdapter; format: FormatAdapter; @@ -342,6 +356,11 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { return executeCreateParagraph(adapters.create, input, options); }, }, + capabilities: { + get(): DocumentApiCapabilities { + return executeCapabilities(adapters.capabilities); + }, + }, lists: { list(query?: ListsListQuery): ListsListResult { return executeListsList(adapters.lists, query); diff --git a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts index 58456072d..bacb4660b 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts @@ -4,6 +4,7 @@ import { findAdapter } from './find-adapter.js'; import { getNodeAdapter, getNodeByIdAdapter } from './get-node-adapter.js'; import { getTextAdapter } from './get-text-adapter.js'; import { infoAdapter } from './info-adapter.js'; +import { getDocumentApiCapabilities } from './capabilities-adapter.js'; import { createCommentsAdapter } from './comments-adapter.js'; import { writeAdapter } from './write-adapter.js'; import { formatBoldAdapter } from './format-adapter.js'; @@ -48,6 +49,9 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters info: { info: (input) => infoAdapter(editor, input), }, + capabilities: { + get: () => getDocumentApiCapabilities(editor), + }, comments: createCommentsAdapter(editor), write: { write: (request, options) => writeAdapter(editor, request, options), diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts new file mode 100644 index 000000000..9ca1a7253 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Editor } from '../core/Editor.js'; +import { OPERATION_IDS } from '@superdoc/document-api'; +import { TrackFormatMarkName } from '../extensions/track-changes/constants.js'; +import { getDocumentApiCapabilities } from './capabilities-adapter.js'; + +function makeEditor(overrides: Partial = {}): Editor { + const defaultCommands = { + insertParagraphAt: vi.fn(() => true), + insertListItemAt: vi.fn(() => true), + setListTypeAt: vi.fn(() => true), + setTextSelection: vi.fn(() => true), + increaseListIndent: vi.fn(() => true), + decreaseListIndent: vi.fn(() => true), + restartNumbering: vi.fn(() => true), + exitListItemAt: vi.fn(() => true), + addComment: vi.fn(() => true), + editComment: vi.fn(() => true), + addCommentReply: vi.fn(() => true), + moveComment: vi.fn(() => true), + resolveComment: vi.fn(() => true), + removeComment: vi.fn(() => true), + setCommentInternal: vi.fn(() => true), + setActiveComment: vi.fn(() => true), + setCursorById: vi.fn(() => true), + insertTrackedChange: vi.fn(() => true), + acceptTrackedChangeById: vi.fn(() => true), + rejectTrackedChangeById: vi.fn(() => true), + acceptAllTrackedChanges: vi.fn(() => true), + rejectAllTrackedChanges: vi.fn(() => true), + }; + + const defaultMarks = { + bold: { + create: vi.fn(() => ({ type: 'bold' })), + }, + [TrackFormatMarkName]: { + create: vi.fn(() => ({ type: TrackFormatMarkName })), + }, + }; + + const overrideCommands = (overrides.commands ?? {}) as Partial; + const overrideSchema = (overrides.schema ?? {}) as Partial; + const overrideMarks = (overrideSchema.marks ?? {}) as Record; + + const commands = { + ...defaultCommands, + ...overrideCommands, + }; + + const schema = { + ...overrideSchema, + marks: { + ...defaultMarks, + ...overrideMarks, + }, + }; + + return { + ...overrides, + commands, + schema, + } as unknown as Editor; +} + +describe('getDocumentApiCapabilities', () => { + it('returns deterministic per-operation coverage for the full operation inventory', () => { + const capabilities = getDocumentApiCapabilities(makeEditor()); + const operationKeys = Object.keys(capabilities.operations).sort(); + expect(operationKeys).toEqual([...OPERATION_IDS].sort()); + }); + + it('marks namespaces as unavailable when required commands are missing', () => { + const editor = makeEditor({ + commands: { + addComment: undefined, + setListTypeAt: undefined, + insertTrackedChange: undefined, + } as unknown as Editor['commands'], + schema: { + marks: { + bold: undefined, + [TrackFormatMarkName]: {}, + }, + } as unknown as Editor['schema'], + }); + + const capabilities = getDocumentApiCapabilities(editor); + + expect(capabilities.global.comments.enabled).toBe(false); + expect(capabilities.global.lists.enabled).toBe(false); + expect(capabilities.global.trackChanges.enabled).toBe(false); + expect(capabilities.operations['comments.add'].available).toBe(false); + expect(capabilities.operations['lists.setType'].available).toBe(false); + expect(capabilities.operations.insert.tracked).toBe(false); + expect(capabilities.operations['format.bold'].available).toBe(false); + }); + + it('exposes tracked + dryRun flags in line with command catalog capabilities', () => { + const capabilities = getDocumentApiCapabilities(makeEditor()); + + expect(capabilities.operations.insert.tracked).toBe(true); + expect(capabilities.operations.insert.dryRun).toBe(true); + expect(capabilities.operations['lists.setType'].tracked).toBe(false); + expect(capabilities.operations['lists.setType'].dryRun).toBe(false); + expect(capabilities.operations['trackChanges.accept'].dryRun).toBe(false); + expect(capabilities.operations['create.paragraph'].dryRun).toBe(true); + }); + + it('never reports tracked=true when the operation is unavailable', () => { + const capabilities = getDocumentApiCapabilities( + makeEditor({ + commands: { + insertTrackedChange: vi.fn(() => true), + insertParagraphAt: undefined, + } as unknown as Editor['commands'], + }), + ); + + expect(capabilities.operations['create.paragraph'].available).toBe(false); + expect(capabilities.operations['create.paragraph'].tracked).toBe(false); + }); + + it('does not emit unavailable reasons for modes that are unsupported by design', () => { + const capabilities = getDocumentApiCapabilities(makeEditor()); + const setTypeReasons = capabilities.operations['lists.setType'].reasons ?? []; + const acceptAllReasons = capabilities.operations['trackChanges.acceptAll'].reasons ?? []; + + expect(setTypeReasons).not.toContain('TRACKED_MODE_UNAVAILABLE'); + expect(setTypeReasons).not.toContain('DRY_RUN_UNAVAILABLE'); + expect(acceptAllReasons).not.toContain('DRY_RUN_UNAVAILABLE'); + }); + + it('handles an editor with undefined schema gracefully', () => { + const editor = makeEditor({ + schema: undefined as unknown as Editor['schema'], + }); + + const capabilities = getDocumentApiCapabilities(editor); + + expect(capabilities.operations['format.bold'].available).toBe(false); + expect(capabilities.operations.insert.tracked).toBe(false); + // Smoke-test: every operation has a defined entry + for (const id of OPERATION_IDS) { + expect(capabilities.operations[id]).toBeDefined(); + } + }); + + it('uses OPERATION_UNAVAILABLE without COMMAND_UNAVAILABLE for non-command-backed availability failures', () => { + const capabilities = getDocumentApiCapabilities( + makeEditor({ + schema: { + marks: { + bold: undefined, + [TrackFormatMarkName]: {}, + }, + } as unknown as Editor['schema'], + }), + ); + + const formatReasons = capabilities.operations['format.bold'].reasons ?? []; + expect(formatReasons).toContain('OPERATION_UNAVAILABLE'); + expect(formatReasons).not.toContain('COMMAND_UNAVAILABLE'); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts new file mode 100644 index 000000000..a64a2fe55 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts @@ -0,0 +1,180 @@ +import type { Editor } from '../core/Editor.js'; +import { + CAPABILITY_REASON_CODES, + COMMAND_CATALOG, + type CapabilityReasonCode, + type DocumentApiCapabilities, + type OperationId, + OPERATION_IDS, +} from '@superdoc/document-api'; +import { TrackFormatMarkName } from '../extensions/track-changes/constants.js'; + +type EditorCommandName = string; + +// Singleton write operations (insert, replace, delete) have no entry here because +// they are backed by writeAdapter which is always available when the editor exists. +// Read-only operations (find, getNode, getText, info, etc.) similarly need no commands. +const REQUIRED_COMMANDS: Partial> = { + 'create.paragraph': ['insertParagraphAt'], + 'lists.insert': ['insertListItemAt'], + 'lists.setType': ['setListTypeAt'], + 'lists.indent': ['setTextSelection', 'increaseListIndent'], + 'lists.outdent': ['setTextSelection', 'decreaseListIndent'], + 'lists.restart': ['setTextSelection', 'restartNumbering'], + 'lists.exit': ['exitListItemAt'], + 'comments.add': ['addComment', 'setTextSelection'], + 'comments.edit': ['editComment'], + 'comments.reply': ['addCommentReply'], + 'comments.move': ['moveComment'], + 'comments.resolve': ['resolveComment'], + 'comments.remove': ['removeComment'], + 'comments.setInternal': ['setCommentInternal'], + 'comments.setActive': ['setActiveComment'], + 'comments.goTo': ['setCursorById'], + 'trackChanges.accept': ['acceptTrackedChangeById'], + 'trackChanges.reject': ['rejectTrackedChangeById'], + 'trackChanges.acceptAll': ['acceptAllTrackedChanges'], + 'trackChanges.rejectAll': ['rejectAllTrackedChanges'], +}; + +/** Runtime guard — ensures only canonical reason codes are emitted even if the set grows. */ +const VALID_CAPABILITY_REASON_CODES = new Set(CAPABILITY_REASON_CODES); + +function hasCommand(editor: Editor, command: EditorCommandName): boolean { + return typeof (editor.commands as Record | undefined)?.[command] === 'function'; +} + +function hasAllCommands(editor: Editor, operationId: OperationId): boolean { + const required = REQUIRED_COMMANDS[operationId]; + if (!required || required.length === 0) return true; + return required.every((command) => hasCommand(editor, command)); +} + +function hasBoldCapability(editor: Editor): boolean { + return Boolean(editor.schema?.marks?.bold); +} + +function hasTrackedModeCapability(editor: Editor, operationId: OperationId): boolean { + if (!hasCommand(editor, 'insertTrackedChange')) return false; + if (operationId === 'format.bold') { + return Boolean(editor.schema?.marks?.[TrackFormatMarkName]); + } + return true; +} + +function getNamespaceOperationIds(prefix: string): OperationId[] { + return (Object.keys(REQUIRED_COMMANDS) as OperationId[]).filter((id) => id.startsWith(`${prefix}.`)); +} + +function isCommentsNamespaceEnabled(editor: Editor): boolean { + return getNamespaceOperationIds('comments').every((id) => hasAllCommands(editor, id)); +} + +function isListsNamespaceEnabled(editor: Editor): boolean { + return getNamespaceOperationIds('lists').every((id) => hasAllCommands(editor, id)); +} + +function isTrackChangesEnabled(editor: Editor): boolean { + return ( + hasCommand(editor, 'insertTrackedChange') && + hasCommand(editor, 'acceptTrackedChangeById') && + hasCommand(editor, 'rejectTrackedChangeById') && + hasCommand(editor, 'acceptAllTrackedChanges') && + hasCommand(editor, 'rejectAllTrackedChanges') + ); +} + +function getNamespaceReason(enabled: boolean): CapabilityReasonCode[] | undefined { + return enabled ? undefined : ['NAMESPACE_UNAVAILABLE']; +} + +function pushReason(reasons: CapabilityReasonCode[], reason: CapabilityReasonCode): void { + if (!VALID_CAPABILITY_REASON_CODES.has(reason)) return; + if (!reasons.includes(reason)) reasons.push(reason); +} + +function isOperationAvailable(editor: Editor, operationId: OperationId): boolean { + if (operationId === 'format.bold') { + return hasBoldCapability(editor); + } + + return hasAllCommands(editor, operationId); +} + +function isCommandBackedAvailability(operationId: OperationId): boolean { + return operationId !== 'format.bold'; +} + +function buildOperationCapabilities(editor: Editor): DocumentApiCapabilities['operations'] { + const operations = {} as DocumentApiCapabilities['operations']; + + for (const operationId of OPERATION_IDS) { + const metadata = COMMAND_CATALOG[operationId]; + const available = isOperationAvailable(editor, operationId); + const tracked = available && metadata.supportsTrackedMode && hasTrackedModeCapability(editor, operationId); + // dryRun is only meaningful for an operation that is currently executable. + const dryRun = metadata.supportsDryRun && available; + const reasons: CapabilityReasonCode[] = []; + + if (!available) { + if (isCommandBackedAvailability(operationId)) { + pushReason(reasons, 'COMMAND_UNAVAILABLE'); + } + pushReason(reasons, 'OPERATION_UNAVAILABLE'); + } + + if (metadata.supportsTrackedMode && !tracked) { + pushReason(reasons, 'TRACKED_MODE_UNAVAILABLE'); + } + + if (metadata.supportsDryRun && !dryRun) { + pushReason(reasons, 'DRY_RUN_UNAVAILABLE'); + } + + operations[operationId] = { + available, + tracked, + dryRun, + reasons: reasons.length > 0 ? reasons : undefined, + }; + } + + return operations; +} + +/** + * Builds a {@link DocumentApiCapabilities} snapshot by introspecting the editor's + * registered commands and schema marks. + * + * @param editor - The ProseMirror-backed editor instance to introspect. + * @returns A complete capability snapshot covering global flags and per-operation details. + */ +export function getDocumentApiCapabilities(editor: Editor): DocumentApiCapabilities { + const operations = buildOperationCapabilities(editor); + const commentsEnabled = isCommentsNamespaceEnabled(editor); + const listsEnabled = isListsNamespaceEnabled(editor); + const trackChangesEnabled = isTrackChangesEnabled(editor); + const dryRunEnabled = OPERATION_IDS.some((operationId) => operations[operationId].dryRun); + + return { + global: { + trackChanges: { + enabled: trackChangesEnabled, + reasons: getNamespaceReason(trackChangesEnabled), + }, + comments: { + enabled: commentsEnabled, + reasons: getNamespaceReason(commentsEnabled), + }, + lists: { + enabled: listsEnabled, + reasons: getNamespaceReason(listsEnabled), + }, + dryRun: { + enabled: dryRunEnabled, + reasons: dryRunEnabled ? undefined : ['DRY_RUN_UNAVAILABLE'], + }, + }, + operations, + }; +} diff --git a/packages/super-editor/src/document-api-adapters/index.ts b/packages/super-editor/src/document-api-adapters/index.ts new file mode 100644 index 000000000..753c919c5 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/index.ts @@ -0,0 +1,97 @@ +import type { + DocumentApiAdapters, + GetNodeByIdInput, + GetTextInput, + InfoInput, + NodeAddress, + Query, + TrackChangesAcceptAllInput, + TrackChangesAcceptInput, + TrackChangesGetInput, + TrackChangesRejectAllInput, + TrackChangesRejectInput, +} from '@superdoc/document-api'; +import type { Editor } from '../core/Editor.js'; +import { getDocumentApiCapabilities } from './capabilities-adapter.js'; +import { createCommentsAdapter } from './comments-adapter.js'; +import { createParagraphAdapter } from './create-adapter.js'; +import { findAdapter } from './find-adapter.js'; +import { formatBoldAdapter } from './format-adapter.js'; +import { getNodeAdapter, getNodeByIdAdapter } from './get-node-adapter.js'; +import { getTextAdapter } from './get-text-adapter.js'; +import { infoAdapter } from './info-adapter.js'; +import { + listsExitAdapter, + listsGetAdapter, + listsIndentAdapter, + listsInsertAdapter, + listsListAdapter, + listsOutdentAdapter, + listsRestartAdapter, + listsSetTypeAdapter, +} from './lists-adapter.js'; +import { + trackChangesAcceptAdapter, + trackChangesAcceptAllAdapter, + trackChangesGetAdapter, + trackChangesListAdapter, + trackChangesRejectAdapter, + trackChangesRejectAllAdapter, +} from './track-changes-adapter.js'; +import { writeAdapter } from './write-adapter.js'; + +/** + * Creates the full set of Document API adapters backed by the given editor instance. + * + * @param editor - The editor instance to bind adapters to. + * @returns Adapter implementations for document query/mutation APIs. + */ +export function getDocumentApiAdapters(editor: Editor): DocumentApiAdapters { + return { + find: { + find: (query: Query) => findAdapter(editor, query), + }, + getNode: { + getNode: (address: NodeAddress) => getNodeAdapter(editor, address), + getNodeById: (input: GetNodeByIdInput) => getNodeByIdAdapter(editor, input), + }, + getText: { + getText: (input: GetTextInput) => getTextAdapter(editor, input), + }, + info: { + info: (input: InfoInput) => infoAdapter(editor, input), + }, + capabilities: { + get: () => getDocumentApiCapabilities(editor), + }, + // Factory pattern — comments has 11 methods; inline lambdas would be unwieldy. + comments: createCommentsAdapter(editor), + write: { + write: (request, options) => writeAdapter(editor, request, options), + }, + format: { + bold: (input, options) => formatBoldAdapter(editor, input, options), + }, + trackChanges: { + list: (query) => trackChangesListAdapter(editor, query), + get: (input: TrackChangesGetInput) => trackChangesGetAdapter(editor, input), + accept: (input: TrackChangesAcceptInput) => trackChangesAcceptAdapter(editor, input), + reject: (input: TrackChangesRejectInput) => trackChangesRejectAdapter(editor, input), + acceptAll: (input: TrackChangesAcceptAllInput) => trackChangesAcceptAllAdapter(editor, input), + rejectAll: (input: TrackChangesRejectAllInput) => trackChangesRejectAllAdapter(editor, input), + }, + create: { + paragraph: (input, options) => createParagraphAdapter(editor, input, options), + }, + lists: { + list: (query) => listsListAdapter(editor, query), + get: (input) => listsGetAdapter(editor, input), + insert: (input, options) => listsInsertAdapter(editor, input, options), + setType: (input, options) => listsSetTypeAdapter(editor, input, options), + indent: (input, options) => listsIndentAdapter(editor, input, options), + outdent: (input, options) => listsOutdentAdapter(editor, input, options), + restart: (input, options) => listsRestartAdapter(editor, input, options), + exit: (input, options) => listsExitAdapter(editor, input, options), + }, + }; +} From a9519d9a2fb6a0b0e5e039c3eb5be48323a0120f Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Feb 2026 13:19:47 -0800 Subject: [PATCH 12/25] feat(document-api): add contract schemas and derive operation member paths --- .../src/contract/contract.test.ts | 63 ++ packages/document-api/src/contract/index.ts | 1 + .../src/contract/operation-map.ts | 19 + .../src/contract/reference-doc-map.ts | 157 +++ packages/document-api/src/contract/schemas.ts | 911 ++++++++++++++++++ .../capabilities-adapter.test.ts | 45 +- .../capabilities-adapter.ts | 4 + .../comments-adapter.test.ts | 74 ++ .../document-api-adapters/comments-adapter.ts | 51 +- .../helpers/inline-address-resolver.test.ts | 99 ++ .../helpers/inline-address-resolver.ts | 8 +- 11 files changed, 1411 insertions(+), 21 deletions(-) create mode 100644 packages/document-api/src/contract/contract.test.ts create mode 100644 packages/document-api/src/contract/operation-map.ts create mode 100644 packages/document-api/src/contract/reference-doc-map.ts create mode 100644 packages/document-api/src/contract/schemas.ts diff --git a/packages/document-api/src/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts new file mode 100644 index 000000000..c3d78b92e --- /dev/null +++ b/packages/document-api/src/contract/contract.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { COMMAND_CATALOG } from './command-catalog.js'; +import { DOCUMENT_API_MEMBER_PATHS, memberPathForOperation } from './operation-map.js'; +import { OPERATION_REFERENCE_DOC_PATH_MAP, REFERENCE_OPERATION_GROUPS } from './reference-doc-map.js'; +import { buildInternalContractSchemas } from './schemas.js'; +import { OPERATION_IDS, PRE_APPLY_THROW_CODES, isValidOperationIdFormat } from './types.js'; + +describe('document-api contract catalog', () => { + it('keeps operation ids explicit and format-valid', () => { + expect([...new Set(OPERATION_IDS)]).toHaveLength(OPERATION_IDS.length); + for (const operationId of OPERATION_IDS) { + expect(isValidOperationIdFormat(operationId)).toBe(true); + } + }); + + it('keeps catalog key coverage in lockstep with operation ids', () => { + const catalogKeys = Object.keys(COMMAND_CATALOG).sort(); + const operationIds = [...OPERATION_IDS].sort(); + expect(catalogKeys).toEqual(operationIds); + }); + + it('derives member paths from operation ids with no duplicates', () => { + expect(new Set(DOCUMENT_API_MEMBER_PATHS).size).toBe(DOCUMENT_API_MEMBER_PATHS.length); + for (const operationId of OPERATION_IDS) { + expect(typeof memberPathForOperation(operationId)).toBe('string'); + } + }); + + it('keeps reference-doc mappings explicit and coverage-complete', () => { + const operationIds = [...OPERATION_IDS].sort(); + const docPathKeys = Object.keys(OPERATION_REFERENCE_DOC_PATH_MAP).sort(); + expect(docPathKeys).toEqual(operationIds); + + const grouped = REFERENCE_OPERATION_GROUPS.flatMap((group) => group.operations); + expect(grouped).toHaveLength(operationIds.length); + expect(new Set(grouped).size).toBe(grouped.length); + expect([...grouped].sort()).toEqual(operationIds); + }); + + it('enforces typed throw and post-apply policy metadata for mutation operations', () => { + const validPreApplyThrowCodes = new Set(PRE_APPLY_THROW_CODES); + + for (const operationId of OPERATION_IDS) { + const metadata = COMMAND_CATALOG[operationId]; + for (const throwCode of metadata.throws.preApply) { + expect(validPreApplyThrowCodes.has(throwCode)).toBe(true); + } + + if (!metadata.mutates) continue; + expect(metadata.throws.postApplyForbidden).toBe(true); + } + }); + + it('keeps input schemas closed for object-shaped payloads', () => { + const schemas = buildInternalContractSchemas(); + + for (const operationId of OPERATION_IDS) { + const inputSchema = schemas.operations[operationId].input as { type?: string; additionalProperties?: unknown }; + if (inputSchema.type !== 'object') continue; + expect(inputSchema.additionalProperties).toBe(false); + } + }); +}); diff --git a/packages/document-api/src/contract/index.ts b/packages/document-api/src/contract/index.ts index a541b4622..1c3951c55 100644 --- a/packages/document-api/src/contract/index.ts +++ b/packages/document-api/src/contract/index.ts @@ -1,2 +1,3 @@ export * from './types.js'; export * from './command-catalog.js'; +export * from './schemas.js'; diff --git a/packages/document-api/src/contract/operation-map.ts b/packages/document-api/src/contract/operation-map.ts new file mode 100644 index 000000000..04f90f4c7 --- /dev/null +++ b/packages/document-api/src/contract/operation-map.ts @@ -0,0 +1,19 @@ +import { OPERATION_IDS, type OperationId } from './types.js'; + +/** + * Overrides for operation IDs whose public DocumentApi member path + * differs from the canonical operation ID. + */ +const MEMBER_PATH_OVERRIDES: Partial> = { + // capabilities() is exposed as a top-level getter-like method on DocumentApi. + // The canonical operationId remains capabilities.get for catalog consistency. + 'capabilities.get': 'capabilities', +}; + +export function memberPathForOperation(operationId: OperationId): string { + return MEMBER_PATH_OVERRIDES[operationId] ?? operationId; +} + +export const DOCUMENT_API_MEMBER_PATHS = [...new Set(OPERATION_IDS.map(memberPathForOperation))] as const; + +export type DocumentApiMemberPath = (typeof DOCUMENT_API_MEMBER_PATHS)[number]; diff --git a/packages/document-api/src/contract/reference-doc-map.ts b/packages/document-api/src/contract/reference-doc-map.ts new file mode 100644 index 000000000..b73198fc9 --- /dev/null +++ b/packages/document-api/src/contract/reference-doc-map.ts @@ -0,0 +1,157 @@ +import { OPERATION_IDS, type OperationId } from './types.js'; + +export type ReferenceGroupKey = 'core' | 'capabilities' | 'create' | 'format' | 'lists' | 'comments' | 'trackChanges'; + +export interface ReferenceOperationGroupDefinition { + key: ReferenceGroupKey; + title: string; + description: string; + pagePath: string; + operations: readonly OperationId[]; +} + +export const OPERATION_REFERENCE_DOC_PATH_MAP: Record = { + find: 'find.mdx', + getNode: 'get-node.mdx', + getNodeById: 'get-node-by-id.mdx', + getText: 'get-text.mdx', + info: 'info.mdx', + insert: 'insert.mdx', + replace: 'replace.mdx', + delete: 'delete.mdx', + 'format.bold': 'format/bold.mdx', + 'create.paragraph': 'create/paragraph.mdx', + 'lists.list': 'lists/list.mdx', + 'lists.get': 'lists/get.mdx', + 'lists.insert': 'lists/insert.mdx', + 'lists.setType': 'lists/set-type.mdx', + 'lists.indent': 'lists/indent.mdx', + 'lists.outdent': 'lists/outdent.mdx', + 'lists.restart': 'lists/restart.mdx', + 'lists.exit': 'lists/exit.mdx', + 'comments.add': 'comments/add.mdx', + 'comments.edit': 'comments/edit.mdx', + 'comments.reply': 'comments/reply.mdx', + 'comments.move': 'comments/move.mdx', + 'comments.resolve': 'comments/resolve.mdx', + 'comments.remove': 'comments/remove.mdx', + 'comments.setInternal': 'comments/set-internal.mdx', + 'comments.setActive': 'comments/set-active.mdx', + 'comments.goTo': 'comments/go-to.mdx', + 'comments.get': 'comments/get.mdx', + 'comments.list': 'comments/list.mdx', + 'trackChanges.list': 'track-changes/list.mdx', + 'trackChanges.get': 'track-changes/get.mdx', + 'trackChanges.accept': 'track-changes/accept.mdx', + 'trackChanges.reject': 'track-changes/reject.mdx', + 'trackChanges.acceptAll': 'track-changes/accept-all.mdx', + 'trackChanges.rejectAll': 'track-changes/reject-all.mdx', + 'capabilities.get': 'capabilities/get.mdx', +}; + +export const REFERENCE_OPERATION_GROUPS: readonly ReferenceOperationGroupDefinition[] = [ + { + key: 'core', + title: 'Core', + description: 'Primary read and write operations.', + pagePath: 'core/index.mdx', + operations: ['find', 'getNode', 'getNodeById', 'getText', 'info', 'insert', 'replace', 'delete'], + }, + { + key: 'capabilities', + title: 'Capabilities', + description: 'Runtime support discovery for capability-aware branching.', + pagePath: 'capabilities/index.mdx', + operations: ['capabilities.get'], + }, + { + key: 'create', + title: 'Create', + description: 'Structured creation helpers.', + pagePath: 'create/index.mdx', + operations: ['create.paragraph'], + }, + { + key: 'format', + title: 'Format', + description: 'Formatting mutations.', + pagePath: 'format/index.mdx', + operations: ['format.bold'], + }, + { + key: 'lists', + title: 'Lists', + description: 'List inspection and list mutations.', + pagePath: 'lists/index.mdx', + operations: [ + 'lists.list', + 'lists.get', + 'lists.insert', + 'lists.setType', + 'lists.indent', + 'lists.outdent', + 'lists.restart', + 'lists.exit', + ], + }, + { + key: 'comments', + title: 'Comments', + description: 'Comment authoring and thread lifecycle operations.', + pagePath: 'comments/index.mdx', + operations: [ + 'comments.add', + 'comments.edit', + 'comments.reply', + 'comments.move', + 'comments.resolve', + 'comments.remove', + 'comments.setInternal', + 'comments.setActive', + 'comments.goTo', + 'comments.get', + 'comments.list', + ], + }, + { + key: 'trackChanges', + title: 'Track Changes', + description: 'Tracked-change inspection and review operations.', + pagePath: 'track-changes/index.mdx', + operations: [ + 'trackChanges.list', + 'trackChanges.get', + 'trackChanges.accept', + 'trackChanges.reject', + 'trackChanges.acceptAll', + 'trackChanges.rejectAll', + ], + }, +]; + +/** + * Fail-fast guard that runs at import time to catch stale reference-doc + * mappings before they reach consumers. The same invariants are also covered + * by contract.test.ts; this assertion provides an immediate signal during + * development when a new operation is added but the doc map is not updated. + */ +function assertReferenceMapCoverage(): void { + const operationIds = [...OPERATION_IDS].sort(); + + const docPathKeys = Object.keys(OPERATION_REFERENCE_DOC_PATH_MAP).sort(); + if (docPathKeys.join('|') !== operationIds.join('|')) { + throw new Error('OPERATION_REFERENCE_DOC_PATH_MAP keys must match OPERATION_IDS exactly.'); + } + + const grouped = REFERENCE_OPERATION_GROUPS.flatMap((group) => group.operations); + const groupedSorted = [...grouped].sort(); + if (groupedSorted.join('|') !== operationIds.join('|')) { + throw new Error('REFERENCE_OPERATION_GROUPS operation coverage must match OPERATION_IDS exactly.'); + } + + if (new Set(grouped).size !== grouped.length) { + throw new Error('REFERENCE_OPERATION_GROUPS contains duplicate operations.'); + } +} + +assertReferenceMapCoverage(); diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts new file mode 100644 index 000000000..9a3defcf8 --- /dev/null +++ b/packages/document-api/src/contract/schemas.ts @@ -0,0 +1,911 @@ +import { COMMAND_CATALOG } from './command-catalog.js'; +import { CONTRACT_VERSION, JSON_SCHEMA_DIALECT, OPERATION_IDS, type OperationId } from './types.js'; +import { NODE_TYPES, BLOCK_NODE_TYPES, INLINE_NODE_TYPES } from '../types/base.js'; + +type JsonSchema = Record; + +/** JSON Schema descriptors for a single operation's input, output, and result variants. */ +export interface OperationSchemaSet { + /** Schema describing the operation's accepted input payload. */ + input: JsonSchema; + /** Schema describing the full output (success | failure union for mutations). */ + output: JsonSchema; + /** Schema describing only the success branch of a mutation result. */ + success?: JsonSchema; + /** Schema describing only the failure branch of a mutation result. */ + failure?: JsonSchema; +} + +/** Top-level contract envelope containing versioned operation schemas. */ +export interface InternalContractSchemas { + /** JSON Schema dialect URI (e.g. `https://json-schema.org/draft/2020-12/schema`). */ + $schema: string; + /** Semantic version of the document-api contract these schemas describe. */ + contractVersion: string; + /** Per-operation schema sets keyed by {@link OperationId}. */ + operations: Record; +} + +function objectSchema(properties: Record, required: readonly string[] = []): JsonSchema { + const schema: JsonSchema = { + type: 'object', + properties, + additionalProperties: false, + }; + if (required.length > 0) { + schema.required = [...required]; + } + return schema; +} + +function arraySchema(items: JsonSchema): JsonSchema { + return { + type: 'array', + items, + }; +} + +const nodeTypeValues = NODE_TYPES; +const blockNodeTypeValues = BLOCK_NODE_TYPES; +const inlineNodeTypeValues = INLINE_NODE_TYPES; + +const rangeSchema = objectSchema( + { + start: { type: 'integer' }, + end: { type: 'integer' }, + }, + ['start', 'end'], +); + +const positionSchema = objectSchema( + { + blockId: { type: 'string' }, + offset: { type: 'integer' }, + }, + ['blockId', 'offset'], +); + +const inlineAnchorSchema = objectSchema( + { + start: positionSchema, + end: positionSchema, + }, + ['start', 'end'], +); + +const textAddressSchema = objectSchema( + { + kind: { const: 'text' }, + blockId: { type: 'string' }, + range: rangeSchema, + }, + ['kind', 'blockId', 'range'], +); + +const blockNodeAddressSchema = objectSchema( + { + kind: { const: 'block' }, + nodeType: { enum: [...blockNodeTypeValues] }, + nodeId: { type: 'string' }, + }, + ['kind', 'nodeType', 'nodeId'], +); + +const paragraphAddressSchema = objectSchema( + { + kind: { const: 'block' }, + nodeType: { const: 'paragraph' }, + nodeId: { type: 'string' }, + }, + ['kind', 'nodeType', 'nodeId'], +); + +const listItemAddressSchema = objectSchema( + { + kind: { const: 'block' }, + nodeType: { const: 'listItem' }, + nodeId: { type: 'string' }, + }, + ['kind', 'nodeType', 'nodeId'], +); + +const inlineNodeAddressSchema = objectSchema( + { + kind: { const: 'inline' }, + nodeType: { enum: [...inlineNodeTypeValues] }, + anchor: inlineAnchorSchema, + }, + ['kind', 'nodeType', 'anchor'], +); + +const nodeAddressSchema: JsonSchema = { + oneOf: [blockNodeAddressSchema, inlineNodeAddressSchema], +}; + +const commentAddressSchema = objectSchema( + { + kind: { const: 'entity' }, + entityType: { const: 'comment' }, + entityId: { type: 'string' }, + }, + ['kind', 'entityType', 'entityId'], +); + +const trackedChangeAddressSchema = objectSchema( + { + kind: { const: 'entity' }, + entityType: { const: 'trackedChange' }, + entityId: { type: 'string' }, + }, + ['kind', 'entityType', 'entityId'], +); + +const entityAddressSchema: JsonSchema = { + oneOf: [commentAddressSchema, trackedChangeAddressSchema], +}; + +function possibleFailureCodes(operationId: OperationId): string[] { + return [...COMMAND_CATALOG[operationId].possibleFailureCodes]; +} + +function receiptFailureSchemaFor(operationId: OperationId): JsonSchema { + const codes = possibleFailureCodes(operationId); + if (codes.length === 0) { + throw new Error(`Operation "${operationId}" does not declare non-applied failure codes.`); + } + + return objectSchema( + { + code: { + enum: codes, + }, + message: { type: 'string' }, + details: {}, + }, + ['code', 'message'], + ); +} + +const receiptSuccessSchema = objectSchema( + { + success: { const: true }, + inserted: arraySchema(entityAddressSchema), + updated: arraySchema(entityAddressSchema), + removed: arraySchema(entityAddressSchema), + }, + ['success'], +); + +function receiptFailureResultSchemaFor(operationId: OperationId): JsonSchema { + return objectSchema( + { + success: { const: false }, + failure: receiptFailureSchemaFor(operationId), + }, + ['success', 'failure'], + ); +} + +function receiptResultSchemaFor(operationId: OperationId): JsonSchema { + return { + oneOf: [receiptSuccessSchema, receiptFailureResultSchemaFor(operationId)], + }; +} + +const textMutationRangeSchema = objectSchema( + { + from: { type: 'integer' }, + to: { type: 'integer' }, + }, + ['from', 'to'], +); + +const textMutationResolutionSchema = objectSchema( + { + requestedTarget: textAddressSchema, + target: textAddressSchema, + range: textMutationRangeSchema, + text: { type: 'string' }, + }, + ['target', 'range', 'text'], +); + +const textMutationSuccessSchema = objectSchema( + { + success: { const: true }, + resolution: textMutationResolutionSchema, + inserted: arraySchema(entityAddressSchema), + updated: arraySchema(entityAddressSchema), + removed: arraySchema(entityAddressSchema), + }, + ['success', 'resolution'], +); + +function textMutationFailureSchemaFor(operationId: OperationId): JsonSchema { + return objectSchema( + { + success: { const: false }, + failure: receiptFailureSchemaFor(operationId), + resolution: textMutationResolutionSchema, + }, + ['success', 'failure', 'resolution'], + ); +} + +function textMutationResultSchemaFor(operationId: OperationId): JsonSchema { + return { + oneOf: [textMutationSuccessSchema, textMutationFailureSchemaFor(operationId)], + }; +} + +const trackChangeRefSchema = trackedChangeAddressSchema; + +const createParagraphSuccessSchema = objectSchema( + { + success: { const: true }, + paragraph: paragraphAddressSchema, + insertionPoint: textAddressSchema, + trackedChangeRefs: arraySchema(trackChangeRefSchema), + }, + ['success', 'paragraph', 'insertionPoint'], +); + +function createParagraphFailureSchemaFor(operationId: OperationId): JsonSchema { + return objectSchema( + { + success: { const: false }, + failure: receiptFailureSchemaFor(operationId), + }, + ['success', 'failure'], + ); +} + +function createParagraphResultSchemaFor(operationId: OperationId): JsonSchema { + return { + oneOf: [createParagraphSuccessSchema, createParagraphFailureSchemaFor(operationId)], + }; +} + +const listsInsertSuccessSchema = objectSchema( + { + success: { const: true }, + item: listItemAddressSchema, + insertionPoint: textAddressSchema, + trackedChangeRefs: arraySchema(trackChangeRefSchema), + }, + ['success', 'item', 'insertionPoint'], +); + +const listsMutateItemSuccessSchema = objectSchema( + { + success: { const: true }, + item: listItemAddressSchema, + }, + ['success', 'item'], +); + +const listsExitSuccessSchema = objectSchema( + { + success: { const: true }, + paragraph: paragraphAddressSchema, + }, + ['success', 'paragraph'], +); + +function listsFailureSchemaFor(operationId: OperationId): JsonSchema { + return objectSchema( + { + success: { const: false }, + failure: receiptFailureSchemaFor(operationId), + }, + ['success', 'failure'], + ); +} + +function listsInsertResultSchemaFor(operationId: OperationId): JsonSchema { + return { + oneOf: [listsInsertSuccessSchema, listsFailureSchemaFor(operationId)], + }; +} + +function listsMutateItemResultSchemaFor(operationId: OperationId): JsonSchema { + return { + oneOf: [listsMutateItemSuccessSchema, listsFailureSchemaFor(operationId)], + }; +} + +function listsExitResultSchemaFor(operationId: OperationId): JsonSchema { + return { + oneOf: [listsExitSuccessSchema, listsFailureSchemaFor(operationId)], + }; +} + +const nodeSummarySchema = objectSchema({ + label: { type: 'string' }, + text: { type: 'string' }, +}); + +const nodeInfoSchema: JsonSchema = { + type: 'object', + required: ['nodeType', 'kind'], + properties: { + nodeType: { enum: [...nodeTypeValues] }, + kind: { enum: ['block', 'inline'] }, + summary: nodeSummarySchema, + text: { type: 'string' }, + nodes: arraySchema({ type: 'object' }), + properties: { type: 'object' }, + bodyText: { type: 'string' }, + bodyNodes: arraySchema({ type: 'object' }), + }, + additionalProperties: false, +}; + +const matchContextSchema = objectSchema( + { + address: nodeAddressSchema, + snippet: { type: 'string' }, + highlightRange: rangeSchema, + textRanges: arraySchema(textAddressSchema), + }, + ['address', 'snippet', 'highlightRange'], +); + +const unknownNodeDiagnosticSchema = objectSchema( + { + message: { type: 'string' }, + address: nodeAddressSchema, + hint: { type: 'string' }, + }, + ['message'], +); + +const textSelectorSchema = objectSchema( + { + type: { const: 'text' }, + pattern: { type: 'string' }, + mode: { enum: ['contains', 'regex'] }, + caseSensitive: { type: 'boolean' }, + }, + ['type', 'pattern'], +); + +const nodeSelectorSchema = objectSchema( + { + type: { const: 'node' }, + nodeType: { enum: [...nodeTypeValues] }, + kind: { enum: ['block', 'inline'] }, + }, + ['type'], +); + +const selectorShorthandSchema = objectSchema( + { + nodeType: { enum: [...nodeTypeValues] }, + }, + ['nodeType'], +); + +const selectSchema: JsonSchema = { + anyOf: [textSelectorSchema, nodeSelectorSchema, selectorShorthandSchema], +}; + +const findInputSchema = objectSchema( + { + select: selectSchema, + within: nodeAddressSchema, + limit: { type: 'integer' }, + offset: { type: 'integer' }, + includeNodes: { type: 'boolean' }, + includeUnknown: { type: 'boolean' }, + }, + ['select'], +); + +const findOutputSchema = objectSchema( + { + matches: arraySchema(nodeAddressSchema), + total: { type: 'integer' }, + nodes: arraySchema(nodeInfoSchema), + context: arraySchema(matchContextSchema), + diagnostics: arraySchema(unknownNodeDiagnosticSchema), + }, + ['matches', 'total'], +); + +const documentInfoCountsSchema = objectSchema( + { + words: { type: 'integer' }, + paragraphs: { type: 'integer' }, + headings: { type: 'integer' }, + tables: { type: 'integer' }, + images: { type: 'integer' }, + comments: { type: 'integer' }, + }, + ['words', 'paragraphs', 'headings', 'tables', 'images', 'comments'], +); + +const documentInfoOutlineItemSchema = objectSchema( + { + level: { type: 'integer' }, + text: { type: 'string' }, + nodeId: { type: 'string' }, + }, + ['level', 'text', 'nodeId'], +); + +const documentInfoCapabilitiesSchema = objectSchema( + { + canFind: { type: 'boolean' }, + canGetNode: { type: 'boolean' }, + canComment: { type: 'boolean' }, + canReplace: { type: 'boolean' }, + }, + ['canFind', 'canGetNode', 'canComment', 'canReplace'], +); + +const documentInfoSchema = objectSchema( + { + counts: documentInfoCountsSchema, + outline: arraySchema(documentInfoOutlineItemSchema), + capabilities: documentInfoCapabilitiesSchema, + }, + ['counts', 'outline', 'capabilities'], +); + +const listKindSchema: JsonSchema = { enum: ['ordered', 'bullet'] }; +const listInsertPositionSchema: JsonSchema = { enum: ['before', 'after'] }; + +const listItemInfoSchema = objectSchema( + { + address: listItemAddressSchema, + marker: { type: 'string' }, + ordinal: { type: 'integer' }, + path: arraySchema({ type: 'integer' }), + level: { type: 'integer' }, + kind: listKindSchema, + text: { type: 'string' }, + }, + ['address'], +); + +const listsListResultSchema = objectSchema( + { + matches: arraySchema(listItemAddressSchema), + total: { type: 'integer' }, + items: arraySchema(listItemInfoSchema), + }, + ['matches', 'total', 'items'], +); + +const commentInfoSchema = objectSchema( + { + address: commentAddressSchema, + commentId: { type: 'string' }, + importedId: { type: 'string' }, + parentCommentId: { type: 'string' }, + text: { type: 'string' }, + isInternal: { type: 'boolean' }, + status: { enum: ['open', 'resolved'] }, + target: textAddressSchema, + createdTime: { type: 'number' }, + creatorName: { type: 'string' }, + creatorEmail: { type: 'string' }, + }, + ['address', 'commentId', 'status'], +); + +const commentsListResultSchema = objectSchema( + { + matches: arraySchema(commentInfoSchema), + total: { type: 'integer' }, + }, + ['matches', 'total'], +); + +const trackChangeInfoSchema = objectSchema( + { + address: trackedChangeAddressSchema, + id: { type: 'string' }, + type: { enum: ['insert', 'delete', 'format'] }, + author: { type: 'string' }, + authorEmail: { type: 'string' }, + authorImage: { type: 'string' }, + date: { type: 'string' }, + excerpt: { type: 'string' }, + }, + ['address', 'id', 'type'], +); + +const trackChangesListResultSchema = objectSchema( + { + matches: arraySchema(trackedChangeAddressSchema), + total: { type: 'integer' }, + changes: arraySchema(trackChangeInfoSchema), + }, + ['matches', 'total'], +); + +const capabilityReasonCodeSchema: JsonSchema = { + enum: [ + 'COMMAND_UNAVAILABLE', + 'OPERATION_UNAVAILABLE', + 'TRACKED_MODE_UNAVAILABLE', + 'DRY_RUN_UNAVAILABLE', + 'NAMESPACE_UNAVAILABLE', + ], +}; + +const capabilityReasonsSchema = arraySchema(capabilityReasonCodeSchema); + +const capabilityFlagSchema = objectSchema( + { + enabled: { type: 'boolean' }, + reasons: capabilityReasonsSchema, + }, + ['enabled'], +); + +const operationRuntimeCapabilitySchema = objectSchema( + { + available: { type: 'boolean' }, + tracked: { type: 'boolean' }, + dryRun: { type: 'boolean' }, + reasons: capabilityReasonsSchema, + }, + ['available', 'tracked', 'dryRun'], +); + +const operationCapabilitiesSchema = objectSchema( + Object.fromEntries(OPERATION_IDS.map((operationId) => [operationId, operationRuntimeCapabilitySchema])) as Record< + string, + JsonSchema + >, + OPERATION_IDS, +); + +const capabilitiesOutputSchema = objectSchema( + { + global: objectSchema( + { + trackChanges: capabilityFlagSchema, + comments: capabilityFlagSchema, + lists: capabilityFlagSchema, + dryRun: capabilityFlagSchema, + }, + ['trackChanges', 'comments', 'lists', 'dryRun'], + ), + operations: operationCapabilitiesSchema, + }, + ['global', 'operations'], +); + +const strictEmptyObjectSchema = objectSchema({}); + +const operationSchemas: Record = { + find: { + input: findInputSchema, + output: findOutputSchema, + }, + getNode: { + input: nodeAddressSchema, + output: nodeInfoSchema, + }, + getNodeById: { + input: objectSchema( + { + nodeId: { type: 'string' }, + nodeType: { enum: [...blockNodeTypeValues] }, + }, + ['nodeId'], + ), + output: nodeInfoSchema, + }, + getText: { + input: strictEmptyObjectSchema, + output: { type: 'string' }, + }, + info: { + input: strictEmptyObjectSchema, + output: documentInfoSchema, + }, + insert: { + input: objectSchema( + { + target: textAddressSchema, + text: { type: 'string' }, + }, + ['text'], + ), + output: textMutationResultSchemaFor('insert'), + success: textMutationSuccessSchema, + failure: textMutationFailureSchemaFor('insert'), + }, + replace: { + input: objectSchema( + { + target: textAddressSchema, + text: { type: 'string' }, + }, + ['target', 'text'], + ), + output: textMutationResultSchemaFor('replace'), + success: textMutationSuccessSchema, + failure: textMutationFailureSchemaFor('replace'), + }, + delete: { + input: objectSchema( + { + target: textAddressSchema, + }, + ['target'], + ), + output: textMutationResultSchemaFor('delete'), + success: textMutationSuccessSchema, + failure: textMutationFailureSchemaFor('delete'), + }, + 'format.bold': { + input: objectSchema( + { + target: textAddressSchema, + }, + ['target'], + ), + output: textMutationResultSchemaFor('format.bold'), + success: textMutationSuccessSchema, + failure: textMutationFailureSchemaFor('format.bold'), + }, + 'create.paragraph': { + input: objectSchema({ + at: { + oneOf: [ + objectSchema({ kind: { const: 'documentStart' } }, ['kind']), + objectSchema({ kind: { const: 'documentEnd' } }, ['kind']), + objectSchema( + { + kind: { const: 'before' }, + target: blockNodeAddressSchema, + }, + ['kind', 'target'], + ), + objectSchema( + { + kind: { const: 'after' }, + target: blockNodeAddressSchema, + }, + ['kind', 'target'], + ), + ], + }, + text: { type: 'string' }, + }), + output: createParagraphResultSchemaFor('create.paragraph'), + success: createParagraphSuccessSchema, + failure: createParagraphFailureSchemaFor('create.paragraph'), + }, + 'lists.list': { + input: objectSchema({ + within: blockNodeAddressSchema, + limit: { type: 'integer' }, + offset: { type: 'integer' }, + kind: listKindSchema, + level: { type: 'integer' }, + ordinal: { type: 'integer' }, + }), + output: listsListResultSchema, + }, + 'lists.get': { + input: objectSchema({ address: listItemAddressSchema }, ['address']), + output: listItemInfoSchema, + }, + 'lists.insert': { + input: objectSchema( + { + target: listItemAddressSchema, + position: listInsertPositionSchema, + text: { type: 'string' }, + }, + ['target', 'position'], + ), + output: listsInsertResultSchemaFor('lists.insert'), + success: listsInsertSuccessSchema, + failure: listsFailureSchemaFor('lists.insert'), + }, + 'lists.setType': { + input: objectSchema( + { + target: listItemAddressSchema, + kind: listKindSchema, + }, + ['target', 'kind'], + ), + output: listsMutateItemResultSchemaFor('lists.setType'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.setType'), + }, + 'lists.indent': { + input: objectSchema({ target: listItemAddressSchema }, ['target']), + output: listsMutateItemResultSchemaFor('lists.indent'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.indent'), + }, + 'lists.outdent': { + input: objectSchema({ target: listItemAddressSchema }, ['target']), + output: listsMutateItemResultSchemaFor('lists.outdent'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.outdent'), + }, + 'lists.restart': { + input: objectSchema({ target: listItemAddressSchema }, ['target']), + output: listsMutateItemResultSchemaFor('lists.restart'), + success: listsMutateItemSuccessSchema, + failure: listsFailureSchemaFor('lists.restart'), + }, + 'lists.exit': { + input: objectSchema({ target: listItemAddressSchema }, ['target']), + output: listsExitResultSchemaFor('lists.exit'), + success: listsExitSuccessSchema, + failure: listsFailureSchemaFor('lists.exit'), + }, + 'comments.add': { + input: objectSchema( + { + target: textAddressSchema, + text: { type: 'string' }, + }, + ['target', 'text'], + ), + output: receiptResultSchemaFor('comments.add'), + success: receiptSuccessSchema, + failure: receiptFailureResultSchemaFor('comments.add'), + }, + 'comments.edit': { + input: objectSchema( + { + commentId: { type: 'string' }, + text: { type: 'string' }, + }, + ['commentId', 'text'], + ), + output: receiptResultSchemaFor('comments.edit'), + success: receiptSuccessSchema, + failure: receiptFailureResultSchemaFor('comments.edit'), + }, + 'comments.reply': { + input: objectSchema( + { + parentCommentId: { type: 'string' }, + text: { type: 'string' }, + }, + ['parentCommentId', 'text'], + ), + output: receiptResultSchemaFor('comments.reply'), + success: receiptSuccessSchema, + failure: receiptFailureResultSchemaFor('comments.reply'), + }, + 'comments.move': { + input: objectSchema( + { + commentId: { type: 'string' }, + target: textAddressSchema, + }, + ['commentId', 'target'], + ), + output: receiptResultSchemaFor('comments.move'), + success: receiptSuccessSchema, + failure: receiptFailureResultSchemaFor('comments.move'), + }, + 'comments.resolve': { + input: objectSchema({ commentId: { type: 'string' } }, ['commentId']), + output: receiptResultSchemaFor('comments.resolve'), + success: receiptSuccessSchema, + failure: receiptFailureResultSchemaFor('comments.resolve'), + }, + 'comments.remove': { + input: objectSchema({ commentId: { type: 'string' } }, ['commentId']), + output: receiptResultSchemaFor('comments.remove'), + success: receiptSuccessSchema, + failure: receiptFailureResultSchemaFor('comments.remove'), + }, + 'comments.setInternal': { + input: objectSchema( + { + commentId: { type: 'string' }, + isInternal: { type: 'boolean' }, + }, + ['commentId', 'isInternal'], + ), + output: receiptResultSchemaFor('comments.setInternal'), + success: receiptSuccessSchema, + failure: receiptFailureResultSchemaFor('comments.setInternal'), + }, + 'comments.setActive': { + input: objectSchema({ commentId: { type: ['string', 'null'] } }, ['commentId']), + output: receiptResultSchemaFor('comments.setActive'), + success: receiptSuccessSchema, + failure: receiptFailureResultSchemaFor('comments.setActive'), + }, + 'comments.goTo': { + input: objectSchema({ commentId: { type: 'string' } }, ['commentId']), + output: receiptSuccessSchema, + }, + 'comments.get': { + input: objectSchema({ commentId: { type: 'string' } }, ['commentId']), + output: commentInfoSchema, + }, + 'comments.list': { + input: objectSchema({ includeResolved: { type: 'boolean' } }), + output: commentsListResultSchema, + }, + 'trackChanges.list': { + input: objectSchema({ + limit: { type: 'integer' }, + offset: { type: 'integer' }, + type: { enum: ['insert', 'delete', 'format'] }, + }), + output: trackChangesListResultSchema, + }, + 'trackChanges.get': { + input: objectSchema({ id: { type: 'string' } }, ['id']), + output: trackChangeInfoSchema, + }, + 'trackChanges.accept': { + input: objectSchema({ id: { type: 'string' } }, ['id']), + output: receiptResultSchemaFor('trackChanges.accept'), + success: receiptSuccessSchema, + failure: receiptFailureResultSchemaFor('trackChanges.accept'), + }, + 'trackChanges.reject': { + input: objectSchema({ id: { type: 'string' } }, ['id']), + output: receiptResultSchemaFor('trackChanges.reject'), + success: receiptSuccessSchema, + failure: receiptFailureResultSchemaFor('trackChanges.reject'), + }, + 'trackChanges.acceptAll': { + input: strictEmptyObjectSchema, + output: receiptResultSchemaFor('trackChanges.acceptAll'), + success: receiptSuccessSchema, + failure: receiptFailureResultSchemaFor('trackChanges.acceptAll'), + }, + 'trackChanges.rejectAll': { + input: strictEmptyObjectSchema, + output: receiptResultSchemaFor('trackChanges.rejectAll'), + success: receiptSuccessSchema, + failure: receiptFailureResultSchemaFor('trackChanges.rejectAll'), + }, + 'capabilities.get': { + input: strictEmptyObjectSchema, + output: capabilitiesOutputSchema, + }, +}; + +/** + * Builds the complete set of JSON Schema definitions for every document-api operation. + * + * Validates that every {@link OperationId} has a corresponding schema entry and + * that no unknown operations are present. + * + * @returns A versioned {@link InternalContractSchemas} envelope. + * @throws {Error} If any operation is missing a schema or an unknown operation is found. + */ +export function buildInternalContractSchemas(): InternalContractSchemas { + const operations = { ...operationSchemas }; + + for (const operationId of OPERATION_IDS) { + if (!operations[operationId]) { + throw new Error(`Schema generation missing operation "${operationId}".`); + } + } + + for (const operationId of Object.keys(operations) as OperationId[]) { + if (!COMMAND_CATALOG[operationId]) { + throw new Error(`Schema generation encountered unknown operation "${operationId}".`); + } + } + + return { + $schema: JSON_SCHEMA_DIALECT, + contractVersion: CONTRACT_VERSION, + operations, + }; +} diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts index 9ca1a7253..e384be339 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts @@ -40,23 +40,34 @@ function makeEditor(overrides: Partial = {}): Editor { }; const overrideCommands = (overrides.commands ?? {}) as Partial; - const overrideSchema = (overrides.schema ?? {}) as Partial; - const overrideMarks = (overrideSchema.marks ?? {}) as Record; const commands = { ...defaultCommands, ...overrideCommands, }; - const schema = { - ...overrideSchema, - marks: { - ...defaultMarks, - ...overrideMarks, - }, + // When the caller explicitly passes `schema: undefined`, respect that instead + // of constructing a default schema with marks. + const explicitUndefinedSchema = 'schema' in overrides && overrides.schema === undefined; + const overrideSchema = (overrides.schema ?? {}) as Partial; + const overrideMarks = (overrideSchema.marks ?? {}) as Record; + + const schema = explicitUndefinedSchema + ? undefined + : { + ...overrideSchema, + marks: { + ...defaultMarks, + ...overrideMarks, + }, + }; + + const defaultOptions = { + user: { name: 'Test User', email: 'test@example.com' }, }; return { + options: defaultOptions, ...overrides, commands, schema, @@ -107,6 +118,20 @@ describe('getDocumentApiCapabilities', () => { expect(capabilities.operations['create.paragraph'].dryRun).toBe(true); }); + it('reports tracked mode unavailable when no editor user is configured', () => { + const capabilities = getDocumentApiCapabilities( + makeEditor({ + options: { user: null } as unknown as Editor['options'], + }), + ); + + expect(capabilities.operations.insert.available).toBe(true); + expect(capabilities.operations.insert.tracked).toBe(false); + expect(capabilities.operations.insert.reasons).toContain('TRACKED_MODE_UNAVAILABLE'); + expect(capabilities.operations['create.paragraph'].tracked).toBe(false); + expect(capabilities.operations['create.paragraph'].reasons).toContain('TRACKED_MODE_UNAVAILABLE'); + }); + it('never reports tracked=true when the operation is unavailable', () => { const capabilities = getDocumentApiCapabilities( makeEditor({ @@ -139,7 +164,9 @@ describe('getDocumentApiCapabilities', () => { const capabilities = getDocumentApiCapabilities(editor); expect(capabilities.operations['format.bold'].available).toBe(false); - expect(capabilities.operations.insert.tracked).toBe(false); + // insert.tracked remains true because the default insertTrackedChange command + // is still present — tracked mode for insert depends on commands, not schema. + expect(capabilities.operations.insert.tracked).toBe(true); // Smoke-test: every operation has a defined entry for (const id of OPERATION_IDS) { expect(capabilities.operations[id]).toBeDefined(); diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts index a64a2fe55..5ebfa8976 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts @@ -56,6 +56,10 @@ function hasBoldCapability(editor: Editor): boolean { function hasTrackedModeCapability(editor: Editor, operationId: OperationId): boolean { if (!hasCommand(editor, 'insertTrackedChange')) return false; + // ensureTrackedCapability (mutation-helpers.ts) requires editor.options.user; + // report tracked mode as unavailable when no user is configured so capability- + // gated clients don't offer tracked actions that would deterministically fail. + if (!editor.options?.user) return false; if (operationId === 'format.bold') { return Boolean(editor.schema?.marks?.[TrackFormatMarkName]); } diff --git a/packages/super-editor/src/document-api-adapters/comments-adapter.test.ts b/packages/super-editor/src/document-api-adapters/comments-adapter.test.ts index ff58b0f85..9ec502f18 100644 --- a/packages/super-editor/src/document-api-adapters/comments-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/comments-adapter.test.ts @@ -617,3 +617,77 @@ describe('commentsAdapter additional operations', () => { expect(all.total).toBeGreaterThanOrEqual(2); }); }); + +describe('invariant: imported comment ID normalization', () => { + // These tests verify that comments with both a canonical commentId and an + // importedId (the w:id from DOCX) are treated as a single identity throughout + // the adapter. The import pipeline (prepareCommentsForImport) guarantees this + // today; these tests guard against regressions if a new code path creates + // marks or store entries with inconsistent IDs. + + it('invariant: list() returns one record when mark carries both commentId and importedId', () => { + const schema = createCommentSchema(); + const mark = schema.marks[CommentMarkName].create({ + commentId: 'canonical-uuid', + importedId: 'imported-5', + internal: false, + }); + const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello', [mark])]); + const doc = schema.node('doc', null, [paragraph]); + + const editor = createPmEditor(doc, {}, [ + { commentId: 'canonical-uuid', importedId: 'imported-5', commentText: 'Body' }, + ]); + const api = createCommentsAdapter(editor); + const result = api.list(); + + const matchingRecords = result.matches.filter( + (c) => c.commentId === 'canonical-uuid' || c.importedId === 'imported-5', + ); + expect(matchingRecords).toHaveLength(1); + expect(matchingRecords[0]!.commentId).toBe('canonical-uuid'); + }); + + it('invariant: get() by importedId returns the canonical record', () => { + const schema = createCommentSchema(); + const mark = schema.marks[CommentMarkName].create({ + commentId: 'canonical-uuid', + importedId: 'imported-5', + internal: false, + }); + const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello', [mark])]); + const doc = schema.node('doc', null, [paragraph]); + + const editor = createPmEditor(doc, {}, [ + { commentId: 'canonical-uuid', importedId: 'imported-5', commentText: 'Body' }, + ]); + const api = createCommentsAdapter(editor); + const info = api.get({ commentId: 'imported-5' }); + + expect(info.commentId).toBe('canonical-uuid'); + expect(info.target).toBeTruthy(); + }); + + it('invariant: move() passes canonical commentId to moveComment command for imported comments', () => { + const schema = createCommentSchema(); + const mark = schema.marks[CommentMarkName].create({ + commentId: 'canonical-uuid', + importedId: 'imported-5', + internal: false, + }); + const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello', [mark])]); + const doc = schema.node('doc', null, [paragraph]); + const moveComment = vi.fn(() => true); + const editor = createPmEditor(doc, { moveComment }, [ + { commentId: 'canonical-uuid', importedId: 'imported-5', commentText: 'Move me' }, + ]); + + const receipt = createCommentsAdapter(editor).move({ + commentId: 'imported-5', + target: { kind: 'text', blockId: 'p1', range: { start: 1, end: 4 } }, + }); + + expect(receipt.success).toBe(true); + expect(moveComment).toHaveBeenCalledWith(expect.objectContaining({ commentId: 'canonical-uuid' })); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/comments-adapter.ts b/packages/super-editor/src/document-api-adapters/comments-adapter.ts index d80852383..a9f376da4 100644 --- a/packages/super-editor/src/document-api-adapters/comments-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/comments-adapter.ts @@ -62,6 +62,16 @@ function isSameTarget( return left.blockId === right.blockId && left.range.start === right.range.start && left.range.end === right.range.end; } +/** + * Attempts to list comment anchors, returning an empty array on failure. + * + * listCommentAnchors walks the ProseMirror document tree and can throw when + * the document is in a transient or inconsistent state (e.g. mid-transaction, + * partially-loaded). Since this is only used by read-path aggregation + * (buildCommentInfos), returning an empty array is a safe degradation — + * callers will simply see fewer anchors rather than crashing the entire + * list/get flow. + */ function listCommentAnchorsSafe(editor: Editor): ReturnType { try { return listCommentAnchors(editor); @@ -139,17 +149,18 @@ function resolveCommentIdentity( }; } -function buildCommentInfos(editor: Editor): CommentInfo[] { - const store = getCommentEntityStore(editor); - const infosById = new Map(); - - for (const entry of store) { - const commentId = toNonEmptyString(entry.commentId) ?? toNonEmptyString(entry.importedId) ?? null; - if (!commentId) continue; - infosById.set(commentId, toCommentInfo({ ...entry, commentId })); - } - - const anchors = listCommentAnchorsSafe(editor); +/** + * Merges document anchor data into a partially-built CommentInfo map. + * + * Grouping by anchor.commentId is safe because prepareCommentsForImport always + * sets the canonical commentId on marks (comments-helpers.js:650) and rewrites + * w:id on resolved range nodes (comments-helpers.js:621,639). + * resolveCommentIdFromAttrs returns canonical commentId first, so + * anchor.commentId matches the entity store key. If a non-import path ever + * creates marks without a canonical commentId attr, this grouping would need + * alias-merging by importedId. + */ +function mergeAnchorData(infosById: Map, anchors: ReturnType): void { const grouped = new Map(); for (const anchor of anchors) { const group = grouped.get(anchor.commentId) ?? []; @@ -187,6 +198,19 @@ function buildCommentInfos(editor: Editor): CommentInfo[] { ), ); } +} + +function buildCommentInfos(editor: Editor): CommentInfo[] { + const store = getCommentEntityStore(editor); + const infosById = new Map(); + + for (const entry of store) { + const commentId = toNonEmptyString(entry.commentId) ?? toNonEmptyString(entry.importedId) ?? null; + if (!commentId) continue; + infosById.set(commentId, toCommentInfo({ ...entry, commentId })); + } + + mergeAnchorData(infosById, listCommentAnchorsSafe(editor)); const infos = Array.from(infosById.values()); infos.sort((left, right) => { @@ -456,6 +480,11 @@ function moveCommentHandler(editor: Editor, input: MoveCommentInput): Receipt { }; } + // NOTE: Passing canonical commentId is sufficient because findRangeById checks + // marks by commentId || importedId (comments-plugin.js:1058) and resolved range + // nodes have w:id rewritten to canonical id during import (comments-helpers.js:621,639). + // If a non-import path ever creates anchors keyed only by importedId, this would + // need to fall back to identity.importedId. const didMove = moveComment({ commentId: identity.commentId, from: resolved.from, diff --git a/packages/super-editor/src/document-api-adapters/helpers/inline-address-resolver.test.ts b/packages/super-editor/src/document-api-adapters/helpers/inline-address-resolver.test.ts index a7cf2eebe..3242947cf 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/inline-address-resolver.test.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/inline-address-resolver.test.ts @@ -127,4 +127,103 @@ describe('inline-address-resolver', () => { expect(bookmarks[0]!.anchor.start.offset).toBe(0); expect(bookmarks[0]!.anchor.end.offset).toBe(1); }); + + it('does not count comment range markers toward comment offsets', () => { + const commentStart = createNode('commentRangeStart', [], { + isInline: true, + isLeaf: true, + attrs: { 'w:id': 'c1' }, + }); + const textNode = createNode('text', [], { text: 'A' }); + const commentEnd = createNode('commentRangeEnd', [], { + isInline: true, + isLeaf: true, + attrs: { 'w:id': 'c1' }, + }); + const paragraph = createNode('paragraph', [commentStart, textNode, commentEnd], { + attrs: { sdBlockId: 'p3' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + + const editor = makeEditor(doc); + const blockIndex = buildBlockIndexFromParagraph(paragraph, 'p3'); + const inlineIndex = buildInlineIndex(editor, blockIndex); + + const comments = findInlineByType(inlineIndex, 'comment'); + expect(comments).toHaveLength(1); + expect(comments[0]!.anchor.start.offset).toBe(0); + expect(comments[0]!.anchor.end.offset).toBe(1); + }); + + it('does not count bookmark range markers toward subsequent offsets', () => { + const bookmarkStart = createNode('bookmarkStart', [], { + isInline: true, + isLeaf: false, + attrs: { id: 'b1', name: 'bm' }, + }); + const textA = createNode('text', [], { text: 'A' }); + const bookmarkEnd = createNode('bookmarkEnd', [], { isInline: true, isLeaf: true, attrs: { id: 'b1' } }); + const linkMark = makeMark('link', { href: 'https://example.com' }); + const textB = createNode('text', [], { text: 'B', marks: [linkMark] }); + const paragraph = createNode('paragraph', [bookmarkStart, textA, bookmarkEnd, textB], { + attrs: { sdBlockId: 'p4' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + + const editor = makeEditor(doc); + const blockIndex = buildBlockIndexFromParagraph(paragraph, 'p4'); + const inlineIndex = buildInlineIndex(editor, blockIndex); + + const bookmarks = findInlineByType(inlineIndex, 'bookmark'); + expect(bookmarks).toHaveLength(1); + expect(bookmarks[0]!.anchor.start.offset).toBe(0); + expect(bookmarks[0]!.anchor.end.offset).toBe(1); + + // "B" starts immediately after "A" at offset 1, not 2. + const hyperlinks = findInlineByType(inlineIndex, 'hyperlink'); + expect(hyperlinks).toHaveLength(1); + expect(hyperlinks[0]!.anchor.start.offset).toBe(1); + expect(hyperlinks[0]!.anchor.end.offset).toBe(2); + }); + + it('does not count comment range markers toward subsequent offsets', () => { + const commentStart = createNode('commentRangeStart', [], { + isInline: true, + isLeaf: true, + attrs: { 'w:id': 'c1' }, + }); + const textA = createNode('text', [], { text: 'A' }); + const commentEnd = createNode('commentRangeEnd', [], { + isInline: true, + isLeaf: true, + attrs: { 'w:id': 'c1' }, + }); + const linkMark = makeMark('link', { href: 'https://example.com' }); + const textB = createNode('text', [], { text: 'B', marks: [linkMark] }); + const paragraph = createNode('paragraph', [commentStart, textA, commentEnd, textB], { + attrs: { sdBlockId: 'p5' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + + const editor = makeEditor(doc); + const blockIndex = buildBlockIndexFromParagraph(paragraph, 'p5'); + const inlineIndex = buildInlineIndex(editor, blockIndex); + + const comments = findInlineByType(inlineIndex, 'comment'); + expect(comments).toHaveLength(1); + expect(comments[0]!.anchor.start.offset).toBe(0); + expect(comments[0]!.anchor.end.offset).toBe(1); + + // "B" starts immediately after "A" at offset 1, not 3. + const hyperlinks = findInlineByType(inlineIndex, 'hyperlink'); + expect(hyperlinks).toHaveLength(1); + expect(hyperlinks[0]!.anchor.start.offset).toBe(1); + expect(hyperlinks[0]!.anchor.end.offset).toBe(2); + }); }); diff --git a/packages/super-editor/src/document-api-adapters/helpers/inline-address-resolver.ts b/packages/super-editor/src/document-api-adapters/helpers/inline-address-resolver.ts index 3a0def034..daa5d34ba 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/inline-address-resolver.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/inline-address-resolver.ts @@ -19,6 +19,7 @@ const SUPPORTED_INLINE_TYPES: ReadonlySet = new Set; }; +/** Position-sorted index of inline candidates with type and anchor lookup maps. */ export type InlineIndex = { candidates: InlineCandidate[]; byType: Map; @@ -104,7 +106,8 @@ type ActiveMark = { * * **Offset model**: Text nodes contribute their UTF-16 length. Leaf atoms * (images, tabs, breaks) contribute 1. Block separators (between sibling - * blocks) contribute 1. This mirrors ProseMirror's + * blocks) contribute 1. Zero-width range delimiters (bookmarkEnd, + * commentRangeStart, commentRangeEnd) contribute 0. This mirrors ProseMirror's * `textBetween(from, to, '\n', '\ufffc')` model. * * **Mark lifecycle**: `syncMarks()` opens/closes mark spans when the active @@ -291,10 +294,13 @@ function walkNode(state: BlockWalkState, node: ProseMirrorNode, docPos: number): if (node.type?.name === 'bookmarkEnd') { handleBookmarkEnd(state, node, docPos); + return; // Zero-width range delimiter — no text offset contribution. } else if (node.type?.name === 'commentRangeStart') { handleCommentRangeStart(state, node, docPos); + return; // Zero-width range delimiter — no text offset contribution. } else if (node.type?.name === 'commentRangeEnd') { handleCommentRangeEnd(state, node, docPos); + return; // Zero-width range delimiter — no text offset contribution. } else if (!isBookmarkStart) { const nodeType = mapInlineNodeType(node); if (nodeType) { From 75078a71b1a9b2b16836a88576b3e7eb24fe95e7 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Feb 2026 13:56:42 -0800 Subject: [PATCH 13/25] feat(document-api): add contract generation/check scripts and parity bridge --- packages/document-api/scripts/README.md | 36 ++ .../scripts/check-agent-artifacts.ts | 13 + .../scripts/check-contract-outputs.ts | 45 +++ .../scripts/check-contract-parity.ts | 235 +++++++++++ .../scripts/check-doc-coverage.ts | 35 ++ .../document-api/scripts/check-examples.ts | 36 ++ .../scripts/check-generated-reference-docs.ts | 22 + .../scripts/check-overview-alignment.ts | 108 +++++ .../scripts/check-stable-schemas.ts | 13 + .../scripts/check-tool-manifests.ts | 13 + .../scripts/generate-agent-artifacts.ts | 11 + .../scripts/generate-contract-outputs.ts | 28 ++ .../scripts/generate-internal-schemas.ts | 34 ++ .../scripts/generate-reference-docs.ts | 16 + .../scripts/generate-stable-schemas.ts | 11 + .../scripts/generate-tool-manifests.ts | 11 + .../scripts/lib/contract-output-artifacts.ts | 249 ++++++++++++ .../scripts/lib/contract-snapshot.ts | 57 +++ .../scripts/lib/generation-utils.ts | 172 ++++++++ .../scripts/lib/reference-docs-artifacts.ts | 380 ++++++++++++++++++ packages/document-api/src/contract/index.ts | 2 + .../src/contract/operation-map.ts | 4 + packages/document-api/src/index.ts | 11 +- 23 files changed, 1537 insertions(+), 5 deletions(-) create mode 100644 packages/document-api/scripts/README.md create mode 100644 packages/document-api/scripts/check-agent-artifacts.ts create mode 100644 packages/document-api/scripts/check-contract-outputs.ts create mode 100644 packages/document-api/scripts/check-contract-parity.ts create mode 100644 packages/document-api/scripts/check-doc-coverage.ts create mode 100644 packages/document-api/scripts/check-examples.ts create mode 100644 packages/document-api/scripts/check-generated-reference-docs.ts create mode 100644 packages/document-api/scripts/check-overview-alignment.ts create mode 100644 packages/document-api/scripts/check-stable-schemas.ts create mode 100644 packages/document-api/scripts/check-tool-manifests.ts create mode 100644 packages/document-api/scripts/generate-agent-artifacts.ts create mode 100644 packages/document-api/scripts/generate-contract-outputs.ts create mode 100644 packages/document-api/scripts/generate-internal-schemas.ts create mode 100644 packages/document-api/scripts/generate-reference-docs.ts create mode 100644 packages/document-api/scripts/generate-stable-schemas.ts create mode 100644 packages/document-api/scripts/generate-tool-manifests.ts create mode 100644 packages/document-api/scripts/lib/contract-output-artifacts.ts create mode 100644 packages/document-api/scripts/lib/contract-snapshot.ts create mode 100644 packages/document-api/scripts/lib/generation-utils.ts create mode 100644 packages/document-api/scripts/lib/reference-docs-artifacts.ts diff --git a/packages/document-api/scripts/README.md b/packages/document-api/scripts/README.md new file mode 100644 index 000000000..9603a2e88 --- /dev/null +++ b/packages/document-api/scripts/README.md @@ -0,0 +1,36 @@ +# Document API Script Catalog + +This folder contains deterministic generator/check entry points for the Document API contract and docs. + +## Calling model + +- `generate-*` scripts write generated artifacts. +- `check-*` scripts validate generated artifacts or docs and fail with non-zero exit code on drift. +- In this repository snapshot, these scripts are not directly referenced from root `package.json` scripts or `.github/workflows`. +- Typical caller today: local ad-hoc invocations or higher-level wrappers in feature branches/CI jobs. + +## Script index + +| Script | Kind | Purpose | Reads | Writes | Typical caller | +| --- | --- | --- | --- | --- | --- | +| `check-contract-outputs.ts` | check | Full generated-output gate across schemas/manifests/agent/reference + overview block | Contract snapshot + generated roots + docs overview | None | CI/local full verification | +| `generate-contract-outputs.ts` | generate | Full regeneration across schemas/manifests/agent/reference + overview block | Contract snapshot + docs overview | `packages/document-api/generated/*`, `apps/docs/document-api/reference/*`, generated block in overview | Main local sync before commit | +| `check-stable-schemas.ts` | check | Validate stable schema artifact drift | Contract snapshot + `packages/document-api/generated/schemas` | None | Focused check during schema work | +| `generate-stable-schemas.ts` | generate | Regenerate stable schema artifacts | Contract snapshot | `packages/document-api/generated/schemas/*` | Focused schema regeneration | +| `check-tool-manifests.ts` | check | Validate tool manifest artifact drift | Contract snapshot + `packages/document-api/generated/manifests` | None | Focused manifest check | +| `generate-tool-manifests.ts` | generate | Regenerate tool manifest artifacts | Contract snapshot | `packages/document-api/generated/manifests/*` | Focused manifest regeneration | +| `check-agent-artifacts.ts` | check | Validate agent artifact drift | Contract snapshot + `packages/document-api/generated/agent` | None | Focused agent-artifact check | +| `generate-agent-artifacts.ts` | generate | Regenerate agent artifacts (remediation/workflow/compatibility) | Contract snapshot | `packages/document-api/generated/agent/*` | Focused agent-artifact regeneration | +| `check-generated-reference-docs.ts` | check | Validate generated reference docs and overview generated block drift | Contract snapshot + `apps/docs/document-api/reference` + overview | None | Focused docs generation check | +| `generate-reference-docs.ts` | generate | Regenerate generated reference docs and overview generated block | Contract snapshot + overview markers | `apps/docs/document-api/reference/*`, generated block in `apps/docs/document-api/overview.mdx` | Focused docs regeneration | +| `check-overview-alignment.ts` | check | Enforce overview quality rules (required copy/markers, forbidden placeholders, known API paths only) | `apps/docs/document-api/overview.mdx` + `DOCUMENT_API_MEMBER_PATHS` | None | Docs consistency gate | +| `check-doc-coverage.ts` | check | Ensure every operation has a `### \`\`` section in `src/README.md` | `packages/document-api/src/README.md` + `OPERATION_IDS` | None | Contract/docs coverage gate | +| `check-examples.ts` | check | Ensure required workflow example headings exist in `src/README.md` | `packages/document-api/src/README.md` | None | Docs workflow example gate | +| `check-contract-parity.ts` | check | Enforce parity between operation IDs, command catalog, maps, and runtime API member paths | `packages/document-api/src/index.js` exports + runtime API shape | None | Contract surface integrity gate | +| `generate-internal-schemas.ts` | generate | Generate internal-only operation schema snapshot | Contract snapshot + schema dialect | `packages/document-api/.generated-internal/contract-schemas/index.json` | Local tooling/debugging | + +## Recommended usage + +1. Change contract/docs sources. +2. Run the relevant `generate-*` script (or the all-in-one `generate-contract-outputs.ts`). +3. Run the matching `check-*` script (or the all-in-one `check-contract-outputs.ts`) to verify zero drift. diff --git a/packages/document-api/scripts/check-agent-artifacts.ts b/packages/document-api/scripts/check-agent-artifacts.ts new file mode 100644 index 000000000..ea46705bd --- /dev/null +++ b/packages/document-api/scripts/check-agent-artifacts.ts @@ -0,0 +1,13 @@ +/** + * Purpose: Verify generated agent artifacts match the current contract snapshot. + * Caller: Focused local/CI check; `check-contract-outputs.ts` is the broader superset. + * Reads: Contract snapshot + files under `packages/document-api/generated/agent`. + * Writes: None (exit code + console output only). + * Fails when: Expected files are missing/extra/stale. + */ +import { buildAgentArtifacts, getAgentArtifactRoot } from './lib/contract-output-artifacts.js'; +import { runArtifactCheck, runScript } from './lib/generation-utils.js'; + +runScript('agent artifacts check', () => + runArtifactCheck('agent artifacts', buildAgentArtifacts, [getAgentArtifactRoot()]), +); diff --git a/packages/document-api/scripts/check-contract-outputs.ts b/packages/document-api/scripts/check-contract-outputs.ts new file mode 100644 index 000000000..5bbd43291 --- /dev/null +++ b/packages/document-api/scripts/check-contract-outputs.ts @@ -0,0 +1,45 @@ +/** + * Purpose: Verify all contract-derived outputs are up to date. + * Caller: Main CI/local gate for generated Document API artifacts. + * Reads: Contract snapshot + generated schemas/manifests/agent artifacts/reference docs + overview. + * Writes: None (exit code + console output only). + * Fails when: Any generated output is missing/extra/stale or overview block is out of sync. + */ +import { + buildStableSchemaArtifacts, + buildToolManifestArtifacts, + buildAgentArtifacts, + getAgentArtifactRoot, + getStableSchemaRoot, + getToolManifestRoot, +} from './lib/contract-output-artifacts.js'; +import { checkGeneratedFiles, formatGeneratedCheckIssues, runScript } from './lib/generation-utils.js'; +import { + buildReferenceDocsArtifacts, + checkReferenceDocsExtras, + getReferenceDocsOutputRoot, +} from './lib/reference-docs-artifacts.js'; + +runScript('contract output artifacts check', async () => { + const files = [ + ...buildStableSchemaArtifacts(), + ...buildToolManifestArtifacts(), + ...buildAgentArtifacts(), + ...buildReferenceDocsArtifacts(), + ]; + + const issues = await checkGeneratedFiles(files, { + roots: [getStableSchemaRoot(), getToolManifestRoot(), getAgentArtifactRoot(), getReferenceDocsOutputRoot()], + }); + + await checkReferenceDocsExtras(files, issues); + + if (issues.length > 0) { + console.error('contract output artifacts check failed'); + console.error(formatGeneratedCheckIssues(issues)); + process.exitCode = 1; + return; + } + + console.log(`contract output artifacts check passed (${files.length} generated files + overview block)`); +}); diff --git a/packages/document-api/scripts/check-contract-parity.ts b/packages/document-api/scripts/check-contract-parity.ts new file mode 100644 index 000000000..387138f16 --- /dev/null +++ b/packages/document-api/scripts/check-contract-parity.ts @@ -0,0 +1,235 @@ +/** + * Purpose: Enforce parity between operation IDs, operation/member maps, and runtime API surface. + * Caller: Contract maintenance check (local or CI). + * Reads: `../src/index.js` contract metadata and runtime API shape. + * Writes: None (exit code + console output only). + * Fails when: Any catalog/map/member-path parity rule is violated. + */ +import { + COMMAND_CATALOG, + DOCUMENT_API_MEMBER_PATHS, + OPERATION_IDS, + OPERATION_MEMBER_PATH_MAP, + createDocumentApi, + isValidOperationIdFormat, + type DocumentApiAdapters, +} from '../src/index.js'; + +function collectFunctionMemberPaths(value: unknown, prefix = ''): string[] { + if (!value || typeof value !== 'object') return []; + + const paths: string[] = []; + const entries = Object.entries(value as Record).sort(([left], [right]) => left.localeCompare(right)); + + for (const [key, member] of entries) { + const path = prefix ? `${prefix}.${key}` : key; + if (typeof member === 'function') { + paths.push(path); + continue; + } + if (member && typeof member === 'object') { + paths.push(...collectFunctionMemberPaths(member, path)); + } + } + + return paths; +} + +function createNoopAdapters(): DocumentApiAdapters { + return { + find: { + find: () => ({ matches: [], total: 0 }), + }, + getNode: { + getNode: () => ({ kind: 'block', nodeType: 'paragraph', properties: {} }), + getNodeById: () => ({ kind: 'block', nodeType: 'paragraph', properties: {} }), + }, + getText: { + getText: () => '', + }, + info: { + info: () => ({ + counts: { words: 0, paragraphs: 0, headings: 0, tables: 0, images: 0, comments: 0 }, + outline: [], + capabilities: { canFind: true, canGetNode: true, canComment: true, canReplace: true }, + }), + }, + capabilities: { + get: () => ({ + global: { + trackChanges: { enabled: false }, + comments: { enabled: false }, + lists: { enabled: false }, + dryRun: { enabled: false }, + }, + operations: {} as ReturnType['operations'], + }), + }, + comments: { + add: () => ({ success: true }), + edit: () => ({ success: true }), + reply: () => ({ success: true }), + move: () => ({ success: true }), + resolve: () => ({ success: true }), + remove: () => ({ success: true }), + setInternal: () => ({ success: true }), + setActive: () => ({ success: true }), + goTo: () => ({ success: true }), + get: () => ({ + address: { kind: 'entity', entityType: 'comment', entityId: 'comment-1' }, + commentId: 'comment-1', + status: 'open', + }), + list: () => ({ matches: [], total: 0 }), + }, + write: { + write: () => ({ + success: true, + resolution: { + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 0 } }, + range: { from: 1, to: 1 }, + text: '', + }, + }), + }, + format: { + bold: () => ({ + success: true, + resolution: { + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 1 } }, + range: { from: 1, to: 2 }, + text: 'x', + }, + }), + }, + trackChanges: { + list: () => ({ matches: [], total: 0 }), + get: ({ id }) => ({ + address: { kind: 'entity', entityType: 'trackedChange', entityId: id }, + id, + type: 'insert', + }), + accept: () => ({ success: true }), + reject: () => ({ success: true }), + acceptAll: () => ({ success: true }), + rejectAll: () => ({ success: true }), + }, + create: { + paragraph: () => ({ + success: true, + paragraph: { kind: 'block', nodeType: 'paragraph', nodeId: 'p2' }, + insertionPoint: { kind: 'text', blockId: 'p2', range: { start: 0, end: 0 } }, + }), + }, + lists: { + list: () => ({ matches: [], total: 0, items: [] }), + get: () => ({ + address: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }), + insert: () => ({ + success: true, + item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-2' }, + insertionPoint: { kind: 'text', blockId: 'li-2', range: { start: 0, end: 0 } }, + }), + setType: () => ({ + success: true, + item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }), + indent: () => ({ + success: true, + item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }), + outdent: () => ({ + success: true, + item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }), + restart: () => ({ + success: true, + item: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }), + exit: () => ({ + success: true, + paragraph: { kind: 'block', nodeType: 'paragraph', nodeId: 'p3' }, + }), + }, + }; +} + +function diff(left: string[], right: string[]) { + const rightSet = new Set(right); + return left.filter((value) => !rightSet.has(value)); +} + +function run(): void { + const errors: string[] = []; + const operationIds = [...OPERATION_IDS]; + const catalogKeys = Object.keys(COMMAND_CATALOG); + const mappedKeys = Object.keys(OPERATION_MEMBER_PATH_MAP); + + const invalidFormatIds = operationIds.filter((operationId) => !isValidOperationIdFormat(operationId)); + if (invalidFormatIds.length > 0) { + errors.push(`Invalid operationId format: ${invalidFormatIds.join(', ')}`); + } + + const missingFromCatalog = diff(operationIds, catalogKeys); + const extraInCatalog = diff(catalogKeys, operationIds); + if (missingFromCatalog.length > 0 || extraInCatalog.length > 0) { + errors.push( + `COMMAND_CATALOG parity failed (missing: ${missingFromCatalog.join(', ') || 'none'}, extra: ${extraInCatalog.join(', ') || 'none'})`, + ); + } + + const missingFromMap = diff(operationIds, mappedKeys); + const extraInMap = diff(mappedKeys, operationIds); + if (missingFromMap.length > 0 || extraInMap.length > 0) { + errors.push( + `operation-map key parity failed (missing: ${missingFromMap.join(', ') || 'none'}, extra: ${extraInMap.join(', ') || 'none'})`, + ); + } + + const api = createDocumentApi(createNoopAdapters()); + const runtimeMemberPaths = collectFunctionMemberPaths(api).sort(); + const declaredMemberPaths = [...DOCUMENT_API_MEMBER_PATHS].sort(); + + const missingRuntimeMembers = diff(declaredMemberPaths, runtimeMemberPaths); + const extraRuntimeMembers = diff(runtimeMemberPaths, declaredMemberPaths); + if (missingRuntimeMembers.length > 0 || extraRuntimeMembers.length > 0) { + errors.push( + `DocumentApi member-path parity failed (missing runtime: ${missingRuntimeMembers.join(', ') || 'none'}, extra runtime: ${extraRuntimeMembers.join(', ') || 'none'})`, + ); + } + + const mappedMemberPaths = Object.values(OPERATION_MEMBER_PATH_MAP).sort(); + const missingMapMembers = diff(declaredMemberPaths, mappedMemberPaths); + const extraMapMembers = diff(mappedMemberPaths, declaredMemberPaths); + if (missingMapMembers.length > 0 || extraMapMembers.length > 0) { + errors.push( + `operation-map value parity failed (missing map values: ${missingMapMembers.join(', ') || 'none'}, extra map values: ${extraMapMembers.join(', ') || 'none'})`, + ); + } + + for (const operationId of operationIds) { + const memberPath = OPERATION_MEMBER_PATH_MAP[operationId]; + if (!declaredMemberPaths.includes(memberPath)) { + errors.push(`operationId "${operationId}" maps to undeclared member path "${memberPath}".`); + } + if (!runtimeMemberPaths.includes(memberPath)) { + errors.push(`operationId "${operationId}" maps to runtime-missing member path "${memberPath}".`); + } + } + + if (errors.length > 0) { + console.error('contract parity check failed:\n'); + for (const error of errors) { + console.error(`- ${error}`); + } + process.exitCode = 1; + return; + } + + console.log( + `contract parity check passed (${operationIds.length} operations, ${declaredMemberPaths.length} API members).`, + ); +} + +run(); diff --git a/packages/document-api/scripts/check-doc-coverage.ts b/packages/document-api/scripts/check-doc-coverage.ts new file mode 100644 index 000000000..b45983375 --- /dev/null +++ b/packages/document-api/scripts/check-doc-coverage.ts @@ -0,0 +1,35 @@ +/** + * Purpose: Ensure every operation has a dedicated section in `src/README.md`. + * Caller: Documentation quality gate for operation-level docs. + * Reads: `packages/document-api/src/README.md` + `OPERATION_IDS`. + * Writes: None (exit code + console output only). + * Fails when: Any operation ID is missing a `### \`\`` heading. + */ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { OPERATION_IDS } from '../src/index.js'; +import { runScript } from './lib/generation-utils.js'; + +const README_PATH = resolve(process.cwd(), 'packages/document-api/src/README.md'); + +function hasOperationSection(readme: string, operationId: string): boolean { + const escaped = operationId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const sectionPattern = new RegExp(`^###\\s+\`${escaped}\`\\s*$`, 'm'); + return sectionPattern.test(readme); +} + +runScript('doc coverage check', async () => { + const readme = await readFile(README_PATH, 'utf8'); + const missing = OPERATION_IDS.filter((operationId) => !hasOperationSection(readme, operationId)); + + if (missing.length > 0) { + console.error('doc coverage check failed: missing operation sections in README.md'); + for (const operationId of missing) { + console.error(`- ${operationId}`); + } + process.exitCode = 1; + return; + } + + console.log(`doc coverage check passed (${OPERATION_IDS.length} operations documented).`); +}); diff --git a/packages/document-api/scripts/check-examples.ts b/packages/document-api/scripts/check-examples.ts new file mode 100644 index 000000000..c05c80866 --- /dev/null +++ b/packages/document-api/scripts/check-examples.ts @@ -0,0 +1,36 @@ +/** + * Purpose: Ensure required workflow example headings exist in `src/README.md`. + * Caller: Documentation quality gate for canonical workflow examples. + * Reads: `packages/document-api/src/README.md`. + * Writes: None (exit code + console output only). + * Fails when: Any required workflow heading is missing. + */ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { runScript } from './lib/generation-utils.js'; + +const README_PATH = resolve(process.cwd(), 'packages/document-api/src/README.md'); + +const REQUIRED_WORKFLOW_HEADINGS = [ + '### Workflow: Find + Mutate', + '### Workflow: Tracked-Mode Insert', + '### Workflow: Comment Thread Lifecycle', + '### Workflow: List Manipulation', + '### Workflow: Capabilities-Aware Branching', +] as const; + +runScript('workflow example check', async () => { + const readme = await readFile(README_PATH, 'utf8'); + const missing = REQUIRED_WORKFLOW_HEADINGS.filter((heading) => !readme.includes(heading)); + + if (missing.length > 0) { + console.error('workflow example check failed: missing required README headings'); + for (const heading of missing) { + console.error(`- ${heading}`); + } + process.exitCode = 1; + return; + } + + console.log(`workflow example check passed (${REQUIRED_WORKFLOW_HEADINGS.length} examples found).`); +}); diff --git a/packages/document-api/scripts/check-generated-reference-docs.ts b/packages/document-api/scripts/check-generated-reference-docs.ts new file mode 100644 index 000000000..fe73fe504 --- /dev/null +++ b/packages/document-api/scripts/check-generated-reference-docs.ts @@ -0,0 +1,22 @@ +/** + * Purpose: Verify generated reference docs and related overview block are up to date. + * Caller: Focused local/CI check; `check-contract-outputs.ts` is the broader superset. + * Reads: Contract snapshot + files under `apps/docs/document-api/reference` + overview markers. + * Writes: None (exit code + console output only). + * Fails when: Generated reference docs or overview generated block drift from contract. + */ +import { + buildReferenceDocsArtifacts, + checkReferenceDocsExtras, + getReferenceDocsOutputRoot, +} from './lib/reference-docs-artifacts.js'; +import { runArtifactCheck, runScript } from './lib/generation-utils.js'; + +runScript('generated reference docs check', () => + runArtifactCheck( + 'generated reference docs', + buildReferenceDocsArtifacts, + [getReferenceDocsOutputRoot()], + checkReferenceDocsExtras, + ), +); diff --git a/packages/document-api/scripts/check-overview-alignment.ts b/packages/document-api/scripts/check-overview-alignment.ts new file mode 100644 index 000000000..92deb3127 --- /dev/null +++ b/packages/document-api/scripts/check-overview-alignment.ts @@ -0,0 +1,108 @@ +/** + * Purpose: Enforce required/forbidden overview content and API-surface path validity. + * Caller: Documentation consistency gate for `apps/docs/document-api/overview.mdx`. + * Reads: Overview doc content + `DOCUMENT_API_MEMBER_PATHS`. + * Writes: None (exit code + console output only). + * Fails when: Disclaimers/markers are missing, forbidden placeholders exist, or unknown API paths appear. + */ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { DOCUMENT_API_MEMBER_PATHS } from '../src/index.js'; +import { runScript } from './lib/generation-utils.js'; +import { + getOverviewApiSurfaceEndMarker, + getOverviewApiSurfaceStartMarker, + getOverviewDocsPath, +} from './lib/reference-docs-artifacts.js'; + +const OVERVIEW_PATH = resolve(process.cwd(), getOverviewDocsPath()); + +const REQUIRED_PATTERNS = [ + { + label: 'alpha disclaimer', + pattern: /\balpha\b/i, + }, + { + label: 'subject-to-change disclaimer', + pattern: /subject to (?:breaking )?changes?/i, + }, + { + label: 'generated reference link', + pattern: /\/document-api\/reference\/index/i, + }, + { + label: 'generated API surface start marker', + pattern: new RegExp(getOverviewApiSurfaceStartMarker().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), + }, + { + label: 'generated API surface end marker', + pattern: new RegExp(getOverviewApiSurfaceEndMarker().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), + }, +] as const; + +const FORBIDDEN_PATTERNS = [ + { + label: 'legacy placeholder query API', + pattern: /\bdoc\.query\s*\(/, + }, + { + label: 'legacy placeholder table API', + pattern: /\bdoc\.table\s*\(/, + }, + { + label: 'legacy field-annotation selector example', + pattern: /field-annotation/i, + }, + { + label: 'coming-soon placeholder copy', + pattern: /coming soon/i, + }, +] as const; + +const MEMBER_PATH_REGEX = /\beditor\.doc\.([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)/g; + +function extractOverviewMemberPaths(content: string): string[] { + const paths = new Set(); + for (const match of content.matchAll(MEMBER_PATH_REGEX)) { + const path = match[1]; + if (!path) continue; + paths.add(path); + } + return [...paths].sort(); +} + +runScript('document-api overview alignment check', async () => { + const content = await readFile(OVERVIEW_PATH, 'utf8'); + const errors: string[] = []; + + for (const requirement of REQUIRED_PATTERNS) { + if (!requirement.pattern.test(content)) { + errors.push(`missing ${requirement.label}`); + } + } + + for (const forbidden of FORBIDDEN_PATTERNS) { + if (forbidden.pattern.test(content)) { + errors.push(`contains ${forbidden.label}`); + } + } + + const knownMemberPaths = new Set(DOCUMENT_API_MEMBER_PATHS); + const overviewMemberPaths = extractOverviewMemberPaths(content); + + const unknownPaths = overviewMemberPaths.filter((path) => !knownMemberPaths.has(path)); + if (unknownPaths.length > 0) { + errors.push(`overview includes unknown Document API paths: ${unknownPaths.join(', ')}`); + } + + if (errors.length > 0) { + console.error('document-api overview alignment check failed:'); + for (const error of errors) { + console.error(`- ${error}`); + } + process.exitCode = 1; + return; + } + + console.log(`document-api overview alignment check passed (${overviewMemberPaths.length} member paths referenced).`); +}); diff --git a/packages/document-api/scripts/check-stable-schemas.ts b/packages/document-api/scripts/check-stable-schemas.ts new file mode 100644 index 000000000..18f088abf --- /dev/null +++ b/packages/document-api/scripts/check-stable-schemas.ts @@ -0,0 +1,13 @@ +/** + * Purpose: Verify generated stable schema artifacts match the current contract snapshot. + * Caller: Focused local/CI check; `check-contract-outputs.ts` is the broader superset. + * Reads: Contract snapshot + files under `packages/document-api/generated/schemas`. + * Writes: None (exit code + console output only). + * Fails when: Stable schema files are missing/extra/stale. + */ +import { buildStableSchemaArtifacts, getStableSchemaRoot } from './lib/contract-output-artifacts.js'; +import { runArtifactCheck, runScript } from './lib/generation-utils.js'; + +runScript('stable schema check', () => + runArtifactCheck('stable schema', buildStableSchemaArtifacts, [getStableSchemaRoot()]), +); diff --git a/packages/document-api/scripts/check-tool-manifests.ts b/packages/document-api/scripts/check-tool-manifests.ts new file mode 100644 index 000000000..3c348c31d --- /dev/null +++ b/packages/document-api/scripts/check-tool-manifests.ts @@ -0,0 +1,13 @@ +/** + * Purpose: Verify generated tool manifest artifacts match the current contract snapshot. + * Caller: Focused local/CI check; `check-contract-outputs.ts` is the broader superset. + * Reads: Contract snapshot + files under `packages/document-api/generated/manifests`. + * Writes: None (exit code + console output only). + * Fails when: Tool manifest files are missing/extra/stale. + */ +import { buildToolManifestArtifacts, getToolManifestRoot } from './lib/contract-output-artifacts.js'; +import { runArtifactCheck, runScript } from './lib/generation-utils.js'; + +runScript('tool manifest check', () => + runArtifactCheck('tool manifest', buildToolManifestArtifacts, [getToolManifestRoot()]), +); diff --git a/packages/document-api/scripts/generate-agent-artifacts.ts b/packages/document-api/scripts/generate-agent-artifacts.ts new file mode 100644 index 000000000..7a82ff076 --- /dev/null +++ b/packages/document-api/scripts/generate-agent-artifacts.ts @@ -0,0 +1,11 @@ +/** + * Purpose: Generate agent-facing contract artifacts from the canonical contract snapshot. + * Caller: Focused local regeneration; `generate-contract-outputs.ts` is the broader superset. + * Reads: Contract snapshot. + * Writes: `packages/document-api/generated/agent/*`. + * Output: Deterministic JSON artifacts for agent remediation/workflow/compatibility guidance. + */ +import { buildAgentArtifacts } from './lib/contract-output-artifacts.js'; +import { runArtifactGenerate, runScript } from './lib/generation-utils.js'; + +runScript('generate agent artifacts', () => runArtifactGenerate('agent artifacts', buildAgentArtifacts)); diff --git a/packages/document-api/scripts/generate-contract-outputs.ts b/packages/document-api/scripts/generate-contract-outputs.ts new file mode 100644 index 000000000..a92aa491d --- /dev/null +++ b/packages/document-api/scripts/generate-contract-outputs.ts @@ -0,0 +1,28 @@ +/** + * Purpose: Generate all contract-derived outputs in one pass. + * Caller: Main local sync command before committing contract/docs changes. + * Reads: Contract snapshot + existing overview doc markers/content. + * Writes: Stable schemas, tool manifests, agent artifacts, reference docs, and overview generated block. + * Output: Deterministic generated files aligned to the current contract. + */ +import { + buildStableSchemaArtifacts, + buildToolManifestArtifacts, + buildAgentArtifacts, +} from './lib/contract-output-artifacts.js'; +import { buildReferenceDocsArtifacts, buildOverviewArtifact } from './lib/reference-docs-artifacts.js'; +import { runScript, writeGeneratedFiles } from './lib/generation-utils.js'; + +runScript('generate contract outputs', async () => { + const overview = await buildOverviewArtifact(); + const files = [ + ...buildStableSchemaArtifacts(), + ...buildToolManifestArtifacts(), + ...buildAgentArtifacts(), + ...buildReferenceDocsArtifacts(), + overview, + ]; + + await writeGeneratedFiles(files); + console.log(`generated contract outputs (${files.length} files, including overview block)`); +}); diff --git a/packages/document-api/scripts/generate-internal-schemas.ts b/packages/document-api/scripts/generate-internal-schemas.ts new file mode 100644 index 000000000..aae6f447f --- /dev/null +++ b/packages/document-api/scripts/generate-internal-schemas.ts @@ -0,0 +1,34 @@ +/** + * Purpose: Generate an internal-only schema snapshot keyed by operation ID. + * Caller: Local tooling/debugging; not part of published/generated docs outputs. + * Reads: Contract snapshot + schema dialect from `../src/index.js`. + * Writes: `packages/document-api/.generated-internal/contract-schemas/index.json`. + * Output: Deterministic internal artifact for local inspection/tooling workflows. + */ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { JSON_SCHEMA_DIALECT } from '../src/index.js'; +import { buildContractSnapshot } from './lib/contract-snapshot.js'; +import { runScript, stableStringify } from './lib/generation-utils.js'; + +const DEFAULT_OUTPUT_PATH = resolve( + process.cwd(), + 'packages/document-api/.generated-internal/contract-schemas/index.json', +); + +runScript('generate internal contract schemas', async () => { + const outputPath = DEFAULT_OUTPUT_PATH; + const snapshot = buildContractSnapshot(); + + const artifact = { + $schema: JSON_SCHEMA_DIALECT, + contractVersion: snapshot.contractVersion, + sourceHash: snapshot.sourceHash, + operations: Object.fromEntries(snapshot.operations.map((op) => [op.operationId, op.schemas])), + }; + + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${stableStringify(artifact)}\n`, 'utf8'); + + console.log(`generated internal contract schemas at ${outputPath}`); +}); diff --git a/packages/document-api/scripts/generate-reference-docs.ts b/packages/document-api/scripts/generate-reference-docs.ts new file mode 100644 index 000000000..f93f29c71 --- /dev/null +++ b/packages/document-api/scripts/generate-reference-docs.ts @@ -0,0 +1,16 @@ +/** + * Purpose: Generate Document API reference docs and refresh the overview API-surface block. + * Caller: Focused local regeneration; `generate-contract-outputs.ts` is the broader superset. + * Reads: Contract snapshot + existing overview doc markers/content. + * Writes: `apps/docs/document-api/reference/*` + generated block in `apps/docs/document-api/overview.mdx`. + * Output: Deterministic MDX reference pages/index/manifest and synchronized overview section. + */ +import { buildReferenceDocsArtifacts, buildOverviewArtifact } from './lib/reference-docs-artifacts.js'; +import { runScript, writeGeneratedFiles } from './lib/generation-utils.js'; + +runScript('generate reference docs', async () => { + const files = buildReferenceDocsArtifacts(); + const overview = await buildOverviewArtifact(); + await writeGeneratedFiles([...files, overview]); + console.log(`generated document-api reference docs and overview block (${files.length} reference files)`); +}); diff --git a/packages/document-api/scripts/generate-stable-schemas.ts b/packages/document-api/scripts/generate-stable-schemas.ts new file mode 100644 index 000000000..8fde83bc2 --- /dev/null +++ b/packages/document-api/scripts/generate-stable-schemas.ts @@ -0,0 +1,11 @@ +/** + * Purpose: Generate stable schema artifacts from the current contract snapshot. + * Caller: Focused local regeneration; `generate-contract-outputs.ts` is the broader superset. + * Reads: Contract snapshot. + * Writes: `packages/document-api/generated/schemas/*`. + * Output: Deterministic stable schema JSON/README artifacts. + */ +import { buildStableSchemaArtifacts } from './lib/contract-output-artifacts.js'; +import { runArtifactGenerate, runScript } from './lib/generation-utils.js'; + +runScript('generate stable schemas', () => runArtifactGenerate('stable schemas', buildStableSchemaArtifacts)); diff --git a/packages/document-api/scripts/generate-tool-manifests.ts b/packages/document-api/scripts/generate-tool-manifests.ts new file mode 100644 index 000000000..53bca1106 --- /dev/null +++ b/packages/document-api/scripts/generate-tool-manifests.ts @@ -0,0 +1,11 @@ +/** + * Purpose: Generate tool manifest artifacts from the current contract snapshot. + * Caller: Focused local regeneration; `generate-contract-outputs.ts` is the broader superset. + * Reads: Contract snapshot. + * Writes: `packages/document-api/generated/manifests/*`. + * Output: Deterministic tool manifest JSON artifacts. + */ +import { buildToolManifestArtifacts } from './lib/contract-output-artifacts.js'; +import { runArtifactGenerate, runScript } from './lib/generation-utils.js'; + +runScript('generate tool manifests', () => runArtifactGenerate('tool manifests', buildToolManifestArtifacts)); diff --git a/packages/document-api/scripts/lib/contract-output-artifacts.ts b/packages/document-api/scripts/lib/contract-output-artifacts.ts new file mode 100644 index 000000000..b93925057 --- /dev/null +++ b/packages/document-api/scripts/lib/contract-output-artifacts.ts @@ -0,0 +1,249 @@ +import { buildContractSnapshot } from './contract-snapshot.js'; +import { stableStringify, type GeneratedFile } from './generation-utils.js'; + +const GENERATED_FILE_HEADER = 'GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`.\n'; + +const STABLE_SCHEMA_ROOT = 'packages/document-api/generated/schemas'; +const TOOL_MANIFEST_ROOT = 'packages/document-api/generated/manifests'; +const AGENT_ARTIFACT_ROOT = 'packages/document-api/generated/agent'; + +function buildOperationContractMap() { + const snapshot = buildContractSnapshot(); + + const operations = Object.fromEntries( + snapshot.operations.map((operation) => [ + operation.operationId, + { + memberPath: operation.memberPath, + metadata: operation.metadata, + inputSchema: operation.schemas.input, + outputSchema: operation.schemas.output, + successSchema: operation.schemas.success, + failureSchema: operation.schemas.failure, + }, + ]), + ); + + return { + contractVersion: snapshot.contractVersion, + schemaDialect: snapshot.schemaDialect, + sourceHash: snapshot.sourceHash, + operations, + }; +} + +export function buildStableSchemaArtifacts(): GeneratedFile[] { + const contractMap = buildOperationContractMap(); + + const artifact = { + $schema: contractMap.schemaDialect, + contractVersion: contractMap.contractVersion, + generatedAt: null, + sourceCommit: null, + sourceHash: contractMap.sourceHash, + operations: contractMap.operations, + }; + + return [ + { + path: `${STABLE_SCHEMA_ROOT}/document-api-contract.json`, + content: stableStringify(artifact), + }, + { + path: `${STABLE_SCHEMA_ROOT}/README.md`, + content: `# Generated Document API schemas\n\n${GENERATED_FILE_HEADER}This directory is generated from \`packages/document-api/src/contract/*\`.\n`, + }, + ]; +} + +function toToolDescription(operationId: string, mutates: boolean): string { + if (mutates) { + return `Apply Document API mutation \`${operationId}\`.`; + } + return `Read Document API data via \`${operationId}\`.`; +} + +export function buildToolManifestArtifacts(): GeneratedFile[] { + const contractMap = buildOperationContractMap(); + + const tools = Object.entries(contractMap.operations).map(([operationId, operation]) => ({ + name: operationId, + memberPath: operation.memberPath, + description: toToolDescription(operationId, operation.metadata.mutates), + mutates: operation.metadata.mutates, + idempotency: operation.metadata.idempotency, + supportsTrackedMode: operation.metadata.supportsTrackedMode, + supportsDryRun: operation.metadata.supportsDryRun, + deterministicTargetResolution: operation.metadata.deterministicTargetResolution, + preApplyThrows: operation.metadata.throws.preApply, + possibleFailureCodes: operation.metadata.possibleFailureCodes, + remediationHints: operation.metadata.remediationHints ?? [], + inputSchema: operation.inputSchema, + outputSchema: operation.outputSchema, + successSchema: operation.successSchema, + failureSchema: operation.failureSchema, + })); + + const manifest = { + contractVersion: contractMap.contractVersion, + sourceHash: contractMap.sourceHash, + generatedAt: null, + sourceCommit: null, + tools, + }; + + return [ + { + path: `${TOOL_MANIFEST_ROOT}/document-api-tools.json`, + content: stableStringify(manifest), + }, + ]; +} + +const DEFAULT_REMEDIATION_BY_CODE: Record = { + TARGET_NOT_FOUND: 'Refresh targets via find/get operations and retry with a fresh address or ID.', + COMMAND_UNAVAILABLE: 'Call capabilities.get and branch to a fallback when operation availability is false.', + TRACK_CHANGE_COMMAND_UNAVAILABLE: 'Verify track-changes support via capabilities.get before requesting tracked mode.', + CAPABILITY_UNAVAILABLE: 'Check runtime capabilities and switch to supported mode or operation.', + INVALID_TARGET: 'Confirm the target shape and operation compatibility, then retry with a valid target.', + NO_OP: 'Treat as idempotent no-op and avoid retry loops unless inputs change.', +}; + +export function buildAgentArtifacts(): GeneratedFile[] { + const contractMap = buildOperationContractMap(); + + const remediationEntries = new Map< + string, + { + code: string; + message: string; + operations: string[]; + preApplyOperations: string[]; + nonAppliedOperations: string[]; + } + >(); + + for (const [operationId, operation] of Object.entries(contractMap.operations)) { + for (const code of operation.metadata.throws.preApply) { + const entry = remediationEntries.get(code) ?? { + code, + message: DEFAULT_REMEDIATION_BY_CODE[code] ?? 'Inspect structured error details and operation capabilities.', + operations: [], + preApplyOperations: [], + nonAppliedOperations: [], + }; + entry.operations.push(operationId); + entry.preApplyOperations.push(operationId); + remediationEntries.set(code, entry); + } + + for (const code of operation.metadata.possibleFailureCodes) { + const entry = remediationEntries.get(code) ?? { + code, + message: DEFAULT_REMEDIATION_BY_CODE[code] ?? 'Inspect structured error details and operation capabilities.', + operations: [], + preApplyOperations: [], + nonAppliedOperations: [], + }; + entry.operations.push(operationId); + entry.nonAppliedOperations.push(operationId); + remediationEntries.set(code, entry); + } + } + + const remediationMap = { + contractVersion: contractMap.contractVersion, + sourceHash: contractMap.sourceHash, + entries: Array.from(remediationEntries.values()) + .map((entry) => ({ + ...entry, + operations: [...new Set(entry.operations)].sort(), + preApplyOperations: [...new Set(entry.preApplyOperations)].sort(), + nonAppliedOperations: [...new Set(entry.nonAppliedOperations)].sort(), + })) + .sort((left, right) => left.code.localeCompare(right.code)), + }; + + const workflowPlaybooks = { + contractVersion: contractMap.contractVersion, + sourceHash: contractMap.sourceHash, + workflows: [ + { + id: 'find-mutate', + title: 'Find + mutate workflow', + operations: ['find', 'replace'], + }, + { + id: 'tracked-insert', + title: 'Tracked insert workflow', + operations: ['capabilities.get', 'insert'], + }, + { + id: 'comment-thread-lifecycle', + title: 'Comment lifecycle workflow', + operations: ['comments.add', 'comments.reply', 'comments.resolve'], + }, + { + id: 'list-manipulation', + title: 'List manipulation workflow', + operations: ['lists.insert', 'lists.setType', 'lists.indent', 'lists.outdent', 'lists.exit'], + }, + { + id: 'capabilities-aware-branching', + title: 'Capabilities-aware branching workflow', + operations: ['capabilities.get', 'replace', 'insert'], + }, + { + id: 'track-change-review', + title: 'Track-change review workflow', + operations: ['trackChanges.list', 'trackChanges.accept', 'trackChanges.reject'], + }, + ], + }; + + const compatibilityHints = { + contractVersion: contractMap.contractVersion, + sourceHash: contractMap.sourceHash, + operations: Object.fromEntries( + Object.entries(contractMap.operations).map(([operationId, operation]) => [ + operationId, + { + memberPath: operation.memberPath, + mutates: operation.metadata.mutates, + supportsTrackedMode: operation.metadata.supportsTrackedMode, + supportsDryRun: operation.metadata.supportsDryRun, + requiresPreflightCapabilitiesCheck: operation.metadata.mutates, + postApplyThrowForbidden: operation.metadata.throws.postApplyForbidden, + deterministicTargetResolution: operation.metadata.deterministicTargetResolution, + }, + ]), + ), + }; + + return [ + { + path: `${AGENT_ARTIFACT_ROOT}/remediation-map.json`, + content: stableStringify(remediationMap), + }, + { + path: `${AGENT_ARTIFACT_ROOT}/workflow-playbooks.json`, + content: stableStringify(workflowPlaybooks), + }, + { + path: `${AGENT_ARTIFACT_ROOT}/compatibility-hints.json`, + content: stableStringify(compatibilityHints), + }, + ]; +} + +export function getStableSchemaRoot(): string { + return STABLE_SCHEMA_ROOT; +} + +export function getToolManifestRoot(): string { + return TOOL_MANIFEST_ROOT; +} + +export function getAgentArtifactRoot(): string { + return AGENT_ARTIFACT_ROOT; +} diff --git a/packages/document-api/scripts/lib/contract-snapshot.ts b/packages/document-api/scripts/lib/contract-snapshot.ts new file mode 100644 index 000000000..af3f4f71c --- /dev/null +++ b/packages/document-api/scripts/lib/contract-snapshot.ts @@ -0,0 +1,57 @@ +import { + COMMAND_CATALOG, + CONTRACT_VERSION, + JSON_SCHEMA_DIALECT, + OPERATION_IDS, + OPERATION_MEMBER_PATH_MAP, + buildInternalContractSchemas, + type OperationId, +} from '../../src/index.js'; +import { sha256 } from './generation-utils.js'; + +export interface ContractOperationSnapshot { + operationId: OperationId; + memberPath: string; + metadata: (typeof COMMAND_CATALOG)[keyof typeof COMMAND_CATALOG]; + schemas: ReturnType['operations'][keyof ReturnType< + typeof buildInternalContractSchemas + >['operations']]; +} + +export interface ContractSnapshot { + contractVersion: string; + schemaDialect: string; + sourceHash: string; + operations: ContractOperationSnapshot[]; +} + +let cached: ContractSnapshot | null = null; + +export function buildContractSnapshot(): ContractSnapshot { + if (cached) return cached; + + const internalSchemas = buildInternalContractSchemas(); + const operations = OPERATION_IDS.map((operationId) => ({ + operationId, + memberPath: OPERATION_MEMBER_PATH_MAP[operationId], + metadata: COMMAND_CATALOG[operationId], + schemas: internalSchemas.operations[operationId], + })); + + const sourcePayload = { + contractVersion: CONTRACT_VERSION, + schemaDialect: JSON_SCHEMA_DIALECT, + operationCatalog: COMMAND_CATALOG, + operationMap: OPERATION_MEMBER_PATH_MAP, + schemas: internalSchemas.operations, + }; + + cached = { + contractVersion: CONTRACT_VERSION, + schemaDialect: JSON_SCHEMA_DIALECT, + sourceHash: sha256(sourcePayload), + operations, + }; + + return cached; +} diff --git a/packages/document-api/scripts/lib/generation-utils.ts b/packages/document-api/scripts/lib/generation-utils.ts new file mode 100644 index 000000000..5ca44278b --- /dev/null +++ b/packages/document-api/scripts/lib/generation-utils.ts @@ -0,0 +1,172 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; + +export interface GeneratedFile { + path: string; + content: string; +} + +export interface GeneratedCheckIssue { + kind: 'missing' | 'extra' | 'content'; + path: string; +} + +export function stableSort(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((entry) => stableSort(entry)); + } + + if (value && typeof value === 'object') { + const sortedEntries = Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => [key, stableSort(nested)]); + return Object.fromEntries(sortedEntries); + } + + return value; +} + +export function stableStringify(value: unknown): string { + return JSON.stringify(stableSort(value), null, 2); +} + +export function sha256(value: unknown): string { + const payload = typeof value === 'string' ? value : stableStringify(value); + return createHash('sha256').update(payload, 'utf8').digest('hex'); +} + +export function normalizeFileContent(content: string): string { + return content.endsWith('\n') ? content : `${content}\n`; +} + +export function resolveWorkspacePath(path: string): string { + return resolve(process.cwd(), path); +} + +export async function writeGeneratedFiles(files: GeneratedFile[]): Promise { + for (const file of files) { + const absolutePath = resolveWorkspacePath(file.path); + await mkdir(dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, normalizeFileContent(file.content), 'utf8'); + } +} + +async function listFilesRecursive(root: string): Promise { + const absoluteRoot = resolveWorkspacePath(root); + const entries = await readdir(absoluteRoot, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + const relativePath = `${root}/${entry.name}`; + if (entry.isDirectory()) { + files.push(...(await listFilesRecursive(relativePath))); + continue; + } + files.push(relativePath); + } + + return files; +} + +async function pathExists(path: string): Promise { + try { + await stat(resolveWorkspacePath(path)); + return true; + } catch { + return false; + } +} + +export async function checkGeneratedFiles( + expectedFiles: GeneratedFile[], + options: { + roots?: string[]; + } = {}, +): Promise { + const issues: GeneratedCheckIssue[] = []; + const expected = new Map(expectedFiles.map((file) => [file.path, normalizeFileContent(file.content)])); + + for (const [path, expectedContent] of expected.entries()) { + if (!(await pathExists(path))) { + issues.push({ kind: 'missing', path }); + continue; + } + + const actualContent = await readFile(resolveWorkspacePath(path), 'utf8'); + if (actualContent !== expectedContent) { + issues.push({ kind: 'content', path }); + } + } + + const roots = options.roots ?? []; + const actualFiles = new Set(); + + for (const root of roots) { + if (!(await pathExists(root))) continue; + const rootFiles = await listFilesRecursive(root); + for (const path of rootFiles) { + actualFiles.add(path); + } + } + + for (const path of actualFiles) { + if (!expected.has(path)) { + issues.push({ kind: 'extra', path }); + } + } + + return issues.sort((left, right) => { + if (left.kind !== right.kind) return left.kind.localeCompare(right.kind); + return left.path.localeCompare(right.path); + }); +} + +export function formatGeneratedCheckIssues(issues: GeneratedCheckIssue[]): string { + if (issues.length === 0) return ''; + + return issues + .map((issue) => { + if (issue.kind === 'missing') return `missing generated file: ${issue.path}`; + if (issue.kind === 'extra') return `unexpected generated file: ${issue.path}`; + return `stale generated file content: ${issue.path}`; + }) + .join('\n'); +} + +export async function runArtifactCheck( + label: string, + buildFiles: () => GeneratedFile[], + roots: string[], + extraChecks?: (files: GeneratedFile[], issues: GeneratedCheckIssue[]) => Promise, +): Promise { + const files = buildFiles(); + const issues = await checkGeneratedFiles(files, { roots }); + + if (extraChecks) { + await extraChecks(files, issues); + } + + if (issues.length > 0) { + console.error(`${label} check failed`); + console.error(formatGeneratedCheckIssues(issues)); + process.exitCode = 1; + return; + } + + console.log(`${label} check passed (${files.length} files)`); +} + +export async function runArtifactGenerate(label: string, buildFiles: () => GeneratedFile[]): Promise { + const files = buildFiles(); + await writeGeneratedFiles(files); + console.log(`generated ${label} (${files.length} files)`); +} + +export function runScript(label: string, fn: () => Promise): void { + fn().catch((error) => { + console.error(`${label} failed with an unexpected error`); + console.error(error); + process.exitCode = 1; + }); +} diff --git a/packages/document-api/scripts/lib/reference-docs-artifacts.ts b/packages/document-api/scripts/lib/reference-docs-artifacts.ts new file mode 100644 index 000000000..0f6746ccc --- /dev/null +++ b/packages/document-api/scripts/lib/reference-docs-artifacts.ts @@ -0,0 +1,380 @@ +import { readFile } from 'node:fs/promises'; +import { posix as pathPosix } from 'node:path'; +import type { ContractOperationSnapshot } from './contract-snapshot.js'; +import { buildContractSnapshot } from './contract-snapshot.js'; +import { + resolveWorkspacePath, + stableStringify, + type GeneratedCheckIssue, + type GeneratedFile, +} from './generation-utils.js'; +import { + OPERATION_REFERENCE_DOC_PATH_MAP, + REFERENCE_OPERATION_GROUPS, + type ReferenceOperationGroupDefinition, +} from '../../src/index.js'; + +const GENERATED_MARKER = '{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}'; +const OUTPUT_ROOT = 'apps/docs/document-api/reference'; +const REFERENCE_INDEX_PATH = `${OUTPUT_ROOT}/index.mdx`; +const OVERVIEW_PATH = 'apps/docs/document-api/overview.mdx'; +const OVERVIEW_API_SURFACE_START = '{/* DOC_API_GENERATED_API_SURFACE_START */}'; +const OVERVIEW_API_SURFACE_END = '{/* DOC_API_GENERATED_API_SURFACE_END */}'; + +interface OperationGroup { + definition: ReferenceOperationGroupDefinition; + pagePath: string; + operations: ContractOperationSnapshot[]; +} + +function formatMemberPath(memberPath: string): string { + return `editor.doc.${memberPath}${memberPath === 'capabilities' ? '()' : '(...)'}`; +} + +function toOperationDocPath(operationId: ContractOperationSnapshot['operationId']): string { + return `${OUTPUT_ROOT}/${OPERATION_REFERENCE_DOC_PATH_MAP[operationId]}`; +} + +function toGroupPath(group: ReferenceOperationGroupDefinition): string { + return `${OUTPUT_ROOT}/${group.pagePath}`; +} + +function toRelativeDocHref(fromPath: string, toPath: string): string { + const fromDir = pathPosix.dirname(fromPath); + const relativePath = pathPosix.relative(fromDir, toPath).replace(/\.mdx$/u, ''); + return relativePath.startsWith('.') ? relativePath : `./${relativePath}`; +} + +function toPublicDocHref(path: string): string { + return `/${path.replace(/^apps\/docs\//u, '').replace(/\.mdx$/u, '')}`; +} + +function renderList(values: readonly string[]): string { + if (values.length === 0) return '- None'; + return values.map((value) => `- \`${value}\``).join('\n'); +} + +function buildOperationGroups(operations: ContractOperationSnapshot[]): OperationGroup[] { + const operationById = new Map(operations.map((operation) => [operation.operationId, operation] as const)); + + return REFERENCE_OPERATION_GROUPS.map((definition) => { + const groupedOperations = definition.operations.map((operationId) => { + const operation = operationById.get(operationId); + if (!operation) { + throw new Error(`Missing operation snapshot for "${operationId}" in reference docs generation.`); + } + return operation; + }); + + return { + definition, + pagePath: toGroupPath(definition), + operations: groupedOperations, + }; + }); +} + +function renderOperationPage(operation: ContractOperationSnapshot): string { + const title = operation.operationId; + const metadata = operation.metadata; + + return `--- +title: ${title} +sidebarTitle: ${title} +description: Generated reference for ${title} +--- + +${GENERATED_MARKER} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: \`${operation.operationId}\` +- API member path: \`${formatMemberPath(operation.memberPath)}\` +- Mutates document: \`${metadata.mutates ? 'yes' : 'no'}\` +- Idempotency: \`${metadata.idempotency}\` +- Supports tracked mode: \`${metadata.supportsTrackedMode ? 'yes' : 'no'}\` +- Supports dry run: \`${metadata.supportsDryRun ? 'yes' : 'no'}\` +- Deterministic target resolution: \`${metadata.deterministicTargetResolution ? 'yes' : 'no'}\` + +## Pre-apply throws + +${renderList(metadata.throws.preApply)} + +## Non-applied failure codes + +${renderList(metadata.possibleFailureCodes)} + +## Input schema + +\`\`\`json +${stableStringify(operation.schemas.input)} +\`\`\` + +## Output schema + +\`\`\`json +${stableStringify(operation.schemas.output)} +\`\`\` +${ + operation.schemas.success + ? ` +## Success schema + +\`\`\`json +${stableStringify(operation.schemas.success)} +\`\`\` +` + : '' +}${ + operation.schemas.failure + ? ` +## Failure schema + +\`\`\`json +${stableStringify(operation.schemas.failure)} +\`\`\` +` + : '' + }${ + metadata.remediationHints && metadata.remediationHints.length > 0 + ? ` +## Remediation hints + +${renderList(metadata.remediationHints)} +` + : '' + }`; +} + +function renderGroupIndex(group: OperationGroup): string { + const rows = group.operations + .map((operation) => { + const metadata = operation.metadata; + return `| [\`${operation.operationId}\`](${toRelativeDocHref(group.pagePath, toOperationDocPath(operation.operationId))}) | \`${operation.memberPath}\` | ${metadata.mutates ? 'Yes' : 'No'} | \`${metadata.idempotency}\` | ${metadata.supportsTrackedMode ? 'Yes' : 'No'} | ${metadata.supportsDryRun ? 'Yes' : 'No'} |`; + }) + .join('\n'); + + return `--- +title: ${group.definition.title} operations +sidebarTitle: ${group.definition.title} +description: Generated ${group.definition.title} operation reference from the canonical Document API contract. +--- + +${GENERATED_MARKER} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +[Back to full reference](${toRelativeDocHref(group.pagePath, REFERENCE_INDEX_PATH)}) + +${group.definition.description} + +| Operation | Member path | Mutates | Idempotency | Tracked | Dry run | +| --- | --- | --- | --- | --- | --- | +${rows} +`; +} + +function renderReferenceIndex(operations: ContractOperationSnapshot[], groups: OperationGroup[]): string { + const groupRows = groups + .map((group) => { + return `| ${group.definition.title} | ${group.operations.length} | [Open](${toRelativeDocHref(REFERENCE_INDEX_PATH, group.pagePath)}) |`; + }) + .join('\n'); + + const operationGroupTitleById = new Map(); + for (const group of groups) { + for (const operation of group.operations) { + operationGroupTitleById.set(operation.operationId, group.definition.title); + } + } + + const operationRows = operations + .map((operation) => { + const metadata = operation.metadata; + const groupTitle = operationGroupTitleById.get(operation.operationId) ?? 'Unknown'; + return `| [\`${operation.operationId}\`](${toRelativeDocHref(REFERENCE_INDEX_PATH, toOperationDocPath(operation.operationId))}) | ${groupTitle} | \`${operation.memberPath}\` | ${metadata.mutates ? 'Yes' : 'No'} | \`${metadata.idempotency}\` | ${metadata.supportsTrackedMode ? 'Yes' : 'No'} | ${metadata.supportsDryRun ? 'Yes' : 'No'} |`; + }) + .join('\n'); + + return `--- +title: Document API reference +sidebarTitle: Reference +description: Generated operation reference from the canonical Document API contract. +--- + +${GENERATED_MARKER} + +This reference is generated from \`packages/document-api/src/contract/*\`. +Document API is currently alpha and subject to breaking changes. + +## Browse by namespace + +| Namespace | Operations | Reference | +| --- | --- | --- | +${groupRows} + +## All operations + +| Operation | Namespace | Member path | Mutates | Idempotency | Tracked | Dry run | +| --- | --- | --- | --- | --- | --- | --- | +${operationRows} +`; +} + +function renderOverviewApiSurfaceSection(operations: ContractOperationSnapshot[], groups: OperationGroup[]): string { + const namespaceRows = groups + .map((group) => { + return `| ${group.definition.title} | ${group.operations.length} | [Reference](${toPublicDocHref(group.pagePath)}) |`; + }) + .join('\n'); + + const operationRows = operations + .map((operation) => { + return `| \`${formatMemberPath(operation.memberPath)}\` | [\`${operation.operationId}\`](${toPublicDocHref(toOperationDocPath(operation.operationId))}) |`; + }) + .join('\n'); + + return `${OVERVIEW_API_SURFACE_START} +### Current API surface (generated) + +This section is generated from the canonical contract. Update the contract, then run \`pnpm run docapi:sync\`. + +| Namespace | Operations | Reference | +| --- | --- | --- | +${namespaceRows} + +| API member path | Operation ID | +| --- | --- | +${operationRows} +${OVERVIEW_API_SURFACE_END}`; +} + +function replaceOverviewSection(content: string, section: string): string { + const startIndex = content.indexOf(OVERVIEW_API_SURFACE_START); + const endIndex = content.indexOf(OVERVIEW_API_SURFACE_END); + + if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) { + throw new Error( + `overview marker block not found in ${OVERVIEW_PATH}. Expected ${OVERVIEW_API_SURFACE_START} ... ${OVERVIEW_API_SURFACE_END}.`, + ); + } + + const endMarkerEndIndex = endIndex + OVERVIEW_API_SURFACE_END.length; + return `${content.slice(0, startIndex)}${section}${content.slice(endMarkerEndIndex)}`; +} + +export function applyGeneratedOverviewApiSurface(overviewContent: string): string { + const snapshot = buildContractSnapshot(); + const groups = buildOperationGroups(snapshot.operations); + const section = renderOverviewApiSurfaceSection(snapshot.operations, groups); + return replaceOverviewSection(overviewContent, section); +} + +export async function buildOverviewArtifact(): Promise { + const overviewPath = OVERVIEW_PATH; + const currentOverview = await readFile(resolveWorkspacePath(overviewPath), 'utf8'); + const nextOverview = applyGeneratedOverviewApiSurface(currentOverview); + return { path: overviewPath, content: nextOverview }; +} + +export function buildReferenceDocsArtifacts(): GeneratedFile[] { + const snapshot = buildContractSnapshot(); + const groups = buildOperationGroups(snapshot.operations); + + const operationFiles = snapshot.operations.map((operation) => ({ + path: toOperationDocPath(operation.operationId), + content: renderOperationPage(operation), + })); + + const groupFiles = groups.map((group) => ({ + path: group.pagePath, + content: renderGroupIndex(group), + })); + + const allFiles = [ + { + path: REFERENCE_INDEX_PATH, + content: renderReferenceIndex(snapshot.operations, groups), + }, + ...groupFiles, + ...operationFiles, + ]; + + const manifest = { + generatedBy: 'packages/document-api/scripts/generate-reference-docs.ts', + marker: GENERATED_MARKER, + contractVersion: snapshot.contractVersion, + sourceHash: snapshot.sourceHash, + groups: groups.map((group) => ({ + key: group.definition.key, + title: group.definition.title, + pagePath: group.pagePath, + operationIds: group.operations.map((operation) => operation.operationId), + })), + files: allFiles.map((file) => file.path).sort(), + }; + + return [ + ...allFiles, + { + path: `${OUTPUT_ROOT}/_generated-manifest.json`, + content: stableStringify(manifest), + }, + ]; +} + +/** + * Checks that generated `.mdx` files contain the generated marker and that + * the overview doc's API-surface block is up to date. Skips files already + * present in {@link existingIssuePaths} to avoid duplicate reports. + */ +export async function checkReferenceDocsExtras(files: GeneratedFile[], issues: GeneratedCheckIssue[]): Promise { + const existingIssuePaths = new Set(issues.map((issue) => issue.path)); + + for (const file of files) { + if (!file.path.endsWith('.mdx') || existingIssuePaths.has(file.path)) continue; + const content = await readFile(resolveWorkspacePath(file.path), 'utf8').catch(() => null); + if (content == null || !content.includes(GENERATED_MARKER)) { + issues.push({ kind: 'content', path: file.path }); + } + } + + const overviewPath = OVERVIEW_PATH; + if (existingIssuePaths.has(overviewPath)) return; + + const overviewContent = await readFile(resolveWorkspacePath(overviewPath), 'utf8').catch(() => null); + if (overviewContent == null) { + issues.push({ kind: 'missing', path: overviewPath }); + } else { + try { + const expectedOverview = applyGeneratedOverviewApiSurface(overviewContent); + if (expectedOverview !== overviewContent) { + issues.push({ kind: 'content', path: overviewPath }); + } + } catch { + issues.push({ kind: 'content', path: overviewPath }); + } + } +} + +export function getReferenceDocsOutputRoot(): string { + return OUTPUT_ROOT; +} + +export function getReferenceDocsGeneratedMarker(): string { + return GENERATED_MARKER; +} + +export function getOverviewDocsPath(): string { + return OVERVIEW_PATH; +} + +export function getOverviewApiSurfaceStartMarker(): string { + return OVERVIEW_API_SURFACE_START; +} + +export function getOverviewApiSurfaceEndMarker(): string { + return OVERVIEW_API_SURFACE_END; +} diff --git a/packages/document-api/src/contract/index.ts b/packages/document-api/src/contract/index.ts index 1c3951c55..45de2e9d5 100644 --- a/packages/document-api/src/contract/index.ts +++ b/packages/document-api/src/contract/index.ts @@ -1,3 +1,5 @@ export * from './types.js'; export * from './command-catalog.js'; export * from './schemas.js'; +export * from './operation-map.js'; +export * from './reference-doc-map.js'; diff --git a/packages/document-api/src/contract/operation-map.ts b/packages/document-api/src/contract/operation-map.ts index 04f90f4c7..e371f9c09 100644 --- a/packages/document-api/src/contract/operation-map.ts +++ b/packages/document-api/src/contract/operation-map.ts @@ -17,3 +17,7 @@ export function memberPathForOperation(operationId: OperationId): string { export const DOCUMENT_API_MEMBER_PATHS = [...new Set(OPERATION_IDS.map(memberPathForOperation))] as const; export type DocumentApiMemberPath = (typeof DOCUMENT_API_MEMBER_PATHS)[number]; + +export const OPERATION_MEMBER_PATH_MAP = Object.fromEntries( + OPERATION_IDS.map((operationId) => [operationId, memberPathForOperation(operationId)]), +) as Record; diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index e849f1044..eb187355e 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -266,6 +266,11 @@ export interface DocumentApiAdapters { * ``` */ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { + const capabilities = (() => executeCapabilities(adapters.capabilities)) as (() => DocumentApiCapabilities) & { + get: () => DocumentApiCapabilities; + }; + capabilities.get = capabilities; + return { find(selectorOrQuery: Selector | Query, options?: FindOptions): QueryResult { return executeFind(adapters.find, selectorOrQuery, options); @@ -356,11 +361,7 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { return executeCreateParagraph(adapters.create, input, options); }, }, - capabilities: { - get(): DocumentApiCapabilities { - return executeCapabilities(adapters.capabilities); - }, - }, + capabilities, lists: { list(query?: ListsListQuery): ListsListResult { return executeListsList(adapters.lists, query); From 469784104fcece385e30bd0b3e0fa246c5e1839b Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Feb 2026 14:16:59 -0800 Subject: [PATCH 14/25] chore(document-api): document generated vs manual file ownership boundaries --- apps/docs/CLAUDE.md | 17 + apps/docs/docs.json | 2 +- apps/docs/document-api/overview.mdx | 182 +- .../reference/_generated-manifest.json | 124 + .../reference/capabilities/get.mdx | 1356 ++ .../reference/capabilities/index.mdx | 17 + .../document-api/reference/comments/add.mdx | 472 + .../document-api/reference/comments/edit.mdx | 439 + .../document-api/reference/comments/get.mdx | 143 + .../document-api/reference/comments/go-to.mdx | 204 + .../document-api/reference/comments/index.mdx | 27 + .../document-api/reference/comments/list.mdx | 156 + .../document-api/reference/comments/move.mdx | 472 + .../reference/comments/remove.mdx | 435 + .../document-api/reference/comments/reply.mdx | 439 + .../reference/comments/resolve.mdx | 435 + .../reference/comments/set-active.mdx | 438 + .../reference/comments/set-internal.mdx | 442 + .../document-api/reference/core/index.mdx | 24 + .../document-api/reference/create/index.mdx | 17 + .../reference/create/paragraph.mdx | 419 + apps/docs/document-api/reference/delete.mdx | 853 ++ apps/docs/document-api/reference/find.mdx | 734 ++ .../document-api/reference/format/bold.mdx | 854 ++ .../document-api/reference/format/index.mdx | 17 + .../document-api/reference/get-node-by-id.mdx | 129 + apps/docs/document-api/reference/get-node.mdx | 207 + apps/docs/document-api/reference/get-text.mdx | 45 + apps/docs/document-api/reference/index.mdx | 63 + apps/docs/document-api/reference/info.mdx | 132 + apps/docs/document-api/reference/insert.mdx | 859 ++ .../document-api/reference/lists/exit.mdx | 213 + .../docs/document-api/reference/lists/get.mdx | 119 + .../document-api/reference/lists/indent.mdx | 216 + .../document-api/reference/lists/index.mdx | 24 + .../document-api/reference/lists/insert.mdx | 337 + .../document-api/reference/lists/list.mdx | 183 + .../document-api/reference/lists/outdent.mdx | 216 + .../document-api/reference/lists/restart.mdx | 216 + .../document-api/reference/lists/set-type.mdx | 223 + apps/docs/document-api/reference/replace.mdx | 860 ++ .../reference/track-changes/accept-all.mdx | 427 + .../reference/track-changes/accept.mdx | 435 + .../reference/track-changes/get.mdx | 105 + .../reference/track-changes/index.mdx | 22 + .../reference/track-changes/list.mdx | 151 + .../reference/track-changes/reject-all.mdx | 427 + .../reference/track-changes/reject.mdx | 435 + packages/document-api/README.md | 35 + .../generated/agent/compatibility-hints.json | 330 + .../generated/agent/remediation-map.json | 292 + .../generated/agent/workflow-playbooks.json | 36 + .../manifests/document-api-tools.json | 10705 +++++++++++++++ .../document-api/generated/schemas/README.md | 4 + .../schemas/document-api-contract.json | 10778 ++++++++++++++++ packages/document-api/scripts/README.md | 13 + .../scripts/lib/reference-docs-artifacts.ts | 26 +- packages/document-api/src/README.md | 12 + .../src/contract/command-catalog.ts | 7 +- .../src/contract/contract.test.ts | 11 + packages/document-api/src/index.test.ts | 39 + packages/document-api/src/index.ts | 22 +- 62 files changed, 36933 insertions(+), 139 deletions(-) create mode 100644 apps/docs/document-api/reference/_generated-manifest.json create mode 100644 apps/docs/document-api/reference/capabilities/get.mdx create mode 100644 apps/docs/document-api/reference/capabilities/index.mdx create mode 100644 apps/docs/document-api/reference/comments/add.mdx create mode 100644 apps/docs/document-api/reference/comments/edit.mdx create mode 100644 apps/docs/document-api/reference/comments/get.mdx create mode 100644 apps/docs/document-api/reference/comments/go-to.mdx create mode 100644 apps/docs/document-api/reference/comments/index.mdx create mode 100644 apps/docs/document-api/reference/comments/list.mdx create mode 100644 apps/docs/document-api/reference/comments/move.mdx create mode 100644 apps/docs/document-api/reference/comments/remove.mdx create mode 100644 apps/docs/document-api/reference/comments/reply.mdx create mode 100644 apps/docs/document-api/reference/comments/resolve.mdx create mode 100644 apps/docs/document-api/reference/comments/set-active.mdx create mode 100644 apps/docs/document-api/reference/comments/set-internal.mdx create mode 100644 apps/docs/document-api/reference/core/index.mdx create mode 100644 apps/docs/document-api/reference/create/index.mdx create mode 100644 apps/docs/document-api/reference/create/paragraph.mdx create mode 100644 apps/docs/document-api/reference/delete.mdx create mode 100644 apps/docs/document-api/reference/find.mdx create mode 100644 apps/docs/document-api/reference/format/bold.mdx create mode 100644 apps/docs/document-api/reference/format/index.mdx create mode 100644 apps/docs/document-api/reference/get-node-by-id.mdx create mode 100644 apps/docs/document-api/reference/get-node.mdx create mode 100644 apps/docs/document-api/reference/get-text.mdx create mode 100644 apps/docs/document-api/reference/index.mdx create mode 100644 apps/docs/document-api/reference/info.mdx create mode 100644 apps/docs/document-api/reference/insert.mdx create mode 100644 apps/docs/document-api/reference/lists/exit.mdx create mode 100644 apps/docs/document-api/reference/lists/get.mdx create mode 100644 apps/docs/document-api/reference/lists/indent.mdx create mode 100644 apps/docs/document-api/reference/lists/index.mdx create mode 100644 apps/docs/document-api/reference/lists/insert.mdx create mode 100644 apps/docs/document-api/reference/lists/list.mdx create mode 100644 apps/docs/document-api/reference/lists/outdent.mdx create mode 100644 apps/docs/document-api/reference/lists/restart.mdx create mode 100644 apps/docs/document-api/reference/lists/set-type.mdx create mode 100644 apps/docs/document-api/reference/replace.mdx create mode 100644 apps/docs/document-api/reference/track-changes/accept-all.mdx create mode 100644 apps/docs/document-api/reference/track-changes/accept.mdx create mode 100644 apps/docs/document-api/reference/track-changes/get.mdx create mode 100644 apps/docs/document-api/reference/track-changes/index.mdx create mode 100644 apps/docs/document-api/reference/track-changes/list.mdx create mode 100644 apps/docs/document-api/reference/track-changes/reject-all.mdx create mode 100644 apps/docs/document-api/reference/track-changes/reject.mdx create mode 100644 packages/document-api/README.md create mode 100644 packages/document-api/generated/agent/compatibility-hints.json create mode 100644 packages/document-api/generated/agent/remediation-map.json create mode 100644 packages/document-api/generated/agent/workflow-playbooks.json create mode 100644 packages/document-api/generated/manifests/document-api-tools.json create mode 100644 packages/document-api/generated/schemas/README.md create mode 100644 packages/document-api/generated/schemas/document-api-contract.json diff --git a/apps/docs/CLAUDE.md b/apps/docs/CLAUDE.md index 97deaf868..29e3d3d39 100644 --- a/apps/docs/CLAUDE.md +++ b/apps/docs/CLAUDE.md @@ -15,6 +15,23 @@ When moving or renaming a page, always add a redirect in `docs.json`: } ``` +## Document API generation boundary + +Document API docs have mixed manual/generated ownership. Treat these paths as authoritative: + +- `apps/docs/document-api/reference/*`: generated, commit to git, do not hand-edit. +- `packages/document-api/generated/*`: generated, commit to git, do not hand-edit. +- `apps/docs/document-api/overview.mdx`: manual except for the block between: + - `/* DOC_API_GENERATED_API_SURFACE_START */` + - `/* DOC_API_GENERATED_API_SURFACE_END */` + +To refresh generated content: + +```bash +pnpm exec tsx packages/document-api/scripts/generate-contract-outputs.ts +pnpm exec tsx packages/document-api/scripts/check-contract-outputs.ts +``` + ## Brand voice One personality, two registers. SuperDoc is the same person in every conversation — warm, clear, technically confident. It adjusts **what it emphasizes** based on who's listening. Developers hear about the how. Leaders hear about the why. diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 1945507d1..9525d0568 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -91,7 +91,7 @@ }, { "group": "Document API", - "tag": "SOON", + "tag": "ALPHA", "pages": ["document-api/overview"] }, { diff --git a/apps/docs/document-api/overview.mdx b/apps/docs/document-api/overview.mdx index f786ba50d..e457dfe51 100644 --- a/apps/docs/document-api/overview.mdx +++ b/apps/docs/document-api/overview.mdx @@ -5,122 +5,74 @@ description: A stable, engine-agnostic interface for programmatic document acces keywords: "document api, programmatic access, query documents, document manipulation, headless docx" --- -The Document API is a new way to interact with documents programmatically. Query content, make changes, and build automations — all without touching editor internals. +Document API gives you a consistent way to read and edit documents without relying on editor internals. -**Coming Soon** — The Document API is currently in development. This page previews what's coming. +Document API is in alpha and subject to breaking changes while the contract and adapters continue to evolve. -## Why Document API? - -Today, programmatic access requires using internal editor methods: - -```javascript -// Current approach - uses internal APIs -editor.commands.insertContent(content); -editor.state.doc.descendants((node) => { ... }); -``` - -This works, but: -- Internal APIs can change between versions -- Requires understanding ProseMirror internals -- Tightly coupled to the editor implementation - -## What's coming - -The Document API provides a stable, high-level interface: - -```javascript -// Document API - stable public interface -const paragraphs = doc.query({ type: 'paragraph' }); -const tables = doc.query({ type: 'table', contains: 'Revenue' }); - -doc.replace(paragraphs[0], { text: 'New content' }); -``` - - - - Find content by type, attributes, or text. Filter tables, paragraphs, lists — anything in the document. - - - Public API that won't break between versions. Build with confidence. - - - Works the same whether you're in the browser, Node.js, or headless mode. - - - Full TypeScript support with autocomplete and type checking. - - - -## Feature preview - -### Querying content - -Find any content in your document: - -```javascript -// Find all paragraphs -const paragraphs = doc.query({ type: 'paragraph' }); - -// Find tables containing specific text -const tables = doc.query({ - type: 'table', - contains: 'Q4 Revenue' -}); - -// Find content by attributes -const signatures = doc.query({ - type: 'field-annotation', - attrs: { fieldType: 'signature' } -}); -``` - -### Making changes - -Modify documents with a clean API: - -```javascript -// Replace content -doc.replace(address, { text: 'Updated text' }); - -// Insert at position -doc.insert(address, { type: 'paragraph', text: 'New paragraph' }); - -// Delete content -doc.delete(address); -``` - -### Working with tables - -First-class table operations: - -```javascript -// Add a row -doc.table(tableAddress).addRow({ after: 2 }); - -// Update a cell -doc.table(tableAddress).cell(1, 2).replace({ text: 'New value' }); -``` - -## Timeline - - - - Query DSL for finding and reading document content - - - Insert, replace, and delete operations - - - Table operations, list manipulation, track changes integration - - - -## Stay updated - -Join Discord to get notified when Document API launches: - - - Get early access and share feedback - +## Why use Document API + +- Build automations without editor-specific code. +- Work with predictable inputs and outputs defined per operation. +- Check capabilities up front and branch safely when features are unavailable. + +## Reference + +- Full operation reference: [/document-api/reference/index](/document-api/reference/index) +- Machine-readable files are available for automation use (contract schema, tool manifest, and agent compatibility artifacts). + +{/* DOC_API_OPERATIONS_START */} +### Available operations + +Use the tables below to see what operations are available and where each one is documented. + +| Namespace | Operations | Reference | +| --- | --- | --- | +| Core | 8 | [Reference](/document-api/reference/core/index) | +| Capabilities | 1 | [Reference](/document-api/reference/capabilities/index) | +| Create | 1 | [Reference](/document-api/reference/create/index) | +| Format | 1 | [Reference](/document-api/reference/format/index) | +| Lists | 8 | [Reference](/document-api/reference/lists/index) | +| Comments | 11 | [Reference](/document-api/reference/comments/index) | +| Track Changes | 6 | [Reference](/document-api/reference/track-changes/index) | + +| Editor method | Operation ID | +| --- | --- | +| `editor.doc.find(...)` | [`find`](/document-api/reference/find) | +| `editor.doc.getNode(...)` | [`getNode`](/document-api/reference/get-node) | +| `editor.doc.getNodeById(...)` | [`getNodeById`](/document-api/reference/get-node-by-id) | +| `editor.doc.getText(...)` | [`getText`](/document-api/reference/get-text) | +| `editor.doc.info(...)` | [`info`](/document-api/reference/info) | +| `editor.doc.insert(...)` | [`insert`](/document-api/reference/insert) | +| `editor.doc.replace(...)` | [`replace`](/document-api/reference/replace) | +| `editor.doc.delete(...)` | [`delete`](/document-api/reference/delete) | +| `editor.doc.format.bold(...)` | [`format.bold`](/document-api/reference/format/bold) | +| `editor.doc.create.paragraph(...)` | [`create.paragraph`](/document-api/reference/create/paragraph) | +| `editor.doc.lists.list(...)` | [`lists.list`](/document-api/reference/lists/list) | +| `editor.doc.lists.get(...)` | [`lists.get`](/document-api/reference/lists/get) | +| `editor.doc.lists.insert(...)` | [`lists.insert`](/document-api/reference/lists/insert) | +| `editor.doc.lists.setType(...)` | [`lists.setType`](/document-api/reference/lists/set-type) | +| `editor.doc.lists.indent(...)` | [`lists.indent`](/document-api/reference/lists/indent) | +| `editor.doc.lists.outdent(...)` | [`lists.outdent`](/document-api/reference/lists/outdent) | +| `editor.doc.lists.restart(...)` | [`lists.restart`](/document-api/reference/lists/restart) | +| `editor.doc.lists.exit(...)` | [`lists.exit`](/document-api/reference/lists/exit) | +| `editor.doc.comments.add(...)` | [`comments.add`](/document-api/reference/comments/add) | +| `editor.doc.comments.edit(...)` | [`comments.edit`](/document-api/reference/comments/edit) | +| `editor.doc.comments.reply(...)` | [`comments.reply`](/document-api/reference/comments/reply) | +| `editor.doc.comments.move(...)` | [`comments.move`](/document-api/reference/comments/move) | +| `editor.doc.comments.resolve(...)` | [`comments.resolve`](/document-api/reference/comments/resolve) | +| `editor.doc.comments.remove(...)` | [`comments.remove`](/document-api/reference/comments/remove) | +| `editor.doc.comments.setInternal(...)` | [`comments.setInternal`](/document-api/reference/comments/set-internal) | +| `editor.doc.comments.setActive(...)` | [`comments.setActive`](/document-api/reference/comments/set-active) | +| `editor.doc.comments.goTo(...)` | [`comments.goTo`](/document-api/reference/comments/go-to) | +| `editor.doc.comments.get(...)` | [`comments.get`](/document-api/reference/comments/get) | +| `editor.doc.comments.list(...)` | [`comments.list`](/document-api/reference/comments/list) | +| `editor.doc.trackChanges.list(...)` | [`trackChanges.list`](/document-api/reference/track-changes/list) | +| `editor.doc.trackChanges.get(...)` | [`trackChanges.get`](/document-api/reference/track-changes/get) | +| `editor.doc.trackChanges.accept(...)` | [`trackChanges.accept`](/document-api/reference/track-changes/accept) | +| `editor.doc.trackChanges.reject(...)` | [`trackChanges.reject`](/document-api/reference/track-changes/reject) | +| `editor.doc.trackChanges.acceptAll(...)` | [`trackChanges.acceptAll`](/document-api/reference/track-changes/accept-all) | +| `editor.doc.trackChanges.rejectAll(...)` | [`trackChanges.rejectAll`](/document-api/reference/track-changes/reject-all) | +| `editor.doc.capabilities()` | [`capabilities.get`](/document-api/reference/capabilities/get) | +{/* DOC_API_OPERATIONS_END */} diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json new file mode 100644 index 000000000..21cccf467 --- /dev/null +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -0,0 +1,124 @@ +{ + "contractVersion": "0.1.0", + "files": [ + "apps/docs/document-api/reference/capabilities/get.mdx", + "apps/docs/document-api/reference/capabilities/index.mdx", + "apps/docs/document-api/reference/comments/add.mdx", + "apps/docs/document-api/reference/comments/edit.mdx", + "apps/docs/document-api/reference/comments/get.mdx", + "apps/docs/document-api/reference/comments/go-to.mdx", + "apps/docs/document-api/reference/comments/index.mdx", + "apps/docs/document-api/reference/comments/list.mdx", + "apps/docs/document-api/reference/comments/move.mdx", + "apps/docs/document-api/reference/comments/remove.mdx", + "apps/docs/document-api/reference/comments/reply.mdx", + "apps/docs/document-api/reference/comments/resolve.mdx", + "apps/docs/document-api/reference/comments/set-active.mdx", + "apps/docs/document-api/reference/comments/set-internal.mdx", + "apps/docs/document-api/reference/core/index.mdx", + "apps/docs/document-api/reference/create/index.mdx", + "apps/docs/document-api/reference/create/paragraph.mdx", + "apps/docs/document-api/reference/delete.mdx", + "apps/docs/document-api/reference/find.mdx", + "apps/docs/document-api/reference/format/bold.mdx", + "apps/docs/document-api/reference/format/index.mdx", + "apps/docs/document-api/reference/get-node-by-id.mdx", + "apps/docs/document-api/reference/get-node.mdx", + "apps/docs/document-api/reference/get-text.mdx", + "apps/docs/document-api/reference/index.mdx", + "apps/docs/document-api/reference/info.mdx", + "apps/docs/document-api/reference/insert.mdx", + "apps/docs/document-api/reference/lists/exit.mdx", + "apps/docs/document-api/reference/lists/get.mdx", + "apps/docs/document-api/reference/lists/indent.mdx", + "apps/docs/document-api/reference/lists/index.mdx", + "apps/docs/document-api/reference/lists/insert.mdx", + "apps/docs/document-api/reference/lists/list.mdx", + "apps/docs/document-api/reference/lists/outdent.mdx", + "apps/docs/document-api/reference/lists/restart.mdx", + "apps/docs/document-api/reference/lists/set-type.mdx", + "apps/docs/document-api/reference/replace.mdx", + "apps/docs/document-api/reference/track-changes/accept-all.mdx", + "apps/docs/document-api/reference/track-changes/accept.mdx", + "apps/docs/document-api/reference/track-changes/get.mdx", + "apps/docs/document-api/reference/track-changes/index.mdx", + "apps/docs/document-api/reference/track-changes/list.mdx", + "apps/docs/document-api/reference/track-changes/reject-all.mdx", + "apps/docs/document-api/reference/track-changes/reject.mdx" + ], + "generatedBy": "packages/document-api/scripts/generate-reference-docs.ts", + "groups": [ + { + "key": "core", + "operationIds": ["find", "getNode", "getNodeById", "getText", "info", "insert", "replace", "delete"], + "pagePath": "apps/docs/document-api/reference/core/index.mdx", + "title": "Core" + }, + { + "key": "capabilities", + "operationIds": ["capabilities.get"], + "pagePath": "apps/docs/document-api/reference/capabilities/index.mdx", + "title": "Capabilities" + }, + { + "key": "create", + "operationIds": ["create.paragraph"], + "pagePath": "apps/docs/document-api/reference/create/index.mdx", + "title": "Create" + }, + { + "key": "format", + "operationIds": ["format.bold"], + "pagePath": "apps/docs/document-api/reference/format/index.mdx", + "title": "Format" + }, + { + "key": "lists", + "operationIds": [ + "lists.list", + "lists.get", + "lists.insert", + "lists.setType", + "lists.indent", + "lists.outdent", + "lists.restart", + "lists.exit" + ], + "pagePath": "apps/docs/document-api/reference/lists/index.mdx", + "title": "Lists" + }, + { + "key": "comments", + "operationIds": [ + "comments.add", + "comments.edit", + "comments.reply", + "comments.move", + "comments.resolve", + "comments.remove", + "comments.setInternal", + "comments.setActive", + "comments.goTo", + "comments.get", + "comments.list" + ], + "pagePath": "apps/docs/document-api/reference/comments/index.mdx", + "title": "Comments" + }, + { + "key": "trackChanges", + "operationIds": [ + "trackChanges.list", + "trackChanges.get", + "trackChanges.accept", + "trackChanges.reject", + "trackChanges.acceptAll", + "trackChanges.rejectAll" + ], + "pagePath": "apps/docs/document-api/reference/track-changes/index.mdx", + "title": "Track Changes" + } + ], + "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", + "sourceHash": "50a44d02c22957a93c8ce085776af0197e3200f02d3b402f2625dad31a0dc756" +} diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx new file mode 100644 index 000000000..518516789 --- /dev/null +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -0,0 +1,1356 @@ +--- +title: capabilities.get +sidebarTitle: capabilities.get +description: Generated reference for capabilities.get +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `capabilities.get` +- API member path: `editor.doc.capabilities()` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- None + +## Non-applied failure codes + +- None + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": {}, + "type": "object" +} +``` + +## Output schema + +```json +{ + "additionalProperties": false, + "properties": { + "global": { + "additionalProperties": false, + "properties": { + "comments": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "dryRun": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "lists": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "trackChanges": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + } + }, + "required": [ + "enabled" + ], + "type": "object" + } + }, + "required": [ + "trackChanges", + "comments", + "lists", + "dryRun" + ], + "type": "object" + }, + "operations": { + "additionalProperties": false, + "properties": { + "capabilities.get": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "comments.add": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "comments.edit": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "comments.get": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "comments.goTo": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "comments.list": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "comments.move": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "comments.remove": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "comments.reply": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "comments.resolve": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "comments.setActive": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "comments.setInternal": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "create.paragraph": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "delete": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "find": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "format.bold": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "getNode": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "getNodeById": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "getText": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "info": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "insert": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "lists.exit": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "lists.get": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "lists.indent": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "lists.insert": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "lists.list": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "lists.outdent": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "lists.restart": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "lists.setType": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "replace": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "trackChanges.accept": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "trackChanges.acceptAll": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "trackChanges.get": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "trackChanges.list": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "trackChanges.reject": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "trackChanges.rejectAll": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + } + }, + "required": [ + "find", + "getNode", + "getNodeById", + "getText", + "info", + "insert", + "replace", + "delete", + "format.bold", + "create.paragraph", + "lists.list", + "lists.get", + "lists.insert", + "lists.setType", + "lists.indent", + "lists.outdent", + "lists.restart", + "lists.exit", + "comments.add", + "comments.edit", + "comments.reply", + "comments.move", + "comments.resolve", + "comments.remove", + "comments.setInternal", + "comments.setActive", + "comments.goTo", + "comments.get", + "comments.list", + "trackChanges.list", + "trackChanges.get", + "trackChanges.accept", + "trackChanges.reject", + "trackChanges.acceptAll", + "trackChanges.rejectAll", + "capabilities.get" + ], + "type": "object" + } + }, + "required": [ + "global", + "operations" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/capabilities/index.mdx b/apps/docs/document-api/reference/capabilities/index.mdx new file mode 100644 index 000000000..0047ee55c --- /dev/null +++ b/apps/docs/document-api/reference/capabilities/index.mdx @@ -0,0 +1,17 @@ +--- +title: Capabilities operations +sidebarTitle: Capabilities +description: Generated Capabilities operation reference from the canonical Document API contract. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +[Back to full reference](../index) + +Runtime support discovery for capability-aware branching. + +| Operation | Member path | Mutates | Idempotency | Tracked | Dry run | +| --- | --- | --- | --- | --- | --- | +| [`capabilities.get`](./get) | `capabilities` | No | `idempotent` | No | No | diff --git a/apps/docs/document-api/reference/comments/add.mdx b/apps/docs/document-api/reference/comments/add.mdx new file mode 100644 index 000000000..b1a51c07c --- /dev/null +++ b/apps/docs/document-api/reference/comments/add.mdx @@ -0,0 +1,472 @@ +--- +title: comments.add +sidebarTitle: comments.add +description: Generated reference for comments.add +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `comments.add` +- API member path: `editor.doc.comments.add(...)` +- Mutates document: `yes` +- Idempotency: `non-idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `INVALID_TARGET` +- `NO_OP` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "target", + "text" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/comments/edit.mdx b/apps/docs/document-api/reference/comments/edit.mdx new file mode 100644 index 000000000..527c7892d --- /dev/null +++ b/apps/docs/document-api/reference/comments/edit.mdx @@ -0,0 +1,439 @@ +--- +title: comments.edit +sidebarTitle: comments.edit +description: Generated reference for comments.edit +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `comments.edit` +- API member path: `editor.doc.comments.edit(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "commentId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "commentId", + "text" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/comments/get.mdx b/apps/docs/document-api/reference/comments/get.mdx new file mode 100644 index 000000000..a9253266d --- /dev/null +++ b/apps/docs/document-api/reference/comments/get.mdx @@ -0,0 +1,143 @@ +--- +title: comments.get +sidebarTitle: comments.get +description: Generated reference for comments.get +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `comments.get` +- API member path: `editor.doc.comments.get(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` + +## Non-applied failure codes + +- None + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "commentId": { + "type": "string" + } + }, + "required": [ + "commentId" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + "commentId": { + "type": "string" + }, + "createdTime": { + "type": "number" + }, + "creatorEmail": { + "type": "string" + }, + "creatorName": { + "type": "string" + }, + "importedId": { + "type": "string" + }, + "isInternal": { + "type": "boolean" + }, + "parentCommentId": { + "type": "string" + }, + "status": { + "enum": [ + "open", + "resolved" + ] + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "address", + "commentId", + "status" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/comments/go-to.mdx b/apps/docs/document-api/reference/comments/go-to.mdx new file mode 100644 index 000000000..2a1745655 --- /dev/null +++ b/apps/docs/document-api/reference/comments/go-to.mdx @@ -0,0 +1,204 @@ +--- +title: comments.goTo +sidebarTitle: comments.goTo +description: Generated reference for comments.goTo +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `comments.goTo` +- API member path: `editor.doc.comments.goTo(...)` +- Mutates document: `no` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- None + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "commentId": { + "type": "string" + } + }, + "required": [ + "commentId" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/comments/index.mdx b/apps/docs/document-api/reference/comments/index.mdx new file mode 100644 index 000000000..ba7588835 --- /dev/null +++ b/apps/docs/document-api/reference/comments/index.mdx @@ -0,0 +1,27 @@ +--- +title: Comments operations +sidebarTitle: Comments +description: Generated Comments operation reference from the canonical Document API contract. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +[Back to full reference](../index) + +Comment authoring and thread lifecycle operations. + +| Operation | Member path | Mutates | Idempotency | Tracked | Dry run | +| --- | --- | --- | --- | --- | --- | +| [`comments.add`](./add) | `comments.add` | Yes | `non-idempotent` | No | No | +| [`comments.edit`](./edit) | `comments.edit` | Yes | `conditional` | No | No | +| [`comments.reply`](./reply) | `comments.reply` | Yes | `non-idempotent` | No | No | +| [`comments.move`](./move) | `comments.move` | Yes | `conditional` | No | No | +| [`comments.resolve`](./resolve) | `comments.resolve` | Yes | `conditional` | No | No | +| [`comments.remove`](./remove) | `comments.remove` | Yes | `conditional` | No | No | +| [`comments.setInternal`](./set-internal) | `comments.setInternal` | Yes | `conditional` | No | No | +| [`comments.setActive`](./set-active) | `comments.setActive` | Yes | `conditional` | No | No | +| [`comments.goTo`](./go-to) | `comments.goTo` | No | `conditional` | No | No | +| [`comments.get`](./get) | `comments.get` | No | `idempotent` | No | No | +| [`comments.list`](./list) | `comments.list` | No | `idempotent` | No | No | diff --git a/apps/docs/document-api/reference/comments/list.mdx b/apps/docs/document-api/reference/comments/list.mdx new file mode 100644 index 000000000..277e15946 --- /dev/null +++ b/apps/docs/document-api/reference/comments/list.mdx @@ -0,0 +1,156 @@ +--- +title: comments.list +sidebarTitle: comments.list +description: Generated reference for comments.list +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `comments.list` +- API member path: `editor.doc.comments.list(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- None + +## Non-applied failure codes + +- None + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "includeResolved": { + "type": "boolean" + } + }, + "type": "object" +} +``` + +## Output schema + +```json +{ + "additionalProperties": false, + "properties": { + "matches": { + "items": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + "commentId": { + "type": "string" + }, + "createdTime": { + "type": "number" + }, + "creatorEmail": { + "type": "string" + }, + "creatorName": { + "type": "string" + }, + "importedId": { + "type": "string" + }, + "isInternal": { + "type": "boolean" + }, + "parentCommentId": { + "type": "string" + }, + "status": { + "enum": [ + "open", + "resolved" + ] + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "address", + "commentId", + "status" + ], + "type": "object" + }, + "type": "array" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "matches", + "total" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/comments/move.mdx b/apps/docs/document-api/reference/comments/move.mdx new file mode 100644 index 000000000..69980badf --- /dev/null +++ b/apps/docs/document-api/reference/comments/move.mdx @@ -0,0 +1,472 @@ +--- +title: comments.move +sidebarTitle: comments.move +description: Generated reference for comments.move +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `comments.move` +- API member path: `editor.doc.comments.move(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `INVALID_TARGET` +- `NO_OP` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "commentId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + } + }, + "required": [ + "commentId", + "target" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/comments/remove.mdx b/apps/docs/document-api/reference/comments/remove.mdx new file mode 100644 index 000000000..ff5ad9973 --- /dev/null +++ b/apps/docs/document-api/reference/comments/remove.mdx @@ -0,0 +1,435 @@ +--- +title: comments.remove +sidebarTitle: comments.remove +description: Generated reference for comments.remove +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `comments.remove` +- API member path: `editor.doc.comments.remove(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "commentId": { + "type": "string" + } + }, + "required": [ + "commentId" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/comments/reply.mdx b/apps/docs/document-api/reference/comments/reply.mdx new file mode 100644 index 000000000..bd2336b4f --- /dev/null +++ b/apps/docs/document-api/reference/comments/reply.mdx @@ -0,0 +1,439 @@ +--- +title: comments.reply +sidebarTitle: comments.reply +description: Generated reference for comments.reply +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `comments.reply` +- API member path: `editor.doc.comments.reply(...)` +- Mutates document: `yes` +- Idempotency: `non-idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `INVALID_TARGET` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "parentCommentId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "parentCommentId", + "text" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/comments/resolve.mdx b/apps/docs/document-api/reference/comments/resolve.mdx new file mode 100644 index 000000000..c757af1ca --- /dev/null +++ b/apps/docs/document-api/reference/comments/resolve.mdx @@ -0,0 +1,435 @@ +--- +title: comments.resolve +sidebarTitle: comments.resolve +description: Generated reference for comments.resolve +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `comments.resolve` +- API member path: `editor.doc.comments.resolve(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "commentId": { + "type": "string" + } + }, + "required": [ + "commentId" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/comments/set-active.mdx b/apps/docs/document-api/reference/comments/set-active.mdx new file mode 100644 index 000000000..bb9d5ecd6 --- /dev/null +++ b/apps/docs/document-api/reference/comments/set-active.mdx @@ -0,0 +1,438 @@ +--- +title: comments.setActive +sidebarTitle: comments.setActive +description: Generated reference for comments.setActive +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `comments.setActive` +- API member path: `editor.doc.comments.setActive(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `INVALID_TARGET` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "commentId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "commentId" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/comments/set-internal.mdx b/apps/docs/document-api/reference/comments/set-internal.mdx new file mode 100644 index 000000000..1a39ffec2 --- /dev/null +++ b/apps/docs/document-api/reference/comments/set-internal.mdx @@ -0,0 +1,442 @@ +--- +title: comments.setInternal +sidebarTitle: comments.setInternal +description: Generated reference for comments.setInternal +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `comments.setInternal` +- API member path: `editor.doc.comments.setInternal(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` +- `INVALID_TARGET` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "commentId": { + "type": "string" + }, + "isInternal": { + "type": "boolean" + } + }, + "required": [ + "commentId", + "isInternal" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/core/index.mdx b/apps/docs/document-api/reference/core/index.mdx new file mode 100644 index 000000000..87e0aea00 --- /dev/null +++ b/apps/docs/document-api/reference/core/index.mdx @@ -0,0 +1,24 @@ +--- +title: Core operations +sidebarTitle: Core +description: Generated Core operation reference from the canonical Document API contract. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +[Back to full reference](../index) + +Primary read and write operations. + +| Operation | Member path | Mutates | Idempotency | Tracked | Dry run | +| --- | --- | --- | --- | --- | --- | +| [`find`](../find) | `find` | No | `idempotent` | No | No | +| [`getNode`](../get-node) | `getNode` | No | `idempotent` | No | No | +| [`getNodeById`](../get-node-by-id) | `getNodeById` | No | `idempotent` | No | No | +| [`getText`](../get-text) | `getText` | No | `idempotent` | No | No | +| [`info`](../info) | `info` | No | `idempotent` | No | No | +| [`insert`](../insert) | `insert` | Yes | `non-idempotent` | Yes | Yes | +| [`replace`](../replace) | `replace` | Yes | `conditional` | Yes | Yes | +| [`delete`](../delete) | `delete` | Yes | `conditional` | Yes | Yes | diff --git a/apps/docs/document-api/reference/create/index.mdx b/apps/docs/document-api/reference/create/index.mdx new file mode 100644 index 000000000..e93391383 --- /dev/null +++ b/apps/docs/document-api/reference/create/index.mdx @@ -0,0 +1,17 @@ +--- +title: Create operations +sidebarTitle: Create +description: Generated Create operation reference from the canonical Document API contract. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +[Back to full reference](../index) + +Structured creation helpers. + +| Operation | Member path | Mutates | Idempotency | Tracked | Dry run | +| --- | --- | --- | --- | --- | --- | +| [`create.paragraph`](./paragraph) | `create.paragraph` | Yes | `non-idempotent` | Yes | Yes | diff --git a/apps/docs/document-api/reference/create/paragraph.mdx b/apps/docs/document-api/reference/create/paragraph.mdx new file mode 100644 index 000000000..edbc13208 --- /dev/null +++ b/apps/docs/document-api/reference/create/paragraph.mdx @@ -0,0 +1,419 @@ +--- +title: create.paragraph +sidebarTitle: create.paragraph +description: Generated reference for create.paragraph +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `create.paragraph` +- API member path: `editor.doc.create.paragraph(...)` +- Mutates document: `yes` +- Idempotency: `non-idempotent` +- Supports tracked mode: `yes` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `COMMAND_UNAVAILABLE` +- `TRACK_CHANGE_COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `INVALID_TARGET` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "at": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "documentStart" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "documentEnd" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "before" + }, + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt" + ] + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + } + }, + "required": [ + "kind", + "target" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "after" + }, + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt" + ] + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + } + }, + "required": [ + "kind", + "target" + ], + "type": "object" + } + ] + }, + "text": { + "type": "string" + } + }, + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "insertionPoint": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "paragraph": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "paragraph" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "success": { + "const": true + }, + "trackedChangeRefs": { + "items": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "success", + "paragraph", + "insertionPoint" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "insertionPoint": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "paragraph": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "paragraph" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "success": { + "const": true + }, + "trackedChangeRefs": { + "items": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "success", + "paragraph", + "insertionPoint" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/delete.mdx b/apps/docs/document-api/reference/delete.mdx new file mode 100644 index 000000000..3904bca91 --- /dev/null +++ b/apps/docs/document-api/reference/delete.mdx @@ -0,0 +1,853 @@ +--- +title: delete +sidebarTitle: delete +description: Generated reference for delete +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `delete` +- API member path: `editor.doc.delete(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `yes` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `TRACK_CHANGE_COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + } + }, + "required": [ + "target" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "target", + "range", + "text" + ], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success", + "resolution" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "target", + "range", + "text" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure", + "resolution" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "target", + "range", + "text" + ], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success", + "resolution" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "target", + "range", + "text" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure", + "resolution" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/find.mdx b/apps/docs/document-api/reference/find.mdx new file mode 100644 index 000000000..e1a3fca4f --- /dev/null +++ b/apps/docs/document-api/reference/find.mdx @@ -0,0 +1,734 @@ +--- +title: find +sidebarTitle: find +description: Generated reference for find +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `find` +- API member path: `editor.doc.find(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `no` + +## Pre-apply throws + +- None + +## Non-applied failure codes + +- None + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "includeNodes": { + "type": "boolean" + }, + "includeUnknown": { + "type": "boolean" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "select": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "caseSensitive": { + "type": "boolean" + }, + "mode": { + "enum": [ + "contains", + "regex" + ] + }, + "pattern": { + "type": "string" + }, + "type": { + "const": "text" + } + }, + "required": [ + "type", + "pattern" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "block", + "inline" + ] + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "type": { + "const": "node" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + } + }, + "required": [ + "nodeType" + ], + "type": "object" + } + ] + }, + "within": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt" + ] + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "anchor": { + "additionalProperties": false, + "properties": { + "end": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "blockId", + "offset" + ], + "type": "object" + }, + "start": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "blockId", + "offset" + ], + "type": "object" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "enum": [ + "run", + "bookmark", + "comment", + "hyperlink", + "sdt", + "image", + "footnoteRef", + "tab", + "lineBreak" + ] + } + }, + "required": [ + "kind", + "nodeType", + "anchor" + ], + "type": "object" + } + ] + } + }, + "required": [ + "select" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "additionalProperties": false, + "properties": { + "context": { + "items": { + "additionalProperties": false, + "properties": { + "address": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt" + ] + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "anchor": { + "additionalProperties": false, + "properties": { + "end": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "blockId", + "offset" + ], + "type": "object" + }, + "start": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "blockId", + "offset" + ], + "type": "object" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "enum": [ + "run", + "bookmark", + "comment", + "hyperlink", + "sdt", + "image", + "footnoteRef", + "tab", + "lineBreak" + ] + } + }, + "required": [ + "kind", + "nodeType", + "anchor" + ], + "type": "object" + } + ] + }, + "highlightRange": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + }, + "snippet": { + "type": "string" + }, + "textRanges": { + "items": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "address", + "snippet", + "highlightRange" + ], + "type": "object" + }, + "type": "array" + }, + "diagnostics": { + "items": { + "additionalProperties": false, + "properties": { + "address": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt" + ] + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "anchor": { + "additionalProperties": false, + "properties": { + "end": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "blockId", + "offset" + ], + "type": "object" + }, + "start": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "blockId", + "offset" + ], + "type": "object" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "enum": [ + "run", + "bookmark", + "comment", + "hyperlink", + "sdt", + "image", + "footnoteRef", + "tab", + "lineBreak" + ] + } + }, + "required": [ + "kind", + "nodeType", + "anchor" + ], + "type": "object" + } + ] + }, + "hint": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "type": "array" + }, + "matches": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt" + ] + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "anchor": { + "additionalProperties": false, + "properties": { + "end": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "blockId", + "offset" + ], + "type": "object" + }, + "start": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "blockId", + "offset" + ], + "type": "object" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "enum": [ + "run", + "bookmark", + "comment", + "hyperlink", + "sdt", + "image", + "footnoteRef", + "tab", + "lineBreak" + ] + } + }, + "required": [ + "kind", + "nodeType", + "anchor" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "nodes": { + "items": { + "additionalProperties": false, + "properties": { + "bodyNodes": { + "items": { + "type": "object" + }, + "type": "array" + }, + "bodyText": { + "type": "string" + }, + "kind": { + "enum": [ + "block", + "inline" + ] + }, + "nodes": { + "items": { + "type": "object" + }, + "type": "array" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "properties": { + "type": "object" + }, + "summary": { + "additionalProperties": false, + "properties": { + "label": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "nodeType", + "kind" + ], + "type": "object" + }, + "type": "array" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "matches", + "total" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/format/bold.mdx b/apps/docs/document-api/reference/format/bold.mdx new file mode 100644 index 000000000..cd42e6214 --- /dev/null +++ b/apps/docs/document-api/reference/format/bold.mdx @@ -0,0 +1,854 @@ +--- +title: format.bold +sidebarTitle: format.bold +description: Generated reference for format.bold +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `format.bold` +- API member path: `editor.doc.format.bold(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `yes` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `COMMAND_UNAVAILABLE` +- `TRACK_CHANGE_COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `INVALID_TARGET` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + } + }, + "required": [ + "target" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "target", + "range", + "text" + ], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success", + "resolution" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "target", + "range", + "text" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure", + "resolution" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "target", + "range", + "text" + ], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success", + "resolution" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "target", + "range", + "text" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure", + "resolution" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/format/index.mdx b/apps/docs/document-api/reference/format/index.mdx new file mode 100644 index 000000000..d472ae109 --- /dev/null +++ b/apps/docs/document-api/reference/format/index.mdx @@ -0,0 +1,17 @@ +--- +title: Format operations +sidebarTitle: Format +description: Generated Format operation reference from the canonical Document API contract. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +[Back to full reference](../index) + +Formatting mutations. + +| Operation | Member path | Mutates | Idempotency | Tracked | Dry run | +| --- | --- | --- | --- | --- | --- | +| [`format.bold`](./bold) | `format.bold` | Yes | `conditional` | Yes | Yes | diff --git a/apps/docs/document-api/reference/get-node-by-id.mdx b/apps/docs/document-api/reference/get-node-by-id.mdx new file mode 100644 index 000000000..7d6d43ca2 --- /dev/null +++ b/apps/docs/document-api/reference/get-node-by-id.mdx @@ -0,0 +1,129 @@ +--- +title: getNodeById +sidebarTitle: getNodeById +description: Generated reference for getNodeById +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `getNodeById` +- API member path: `editor.doc.getNodeById(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` + +## Non-applied failure codes + +- None + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt" + ] + } + }, + "required": [ + "nodeId" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "additionalProperties": false, + "properties": { + "bodyNodes": { + "items": { + "type": "object" + }, + "type": "array" + }, + "bodyText": { + "type": "string" + }, + "kind": { + "enum": [ + "block", + "inline" + ] + }, + "nodes": { + "items": { + "type": "object" + }, + "type": "array" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "properties": { + "type": "object" + }, + "summary": { + "additionalProperties": false, + "properties": { + "label": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "nodeType", + "kind" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/get-node.mdx b/apps/docs/document-api/reference/get-node.mdx new file mode 100644 index 000000000..2258060aa --- /dev/null +++ b/apps/docs/document-api/reference/get-node.mdx @@ -0,0 +1,207 @@ +--- +title: getNode +sidebarTitle: getNode +description: Generated reference for getNode +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `getNode` +- API member path: `editor.doc.getNode(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` + +## Non-applied failure codes + +- None + +## Input schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt" + ] + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "anchor": { + "additionalProperties": false, + "properties": { + "end": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "blockId", + "offset" + ], + "type": "object" + }, + "start": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "blockId", + "offset" + ], + "type": "object" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "enum": [ + "run", + "bookmark", + "comment", + "hyperlink", + "sdt", + "image", + "footnoteRef", + "tab", + "lineBreak" + ] + } + }, + "required": [ + "kind", + "nodeType", + "anchor" + ], + "type": "object" + } + ] +} +``` + +## Output schema + +```json +{ + "additionalProperties": false, + "properties": { + "bodyNodes": { + "items": { + "type": "object" + }, + "type": "array" + }, + "bodyText": { + "type": "string" + }, + "kind": { + "enum": [ + "block", + "inline" + ] + }, + "nodes": { + "items": { + "type": "object" + }, + "type": "array" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "properties": { + "type": "object" + }, + "summary": { + "additionalProperties": false, + "properties": { + "label": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "nodeType", + "kind" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/get-text.mdx b/apps/docs/document-api/reference/get-text.mdx new file mode 100644 index 000000000..6aeaa7de4 --- /dev/null +++ b/apps/docs/document-api/reference/get-text.mdx @@ -0,0 +1,45 @@ +--- +title: getText +sidebarTitle: getText +description: Generated reference for getText +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `getText` +- API member path: `editor.doc.getText(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- None + +## Non-applied failure codes + +- None + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": {}, + "type": "object" +} +``` + +## Output schema + +```json +{ + "type": "string" +} +``` diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx new file mode 100644 index 000000000..8c1030bce --- /dev/null +++ b/apps/docs/document-api/reference/index.mdx @@ -0,0 +1,63 @@ +--- +title: Document API reference +sidebarTitle: Reference +description: Generated operation reference from the canonical Document API contract. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +This reference is generated from `packages/document-api/src/contract/*`. +Document API is currently alpha and subject to breaking changes. + +## Browse by namespace + +| Namespace | Operations | Reference | +| --- | --- | --- | +| Core | 8 | [Open](./core/index) | +| Capabilities | 1 | [Open](./capabilities/index) | +| Create | 1 | [Open](./create/index) | +| Format | 1 | [Open](./format/index) | +| Lists | 8 | [Open](./lists/index) | +| Comments | 11 | [Open](./comments/index) | +| Track Changes | 6 | [Open](./track-changes/index) | + +## All operations + +| Operation | Namespace | Member path | Mutates | Idempotency | Tracked | Dry run | +| --- | --- | --- | --- | --- | --- | --- | +| [`find`](./find) | Core | `find` | No | `idempotent` | No | No | +| [`getNode`](./get-node) | Core | `getNode` | No | `idempotent` | No | No | +| [`getNodeById`](./get-node-by-id) | Core | `getNodeById` | No | `idempotent` | No | No | +| [`getText`](./get-text) | Core | `getText` | No | `idempotent` | No | No | +| [`info`](./info) | Core | `info` | No | `idempotent` | No | No | +| [`insert`](./insert) | Core | `insert` | Yes | `non-idempotent` | Yes | Yes | +| [`replace`](./replace) | Core | `replace` | Yes | `conditional` | Yes | Yes | +| [`delete`](./delete) | Core | `delete` | Yes | `conditional` | Yes | Yes | +| [`format.bold`](./format/bold) | Format | `format.bold` | Yes | `conditional` | Yes | Yes | +| [`create.paragraph`](./create/paragraph) | Create | `create.paragraph` | Yes | `non-idempotent` | Yes | Yes | +| [`lists.list`](./lists/list) | Lists | `lists.list` | No | `idempotent` | No | No | +| [`lists.get`](./lists/get) | Lists | `lists.get` | No | `idempotent` | No | No | +| [`lists.insert`](./lists/insert) | Lists | `lists.insert` | Yes | `non-idempotent` | Yes | No | +| [`lists.setType`](./lists/set-type) | Lists | `lists.setType` | Yes | `conditional` | No | No | +| [`lists.indent`](./lists/indent) | Lists | `lists.indent` | Yes | `conditional` | No | No | +| [`lists.outdent`](./lists/outdent) | Lists | `lists.outdent` | Yes | `conditional` | No | No | +| [`lists.restart`](./lists/restart) | Lists | `lists.restart` | Yes | `conditional` | No | No | +| [`lists.exit`](./lists/exit) | Lists | `lists.exit` | Yes | `conditional` | No | No | +| [`comments.add`](./comments/add) | Comments | `comments.add` | Yes | `non-idempotent` | No | No | +| [`comments.edit`](./comments/edit) | Comments | `comments.edit` | Yes | `conditional` | No | No | +| [`comments.reply`](./comments/reply) | Comments | `comments.reply` | Yes | `non-idempotent` | No | No | +| [`comments.move`](./comments/move) | Comments | `comments.move` | Yes | `conditional` | No | No | +| [`comments.resolve`](./comments/resolve) | Comments | `comments.resolve` | Yes | `conditional` | No | No | +| [`comments.remove`](./comments/remove) | Comments | `comments.remove` | Yes | `conditional` | No | No | +| [`comments.setInternal`](./comments/set-internal) | Comments | `comments.setInternal` | Yes | `conditional` | No | No | +| [`comments.setActive`](./comments/set-active) | Comments | `comments.setActive` | Yes | `conditional` | No | No | +| [`comments.goTo`](./comments/go-to) | Comments | `comments.goTo` | No | `conditional` | No | No | +| [`comments.get`](./comments/get) | Comments | `comments.get` | No | `idempotent` | No | No | +| [`comments.list`](./comments/list) | Comments | `comments.list` | No | `idempotent` | No | No | +| [`trackChanges.list`](./track-changes/list) | Track Changes | `trackChanges.list` | No | `idempotent` | No | No | +| [`trackChanges.get`](./track-changes/get) | Track Changes | `trackChanges.get` | No | `idempotent` | No | No | +| [`trackChanges.accept`](./track-changes/accept) | Track Changes | `trackChanges.accept` | Yes | `conditional` | No | No | +| [`trackChanges.reject`](./track-changes/reject) | Track Changes | `trackChanges.reject` | Yes | `conditional` | No | No | +| [`trackChanges.acceptAll`](./track-changes/accept-all) | Track Changes | `trackChanges.acceptAll` | Yes | `conditional` | No | No | +| [`trackChanges.rejectAll`](./track-changes/reject-all) | Track Changes | `trackChanges.rejectAll` | Yes | `conditional` | No | No | +| [`capabilities.get`](./capabilities/get) | Capabilities | `capabilities` | No | `idempotent` | No | No | diff --git a/apps/docs/document-api/reference/info.mdx b/apps/docs/document-api/reference/info.mdx new file mode 100644 index 000000000..af48f1715 --- /dev/null +++ b/apps/docs/document-api/reference/info.mdx @@ -0,0 +1,132 @@ +--- +title: info +sidebarTitle: info +description: Generated reference for info +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `info` +- API member path: `editor.doc.info(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- None + +## Non-applied failure codes + +- None + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": {}, + "type": "object" +} +``` + +## Output schema + +```json +{ + "additionalProperties": false, + "properties": { + "capabilities": { + "additionalProperties": false, + "properties": { + "canComment": { + "type": "boolean" + }, + "canFind": { + "type": "boolean" + }, + "canGetNode": { + "type": "boolean" + }, + "canReplace": { + "type": "boolean" + } + }, + "required": [ + "canFind", + "canGetNode", + "canComment", + "canReplace" + ], + "type": "object" + }, + "counts": { + "additionalProperties": false, + "properties": { + "comments": { + "type": "integer" + }, + "headings": { + "type": "integer" + }, + "images": { + "type": "integer" + }, + "paragraphs": { + "type": "integer" + }, + "tables": { + "type": "integer" + }, + "words": { + "type": "integer" + } + }, + "required": [ + "words", + "paragraphs", + "headings", + "tables", + "images", + "comments" + ], + "type": "object" + }, + "outline": { + "items": { + "additionalProperties": false, + "properties": { + "level": { + "type": "integer" + }, + "nodeId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "level", + "text", + "nodeId" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "counts", + "outline", + "capabilities" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/insert.mdx b/apps/docs/document-api/reference/insert.mdx new file mode 100644 index 000000000..ae409fbf2 --- /dev/null +++ b/apps/docs/document-api/reference/insert.mdx @@ -0,0 +1,859 @@ +--- +title: insert +sidebarTitle: insert +description: Generated reference for insert +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `insert` +- API member path: `editor.doc.insert(...)` +- Mutates document: `yes` +- Idempotency: `non-idempotent` +- Supports tracked mode: `yes` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `TRACK_CHANGE_COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `INVALID_TARGET` +- `NO_OP` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "target", + "range", + "text" + ], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success", + "resolution" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "target", + "range", + "text" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure", + "resolution" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "target", + "range", + "text" + ], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success", + "resolution" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "target", + "range", + "text" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure", + "resolution" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/lists/exit.mdx b/apps/docs/document-api/reference/lists/exit.mdx new file mode 100644 index 000000000..012458a0d --- /dev/null +++ b/apps/docs/document-api/reference/lists/exit.mdx @@ -0,0 +1,213 @@ +--- +title: lists.exit +sidebarTitle: lists.exit +description: Generated reference for lists.exit +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `lists.exit` +- API member path: `editor.doc.lists.exit(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `COMMAND_UNAVAILABLE` +- `TRACK_CHANGE_COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `INVALID_TARGET` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + } + }, + "required": [ + "target" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "paragraph": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "paragraph" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "paragraph" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "paragraph": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "paragraph" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "paragraph" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/lists/get.mdx b/apps/docs/document-api/reference/lists/get.mdx new file mode 100644 index 000000000..2b7bf5527 --- /dev/null +++ b/apps/docs/document-api/reference/lists/get.mdx @@ -0,0 +1,119 @@ +--- +title: lists.get +sidebarTitle: lists.get +description: Generated reference for lists.get +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `lists.get` +- API member path: `editor.doc.lists.get(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` + +## Non-applied failure codes + +- None + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + } + }, + "required": [ + "address" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "kind": { + "enum": [ + "ordered", + "bullet" + ] + }, + "level": { + "type": "integer" + }, + "marker": { + "type": "string" + }, + "ordinal": { + "type": "integer" + }, + "path": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": [ + "address" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/lists/indent.mdx b/apps/docs/document-api/reference/lists/indent.mdx new file mode 100644 index 000000000..7ee3e0e71 --- /dev/null +++ b/apps/docs/document-api/reference/lists/indent.mdx @@ -0,0 +1,216 @@ +--- +title: lists.indent +sidebarTitle: lists.indent +description: Generated reference for lists.indent +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `lists.indent` +- API member path: `editor.doc.lists.indent(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `COMMAND_UNAVAILABLE` +- `TRACK_CHANGE_COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` +- `INVALID_TARGET` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + } + }, + "required": [ + "target" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/lists/index.mdx b/apps/docs/document-api/reference/lists/index.mdx new file mode 100644 index 000000000..ea576a027 --- /dev/null +++ b/apps/docs/document-api/reference/lists/index.mdx @@ -0,0 +1,24 @@ +--- +title: Lists operations +sidebarTitle: Lists +description: Generated Lists operation reference from the canonical Document API contract. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +[Back to full reference](../index) + +List inspection and list mutations. + +| Operation | Member path | Mutates | Idempotency | Tracked | Dry run | +| --- | --- | --- | --- | --- | --- | +| [`lists.list`](./list) | `lists.list` | No | `idempotent` | No | No | +| [`lists.get`](./get) | `lists.get` | No | `idempotent` | No | No | +| [`lists.insert`](./insert) | `lists.insert` | Yes | `non-idempotent` | Yes | No | +| [`lists.setType`](./set-type) | `lists.setType` | Yes | `conditional` | No | No | +| [`lists.indent`](./indent) | `lists.indent` | Yes | `conditional` | No | No | +| [`lists.outdent`](./outdent) | `lists.outdent` | Yes | `conditional` | No | No | +| [`lists.restart`](./restart) | `lists.restart` | Yes | `conditional` | No | No | +| [`lists.exit`](./exit) | `lists.exit` | Yes | `conditional` | No | No | diff --git a/apps/docs/document-api/reference/lists/insert.mdx b/apps/docs/document-api/reference/lists/insert.mdx new file mode 100644 index 000000000..bd62e6cb3 --- /dev/null +++ b/apps/docs/document-api/reference/lists/insert.mdx @@ -0,0 +1,337 @@ +--- +title: lists.insert +sidebarTitle: lists.insert +description: Generated reference for lists.insert +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `lists.insert` +- API member path: `editor.doc.lists.insert(...)` +- Mutates document: `yes` +- Idempotency: `non-idempotent` +- Supports tracked mode: `yes` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `COMMAND_UNAVAILABLE` +- `TRACK_CHANGE_COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `INVALID_TARGET` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "position": { + "enum": [ + "before", + "after" + ] + }, + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "target", + "position" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "insertionPoint": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "success": { + "const": true + }, + "trackedChangeRefs": { + "items": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "success", + "item", + "insertionPoint" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "insertionPoint": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "success": { + "const": true + }, + "trackedChangeRefs": { + "items": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "success", + "item", + "insertionPoint" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/lists/list.mdx b/apps/docs/document-api/reference/lists/list.mdx new file mode 100644 index 000000000..ba2bc07b8 --- /dev/null +++ b/apps/docs/document-api/reference/lists/list.mdx @@ -0,0 +1,183 @@ +--- +title: lists.list +sidebarTitle: lists.list +description: Generated reference for lists.list +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `lists.list` +- API member path: `editor.doc.lists.list(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` + +## Non-applied failure codes + +- None + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "ordered", + "bullet" + ] + }, + "level": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "ordinal": { + "type": "integer" + }, + "within": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt" + ] + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + } + }, + "type": "object" +} +``` + +## Output schema + +```json +{ + "additionalProperties": false, + "properties": { + "items": { + "items": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "kind": { + "enum": [ + "ordered", + "bullet" + ] + }, + "level": { + "type": "integer" + }, + "marker": { + "type": "string" + }, + "ordinal": { + "type": "integer" + }, + "path": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": [ + "address" + ], + "type": "object" + }, + "type": "array" + }, + "matches": { + "items": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "type": "array" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "matches", + "total", + "items" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/lists/outdent.mdx b/apps/docs/document-api/reference/lists/outdent.mdx new file mode 100644 index 000000000..f4143eef4 --- /dev/null +++ b/apps/docs/document-api/reference/lists/outdent.mdx @@ -0,0 +1,216 @@ +--- +title: lists.outdent +sidebarTitle: lists.outdent +description: Generated reference for lists.outdent +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `lists.outdent` +- API member path: `editor.doc.lists.outdent(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `COMMAND_UNAVAILABLE` +- `TRACK_CHANGE_COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` +- `INVALID_TARGET` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + } + }, + "required": [ + "target" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/lists/restart.mdx b/apps/docs/document-api/reference/lists/restart.mdx new file mode 100644 index 000000000..7b9d82a81 --- /dev/null +++ b/apps/docs/document-api/reference/lists/restart.mdx @@ -0,0 +1,216 @@ +--- +title: lists.restart +sidebarTitle: lists.restart +description: Generated reference for lists.restart +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `lists.restart` +- API member path: `editor.doc.lists.restart(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `COMMAND_UNAVAILABLE` +- `TRACK_CHANGE_COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` +- `INVALID_TARGET` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + } + }, + "required": [ + "target" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/lists/set-type.mdx b/apps/docs/document-api/reference/lists/set-type.mdx new file mode 100644 index 000000000..5b65b48f7 --- /dev/null +++ b/apps/docs/document-api/reference/lists/set-type.mdx @@ -0,0 +1,223 @@ +--- +title: lists.setType +sidebarTitle: lists.setType +description: Generated reference for lists.setType +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `lists.setType` +- API member path: `editor.doc.lists.setType(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `COMMAND_UNAVAILABLE` +- `TRACK_CHANGE_COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` +- `INVALID_TARGET` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "ordered", + "bullet" + ] + }, + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + } + }, + "required": [ + "target", + "kind" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "item" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/replace.mdx b/apps/docs/document-api/reference/replace.mdx new file mode 100644 index 000000000..6393a5a5e --- /dev/null +++ b/apps/docs/document-api/reference/replace.mdx @@ -0,0 +1,860 @@ +--- +title: replace +sidebarTitle: replace +description: Generated reference for replace +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `replace` +- API member path: `editor.doc.replace(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `yes` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `TRACK_CHANGE_COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `INVALID_TARGET` +- `NO_OP` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "target", + "text" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "target", + "range", + "text" + ], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success", + "resolution" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "target", + "range", + "text" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure", + "resolution" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "target", + "range", + "text" + ], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success", + "resolution" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "target", + "range", + "text" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure", + "resolution" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/track-changes/accept-all.mdx b/apps/docs/document-api/reference/track-changes/accept-all.mdx new file mode 100644 index 000000000..d995e2d10 --- /dev/null +++ b/apps/docs/document-api/reference/track-changes/accept-all.mdx @@ -0,0 +1,427 @@ +--- +title: trackChanges.acceptAll +sidebarTitle: trackChanges.acceptAll +description: Generated reference for trackChanges.acceptAll +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `trackChanges.acceptAll` +- API member path: `editor.doc.trackChanges.acceptAll(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": {}, + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/track-changes/accept.mdx b/apps/docs/document-api/reference/track-changes/accept.mdx new file mode 100644 index 000000000..06369d160 --- /dev/null +++ b/apps/docs/document-api/reference/track-changes/accept.mdx @@ -0,0 +1,435 @@ +--- +title: trackChanges.accept +sidebarTitle: trackChanges.accept +description: Generated reference for trackChanges.accept +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `trackChanges.accept` +- API member path: `editor.doc.trackChanges.accept(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/track-changes/get.mdx b/apps/docs/document-api/reference/track-changes/get.mdx new file mode 100644 index 000000000..48a544005 --- /dev/null +++ b/apps/docs/document-api/reference/track-changes/get.mdx @@ -0,0 +1,105 @@ +--- +title: trackChanges.get +sidebarTitle: trackChanges.get +description: Generated reference for trackChanges.get +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `trackChanges.get` +- API member path: `editor.doc.trackChanges.get(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` + +## Non-applied failure codes + +- None + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + "author": { + "type": "string" + }, + "authorEmail": { + "type": "string" + }, + "authorImage": { + "type": "string" + }, + "date": { + "type": "string" + }, + "excerpt": { + "type": "string" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "insert", + "delete", + "format" + ] + } + }, + "required": [ + "address", + "id", + "type" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/track-changes/index.mdx b/apps/docs/document-api/reference/track-changes/index.mdx new file mode 100644 index 000000000..ff64d0a38 --- /dev/null +++ b/apps/docs/document-api/reference/track-changes/index.mdx @@ -0,0 +1,22 @@ +--- +title: Track Changes operations +sidebarTitle: Track Changes +description: Generated Track Changes operation reference from the canonical Document API contract. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +[Back to full reference](../index) + +Tracked-change inspection and review operations. + +| Operation | Member path | Mutates | Idempotency | Tracked | Dry run | +| --- | --- | --- | --- | --- | --- | +| [`trackChanges.list`](./list) | `trackChanges.list` | No | `idempotent` | No | No | +| [`trackChanges.get`](./get) | `trackChanges.get` | No | `idempotent` | No | No | +| [`trackChanges.accept`](./accept) | `trackChanges.accept` | Yes | `conditional` | No | No | +| [`trackChanges.reject`](./reject) | `trackChanges.reject` | Yes | `conditional` | No | No | +| [`trackChanges.acceptAll`](./accept-all) | `trackChanges.acceptAll` | Yes | `conditional` | No | No | +| [`trackChanges.rejectAll`](./reject-all) | `trackChanges.rejectAll` | Yes | `conditional` | No | No | diff --git a/apps/docs/document-api/reference/track-changes/list.mdx b/apps/docs/document-api/reference/track-changes/list.mdx new file mode 100644 index 000000000..24cd808c2 --- /dev/null +++ b/apps/docs/document-api/reference/track-changes/list.mdx @@ -0,0 +1,151 @@ +--- +title: trackChanges.list +sidebarTitle: trackChanges.list +description: Generated reference for trackChanges.list +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `trackChanges.list` +- API member path: `editor.doc.trackChanges.list(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- None + +## Non-applied failure codes + +- None + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "type": { + "enum": [ + "insert", + "delete", + "format" + ] + } + }, + "type": "object" +} +``` + +## Output schema + +```json +{ + "additionalProperties": false, + "properties": { + "changes": { + "items": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + "author": { + "type": "string" + }, + "authorEmail": { + "type": "string" + }, + "authorImage": { + "type": "string" + }, + "date": { + "type": "string" + }, + "excerpt": { + "type": "string" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "insert", + "delete", + "format" + ] + } + }, + "required": [ + "address", + "id", + "type" + ], + "type": "object" + }, + "type": "array" + }, + "matches": { + "items": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + "type": "array" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "matches", + "total" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/track-changes/reject-all.mdx b/apps/docs/document-api/reference/track-changes/reject-all.mdx new file mode 100644 index 000000000..f631dbde5 --- /dev/null +++ b/apps/docs/document-api/reference/track-changes/reject-all.mdx @@ -0,0 +1,427 @@ +--- +title: trackChanges.rejectAll +sidebarTitle: trackChanges.rejectAll +description: Generated reference for trackChanges.rejectAll +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `trackChanges.rejectAll` +- API member path: `editor.doc.trackChanges.rejectAll(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": {}, + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/track-changes/reject.mdx b/apps/docs/document-api/reference/track-changes/reject.mdx new file mode 100644 index 000000000..3599a457d --- /dev/null +++ b/apps/docs/document-api/reference/track-changes/reject.mdx @@ -0,0 +1,435 @@ +--- +title: trackChanges.reject +sidebarTitle: trackChanges.reject +description: Generated reference for trackChanges.reject +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `trackChanges.reject` +- API member path: `editor.doc.trackChanges.reject(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `COMMAND_UNAVAILABLE` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": [ + "kind", + "entityType", + "entityId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "success" + ], + "type": "object" +} +``` + +## Failure schema + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` diff --git a/packages/document-api/README.md b/packages/document-api/README.md new file mode 100644 index 000000000..0bf7b6fc0 --- /dev/null +++ b/packages/document-api/README.md @@ -0,0 +1,35 @@ +# @superdoc/document-api + +Contract-first Document API package (internal workspace package). + +## Generated vs manual files + +This package intentionally checks generated artifacts into git. Use this boundary when editing: + +| Path | Source of truth | Edit directly? | +| --- | --- | --- | +| `packages/document-api/src/contract/*` | Hand-authored contract source | Yes | +| `packages/document-api/src/index.ts` and other `src/**` runtime/types | Hand-authored source | Yes | +| `packages/document-api/scripts/**` | Hand-authored generation/check tooling | Yes | +| `packages/document-api/generated/**` | Generated from contract + scripts | No (regenerate) | +| `apps/docs/document-api/reference/**` | Generated docs from contract + scripts | No (regenerate) | +| `apps/docs/document-api/overview.mdx` | Mixed: manual page + generated section between markers | Yes, but do not hand-edit inside generated marker block | + +Generated marker block in overview: + +- `/* DOC_API_GENERATED_API_SURFACE_START */` +- `/* DOC_API_GENERATED_API_SURFACE_END */` + +## Regeneration commands + +From repo root: + +```bash +pnpm exec tsx packages/document-api/scripts/generate-contract-outputs.ts +pnpm exec tsx packages/document-api/scripts/check-contract-outputs.ts +``` + +## Related docs + +- `packages/document-api/src/README.md` for contract semantics and invariants +- `packages/document-api/scripts/README.md` for script catalog and behavior diff --git a/packages/document-api/generated/agent/compatibility-hints.json b/packages/document-api/generated/agent/compatibility-hints.json new file mode 100644 index 000000000..0ae5e9596 --- /dev/null +++ b/packages/document-api/generated/agent/compatibility-hints.json @@ -0,0 +1,330 @@ +{ + "contractVersion": "0.1.0", + "operations": { + "capabilities.get": { + "deterministicTargetResolution": true, + "memberPath": "capabilities", + "mutates": false, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": false, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "comments.add": { + "deterministicTargetResolution": true, + "memberPath": "comments.add", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "comments.edit": { + "deterministicTargetResolution": true, + "memberPath": "comments.edit", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "comments.get": { + "deterministicTargetResolution": true, + "memberPath": "comments.get", + "mutates": false, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": false, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "comments.goTo": { + "deterministicTargetResolution": true, + "memberPath": "comments.goTo", + "mutates": false, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": false, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "comments.list": { + "deterministicTargetResolution": true, + "memberPath": "comments.list", + "mutates": false, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": false, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "comments.move": { + "deterministicTargetResolution": true, + "memberPath": "comments.move", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "comments.remove": { + "deterministicTargetResolution": true, + "memberPath": "comments.remove", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "comments.reply": { + "deterministicTargetResolution": true, + "memberPath": "comments.reply", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "comments.resolve": { + "deterministicTargetResolution": true, + "memberPath": "comments.resolve", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "comments.setActive": { + "deterministicTargetResolution": true, + "memberPath": "comments.setActive", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "comments.setInternal": { + "deterministicTargetResolution": true, + "memberPath": "comments.setInternal", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "create.paragraph": { + "deterministicTargetResolution": true, + "memberPath": "create.paragraph", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": true, + "supportsTrackedMode": true + }, + "delete": { + "deterministicTargetResolution": true, + "memberPath": "delete", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": true, + "supportsTrackedMode": true + }, + "find": { + "deterministicTargetResolution": false, + "memberPath": "find", + "mutates": false, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": false, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "format.bold": { + "deterministicTargetResolution": true, + "memberPath": "format.bold", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": true, + "supportsTrackedMode": true + }, + "getNode": { + "deterministicTargetResolution": true, + "memberPath": "getNode", + "mutates": false, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": false, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "getNodeById": { + "deterministicTargetResolution": true, + "memberPath": "getNodeById", + "mutates": false, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": false, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "getText": { + "deterministicTargetResolution": true, + "memberPath": "getText", + "mutates": false, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": false, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "info": { + "deterministicTargetResolution": true, + "memberPath": "info", + "mutates": false, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": false, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "insert": { + "deterministicTargetResolution": true, + "memberPath": "insert", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": true, + "supportsTrackedMode": true + }, + "lists.exit": { + "deterministicTargetResolution": true, + "memberPath": "lists.exit", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "lists.get": { + "deterministicTargetResolution": true, + "memberPath": "lists.get", + "mutates": false, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": false, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "lists.indent": { + "deterministicTargetResolution": true, + "memberPath": "lists.indent", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "lists.insert": { + "deterministicTargetResolution": true, + "memberPath": "lists.insert", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": false, + "supportsTrackedMode": true + }, + "lists.list": { + "deterministicTargetResolution": true, + "memberPath": "lists.list", + "mutates": false, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": false, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "lists.outdent": { + "deterministicTargetResolution": true, + "memberPath": "lists.outdent", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "lists.restart": { + "deterministicTargetResolution": true, + "memberPath": "lists.restart", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "lists.setType": { + "deterministicTargetResolution": true, + "memberPath": "lists.setType", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "replace": { + "deterministicTargetResolution": true, + "memberPath": "replace", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": true, + "supportsTrackedMode": true + }, + "trackChanges.accept": { + "deterministicTargetResolution": true, + "memberPath": "trackChanges.accept", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "trackChanges.acceptAll": { + "deterministicTargetResolution": true, + "memberPath": "trackChanges.acceptAll", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "trackChanges.get": { + "deterministicTargetResolution": true, + "memberPath": "trackChanges.get", + "mutates": false, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": false, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "trackChanges.list": { + "deterministicTargetResolution": true, + "memberPath": "trackChanges.list", + "mutates": false, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": false, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "trackChanges.reject": { + "deterministicTargetResolution": true, + "memberPath": "trackChanges.reject", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + "trackChanges.rejectAll": { + "deterministicTargetResolution": true, + "memberPath": "trackChanges.rejectAll", + "mutates": true, + "postApplyThrowForbidden": true, + "requiresPreflightCapabilitiesCheck": true, + "supportsDryRun": false, + "supportsTrackedMode": false + } + }, + "sourceHash": "50a44d02c22957a93c8ce085776af0197e3200f02d3b402f2625dad31a0dc756" +} diff --git a/packages/document-api/generated/agent/remediation-map.json b/packages/document-api/generated/agent/remediation-map.json new file mode 100644 index 000000000..03b72d58e --- /dev/null +++ b/packages/document-api/generated/agent/remediation-map.json @@ -0,0 +1,292 @@ +{ + "contractVersion": "0.1.0", + "entries": [ + { + "code": "CAPABILITY_UNAVAILABLE", + "message": "Check runtime capabilities and switch to supported mode or operation.", + "nonAppliedOperations": [], + "operations": [ + "comments.add", + "comments.edit", + "comments.goTo", + "comments.move", + "comments.remove", + "comments.reply", + "comments.resolve", + "comments.setActive", + "comments.setInternal", + "create.paragraph", + "delete", + "format.bold", + "insert", + "lists.exit", + "lists.indent", + "lists.insert", + "lists.outdent", + "lists.restart", + "lists.setType", + "replace", + "trackChanges.accept", + "trackChanges.acceptAll", + "trackChanges.reject", + "trackChanges.rejectAll" + ], + "preApplyOperations": [ + "comments.add", + "comments.edit", + "comments.goTo", + "comments.move", + "comments.remove", + "comments.reply", + "comments.resolve", + "comments.setActive", + "comments.setInternal", + "create.paragraph", + "delete", + "format.bold", + "insert", + "lists.exit", + "lists.indent", + "lists.insert", + "lists.outdent", + "lists.restart", + "lists.setType", + "replace", + "trackChanges.accept", + "trackChanges.acceptAll", + "trackChanges.reject", + "trackChanges.rejectAll" + ] + }, + { + "code": "COMMAND_UNAVAILABLE", + "message": "Call capabilities.get and branch to a fallback when operation availability is false.", + "nonAppliedOperations": [], + "operations": [ + "comments.add", + "comments.edit", + "comments.goTo", + "comments.move", + "comments.remove", + "comments.reply", + "comments.resolve", + "comments.setActive", + "comments.setInternal", + "create.paragraph", + "format.bold", + "lists.exit", + "lists.indent", + "lists.insert", + "lists.outdent", + "lists.restart", + "lists.setType", + "trackChanges.accept", + "trackChanges.acceptAll", + "trackChanges.reject", + "trackChanges.rejectAll" + ], + "preApplyOperations": [ + "comments.add", + "comments.edit", + "comments.goTo", + "comments.move", + "comments.remove", + "comments.reply", + "comments.resolve", + "comments.setActive", + "comments.setInternal", + "create.paragraph", + "format.bold", + "lists.exit", + "lists.indent", + "lists.insert", + "lists.outdent", + "lists.restart", + "lists.setType", + "trackChanges.accept", + "trackChanges.acceptAll", + "trackChanges.reject", + "trackChanges.rejectAll" + ] + }, + { + "code": "INVALID_TARGET", + "message": "Confirm the target shape and operation compatibility, then retry with a valid target.", + "nonAppliedOperations": [ + "comments.add", + "comments.move", + "comments.reply", + "comments.setActive", + "comments.setInternal", + "create.paragraph", + "format.bold", + "insert", + "lists.exit", + "lists.indent", + "lists.insert", + "lists.outdent", + "lists.restart", + "lists.setType", + "replace" + ], + "operations": [ + "comments.add", + "comments.move", + "comments.reply", + "comments.setActive", + "comments.setInternal", + "create.paragraph", + "format.bold", + "insert", + "lists.exit", + "lists.indent", + "lists.insert", + "lists.outdent", + "lists.restart", + "lists.setType", + "replace" + ], + "preApplyOperations": [] + }, + { + "code": "NO_OP", + "message": "Treat as idempotent no-op and avoid retry loops unless inputs change.", + "nonAppliedOperations": [ + "comments.add", + "comments.edit", + "comments.move", + "comments.remove", + "comments.resolve", + "comments.setInternal", + "delete", + "insert", + "lists.indent", + "lists.outdent", + "lists.restart", + "lists.setType", + "replace", + "trackChanges.accept", + "trackChanges.acceptAll", + "trackChanges.reject", + "trackChanges.rejectAll" + ], + "operations": [ + "comments.add", + "comments.edit", + "comments.move", + "comments.remove", + "comments.resolve", + "comments.setInternal", + "delete", + "insert", + "lists.indent", + "lists.outdent", + "lists.restart", + "lists.setType", + "replace", + "trackChanges.accept", + "trackChanges.acceptAll", + "trackChanges.reject", + "trackChanges.rejectAll" + ], + "preApplyOperations": [] + }, + { + "code": "TARGET_NOT_FOUND", + "message": "Refresh targets via find/get operations and retry with a fresh address or ID.", + "nonAppliedOperations": [], + "operations": [ + "comments.add", + "comments.edit", + "comments.get", + "comments.goTo", + "comments.move", + "comments.remove", + "comments.reply", + "comments.resolve", + "comments.setActive", + "comments.setInternal", + "create.paragraph", + "delete", + "format.bold", + "getNode", + "getNodeById", + "insert", + "lists.exit", + "lists.get", + "lists.indent", + "lists.insert", + "lists.list", + "lists.outdent", + "lists.restart", + "lists.setType", + "replace", + "trackChanges.accept", + "trackChanges.get", + "trackChanges.reject" + ], + "preApplyOperations": [ + "comments.add", + "comments.edit", + "comments.get", + "comments.goTo", + "comments.move", + "comments.remove", + "comments.reply", + "comments.resolve", + "comments.setActive", + "comments.setInternal", + "create.paragraph", + "delete", + "format.bold", + "getNode", + "getNodeById", + "insert", + "lists.exit", + "lists.get", + "lists.indent", + "lists.insert", + "lists.list", + "lists.outdent", + "lists.restart", + "lists.setType", + "replace", + "trackChanges.accept", + "trackChanges.get", + "trackChanges.reject" + ] + }, + { + "code": "TRACK_CHANGE_COMMAND_UNAVAILABLE", + "message": "Verify track-changes support via capabilities.get before requesting tracked mode.", + "nonAppliedOperations": [], + "operations": [ + "create.paragraph", + "delete", + "format.bold", + "insert", + "lists.exit", + "lists.indent", + "lists.insert", + "lists.outdent", + "lists.restart", + "lists.setType", + "replace" + ], + "preApplyOperations": [ + "create.paragraph", + "delete", + "format.bold", + "insert", + "lists.exit", + "lists.indent", + "lists.insert", + "lists.outdent", + "lists.restart", + "lists.setType", + "replace" + ] + } + ], + "sourceHash": "50a44d02c22957a93c8ce085776af0197e3200f02d3b402f2625dad31a0dc756" +} diff --git a/packages/document-api/generated/agent/workflow-playbooks.json b/packages/document-api/generated/agent/workflow-playbooks.json new file mode 100644 index 000000000..7120befea --- /dev/null +++ b/packages/document-api/generated/agent/workflow-playbooks.json @@ -0,0 +1,36 @@ +{ + "contractVersion": "0.1.0", + "sourceHash": "50a44d02c22957a93c8ce085776af0197e3200f02d3b402f2625dad31a0dc756", + "workflows": [ + { + "id": "find-mutate", + "operations": ["find", "replace"], + "title": "Find + mutate workflow" + }, + { + "id": "tracked-insert", + "operations": ["capabilities.get", "insert"], + "title": "Tracked insert workflow" + }, + { + "id": "comment-thread-lifecycle", + "operations": ["comments.add", "comments.reply", "comments.resolve"], + "title": "Comment lifecycle workflow" + }, + { + "id": "list-manipulation", + "operations": ["lists.insert", "lists.setType", "lists.indent", "lists.outdent", "lists.exit"], + "title": "List manipulation workflow" + }, + { + "id": "capabilities-aware-branching", + "operations": ["capabilities.get", "replace", "insert"], + "title": "Capabilities-aware branching workflow" + }, + { + "id": "track-change-review", + "operations": ["trackChanges.list", "trackChanges.accept", "trackChanges.reject"], + "title": "Track-change review workflow" + } + ] +} diff --git a/packages/document-api/generated/manifests/document-api-tools.json b/packages/document-api/generated/manifests/document-api-tools.json new file mode 100644 index 000000000..5e83ab394 --- /dev/null +++ b/packages/document-api/generated/manifests/document-api-tools.json @@ -0,0 +1,10705 @@ +{ + "contractVersion": "0.1.0", + "generatedAt": null, + "sourceCommit": null, + "sourceHash": "50a44d02c22957a93c8ce085776af0197e3200f02d3b402f2625dad31a0dc756", + "tools": [ + { + "description": "Read Document API data via `find`.", + "deterministicTargetResolution": false, + "idempotency": "idempotent", + "inputSchema": { + "additionalProperties": false, + "properties": { + "includeNodes": { + "type": "boolean" + }, + "includeUnknown": { + "type": "boolean" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "select": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "caseSensitive": { + "type": "boolean" + }, + "mode": { + "enum": ["contains", "regex"] + }, + "pattern": { + "type": "string" + }, + "type": { + "const": "text" + } + }, + "required": ["type", "pattern"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "enum": ["block", "inline"] + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "type": { + "const": "node" + } + }, + "required": ["type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + } + }, + "required": ["nodeType"], + "type": "object" + } + ] + }, + "within": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": ["paragraph", "heading", "listItem", "table", "tableRow", "tableCell", "image", "sdt"] + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "anchor": { + "additionalProperties": false, + "properties": { + "end": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": ["blockId", "offset"], + "type": "object" + }, + "start": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": ["blockId", "offset"], + "type": "object" + } + }, + "required": ["start", "end"], + "type": "object" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "enum": [ + "run", + "bookmark", + "comment", + "hyperlink", + "sdt", + "image", + "footnoteRef", + "tab", + "lineBreak" + ] + } + }, + "required": ["kind", "nodeType", "anchor"], + "type": "object" + } + ] + } + }, + "required": ["select"], + "type": "object" + }, + "memberPath": "find", + "mutates": false, + "name": "find", + "outputSchema": { + "additionalProperties": false, + "properties": { + "context": { + "items": { + "additionalProperties": false, + "properties": { + "address": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": ["paragraph", "heading", "listItem", "table", "tableRow", "tableCell", "image", "sdt"] + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "anchor": { + "additionalProperties": false, + "properties": { + "end": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": ["blockId", "offset"], + "type": "object" + }, + "start": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": ["blockId", "offset"], + "type": "object" + } + }, + "required": ["start", "end"], + "type": "object" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "enum": [ + "run", + "bookmark", + "comment", + "hyperlink", + "sdt", + "image", + "footnoteRef", + "tab", + "lineBreak" + ] + } + }, + "required": ["kind", "nodeType", "anchor"], + "type": "object" + } + ] + }, + "highlightRange": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + }, + "snippet": { + "type": "string" + }, + "textRanges": { + "items": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["address", "snippet", "highlightRange"], + "type": "object" + }, + "type": "array" + }, + "diagnostics": { + "items": { + "additionalProperties": false, + "properties": { + "address": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": ["paragraph", "heading", "listItem", "table", "tableRow", "tableCell", "image", "sdt"] + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "anchor": { + "additionalProperties": false, + "properties": { + "end": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": ["blockId", "offset"], + "type": "object" + }, + "start": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": ["blockId", "offset"], + "type": "object" + } + }, + "required": ["start", "end"], + "type": "object" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "enum": [ + "run", + "bookmark", + "comment", + "hyperlink", + "sdt", + "image", + "footnoteRef", + "tab", + "lineBreak" + ] + } + }, + "required": ["kind", "nodeType", "anchor"], + "type": "object" + } + ] + }, + "hint": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + }, + "type": "array" + }, + "matches": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": ["paragraph", "heading", "listItem", "table", "tableRow", "tableCell", "image", "sdt"] + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "anchor": { + "additionalProperties": false, + "properties": { + "end": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": ["blockId", "offset"], + "type": "object" + }, + "start": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": ["blockId", "offset"], + "type": "object" + } + }, + "required": ["start", "end"], + "type": "object" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "enum": [ + "run", + "bookmark", + "comment", + "hyperlink", + "sdt", + "image", + "footnoteRef", + "tab", + "lineBreak" + ] + } + }, + "required": ["kind", "nodeType", "anchor"], + "type": "object" + } + ] + }, + "type": "array" + }, + "nodes": { + "items": { + "additionalProperties": false, + "properties": { + "bodyNodes": { + "items": { + "type": "object" + }, + "type": "array" + }, + "bodyText": { + "type": "string" + }, + "kind": { + "enum": ["block", "inline"] + }, + "nodes": { + "items": { + "type": "object" + }, + "type": "array" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "properties": { + "type": "object" + }, + "summary": { + "additionalProperties": false, + "properties": { + "label": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["nodeType", "kind"], + "type": "object" + }, + "type": "array" + }, + "total": { + "type": "integer" + } + }, + "required": ["matches", "total"], + "type": "object" + }, + "possibleFailureCodes": [], + "preApplyThrows": [], + "remediationHints": [], + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Read Document API data via `getNode`.", + "deterministicTargetResolution": true, + "idempotency": "idempotent", + "inputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": ["paragraph", "heading", "listItem", "table", "tableRow", "tableCell", "image", "sdt"] + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "anchor": { + "additionalProperties": false, + "properties": { + "end": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": ["blockId", "offset"], + "type": "object" + }, + "start": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": ["blockId", "offset"], + "type": "object" + } + }, + "required": ["start", "end"], + "type": "object" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "enum": ["run", "bookmark", "comment", "hyperlink", "sdt", "image", "footnoteRef", "tab", "lineBreak"] + } + }, + "required": ["kind", "nodeType", "anchor"], + "type": "object" + } + ] + }, + "memberPath": "getNode", + "mutates": false, + "name": "getNode", + "outputSchema": { + "additionalProperties": false, + "properties": { + "bodyNodes": { + "items": { + "type": "object" + }, + "type": "array" + }, + "bodyText": { + "type": "string" + }, + "kind": { + "enum": ["block", "inline"] + }, + "nodes": { + "items": { + "type": "object" + }, + "type": "array" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "properties": { + "type": "object" + }, + "summary": { + "additionalProperties": false, + "properties": { + "label": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["nodeType", "kind"], + "type": "object" + }, + "possibleFailureCodes": [], + "preApplyThrows": ["TARGET_NOT_FOUND"], + "remediationHints": [], + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Read Document API data via `getNodeById`.", + "deterministicTargetResolution": true, + "idempotency": "idempotent", + "inputSchema": { + "additionalProperties": false, + "properties": { + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": ["paragraph", "heading", "listItem", "table", "tableRow", "tableCell", "image", "sdt"] + } + }, + "required": ["nodeId"], + "type": "object" + }, + "memberPath": "getNodeById", + "mutates": false, + "name": "getNodeById", + "outputSchema": { + "additionalProperties": false, + "properties": { + "bodyNodes": { + "items": { + "type": "object" + }, + "type": "array" + }, + "bodyText": { + "type": "string" + }, + "kind": { + "enum": ["block", "inline"] + }, + "nodes": { + "items": { + "type": "object" + }, + "type": "array" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "properties": { + "type": "object" + }, + "summary": { + "additionalProperties": false, + "properties": { + "label": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["nodeType", "kind"], + "type": "object" + }, + "possibleFailureCodes": [], + "preApplyThrows": ["TARGET_NOT_FOUND"], + "remediationHints": [], + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Read Document API data via `getText`.", + "deterministicTargetResolution": true, + "idempotency": "idempotent", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "memberPath": "getText", + "mutates": false, + "name": "getText", + "outputSchema": { + "type": "string" + }, + "possibleFailureCodes": [], + "preApplyThrows": [], + "remediationHints": [], + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Read Document API data via `info`.", + "deterministicTargetResolution": true, + "idempotency": "idempotent", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "memberPath": "info", + "mutates": false, + "name": "info", + "outputSchema": { + "additionalProperties": false, + "properties": { + "capabilities": { + "additionalProperties": false, + "properties": { + "canComment": { + "type": "boolean" + }, + "canFind": { + "type": "boolean" + }, + "canGetNode": { + "type": "boolean" + }, + "canReplace": { + "type": "boolean" + } + }, + "required": ["canFind", "canGetNode", "canComment", "canReplace"], + "type": "object" + }, + "counts": { + "additionalProperties": false, + "properties": { + "comments": { + "type": "integer" + }, + "headings": { + "type": "integer" + }, + "images": { + "type": "integer" + }, + "paragraphs": { + "type": "integer" + }, + "tables": { + "type": "integer" + }, + "words": { + "type": "integer" + } + }, + "required": ["words", "paragraphs", "headings", "tables", "images", "comments"], + "type": "object" + }, + "outline": { + "items": { + "additionalProperties": false, + "properties": { + "level": { + "type": "integer" + }, + "nodeId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["level", "text", "nodeId"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["counts", "outline", "capabilities"], + "type": "object" + }, + "possibleFailureCodes": [], + "preApplyThrows": [], + "remediationHints": [], + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Apply Document API mutation `insert`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET", "NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure", "resolution"], + "type": "object" + }, + "idempotency": "non-idempotent", + "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["text"], + "type": "object" + }, + "memberPath": "insert", + "mutates": true, + "name": "insert", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success", "resolution"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET", "NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure", "resolution"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["INVALID_TARGET", "NO_OP"], + "preApplyThrows": ["TARGET_NOT_FOUND", "TRACK_CHANGE_COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success", "resolution"], + "type": "object" + }, + "supportsDryRun": true, + "supportsTrackedMode": true + }, + { + "description": "Apply Document API mutation `replace`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET", "NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure", "resolution"], + "type": "object" + }, + "idempotency": "conditional", + "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "text"], + "type": "object" + }, + "memberPath": "replace", + "mutates": true, + "name": "replace", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success", "resolution"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET", "NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure", "resolution"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["INVALID_TARGET", "NO_OP"], + "preApplyThrows": ["TARGET_NOT_FOUND", "TRACK_CHANGE_COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success", "resolution"], + "type": "object" + }, + "supportsDryRun": true, + "supportsTrackedMode": true + }, + { + "description": "Apply Document API mutation `delete`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure", "resolution"], + "type": "object" + }, + "idempotency": "conditional", + "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + } + }, + "required": ["target"], + "type": "object" + }, + "memberPath": "delete", + "mutates": true, + "name": "delete", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success", "resolution"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure", "resolution"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["NO_OP"], + "preApplyThrows": ["TARGET_NOT_FOUND", "TRACK_CHANGE_COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success", "resolution"], + "type": "object" + }, + "supportsDryRun": true, + "supportsTrackedMode": true + }, + { + "description": "Apply Document API mutation `format.bold`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure", "resolution"], + "type": "object" + }, + "idempotency": "conditional", + "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + } + }, + "required": ["target"], + "type": "object" + }, + "memberPath": "format.bold", + "mutates": true, + "name": "format.bold", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success", "resolution"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure", "resolution"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["INVALID_TARGET"], + "preApplyThrows": [ + "TARGET_NOT_FOUND", + "COMMAND_UNAVAILABLE", + "TRACK_CHANGE_COMMAND_UNAVAILABLE", + "CAPABILITY_UNAVAILABLE" + ], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success", "resolution"], + "type": "object" + }, + "supportsDryRun": true, + "supportsTrackedMode": true + }, + { + "description": "Apply Document API mutation `create.paragraph`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "idempotency": "non-idempotent", + "inputSchema": { + "additionalProperties": false, + "properties": { + "at": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "documentStart" + } + }, + "required": ["kind"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "documentEnd" + } + }, + "required": ["kind"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "before" + }, + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": ["paragraph", "heading", "listItem", "table", "tableRow", "tableCell", "image", "sdt"] + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + } + }, + "required": ["kind", "target"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "after" + }, + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": ["paragraph", "heading", "listItem", "table", "tableRow", "tableCell", "image", "sdt"] + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + } + }, + "required": ["kind", "target"], + "type": "object" + } + ] + }, + "text": { + "type": "string" + } + }, + "type": "object" + }, + "memberPath": "create.paragraph", + "mutates": true, + "name": "create.paragraph", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "insertionPoint": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "paragraph": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "paragraph" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + }, + "trackedChangeRefs": { + "items": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["success", "paragraph", "insertionPoint"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["INVALID_TARGET"], + "preApplyThrows": [ + "TARGET_NOT_FOUND", + "COMMAND_UNAVAILABLE", + "TRACK_CHANGE_COMMAND_UNAVAILABLE", + "CAPABILITY_UNAVAILABLE" + ], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "insertionPoint": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "paragraph": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "paragraph" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + }, + "trackedChangeRefs": { + "items": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["success", "paragraph", "insertionPoint"], + "type": "object" + }, + "supportsDryRun": true, + "supportsTrackedMode": true + }, + { + "description": "Read Document API data via `lists.list`.", + "deterministicTargetResolution": true, + "idempotency": "idempotent", + "inputSchema": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": ["ordered", "bullet"] + }, + "level": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "ordinal": { + "type": "integer" + }, + "within": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": ["paragraph", "heading", "listItem", "table", "tableRow", "tableCell", "image", "sdt"] + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + } + }, + "type": "object" + }, + "memberPath": "lists.list", + "mutates": false, + "name": "lists.list", + "outputSchema": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "kind": { + "enum": ["ordered", "bullet"] + }, + "level": { + "type": "integer" + }, + "marker": { + "type": "string" + }, + "ordinal": { + "type": "integer" + }, + "path": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": ["address"], + "type": "object" + }, + "type": "array" + }, + "matches": { + "items": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "type": "array" + }, + "total": { + "type": "integer" + } + }, + "required": ["matches", "total", "items"], + "type": "object" + }, + "possibleFailureCodes": [], + "preApplyThrows": ["TARGET_NOT_FOUND"], + "remediationHints": [], + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Read Document API data via `lists.get`.", + "deterministicTargetResolution": true, + "idempotency": "idempotent", + "inputSchema": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + } + }, + "required": ["address"], + "type": "object" + }, + "memberPath": "lists.get", + "mutates": false, + "name": "lists.get", + "outputSchema": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "kind": { + "enum": ["ordered", "bullet"] + }, + "level": { + "type": "integer" + }, + "marker": { + "type": "string" + }, + "ordinal": { + "type": "integer" + }, + "path": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": ["address"], + "type": "object" + }, + "possibleFailureCodes": [], + "preApplyThrows": ["TARGET_NOT_FOUND"], + "remediationHints": [], + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Apply Document API mutation `lists.insert`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "idempotency": "non-idempotent", + "inputSchema": { + "additionalProperties": false, + "properties": { + "position": { + "enum": ["before", "after"] + }, + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "position"], + "type": "object" + }, + "memberPath": "lists.insert", + "mutates": true, + "name": "lists.insert", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "insertionPoint": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + }, + "trackedChangeRefs": { + "items": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["success", "item", "insertionPoint"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["INVALID_TARGET"], + "preApplyThrows": [ + "TARGET_NOT_FOUND", + "COMMAND_UNAVAILABLE", + "TRACK_CHANGE_COMMAND_UNAVAILABLE", + "CAPABILITY_UNAVAILABLE" + ], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "insertionPoint": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + }, + "trackedChangeRefs": { + "items": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["success", "item", "insertionPoint"], + "type": "object" + }, + "supportsDryRun": false, + "supportsTrackedMode": true + }, + { + "description": "Apply Document API mutation `lists.setType`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP", "INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "idempotency": "conditional", + "inputSchema": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": ["ordered", "bullet"] + }, + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + } + }, + "required": ["target", "kind"], + "type": "object" + }, + "memberPath": "lists.setType", + "mutates": true, + "name": "lists.setType", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": ["success", "item"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP", "INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["NO_OP", "INVALID_TARGET"], + "preApplyThrows": [ + "TARGET_NOT_FOUND", + "COMMAND_UNAVAILABLE", + "TRACK_CHANGE_COMMAND_UNAVAILABLE", + "CAPABILITY_UNAVAILABLE" + ], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": ["success", "item"], + "type": "object" + }, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Apply Document API mutation `lists.indent`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP", "INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "idempotency": "conditional", + "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + } + }, + "required": ["target"], + "type": "object" + }, + "memberPath": "lists.indent", + "mutates": true, + "name": "lists.indent", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": ["success", "item"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP", "INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["NO_OP", "INVALID_TARGET"], + "preApplyThrows": [ + "TARGET_NOT_FOUND", + "COMMAND_UNAVAILABLE", + "TRACK_CHANGE_COMMAND_UNAVAILABLE", + "CAPABILITY_UNAVAILABLE" + ], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": ["success", "item"], + "type": "object" + }, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Apply Document API mutation `lists.outdent`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP", "INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "idempotency": "conditional", + "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + } + }, + "required": ["target"], + "type": "object" + }, + "memberPath": "lists.outdent", + "mutates": true, + "name": "lists.outdent", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": ["success", "item"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP", "INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["NO_OP", "INVALID_TARGET"], + "preApplyThrows": [ + "TARGET_NOT_FOUND", + "COMMAND_UNAVAILABLE", + "TRACK_CHANGE_COMMAND_UNAVAILABLE", + "CAPABILITY_UNAVAILABLE" + ], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": ["success", "item"], + "type": "object" + }, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Apply Document API mutation `lists.restart`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP", "INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "idempotency": "conditional", + "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + } + }, + "required": ["target"], + "type": "object" + }, + "memberPath": "lists.restart", + "mutates": true, + "name": "lists.restart", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": ["success", "item"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP", "INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["NO_OP", "INVALID_TARGET"], + "preApplyThrows": [ + "TARGET_NOT_FOUND", + "COMMAND_UNAVAILABLE", + "TRACK_CHANGE_COMMAND_UNAVAILABLE", + "CAPABILITY_UNAVAILABLE" + ], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": ["success", "item"], + "type": "object" + }, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Apply Document API mutation `lists.exit`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "idempotency": "conditional", + "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + } + }, + "required": ["target"], + "type": "object" + }, + "memberPath": "lists.exit", + "mutates": true, + "name": "lists.exit", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "paragraph": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "paragraph" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": ["success", "paragraph"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["INVALID_TARGET"], + "preApplyThrows": [ + "TARGET_NOT_FOUND", + "COMMAND_UNAVAILABLE", + "TRACK_CHANGE_COMMAND_UNAVAILABLE", + "CAPABILITY_UNAVAILABLE" + ], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "paragraph": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "paragraph" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": ["success", "paragraph"], + "type": "object" + }, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Apply Document API mutation `comments.add`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET", "NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "idempotency": "non-idempotent", + "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "text"], + "type": "object" + }, + "memberPath": "comments.add", + "mutates": true, + "name": "comments.add", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET", "NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["INVALID_TARGET", "NO_OP"], + "preApplyThrows": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Apply Document API mutation `comments.edit`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "idempotency": "conditional", + "inputSchema": { + "additionalProperties": false, + "properties": { + "commentId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["commentId", "text"], + "type": "object" + }, + "memberPath": "comments.edit", + "mutates": true, + "name": "comments.edit", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["NO_OP"], + "preApplyThrows": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Apply Document API mutation `comments.reply`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "idempotency": "non-idempotent", + "inputSchema": { + "additionalProperties": false, + "properties": { + "parentCommentId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["parentCommentId", "text"], + "type": "object" + }, + "memberPath": "comments.reply", + "mutates": true, + "name": "comments.reply", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["INVALID_TARGET"], + "preApplyThrows": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Apply Document API mutation `comments.move`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET", "NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "idempotency": "conditional", + "inputSchema": { + "additionalProperties": false, + "properties": { + "commentId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + } + }, + "required": ["commentId", "target"], + "type": "object" + }, + "memberPath": "comments.move", + "mutates": true, + "name": "comments.move", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET", "NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["INVALID_TARGET", "NO_OP"], + "preApplyThrows": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Apply Document API mutation `comments.resolve`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "idempotency": "conditional", + "inputSchema": { + "additionalProperties": false, + "properties": { + "commentId": { + "type": "string" + } + }, + "required": ["commentId"], + "type": "object" + }, + "memberPath": "comments.resolve", + "mutates": true, + "name": "comments.resolve", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["NO_OP"], + "preApplyThrows": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Apply Document API mutation `comments.remove`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "idempotency": "conditional", + "inputSchema": { + "additionalProperties": false, + "properties": { + "commentId": { + "type": "string" + } + }, + "required": ["commentId"], + "type": "object" + }, + "memberPath": "comments.remove", + "mutates": true, + "name": "comments.remove", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["NO_OP"], + "preApplyThrows": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Apply Document API mutation `comments.setInternal`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP", "INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "idempotency": "conditional", + "inputSchema": { + "additionalProperties": false, + "properties": { + "commentId": { + "type": "string" + }, + "isInternal": { + "type": "boolean" + } + }, + "required": ["commentId", "isInternal"], + "type": "object" + }, + "memberPath": "comments.setInternal", + "mutates": true, + "name": "comments.setInternal", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP", "INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["NO_OP", "INVALID_TARGET"], + "preApplyThrows": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Apply Document API mutation `comments.setActive`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "idempotency": "conditional", + "inputSchema": { + "additionalProperties": false, + "properties": { + "commentId": { + "type": ["string", "null"] + } + }, + "required": ["commentId"], + "type": "object" + }, + "memberPath": "comments.setActive", + "mutates": true, + "name": "comments.setActive", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["INVALID_TARGET"], + "preApplyThrows": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Read Document API data via `comments.goTo`.", + "deterministicTargetResolution": true, + "idempotency": "conditional", + "inputSchema": { + "additionalProperties": false, + "properties": { + "commentId": { + "type": "string" + } + }, + "required": ["commentId"], + "type": "object" + }, + "memberPath": "comments.goTo", + "mutates": false, + "name": "comments.goTo", + "outputSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + "possibleFailureCodes": [], + "preApplyThrows": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"], + "remediationHints": [], + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Read Document API data via `comments.get`.", + "deterministicTargetResolution": true, + "idempotency": "idempotent", + "inputSchema": { + "additionalProperties": false, + "properties": { + "commentId": { + "type": "string" + } + }, + "required": ["commentId"], + "type": "object" + }, + "memberPath": "comments.get", + "mutates": false, + "name": "comments.get", + "outputSchema": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + "commentId": { + "type": "string" + }, + "createdTime": { + "type": "number" + }, + "creatorEmail": { + "type": "string" + }, + "creatorName": { + "type": "string" + }, + "importedId": { + "type": "string" + }, + "isInternal": { + "type": "boolean" + }, + "parentCommentId": { + "type": "string" + }, + "status": { + "enum": ["open", "resolved"] + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["address", "commentId", "status"], + "type": "object" + }, + "possibleFailureCodes": [], + "preApplyThrows": ["TARGET_NOT_FOUND"], + "remediationHints": [], + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Read Document API data via `comments.list`.", + "deterministicTargetResolution": true, + "idempotency": "idempotent", + "inputSchema": { + "additionalProperties": false, + "properties": { + "includeResolved": { + "type": "boolean" + } + }, + "type": "object" + }, + "memberPath": "comments.list", + "mutates": false, + "name": "comments.list", + "outputSchema": { + "additionalProperties": false, + "properties": { + "matches": { + "items": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + "commentId": { + "type": "string" + }, + "createdTime": { + "type": "number" + }, + "creatorEmail": { + "type": "string" + }, + "creatorName": { + "type": "string" + }, + "importedId": { + "type": "string" + }, + "isInternal": { + "type": "boolean" + }, + "parentCommentId": { + "type": "string" + }, + "status": { + "enum": ["open", "resolved"] + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["address", "commentId", "status"], + "type": "object" + }, + "type": "array" + }, + "total": { + "type": "integer" + } + }, + "required": ["matches", "total"], + "type": "object" + }, + "possibleFailureCodes": [], + "preApplyThrows": [], + "remediationHints": [], + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Read Document API data via `trackChanges.list`.", + "deterministicTargetResolution": true, + "idempotency": "idempotent", + "inputSchema": { + "additionalProperties": false, + "properties": { + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "type": { + "enum": ["insert", "delete", "format"] + } + }, + "type": "object" + }, + "memberPath": "trackChanges.list", + "mutates": false, + "name": "trackChanges.list", + "outputSchema": { + "additionalProperties": false, + "properties": { + "changes": { + "items": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + "author": { + "type": "string" + }, + "authorEmail": { + "type": "string" + }, + "authorImage": { + "type": "string" + }, + "date": { + "type": "string" + }, + "excerpt": { + "type": "string" + }, + "id": { + "type": "string" + }, + "type": { + "enum": ["insert", "delete", "format"] + } + }, + "required": ["address", "id", "type"], + "type": "object" + }, + "type": "array" + }, + "matches": { + "items": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + "type": "array" + }, + "total": { + "type": "integer" + } + }, + "required": ["matches", "total"], + "type": "object" + }, + "possibleFailureCodes": [], + "preApplyThrows": [], + "remediationHints": [], + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Read Document API data via `trackChanges.get`.", + "deterministicTargetResolution": true, + "idempotency": "idempotent", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "type": "object" + }, + "memberPath": "trackChanges.get", + "mutates": false, + "name": "trackChanges.get", + "outputSchema": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + "author": { + "type": "string" + }, + "authorEmail": { + "type": "string" + }, + "authorImage": { + "type": "string" + }, + "date": { + "type": "string" + }, + "excerpt": { + "type": "string" + }, + "id": { + "type": "string" + }, + "type": { + "enum": ["insert", "delete", "format"] + } + }, + "required": ["address", "id", "type"], + "type": "object" + }, + "possibleFailureCodes": [], + "preApplyThrows": ["TARGET_NOT_FOUND"], + "remediationHints": [], + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Apply Document API mutation `trackChanges.accept`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "idempotency": "conditional", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "type": "object" + }, + "memberPath": "trackChanges.accept", + "mutates": true, + "name": "trackChanges.accept", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["NO_OP"], + "preApplyThrows": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Apply Document API mutation `trackChanges.reject`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "idempotency": "conditional", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "type": "object" + }, + "memberPath": "trackChanges.reject", + "mutates": true, + "name": "trackChanges.reject", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["NO_OP"], + "preApplyThrows": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Apply Document API mutation `trackChanges.acceptAll`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "idempotency": "conditional", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "memberPath": "trackChanges.acceptAll", + "mutates": true, + "name": "trackChanges.acceptAll", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["NO_OP"], + "preApplyThrows": ["COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Apply Document API mutation `trackChanges.rejectAll`.", + "deterministicTargetResolution": true, + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "idempotency": "conditional", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "memberPath": "trackChanges.rejectAll", + "mutates": true, + "name": "trackChanges.rejectAll", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "possibleFailureCodes": ["NO_OP"], + "preApplyThrows": ["COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"], + "remediationHints": [], + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + "supportsDryRun": false, + "supportsTrackedMode": false + }, + { + "description": "Read Document API data via `capabilities.get`.", + "deterministicTargetResolution": true, + "idempotency": "idempotent", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "memberPath": "capabilities", + "mutates": false, + "name": "capabilities.get", + "outputSchema": { + "additionalProperties": false, + "properties": { + "global": { + "additionalProperties": false, + "properties": { + "comments": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + } + }, + "required": ["enabled"], + "type": "object" + }, + "dryRun": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + } + }, + "required": ["enabled"], + "type": "object" + }, + "lists": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + } + }, + "required": ["enabled"], + "type": "object" + }, + "trackChanges": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + } + }, + "required": ["enabled"], + "type": "object" + } + }, + "required": ["trackChanges", "comments", "lists", "dryRun"], + "type": "object" + }, + "operations": { + "additionalProperties": false, + "properties": { + "capabilities.get": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "comments.add": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "comments.edit": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "comments.get": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "comments.goTo": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "comments.list": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "comments.move": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "comments.remove": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "comments.reply": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "comments.resolve": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "comments.setActive": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "comments.setInternal": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "create.paragraph": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "delete": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "find": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "format.bold": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "getNode": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "getNodeById": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "getText": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "info": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "insert": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "lists.exit": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "lists.get": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "lists.indent": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "lists.insert": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "lists.list": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "lists.outdent": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "lists.restart": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "lists.setType": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "replace": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "trackChanges.accept": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "trackChanges.acceptAll": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "trackChanges.get": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "trackChanges.list": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "trackChanges.reject": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "trackChanges.rejectAll": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + } + }, + "required": [ + "find", + "getNode", + "getNodeById", + "getText", + "info", + "insert", + "replace", + "delete", + "format.bold", + "create.paragraph", + "lists.list", + "lists.get", + "lists.insert", + "lists.setType", + "lists.indent", + "lists.outdent", + "lists.restart", + "lists.exit", + "comments.add", + "comments.edit", + "comments.reply", + "comments.move", + "comments.resolve", + "comments.remove", + "comments.setInternal", + "comments.setActive", + "comments.goTo", + "comments.get", + "comments.list", + "trackChanges.list", + "trackChanges.get", + "trackChanges.accept", + "trackChanges.reject", + "trackChanges.acceptAll", + "trackChanges.rejectAll", + "capabilities.get" + ], + "type": "object" + } + }, + "required": ["global", "operations"], + "type": "object" + }, + "possibleFailureCodes": [], + "preApplyThrows": [], + "remediationHints": [], + "supportsDryRun": false, + "supportsTrackedMode": false + } + ] +} diff --git a/packages/document-api/generated/schemas/README.md b/packages/document-api/generated/schemas/README.md new file mode 100644 index 000000000..f8de1f16c --- /dev/null +++ b/packages/document-api/generated/schemas/README.md @@ -0,0 +1,4 @@ +# Generated Document API schemas + +GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. +This directory is generated from `packages/document-api/src/contract/*`. diff --git a/packages/document-api/generated/schemas/document-api-contract.json b/packages/document-api/generated/schemas/document-api-contract.json new file mode 100644 index 000000000..95ddafbde --- /dev/null +++ b/packages/document-api/generated/schemas/document-api-contract.json @@ -0,0 +1,10778 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "contractVersion": "0.1.0", + "generatedAt": null, + "operations": { + "capabilities.get": { + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "memberPath": "capabilities", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "idempotent", + "mutates": false, + "possibleFailureCodes": [], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": [] + } + }, + "outputSchema": { + "additionalProperties": false, + "properties": { + "global": { + "additionalProperties": false, + "properties": { + "comments": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + } + }, + "required": ["enabled"], + "type": "object" + }, + "dryRun": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + } + }, + "required": ["enabled"], + "type": "object" + }, + "lists": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + } + }, + "required": ["enabled"], + "type": "object" + }, + "trackChanges": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + } + }, + "required": ["enabled"], + "type": "object" + } + }, + "required": ["trackChanges", "comments", "lists", "dryRun"], + "type": "object" + }, + "operations": { + "additionalProperties": false, + "properties": { + "capabilities.get": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "comments.add": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "comments.edit": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "comments.get": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "comments.goTo": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "comments.list": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "comments.move": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "comments.remove": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "comments.reply": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "comments.resolve": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "comments.setActive": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "comments.setInternal": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "create.paragraph": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "delete": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "find": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "format.bold": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "getNode": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "getNodeById": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "getText": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "info": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "insert": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "lists.exit": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "lists.get": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "lists.indent": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "lists.insert": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "lists.list": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "lists.outdent": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "lists.restart": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "lists.setType": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "replace": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "trackChanges.accept": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "trackChanges.acceptAll": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "trackChanges.get": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "trackChanges.list": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "trackChanges.reject": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + }, + "trackChanges.rejectAll": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": ["available", "tracked", "dryRun"], + "type": "object" + } + }, + "required": [ + "find", + "getNode", + "getNodeById", + "getText", + "info", + "insert", + "replace", + "delete", + "format.bold", + "create.paragraph", + "lists.list", + "lists.get", + "lists.insert", + "lists.setType", + "lists.indent", + "lists.outdent", + "lists.restart", + "lists.exit", + "comments.add", + "comments.edit", + "comments.reply", + "comments.move", + "comments.resolve", + "comments.remove", + "comments.setInternal", + "comments.setActive", + "comments.goTo", + "comments.get", + "comments.list", + "trackChanges.list", + "trackChanges.get", + "trackChanges.accept", + "trackChanges.reject", + "trackChanges.acceptAll", + "trackChanges.rejectAll", + "capabilities.get" + ], + "type": "object" + } + }, + "required": ["global", "operations"], + "type": "object" + } + }, + "comments.add": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET", "NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "text"], + "type": "object" + }, + "memberPath": "comments.add", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "non-idempotent", + "mutates": true, + "possibleFailureCodes": ["INVALID_TARGET", "NO_OP"], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET", "NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + } + }, + "comments.edit": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "commentId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["commentId", "text"], + "type": "object" + }, + "memberPath": "comments.edit", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "conditional", + "mutates": true, + "possibleFailureCodes": ["NO_OP"], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + } + }, + "comments.get": { + "inputSchema": { + "additionalProperties": false, + "properties": { + "commentId": { + "type": "string" + } + }, + "required": ["commentId"], + "type": "object" + }, + "memberPath": "comments.get", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "idempotent", + "mutates": false, + "possibleFailureCodes": [], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": ["TARGET_NOT_FOUND"] + } + }, + "outputSchema": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + "commentId": { + "type": "string" + }, + "createdTime": { + "type": "number" + }, + "creatorEmail": { + "type": "string" + }, + "creatorName": { + "type": "string" + }, + "importedId": { + "type": "string" + }, + "isInternal": { + "type": "boolean" + }, + "parentCommentId": { + "type": "string" + }, + "status": { + "enum": ["open", "resolved"] + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["address", "commentId", "status"], + "type": "object" + } + }, + "comments.goTo": { + "inputSchema": { + "additionalProperties": false, + "properties": { + "commentId": { + "type": "string" + } + }, + "required": ["commentId"], + "type": "object" + }, + "memberPath": "comments.goTo", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "conditional", + "mutates": false, + "possibleFailureCodes": [], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"] + } + }, + "outputSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + } + }, + "comments.list": { + "inputSchema": { + "additionalProperties": false, + "properties": { + "includeResolved": { + "type": "boolean" + } + }, + "type": "object" + }, + "memberPath": "comments.list", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "idempotent", + "mutates": false, + "possibleFailureCodes": [], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": [] + } + }, + "outputSchema": { + "additionalProperties": false, + "properties": { + "matches": { + "items": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + "commentId": { + "type": "string" + }, + "createdTime": { + "type": "number" + }, + "creatorEmail": { + "type": "string" + }, + "creatorName": { + "type": "string" + }, + "importedId": { + "type": "string" + }, + "isInternal": { + "type": "boolean" + }, + "parentCommentId": { + "type": "string" + }, + "status": { + "enum": ["open", "resolved"] + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["address", "commentId", "status"], + "type": "object" + }, + "type": "array" + }, + "total": { + "type": "integer" + } + }, + "required": ["matches", "total"], + "type": "object" + } + }, + "comments.move": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET", "NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "commentId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + } + }, + "required": ["commentId", "target"], + "type": "object" + }, + "memberPath": "comments.move", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "conditional", + "mutates": true, + "possibleFailureCodes": ["INVALID_TARGET", "NO_OP"], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET", "NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + } + }, + "comments.remove": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "commentId": { + "type": "string" + } + }, + "required": ["commentId"], + "type": "object" + }, + "memberPath": "comments.remove", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "conditional", + "mutates": true, + "possibleFailureCodes": ["NO_OP"], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + } + }, + "comments.reply": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "parentCommentId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["parentCommentId", "text"], + "type": "object" + }, + "memberPath": "comments.reply", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "non-idempotent", + "mutates": true, + "possibleFailureCodes": ["INVALID_TARGET"], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + } + }, + "comments.resolve": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "commentId": { + "type": "string" + } + }, + "required": ["commentId"], + "type": "object" + }, + "memberPath": "comments.resolve", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "conditional", + "mutates": true, + "possibleFailureCodes": ["NO_OP"], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + } + }, + "comments.setActive": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "commentId": { + "type": ["string", "null"] + } + }, + "required": ["commentId"], + "type": "object" + }, + "memberPath": "comments.setActive", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "conditional", + "mutates": true, + "possibleFailureCodes": ["INVALID_TARGET"], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + } + }, + "comments.setInternal": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP", "INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "commentId": { + "type": "string" + }, + "isInternal": { + "type": "boolean" + } + }, + "required": ["commentId", "isInternal"], + "type": "object" + }, + "memberPath": "comments.setInternal", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "conditional", + "mutates": true, + "possibleFailureCodes": ["NO_OP", "INVALID_TARGET"], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP", "INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + } + }, + "create.paragraph": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "at": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "documentStart" + } + }, + "required": ["kind"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "documentEnd" + } + }, + "required": ["kind"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "before" + }, + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": ["paragraph", "heading", "listItem", "table", "tableRow", "tableCell", "image", "sdt"] + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + } + }, + "required": ["kind", "target"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "after" + }, + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": ["paragraph", "heading", "listItem", "table", "tableRow", "tableCell", "image", "sdt"] + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + } + }, + "required": ["kind", "target"], + "type": "object" + } + ] + }, + "text": { + "type": "string" + } + }, + "type": "object" + }, + "memberPath": "create.paragraph", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "non-idempotent", + "mutates": true, + "possibleFailureCodes": ["INVALID_TARGET"], + "supportsDryRun": true, + "supportsTrackedMode": true, + "throws": { + "postApplyForbidden": true, + "preApply": [ + "TARGET_NOT_FOUND", + "COMMAND_UNAVAILABLE", + "TRACK_CHANGE_COMMAND_UNAVAILABLE", + "CAPABILITY_UNAVAILABLE" + ] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "insertionPoint": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "paragraph": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "paragraph" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + }, + "trackedChangeRefs": { + "items": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["success", "paragraph", "insertionPoint"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "insertionPoint": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "paragraph": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "paragraph" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + }, + "trackedChangeRefs": { + "items": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["success", "paragraph", "insertionPoint"], + "type": "object" + } + }, + "delete": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure", "resolution"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + } + }, + "required": ["target"], + "type": "object" + }, + "memberPath": "delete", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "conditional", + "mutates": true, + "possibleFailureCodes": ["NO_OP"], + "supportsDryRun": true, + "supportsTrackedMode": true, + "throws": { + "postApplyForbidden": true, + "preApply": ["TARGET_NOT_FOUND", "TRACK_CHANGE_COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success", "resolution"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure", "resolution"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success", "resolution"], + "type": "object" + } + }, + "find": { + "inputSchema": { + "additionalProperties": false, + "properties": { + "includeNodes": { + "type": "boolean" + }, + "includeUnknown": { + "type": "boolean" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "select": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "caseSensitive": { + "type": "boolean" + }, + "mode": { + "enum": ["contains", "regex"] + }, + "pattern": { + "type": "string" + }, + "type": { + "const": "text" + } + }, + "required": ["type", "pattern"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "enum": ["block", "inline"] + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "type": { + "const": "node" + } + }, + "required": ["type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + } + }, + "required": ["nodeType"], + "type": "object" + } + ] + }, + "within": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": ["paragraph", "heading", "listItem", "table", "tableRow", "tableCell", "image", "sdt"] + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "anchor": { + "additionalProperties": false, + "properties": { + "end": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": ["blockId", "offset"], + "type": "object" + }, + "start": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": ["blockId", "offset"], + "type": "object" + } + }, + "required": ["start", "end"], + "type": "object" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "enum": [ + "run", + "bookmark", + "comment", + "hyperlink", + "sdt", + "image", + "footnoteRef", + "tab", + "lineBreak" + ] + } + }, + "required": ["kind", "nodeType", "anchor"], + "type": "object" + } + ] + } + }, + "required": ["select"], + "type": "object" + }, + "memberPath": "find", + "metadata": { + "deterministicTargetResolution": false, + "idempotency": "idempotent", + "mutates": false, + "possibleFailureCodes": [], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": [] + } + }, + "outputSchema": { + "additionalProperties": false, + "properties": { + "context": { + "items": { + "additionalProperties": false, + "properties": { + "address": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": ["paragraph", "heading", "listItem", "table", "tableRow", "tableCell", "image", "sdt"] + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "anchor": { + "additionalProperties": false, + "properties": { + "end": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": ["blockId", "offset"], + "type": "object" + }, + "start": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": ["blockId", "offset"], + "type": "object" + } + }, + "required": ["start", "end"], + "type": "object" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "enum": [ + "run", + "bookmark", + "comment", + "hyperlink", + "sdt", + "image", + "footnoteRef", + "tab", + "lineBreak" + ] + } + }, + "required": ["kind", "nodeType", "anchor"], + "type": "object" + } + ] + }, + "highlightRange": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + }, + "snippet": { + "type": "string" + }, + "textRanges": { + "items": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["address", "snippet", "highlightRange"], + "type": "object" + }, + "type": "array" + }, + "diagnostics": { + "items": { + "additionalProperties": false, + "properties": { + "address": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": ["paragraph", "heading", "listItem", "table", "tableRow", "tableCell", "image", "sdt"] + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "anchor": { + "additionalProperties": false, + "properties": { + "end": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": ["blockId", "offset"], + "type": "object" + }, + "start": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": ["blockId", "offset"], + "type": "object" + } + }, + "required": ["start", "end"], + "type": "object" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "enum": [ + "run", + "bookmark", + "comment", + "hyperlink", + "sdt", + "image", + "footnoteRef", + "tab", + "lineBreak" + ] + } + }, + "required": ["kind", "nodeType", "anchor"], + "type": "object" + } + ] + }, + "hint": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + }, + "type": "array" + }, + "matches": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": ["paragraph", "heading", "listItem", "table", "tableRow", "tableCell", "image", "sdt"] + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "anchor": { + "additionalProperties": false, + "properties": { + "end": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": ["blockId", "offset"], + "type": "object" + }, + "start": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": ["blockId", "offset"], + "type": "object" + } + }, + "required": ["start", "end"], + "type": "object" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "enum": [ + "run", + "bookmark", + "comment", + "hyperlink", + "sdt", + "image", + "footnoteRef", + "tab", + "lineBreak" + ] + } + }, + "required": ["kind", "nodeType", "anchor"], + "type": "object" + } + ] + }, + "type": "array" + }, + "nodes": { + "items": { + "additionalProperties": false, + "properties": { + "bodyNodes": { + "items": { + "type": "object" + }, + "type": "array" + }, + "bodyText": { + "type": "string" + }, + "kind": { + "enum": ["block", "inline"] + }, + "nodes": { + "items": { + "type": "object" + }, + "type": "array" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "properties": { + "type": "object" + }, + "summary": { + "additionalProperties": false, + "properties": { + "label": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["nodeType", "kind"], + "type": "object" + }, + "type": "array" + }, + "total": { + "type": "integer" + } + }, + "required": ["matches", "total"], + "type": "object" + } + }, + "format.bold": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure", "resolution"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + } + }, + "required": ["target"], + "type": "object" + }, + "memberPath": "format.bold", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "conditional", + "mutates": true, + "possibleFailureCodes": ["INVALID_TARGET"], + "supportsDryRun": true, + "supportsTrackedMode": true, + "throws": { + "postApplyForbidden": true, + "preApply": [ + "TARGET_NOT_FOUND", + "COMMAND_UNAVAILABLE", + "TRACK_CHANGE_COMMAND_UNAVAILABLE", + "CAPABILITY_UNAVAILABLE" + ] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success", "resolution"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure", "resolution"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success", "resolution"], + "type": "object" + } + }, + "getNode": { + "inputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": ["paragraph", "heading", "listItem", "table", "tableRow", "tableCell", "image", "sdt"] + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "anchor": { + "additionalProperties": false, + "properties": { + "end": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": ["blockId", "offset"], + "type": "object" + }, + "start": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": ["blockId", "offset"], + "type": "object" + } + }, + "required": ["start", "end"], + "type": "object" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "enum": ["run", "bookmark", "comment", "hyperlink", "sdt", "image", "footnoteRef", "tab", "lineBreak"] + } + }, + "required": ["kind", "nodeType", "anchor"], + "type": "object" + } + ] + }, + "memberPath": "getNode", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "idempotent", + "mutates": false, + "possibleFailureCodes": [], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": ["TARGET_NOT_FOUND"] + } + }, + "outputSchema": { + "additionalProperties": false, + "properties": { + "bodyNodes": { + "items": { + "type": "object" + }, + "type": "array" + }, + "bodyText": { + "type": "string" + }, + "kind": { + "enum": ["block", "inline"] + }, + "nodes": { + "items": { + "type": "object" + }, + "type": "array" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "properties": { + "type": "object" + }, + "summary": { + "additionalProperties": false, + "properties": { + "label": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["nodeType", "kind"], + "type": "object" + } + }, + "getNodeById": { + "inputSchema": { + "additionalProperties": false, + "properties": { + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": ["paragraph", "heading", "listItem", "table", "tableRow", "tableCell", "image", "sdt"] + } + }, + "required": ["nodeId"], + "type": "object" + }, + "memberPath": "getNodeById", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "idempotent", + "mutates": false, + "possibleFailureCodes": [], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": ["TARGET_NOT_FOUND"] + } + }, + "outputSchema": { + "additionalProperties": false, + "properties": { + "bodyNodes": { + "items": { + "type": "object" + }, + "type": "array" + }, + "bodyText": { + "type": "string" + }, + "kind": { + "enum": ["block", "inline"] + }, + "nodes": { + "items": { + "type": "object" + }, + "type": "array" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "properties": { + "type": "object" + }, + "summary": { + "additionalProperties": false, + "properties": { + "label": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["nodeType", "kind"], + "type": "object" + } + }, + "getText": { + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "memberPath": "getText", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "idempotent", + "mutates": false, + "possibleFailureCodes": [], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": [] + } + }, + "outputSchema": { + "type": "string" + } + }, + "info": { + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "memberPath": "info", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "idempotent", + "mutates": false, + "possibleFailureCodes": [], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": [] + } + }, + "outputSchema": { + "additionalProperties": false, + "properties": { + "capabilities": { + "additionalProperties": false, + "properties": { + "canComment": { + "type": "boolean" + }, + "canFind": { + "type": "boolean" + }, + "canGetNode": { + "type": "boolean" + }, + "canReplace": { + "type": "boolean" + } + }, + "required": ["canFind", "canGetNode", "canComment", "canReplace"], + "type": "object" + }, + "counts": { + "additionalProperties": false, + "properties": { + "comments": { + "type": "integer" + }, + "headings": { + "type": "integer" + }, + "images": { + "type": "integer" + }, + "paragraphs": { + "type": "integer" + }, + "tables": { + "type": "integer" + }, + "words": { + "type": "integer" + } + }, + "required": ["words", "paragraphs", "headings", "tables", "images", "comments"], + "type": "object" + }, + "outline": { + "items": { + "additionalProperties": false, + "properties": { + "level": { + "type": "integer" + }, + "nodeId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["level", "text", "nodeId"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["counts", "outline", "capabilities"], + "type": "object" + } + }, + "insert": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET", "NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure", "resolution"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["text"], + "type": "object" + }, + "memberPath": "insert", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "non-idempotent", + "mutates": true, + "possibleFailureCodes": ["INVALID_TARGET", "NO_OP"], + "supportsDryRun": true, + "supportsTrackedMode": true, + "throws": { + "postApplyForbidden": true, + "preApply": ["TARGET_NOT_FOUND", "TRACK_CHANGE_COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success", "resolution"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET", "NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure", "resolution"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success", "resolution"], + "type": "object" + } + }, + "lists.exit": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + } + }, + "required": ["target"], + "type": "object" + }, + "memberPath": "lists.exit", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "conditional", + "mutates": true, + "possibleFailureCodes": ["INVALID_TARGET"], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": [ + "TARGET_NOT_FOUND", + "COMMAND_UNAVAILABLE", + "TRACK_CHANGE_COMMAND_UNAVAILABLE", + "CAPABILITY_UNAVAILABLE" + ] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "paragraph": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "paragraph" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": ["success", "paragraph"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "paragraph": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "paragraph" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": ["success", "paragraph"], + "type": "object" + } + }, + "lists.get": { + "inputSchema": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + } + }, + "required": ["address"], + "type": "object" + }, + "memberPath": "lists.get", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "idempotent", + "mutates": false, + "possibleFailureCodes": [], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": ["TARGET_NOT_FOUND"] + } + }, + "outputSchema": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "kind": { + "enum": ["ordered", "bullet"] + }, + "level": { + "type": "integer" + }, + "marker": { + "type": "string" + }, + "ordinal": { + "type": "integer" + }, + "path": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": ["address"], + "type": "object" + } + }, + "lists.indent": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP", "INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + } + }, + "required": ["target"], + "type": "object" + }, + "memberPath": "lists.indent", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "conditional", + "mutates": true, + "possibleFailureCodes": ["NO_OP", "INVALID_TARGET"], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": [ + "TARGET_NOT_FOUND", + "COMMAND_UNAVAILABLE", + "TRACK_CHANGE_COMMAND_UNAVAILABLE", + "CAPABILITY_UNAVAILABLE" + ] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": ["success", "item"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP", "INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": ["success", "item"], + "type": "object" + } + }, + "lists.insert": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "position": { + "enum": ["before", "after"] + }, + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "position"], + "type": "object" + }, + "memberPath": "lists.insert", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "non-idempotent", + "mutates": true, + "possibleFailureCodes": ["INVALID_TARGET"], + "supportsDryRun": false, + "supportsTrackedMode": true, + "throws": { + "postApplyForbidden": true, + "preApply": [ + "TARGET_NOT_FOUND", + "COMMAND_UNAVAILABLE", + "TRACK_CHANGE_COMMAND_UNAVAILABLE", + "CAPABILITY_UNAVAILABLE" + ] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "insertionPoint": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + }, + "trackedChangeRefs": { + "items": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["success", "item", "insertionPoint"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "insertionPoint": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + }, + "trackedChangeRefs": { + "items": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["success", "item", "insertionPoint"], + "type": "object" + } + }, + "lists.list": { + "inputSchema": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": ["ordered", "bullet"] + }, + "level": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "ordinal": { + "type": "integer" + }, + "within": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": ["paragraph", "heading", "listItem", "table", "tableRow", "tableCell", "image", "sdt"] + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + } + }, + "type": "object" + }, + "memberPath": "lists.list", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "idempotent", + "mutates": false, + "possibleFailureCodes": [], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": ["TARGET_NOT_FOUND"] + } + }, + "outputSchema": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "kind": { + "enum": ["ordered", "bullet"] + }, + "level": { + "type": "integer" + }, + "marker": { + "type": "string" + }, + "ordinal": { + "type": "integer" + }, + "path": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "text": { + "type": "string" + } + }, + "required": ["address"], + "type": "object" + }, + "type": "array" + }, + "matches": { + "items": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "type": "array" + }, + "total": { + "type": "integer" + } + }, + "required": ["matches", "total", "items"], + "type": "object" + } + }, + "lists.outdent": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP", "INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + } + }, + "required": ["target"], + "type": "object" + }, + "memberPath": "lists.outdent", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "conditional", + "mutates": true, + "possibleFailureCodes": ["NO_OP", "INVALID_TARGET"], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": [ + "TARGET_NOT_FOUND", + "COMMAND_UNAVAILABLE", + "TRACK_CHANGE_COMMAND_UNAVAILABLE", + "CAPABILITY_UNAVAILABLE" + ] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": ["success", "item"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP", "INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": ["success", "item"], + "type": "object" + } + }, + "lists.restart": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP", "INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + } + }, + "required": ["target"], + "type": "object" + }, + "memberPath": "lists.restart", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "conditional", + "mutates": true, + "possibleFailureCodes": ["NO_OP", "INVALID_TARGET"], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": [ + "TARGET_NOT_FOUND", + "COMMAND_UNAVAILABLE", + "TRACK_CHANGE_COMMAND_UNAVAILABLE", + "CAPABILITY_UNAVAILABLE" + ] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": ["success", "item"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP", "INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": ["success", "item"], + "type": "object" + } + }, + "lists.setType": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP", "INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": ["ordered", "bullet"] + }, + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + } + }, + "required": ["target", "kind"], + "type": "object" + }, + "memberPath": "lists.setType", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "conditional", + "mutates": true, + "possibleFailureCodes": ["NO_OP", "INVALID_TARGET"], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": [ + "TARGET_NOT_FOUND", + "COMMAND_UNAVAILABLE", + "TRACK_CHANGE_COMMAND_UNAVAILABLE", + "CAPABILITY_UNAVAILABLE" + ] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": ["success", "item"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP", "INVALID_TARGET"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "item": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "listItem" + } + }, + "required": ["kind", "nodeType", "nodeId"], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": ["success", "item"], + "type": "object" + } + }, + "replace": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET", "NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure", "resolution"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "text"], + "type": "object" + }, + "memberPath": "replace", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "conditional", + "mutates": true, + "possibleFailureCodes": ["INVALID_TARGET", "NO_OP"], + "supportsDryRun": true, + "supportsTrackedMode": true, + "throws": { + "postApplyForbidden": true, + "preApply": ["TARGET_NOT_FOUND", "TRACK_CHANGE_COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success", "resolution"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["INVALID_TARGET", "NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure", "resolution"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "range": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": ["from", "to"], + "type": "object" + }, + "requestedTarget": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": ["start", "end"], + "type": "object" + } + }, + "required": ["kind", "blockId", "range"], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": ["target", "range", "text"], + "type": "object" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success", "resolution"], + "type": "object" + } + }, + "trackChanges.accept": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "type": "object" + }, + "memberPath": "trackChanges.accept", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "conditional", + "mutates": true, + "possibleFailureCodes": ["NO_OP"], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + } + }, + "trackChanges.acceptAll": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "memberPath": "trackChanges.acceptAll", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "conditional", + "mutates": true, + "possibleFailureCodes": ["NO_OP"], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": ["COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + } + }, + "trackChanges.get": { + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "type": "object" + }, + "memberPath": "trackChanges.get", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "idempotent", + "mutates": false, + "possibleFailureCodes": [], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": ["TARGET_NOT_FOUND"] + } + }, + "outputSchema": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + "author": { + "type": "string" + }, + "authorEmail": { + "type": "string" + }, + "authorImage": { + "type": "string" + }, + "date": { + "type": "string" + }, + "excerpt": { + "type": "string" + }, + "id": { + "type": "string" + }, + "type": { + "enum": ["insert", "delete", "format"] + } + }, + "required": ["address", "id", "type"], + "type": "object" + } + }, + "trackChanges.list": { + "inputSchema": { + "additionalProperties": false, + "properties": { + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "type": { + "enum": ["insert", "delete", "format"] + } + }, + "type": "object" + }, + "memberPath": "trackChanges.list", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "idempotent", + "mutates": false, + "possibleFailureCodes": [], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": [] + } + }, + "outputSchema": { + "additionalProperties": false, + "properties": { + "changes": { + "items": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + "author": { + "type": "string" + }, + "authorEmail": { + "type": "string" + }, + "authorImage": { + "type": "string" + }, + "date": { + "type": "string" + }, + "excerpt": { + "type": "string" + }, + "id": { + "type": "string" + }, + "type": { + "enum": ["insert", "delete", "format"] + } + }, + "required": ["address", "id", "type"], + "type": "object" + }, + "type": "array" + }, + "matches": { + "items": { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + "type": "array" + }, + "total": { + "type": "integer" + } + }, + "required": ["matches", "total"], + "type": "object" + } + }, + "trackChanges.reject": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "type": "object" + }, + "memberPath": "trackChanges.reject", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "conditional", + "mutates": true, + "possibleFailureCodes": ["NO_OP"], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + } + }, + "trackChanges.rejectAll": { + "failureSchema": { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + }, + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "memberPath": "trackChanges.rejectAll", + "metadata": { + "deterministicTargetResolution": true, + "idempotency": "conditional", + "mutates": true, + "possibleFailureCodes": ["NO_OP"], + "supportsDryRun": false, + "supportsTrackedMode": false, + "throws": { + "postApplyForbidden": true, + "preApply": ["COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"] + } + }, + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": ["NO_OP"] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": ["success", "failure"], + "type": "object" + } + ] + }, + "successSchema": { + "additionalProperties": false, + "properties": { + "inserted": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "removed": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + }, + "success": { + "const": true + }, + "updated": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "comment" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string" + }, + "entityType": { + "const": "trackedChange" + }, + "kind": { + "const": "entity" + } + }, + "required": ["kind", "entityType", "entityId"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": ["success"], + "type": "object" + } + } + }, + "sourceCommit": null, + "sourceHash": "50a44d02c22957a93c8ce085776af0197e3200f02d3b402f2625dad31a0dc756" +} diff --git a/packages/document-api/scripts/README.md b/packages/document-api/scripts/README.md index 9603a2e88..ae7a0a9f3 100644 --- a/packages/document-api/scripts/README.md +++ b/packages/document-api/scripts/README.md @@ -9,6 +9,19 @@ This folder contains deterministic generator/check entry points for the Document - In this repository snapshot, these scripts are not directly referenced from root `package.json` scripts or `.github/workflows`. - Typical caller today: local ad-hoc invocations or higher-level wrappers in feature branches/CI jobs. +## Manual vs generated boundaries + +- Hand-authored inputs: + - `packages/document-api/src/contract/*` + - `packages/document-api/src/index.ts` and related runtime/types + - `packages/document-api/scripts/*` +- Generated outputs (checked into git): + - `packages/document-api/generated/*` + - `apps/docs/document-api/reference/*` + - generated marker block in `apps/docs/document-api/overview.mdx` + +Do not hand-edit generated output files. Regenerate instead. + ## Script index | Script | Kind | Purpose | Reads | Writes | Typical caller | diff --git a/packages/document-api/scripts/lib/reference-docs-artifacts.ts b/packages/document-api/scripts/lib/reference-docs-artifacts.ts index 0f6746ccc..79151f311 100644 --- a/packages/document-api/scripts/lib/reference-docs-artifacts.ts +++ b/packages/document-api/scripts/lib/reference-docs-artifacts.ts @@ -18,8 +18,8 @@ const GENERATED_MARKER = '{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm const OUTPUT_ROOT = 'apps/docs/document-api/reference'; const REFERENCE_INDEX_PATH = `${OUTPUT_ROOT}/index.mdx`; const OVERVIEW_PATH = 'apps/docs/document-api/overview.mdx'; -const OVERVIEW_API_SURFACE_START = '{/* DOC_API_GENERATED_API_SURFACE_START */}'; -const OVERVIEW_API_SURFACE_END = '{/* DOC_API_GENERATED_API_SURFACE_END */}'; +const OVERVIEW_OPERATIONS_START = '{/* DOC_API_OPERATIONS_START */}'; +const OVERVIEW_OPERATIONS_END = '{/* DOC_API_OPERATIONS_END */}'; interface OperationGroup { definition: ReferenceOperationGroupDefinition; @@ -236,32 +236,32 @@ function renderOverviewApiSurfaceSection(operations: ContractOperationSnapshot[] }) .join('\n'); - return `${OVERVIEW_API_SURFACE_START} -### Current API surface (generated) + return `${OVERVIEW_OPERATIONS_START} +### Available operations -This section is generated from the canonical contract. Update the contract, then run \`pnpm run docapi:sync\`. +Use the tables below to see what operations are available and where each one is documented. | Namespace | Operations | Reference | | --- | --- | --- | ${namespaceRows} -| API member path | Operation ID | +| Editor method | Operation ID | | --- | --- | ${operationRows} -${OVERVIEW_API_SURFACE_END}`; +${OVERVIEW_OPERATIONS_END}`; } function replaceOverviewSection(content: string, section: string): string { - const startIndex = content.indexOf(OVERVIEW_API_SURFACE_START); - const endIndex = content.indexOf(OVERVIEW_API_SURFACE_END); + const startIndex = content.indexOf(OVERVIEW_OPERATIONS_START); + const endIndex = content.indexOf(OVERVIEW_OPERATIONS_END); if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) { throw new Error( - `overview marker block not found in ${OVERVIEW_PATH}. Expected ${OVERVIEW_API_SURFACE_START} ... ${OVERVIEW_API_SURFACE_END}.`, + `overview marker block not found in ${OVERVIEW_PATH}. Expected ${OVERVIEW_OPERATIONS_START} ... ${OVERVIEW_OPERATIONS_END}.`, ); } - const endMarkerEndIndex = endIndex + OVERVIEW_API_SURFACE_END.length; + const endMarkerEndIndex = endIndex + OVERVIEW_OPERATIONS_END.length; return `${content.slice(0, startIndex)}${section}${content.slice(endMarkerEndIndex)}`; } @@ -372,9 +372,9 @@ export function getOverviewDocsPath(): string { } export function getOverviewApiSurfaceStartMarker(): string { - return OVERVIEW_API_SURFACE_START; + return OVERVIEW_OPERATIONS_START; } export function getOverviewApiSurfaceEndMarker(): string { - return OVERVIEW_API_SURFACE_END; + return OVERVIEW_OPERATIONS_END; } diff --git a/packages/document-api/src/README.md b/packages/document-api/src/README.md index e36a42f8f..3679fb737 100644 --- a/packages/document-api/src/README.md +++ b/packages/document-api/src/README.md @@ -1,5 +1,17 @@ # Document API +## Ownership boundary (manual vs generated) + +- Manual source of truth: + - `packages/document-api/src/**` (this folder) + - `packages/document-api/scripts/**` +- Generated and committed: + - `packages/document-api/generated/**` + - `apps/docs/document-api/reference/**` + - marker block in `apps/docs/document-api/overview.mdx` + +Do not hand-edit generated files; regenerate via script. + ## Non-Negotiables - The Document API modules are engine-agnostic and must never parse or depend on ProseMirror directly. diff --git a/packages/document-api/src/contract/command-catalog.ts b/packages/document-api/src/contract/command-catalog.ts index b9230228d..35891c940 100644 --- a/packages/document-api/src/contract/command-catalog.ts +++ b/packages/document-api/src/contract/command-catalog.ts @@ -53,13 +53,14 @@ function mutationOperation(options: { } const T_NOT_FOUND = ['TARGET_NOT_FOUND'] as const; -const T_COMMAND = ['COMMAND_UNAVAILABLE'] as const; -const T_NOT_FOUND_COMMAND = ['TARGET_NOT_FOUND', 'COMMAND_UNAVAILABLE'] as const; -const T_NOT_FOUND_TRACKED = ['TARGET_NOT_FOUND', 'TRACK_CHANGE_COMMAND_UNAVAILABLE'] as const; +const T_COMMAND = ['COMMAND_UNAVAILABLE', 'CAPABILITY_UNAVAILABLE'] as const; +const T_NOT_FOUND_COMMAND = ['TARGET_NOT_FOUND', 'COMMAND_UNAVAILABLE', 'CAPABILITY_UNAVAILABLE'] as const; +const T_NOT_FOUND_TRACKED = ['TARGET_NOT_FOUND', 'TRACK_CHANGE_COMMAND_UNAVAILABLE', 'CAPABILITY_UNAVAILABLE'] as const; const T_NOT_FOUND_COMMAND_TRACKED = [ 'TARGET_NOT_FOUND', 'COMMAND_UNAVAILABLE', 'TRACK_CHANGE_COMMAND_UNAVAILABLE', + 'CAPABILITY_UNAVAILABLE', ] as const; export const COMMAND_CATALOG: CommandCatalog = { diff --git a/packages/document-api/src/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts index c3d78b92e..02bb98200 100644 --- a/packages/document-api/src/contract/contract.test.ts +++ b/packages/document-api/src/contract/contract.test.ts @@ -51,6 +51,17 @@ describe('document-api contract catalog', () => { } }); + it('includes CAPABILITY_UNAVAILABLE in throws.preApply for all mutation operations', () => { + for (const operationId of OPERATION_IDS) { + const metadata = COMMAND_CATALOG[operationId]; + if (!metadata.mutates) continue; + expect( + metadata.throws.preApply, + `${operationId} should include CAPABILITY_UNAVAILABLE in throws.preApply`, + ).toContain('CAPABILITY_UNAVAILABLE'); + } + }); + it('keeps input schemas closed for object-shaped payloads', () => { const schemas = buildInternalContractSchemas(); diff --git a/packages/document-api/src/index.test.ts b/packages/document-api/src/index.test.ts index d123f2aac..6c90cfcc4 100644 --- a/packages/document-api/src/index.test.ts +++ b/packages/document-api/src/index.test.ts @@ -21,6 +21,7 @@ import { createDocumentApi } from './index.js'; import type { CommentInfo, CommentsListQuery, CommentsListResult } from './comments/comments.types.js'; import type { CreateAdapter } from './create/create.js'; import type { ListsAdapter } from './lists/lists.js'; +import type { CapabilitiesAdapter, DocumentApiCapabilities } from './capabilities/capabilities.js'; function makeFindAdapter(result: QueryResult): FindAdapter { return { find: vi.fn(() => result) }; @@ -183,6 +184,21 @@ function makeListsAdapter(): ListsAdapter { }; } +function makeCapabilitiesAdapter(overrides?: Partial): CapabilitiesAdapter { + const defaultCapabilities: DocumentApiCapabilities = { + global: { + trackChanges: { enabled: false }, + comments: { enabled: false }, + lists: { enabled: false }, + dryRun: { enabled: false }, + }, + operations: {} as DocumentApiCapabilities['operations'], + }; + return { + get: vi.fn(() => ({ ...defaultCapabilities, ...overrides })), + }; +} + const PARAGRAPH_ADDRESS: NodeAddress = { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }; const PARAGRAPH_INFO: NodeInfo = { @@ -590,4 +606,27 @@ describe('createDocumentApi', () => { expect(listsAdpt.restart).toHaveBeenCalledWith({ target }, { changeMode: 'direct', dryRun: false }); expect(listsAdpt.exit).toHaveBeenCalledWith({ target }, { changeMode: 'direct', dryRun: false }); }); + + it('exposes capabilities as a callable function with .get() alias', () => { + const capAdpt = makeCapabilitiesAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: capAdpt, + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const directResult = api.capabilities(); + const getResult = api.capabilities.get(); + + expect(directResult).toEqual(getResult); + expect(capAdpt.get).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index eb187355e..3114899bf 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -157,6 +157,16 @@ export type { } from './comments/comments.js'; export type { CommentInfo, CommentsListQuery, CommentsListResult } from './comments/comments.types.js'; +/** + * Callable capability accessor returned by `createDocumentApi`. + * + * Can be invoked directly (`capabilities()`) or via the `.get()` alias. + */ +export interface CapabilitiesApi { + (): DocumentApiCapabilities; + get(): DocumentApiCapabilities; +} + /** * The Document API interface for querying and inspecting document nodes. */ @@ -229,10 +239,10 @@ export interface DocumentApi { lists: ListsApi; /** * Runtime capability introspection. + * + * Callable directly (`capabilities()`) or via `.get()`. */ - capabilities: { - get(): DocumentApiCapabilities; - }; + capabilities: CapabilitiesApi; } export interface DocumentApiAdapters { @@ -266,10 +276,8 @@ export interface DocumentApiAdapters { * ``` */ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { - const capabilities = (() => executeCapabilities(adapters.capabilities)) as (() => DocumentApiCapabilities) & { - get: () => DocumentApiCapabilities; - }; - capabilities.get = capabilities; + const capFn = () => executeCapabilities(adapters.capabilities); + const capabilities: CapabilitiesApi = Object.assign(capFn, { get: capFn }); return { find(selectorOrQuery: Selector | Query, options?: FindOptions): QueryResult { From da87f461af842b558617037e76ea4fda6a4996a5 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Feb 2026 14:25:16 -0800 Subject: [PATCH 15/25] chore(super-editor): add doc-api helper utilities and coordsAtPos regression test --- apps/docs/document-api/overview.mdx | 2 +- .../scripts/vendor-document-api-types.cjs | 91 +++++++ .../src/core/Editor.coordsAtPos.test.ts | 47 ++++ .../helpers/transaction-meta.test.ts | 51 ++++ .../helpers/transaction-meta.ts | 27 +++ .../helpers/comment-target-resolver.js | 152 ++++++++++++ .../helpers/comment-target-resolver.test.js | 225 ++++++++++++++++++ 7 files changed, 594 insertions(+), 1 deletion(-) create mode 100644 packages/super-editor/scripts/vendor-document-api-types.cjs create mode 100644 packages/super-editor/src/core/Editor.coordsAtPos.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/transaction-meta.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/transaction-meta.ts create mode 100644 packages/super-editor/src/extensions/comment/helpers/comment-target-resolver.js create mode 100644 packages/super-editor/src/extensions/comment/helpers/comment-target-resolver.test.js diff --git a/apps/docs/document-api/overview.mdx b/apps/docs/document-api/overview.mdx index e457dfe51..d1d4f26bd 100644 --- a/apps/docs/document-api/overview.mdx +++ b/apps/docs/document-api/overview.mdx @@ -8,7 +8,7 @@ keywords: "document api, programmatic access, query documents, document manipula Document API gives you a consistent way to read and edit documents without relying on editor internals. -Document API is in alpha and subject to breaking changes while the contract and adapters continue to evolve. +Document API is in alpha and subject to breaking changes while the contract and adapters continue to evolve. The current API is not yet comprehensive, and more commands and namespaces are being added on an ongoing basis. ## Why use Document API diff --git a/packages/super-editor/scripts/vendor-document-api-types.cjs b/packages/super-editor/scripts/vendor-document-api-types.cjs new file mode 100644 index 000000000..25436279f --- /dev/null +++ b/packages/super-editor/scripts/vendor-document-api-types.cjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const path = require('node:path'); + +const packageRoot = path.resolve(__dirname, '..'); +const distRoot = path.join(packageRoot, 'dist'); +const documentApiDistRoot = path.resolve(packageRoot, '..', 'document-api', 'dist', 'src'); +const vendoredDocumentApiRoot = path.join(distRoot, 'document-api'); + +const toPosix = (value) => value.split(path.sep).join('/'); + +const ensureDotRelative = (value) => { + if (!value) return '.'; + if (value.startsWith('.')) return value; + return `./${value}`; +}; + +const copyDir = (src, dest) => { + fs.mkdirSync(dest, { recursive: true }); + const entries = fs.readdirSync(src, { withFileTypes: true }); + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + if (entry.isDirectory()) { + copyDir(srcPath, destPath); + continue; + } + if (entry.isFile()) { + fs.copyFileSync(srcPath, destPath); + } + } +}; + +const rewriteDeclarationImports = (filePath) => { + const original = fs.readFileSync(filePath, 'utf8'); + if (!original.includes('@superdoc/document-api')) return false; + + const relativeBase = ensureDotRelative(toPosix(path.relative(path.dirname(filePath), vendoredDocumentApiRoot))); + const localIndexPath = `${relativeBase}/index.js`; + const localTypesPath = `${relativeBase}/types/index.js`; + + const rewritten = original + .replace(/(['"])@superdoc\/document-api\/types\1/g, `$1${localTypesPath}$1`) + .replace(/(['"])@superdoc\/document-api\1/g, `$1${localIndexPath}$1`); + + if (rewritten === original) return false; + fs.writeFileSync(filePath, rewritten, 'utf8'); + return true; +}; + +const visitDeclarations = (dirPath, onFile) => { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const childPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + visitDeclarations(childPath, onFile); + continue; + } + if (entry.isFile() && entry.name.endsWith('.d.ts')) { + onFile(childPath); + } + } +}; + +if (!fs.existsSync(distRoot)) { + console.error(`[vendor-document-api-types] Missing dist directory: ${distRoot}`); + process.exit(1); +} + +if (!fs.existsSync(documentApiDistRoot)) { + console.error( + `[vendor-document-api-types] Missing document-api declarations at ${documentApiDistRoot}. ` + + 'Run `pnpm --dir ../document-api exec tsc -p tsconfig.json` first.', + ); + process.exit(1); +} + +copyDir(documentApiDistRoot, vendoredDocumentApiRoot); + +let rewrittenCount = 0; +visitDeclarations(distRoot, (filePath) => { + if (rewriteDeclarationImports(filePath)) { + rewrittenCount += 1; + } +}); + +console.log( + `[vendor-document-api-types] Vendored document-api declarations into ${path.relative(packageRoot, vendoredDocumentApiRoot)}.`, +); +console.log(`[vendor-document-api-types] Rewrote ${rewrittenCount} declaration file(s).`); diff --git a/packages/super-editor/src/core/Editor.coordsAtPos.test.ts b/packages/super-editor/src/core/Editor.coordsAtPos.test.ts new file mode 100644 index 000000000..6974289af --- /dev/null +++ b/packages/super-editor/src/core/Editor.coordsAtPos.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Editor } from './Editor.js'; + +function makeCoords(left: number, top: number) { + return { + left, + top, + right: left + 10, + bottom: top + 10, + width: 10, + height: 10, + }; +} + +describe('Editor.coordsAtPos', () => { + it('prefers PresentationEditor coordinates when available', () => { + const presentationCoords = makeCoords(100, 200); + const pmCoords = makeCoords(1, 2); + + const presentationEditor = { + coordsAtPos: vi.fn(() => presentationCoords), + }; + const view = { + coordsAtPos: vi.fn(() => pmCoords), + }; + + const editor = { presentationEditor, view } as unknown as Editor; + const result = Editor.prototype.coordsAtPos.call(editor, 7); + + expect(result).toEqual(presentationCoords); + expect(presentationEditor.coordsAtPos).toHaveBeenCalledWith(7); + expect(view.coordsAtPos).not.toHaveBeenCalled(); + }); + + it('falls back to ProseMirror view coordinates when presentation editor is absent', () => { + const pmCoords = makeCoords(3, 4); + const view = { + coordsAtPos: vi.fn(() => pmCoords), + }; + + const editor = { presentationEditor: null, view } as unknown as Editor; + const result = Editor.prototype.coordsAtPos.call(editor, 5); + + expect(result).toEqual(pmCoords); + expect(view.coordsAtPos).toHaveBeenCalledWith(5); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/transaction-meta.test.ts b/packages/super-editor/src/document-api-adapters/helpers/transaction-meta.test.ts new file mode 100644 index 000000000..a9a91ba76 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/transaction-meta.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest'; +import { applyDirectMutationMeta, applyTrackedMutationMeta } from './transaction-meta.js'; + +function makeFakeTransaction() { + const meta = new Map(); + return { + setMeta: vi.fn((key: string, value: unknown) => meta.set(key, value)), + getMeta: (key: string) => meta.get(key), + _meta: meta, + }; +} + +describe('applyDirectMutationMeta', () => { + it('sets inputType to programmatic', () => { + const tr = makeFakeTransaction(); + applyDirectMutationMeta(tr as any); + expect(tr.setMeta).toHaveBeenCalledWith('inputType', 'programmatic'); + }); + + it('sets skipTrackChanges to true', () => { + const tr = makeFakeTransaction(); + applyDirectMutationMeta(tr as any); + expect(tr.setMeta).toHaveBeenCalledWith('skipTrackChanges', true); + }); + + it('returns the same transaction', () => { + const tr = makeFakeTransaction(); + const result = applyDirectMutationMeta(tr as any); + expect(result).toBe(tr); + }); +}); + +describe('applyTrackedMutationMeta', () => { + it('sets inputType to programmatic', () => { + const tr = makeFakeTransaction(); + applyTrackedMutationMeta(tr as any); + expect(tr.setMeta).toHaveBeenCalledWith('inputType', 'programmatic'); + }); + + it('sets forceTrackChanges to true', () => { + const tr = makeFakeTransaction(); + applyTrackedMutationMeta(tr as any); + expect(tr.setMeta).toHaveBeenCalledWith('forceTrackChanges', true); + }); + + it('returns the same transaction', () => { + const tr = makeFakeTransaction(); + const result = applyTrackedMutationMeta(tr as any); + expect(result).toBe(tr); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/transaction-meta.ts b/packages/super-editor/src/document-api-adapters/helpers/transaction-meta.ts new file mode 100644 index 000000000..7a6725973 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/transaction-meta.ts @@ -0,0 +1,27 @@ +import type { Transaction } from 'prosemirror-state'; + +/** + * Applies metadata required for direct (non-tracked) document-api mutations. + * This prevents active track-changes sessions from transforming direct writes. + * + * @param tr - The ProseMirror transaction to annotate + * @returns The same transaction, with `inputType` and `skipTrackChanges` meta set + */ +export function applyDirectMutationMeta(tr: Transaction): Transaction { + tr.setMeta('inputType', 'programmatic'); + tr.setMeta('skipTrackChanges', true); + return tr; +} + +/** + * Applies metadata required for tracked mutations implemented via raw transactions. + * Tracked write operations that call tracked commands directly do not use this helper. + * + * @param tr - The ProseMirror transaction to annotate + * @returns The same transaction, with `inputType` and `forceTrackChanges` meta set + */ +export function applyTrackedMutationMeta(tr: Transaction): Transaction { + tr.setMeta('inputType', 'programmatic'); + tr.setMeta('forceTrackChanges', true); + return tr; +} diff --git a/packages/super-editor/src/extensions/comment/helpers/comment-target-resolver.js b/packages/super-editor/src/extensions/comment/helpers/comment-target-resolver.js new file mode 100644 index 000000000..d89d7930c --- /dev/null +++ b/packages/super-editor/src/extensions/comment/helpers/comment-target-resolver.js @@ -0,0 +1,152 @@ +import { CommentMarkName } from '../comments-constants.js'; + +const COMMENT_RANGE_NODE_TYPES = new Set(['commentRangeStart', 'commentRangeEnd']); + +const toNonEmptyString = (value) => { + if (typeof value !== 'string') return null; + return value.length > 0 ? value : null; +}; + +const resolveMoveIds = ({ commentId, importedId }) => { + const canonicalId = toNonEmptyString(commentId); + const candidateImportedId = toNonEmptyString(importedId); + const fallbackImportedId = candidateImportedId && candidateImportedId !== canonicalId ? candidateImportedId : null; + return { canonicalId, fallbackImportedId }; +}; + +const collectMarkSegmentsByAttr = (doc, attrName, attrValue) => { + const segments = []; + doc.descendants((node, pos) => { + if (!node.isInline) return; + const commentMark = node.marks?.find( + (mark) => mark.type.name === CommentMarkName && mark.attrs?.[attrName] === attrValue, + ); + if (!commentMark) return; + segments.push({ + from: pos, + to: pos + node.nodeSize, + attrs: commentMark.attrs ?? {}, + mark: commentMark, + }); + }); + return segments; +}; + +const collectAnchorsById = (doc, id) => { + const anchors = []; + doc.descendants((node, pos) => { + if (!COMMENT_RANGE_NODE_TYPES.has(node.type?.name)) return; + if (node.attrs?.['w:id'] !== id) return; + anchors.push({ + pos, + typeName: node.type.name, + attrs: { ...node.attrs }, + }); + }); + return anchors; +}; + +/** + * Resolve which identity should be used when mutating comment marks/anchors. + * + * Resolution order: + * 1) canonical ID (commentId) + * 2) imported ID fallback (only if canonical has no targets) + * + * @param {import('prosemirror-model').Node} doc - The ProseMirror document to search + * @param {Object} ids - Comment identifiers to resolve + * @param {string} [ids.commentId] - Canonical comment ID + * @param {string} [ids.importedId] - Imported comment ID (used as fallback) + * @returns {{ status: 'resolved', strategy: 'canonical' | 'imported-fallback', matchId: string, canonicalId: string | null, fallbackImportedId: string | null } | { status: 'unresolved', reason: 'missing-identifiers' | 'no-targets' } | { status: 'ambiguous', reason: 'multiple-comment-ids' | 'canonical-mismatch', matchId: string }} + */ +export const resolveCommentIdentity = (doc, { commentId, importedId }) => { + const { canonicalId, fallbackImportedId } = resolveMoveIds({ commentId, importedId }); + + if (!canonicalId && !fallbackImportedId) { + return { status: 'unresolved', reason: 'missing-identifiers' }; + } + + if (canonicalId) { + const canonicalMarks = collectMarkSegmentsByAttr(doc, 'commentId', canonicalId); + const canonicalAnchors = collectAnchorsById(doc, canonicalId); + if (canonicalMarks.length > 0 || canonicalAnchors.length > 0) { + return { + status: 'resolved', + strategy: 'canonical', + matchId: canonicalId, + canonicalId, + fallbackImportedId, + }; + } + } + + if (!fallbackImportedId) { + return { status: 'unresolved', reason: 'no-targets' }; + } + + const fallbackMarks = collectMarkSegmentsByAttr(doc, 'importedId', fallbackImportedId); + const fallbackAnchors = collectAnchorsById(doc, fallbackImportedId); + if (fallbackMarks.length === 0 && fallbackAnchors.length === 0) { + return { status: 'unresolved', reason: 'no-targets' }; + } + + const distinctCommentIds = new Set( + fallbackMarks.map((segment) => toNonEmptyString(segment.attrs?.commentId)).filter((id) => !!id), + ); + if (distinctCommentIds.size > 1) { + return { status: 'ambiguous', reason: 'multiple-comment-ids', matchId: fallbackImportedId }; + } + + if (canonicalId && distinctCommentIds.size === 1 && !distinctCommentIds.has(canonicalId)) { + return { status: 'ambiguous', reason: 'canonical-mismatch', matchId: fallbackImportedId }; + } + + return { + status: 'resolved', + strategy: 'imported-fallback', + matchId: fallbackImportedId, + canonicalId, + fallbackImportedId, + }; +}; + +/** + * Collect all inline-node segments that carry a comment mark matching the resolved identity. + * + * @param {import('prosemirror-model').Node} doc - The ProseMirror document + * @param {ReturnType} identity - A resolved identity from {@link resolveCommentIdentity} + * @returns {Array<{ from: number, to: number, attrs: Record, mark: import('prosemirror-model').Mark }>} Mark segments, empty when identity is not resolved + */ +export const collectCommentMarkSegments = (doc, identity) => { + if (!identity || identity.status !== 'resolved') return []; + const attrName = identity.strategy === 'canonical' ? 'commentId' : 'importedId'; + return collectMarkSegmentsByAttr(doc, attrName, identity.matchId); +}; + +/** + * Collect commentRangeStart/commentRangeEnd anchor nodes matching the resolved identity. + * + * @param {import('prosemirror-model').Node} doc - The ProseMirror document + * @param {ReturnType} identity - A resolved identity from {@link resolveCommentIdentity} + * @returns {Array<{ pos: number, typeName: string, attrs: Record }>} Anchor nodes, empty when identity is not resolved + */ +export const collectCommentAnchorNodes = (doc, identity) => { + if (!identity || identity.status !== 'resolved') return []; + return collectAnchorsById(doc, identity.matchId); +}; + +/** + * Find the paired commentRangeStart/commentRangeEnd positions for a resolved identity. + * + * @param {import('prosemirror-model').Node} doc - The ProseMirror document + * @param {ReturnType} identity - A resolved identity from {@link resolveCommentIdentity} + * @returns {{ startPos: number, endPos: number, startAttrs: Record } | null} Range positions, or null when anchors are missing/incomplete + */ +export const collectCommentRangeAnchors = (doc, identity) => { + if (!identity || identity.status !== 'resolved') return null; + const anchors = collectAnchorsById(doc, identity.matchId); + const start = anchors.find((a) => a.typeName === 'commentRangeStart'); + const end = anchors.find((a) => a.typeName === 'commentRangeEnd'); + if (!start || !end) return null; + return { startPos: start.pos, endPos: end.pos, startAttrs: start.attrs }; +}; diff --git a/packages/super-editor/src/extensions/comment/helpers/comment-target-resolver.test.js b/packages/super-editor/src/extensions/comment/helpers/comment-target-resolver.test.js new file mode 100644 index 000000000..632eb7076 --- /dev/null +++ b/packages/super-editor/src/extensions/comment/helpers/comment-target-resolver.test.js @@ -0,0 +1,225 @@ +import { describe, expect, it } from 'vitest'; +import { Schema } from 'prosemirror-model'; +import { + resolveCommentIdentity, + collectCommentMarkSegments, + collectCommentAnchorNodes, + collectCommentRangeAnchors, +} from './comment-target-resolver.js'; + +const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { group: 'block', content: 'inline*' }, + commentRangeStart: { + group: 'inline', + inline: true, + atom: true, + attrs: { 'w:id': { default: '' } }, + }, + commentRangeEnd: { + group: 'inline', + inline: true, + atom: true, + attrs: { 'w:id': { default: '' } }, + }, + text: { group: 'inline' }, + }, + marks: { + commentMark: { + attrs: { + commentId: { default: '' }, + importedId: { default: '' }, + }, + }, + }, +}); + +function docWithCommentMark(commentId, importedId, text = 'hello') { + const mark = schema.marks.commentMark.create({ commentId, importedId }); + const textNode = schema.text(text, [mark]); + return schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [textNode])]); +} + +function docWithAnchors(id) { + const start = schema.nodes.commentRangeStart.create({ 'w:id': id }); + const end = schema.nodes.commentRangeEnd.create({ 'w:id': id }); + return schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [start, schema.text('body'), end])]); +} + +function emptyDoc() { + return schema.nodes.doc.create(null, [schema.nodes.paragraph.create()]); +} + +// --- resolveCommentIdentity --- + +describe('resolveCommentIdentity', () => { + it('returns unresolved with missing-identifiers when both ids are empty', () => { + const result = resolveCommentIdentity(emptyDoc(), { commentId: '', importedId: '' }); + expect(result).toEqual({ status: 'unresolved', reason: 'missing-identifiers' }); + }); + + it('returns unresolved with missing-identifiers when both ids are undefined', () => { + const result = resolveCommentIdentity(emptyDoc(), {}); + expect(result).toEqual({ status: 'unresolved', reason: 'missing-identifiers' }); + }); + + it('resolves via canonical strategy when commentId matches marks', () => { + const doc = docWithCommentMark('c1', 'i1'); + const result = resolveCommentIdentity(doc, { commentId: 'c1', importedId: 'i1' }); + expect(result).toEqual({ + status: 'resolved', + strategy: 'canonical', + matchId: 'c1', + canonicalId: 'c1', + fallbackImportedId: 'i1', + }); + }); + + it('resolves via canonical strategy when commentId matches anchors', () => { + const doc = docWithAnchors('c1'); + const result = resolveCommentIdentity(doc, { commentId: 'c1' }); + expect(result).toEqual({ + status: 'resolved', + strategy: 'canonical', + matchId: 'c1', + canonicalId: 'c1', + fallbackImportedId: null, + }); + }); + + it('falls back to imported-fallback when canonical has no targets', () => { + const doc = docWithCommentMark('', 'i1'); + const result = resolveCommentIdentity(doc, { commentId: 'c-missing', importedId: 'i1' }); + expect(result).toEqual({ + status: 'resolved', + strategy: 'imported-fallback', + matchId: 'i1', + canonicalId: 'c-missing', + fallbackImportedId: 'i1', + }); + }); + + it('returns unresolved no-targets when canonical has no targets and no importedId', () => { + const doc = emptyDoc(); + const result = resolveCommentIdentity(doc, { commentId: 'c1' }); + expect(result).toEqual({ status: 'unresolved', reason: 'no-targets' }); + }); + + it('returns unresolved no-targets when neither canonical nor imported have targets', () => { + const doc = emptyDoc(); + const result = resolveCommentIdentity(doc, { commentId: 'c1', importedId: 'i1' }); + expect(result).toEqual({ status: 'unresolved', reason: 'no-targets' }); + }); + + it('does not use importedId as fallback when it equals commentId', () => { + const doc = emptyDoc(); + const result = resolveCommentIdentity(doc, { commentId: 'same', importedId: 'same' }); + expect(result).toEqual({ status: 'unresolved', reason: 'no-targets' }); + }); + + it('returns ambiguous multiple-comment-ids when fallback marks have different commentIds', () => { + const mark1 = schema.marks.commentMark.create({ commentId: 'a', importedId: 'shared' }); + const mark2 = schema.marks.commentMark.create({ commentId: 'b', importedId: 'shared' }); + const doc = schema.nodes.doc.create(null, [ + schema.nodes.paragraph.create(null, [schema.text('one', [mark1]), schema.text('two', [mark2])]), + ]); + const result = resolveCommentIdentity(doc, { commentId: 'missing', importedId: 'shared' }); + expect(result).toEqual({ status: 'ambiguous', reason: 'multiple-comment-ids', matchId: 'shared' }); + }); + + it('returns ambiguous canonical-mismatch when fallback mark has a different canonical id', () => { + const doc = docWithCommentMark('other-canonical', 'i1'); + const result = resolveCommentIdentity(doc, { commentId: 'c1', importedId: 'i1' }); + expect(result).toEqual({ status: 'ambiguous', reason: 'canonical-mismatch', matchId: 'i1' }); + }); +}); + +// --- collectCommentMarkSegments --- + +describe('collectCommentMarkSegments', () => { + it('returns empty array for non-resolved identity', () => { + const doc = docWithCommentMark('c1', 'i1'); + expect(collectCommentMarkSegments(doc, null)).toEqual([]); + expect(collectCommentMarkSegments(doc, { status: 'unresolved', reason: 'no-targets' })).toEqual([]); + }); + + it('collects mark segments for canonical strategy', () => { + const doc = docWithCommentMark('c1', 'i1'); + const identity = resolveCommentIdentity(doc, { commentId: 'c1', importedId: 'i1' }); + const segments = collectCommentMarkSegments(doc, identity); + expect(segments.length).toBe(1); + expect(segments[0].attrs.commentId).toBe('c1'); + }); + + it('collects mark segments for imported-fallback strategy', () => { + const doc = docWithCommentMark('', 'i1'); + const identity = resolveCommentIdentity(doc, { commentId: 'missing', importedId: 'i1' }); + expect(identity.status).toBe('resolved'); + const segments = collectCommentMarkSegments(doc, identity); + expect(segments.length).toBe(1); + expect(segments[0].attrs.importedId).toBe('i1'); + }); +}); + +// --- collectCommentAnchorNodes --- + +describe('collectCommentAnchorNodes', () => { + it('returns empty array for non-resolved identity', () => { + const doc = docWithAnchors('c1'); + expect(collectCommentAnchorNodes(doc, null)).toEqual([]); + }); + + it('collects anchor nodes for a resolved identity', () => { + const doc = docWithAnchors('c1'); + const identity = resolveCommentIdentity(doc, { commentId: 'c1' }); + const anchors = collectCommentAnchorNodes(doc, identity); + expect(anchors.length).toBe(2); + const typeNames = anchors.map((a) => a.typeName).sort(); + expect(typeNames).toEqual(['commentRangeEnd', 'commentRangeStart']); + }); +}); + +// --- collectCommentRangeAnchors --- + +describe('collectCommentRangeAnchors', () => { + it('returns null for non-resolved identity', () => { + const doc = docWithAnchors('c1'); + expect(collectCommentRangeAnchors(doc, null)).toBeNull(); + }); + + it('returns start and end positions for a resolved identity', () => { + const doc = docWithAnchors('c1'); + const identity = resolveCommentIdentity(doc, { commentId: 'c1' }); + const range = collectCommentRangeAnchors(doc, identity); + expect(range).not.toBeNull(); + expect(range.startPos).toBeLessThan(range.endPos); + expect(range.startAttrs['w:id']).toBe('c1'); + }); + + it('returns null when only start anchor exists', () => { + const start = schema.nodes.commentRangeStart.create({ 'w:id': 'c1' }); + const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [start])]); + const identity = { + status: 'resolved', + strategy: 'canonical', + matchId: 'c1', + canonicalId: 'c1', + fallbackImportedId: null, + }; + expect(collectCommentRangeAnchors(doc, identity)).toBeNull(); + }); + + it('returns null when only end anchor exists', () => { + const end = schema.nodes.commentRangeEnd.create({ 'w:id': 'c1' }); + const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [end])]); + const identity = { + status: 'resolved', + strategy: 'canonical', + matchId: 'c1', + canonicalId: 'c1', + fallbackImportedId: null, + }; + expect(collectCommentRangeAnchors(doc, identity)).toBeNull(); + }); +}); From 4f0acef941d0c6caa7057a06e5f58198afcae5ff Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Feb 2026 14:27:27 -0800 Subject: [PATCH 16/25] refactor(comment): harden comment target identity and anchor resolution --- .../helpers/comment-target-resolver.js | 61 ++++++++++++++----- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/packages/super-editor/src/extensions/comment/helpers/comment-target-resolver.js b/packages/super-editor/src/extensions/comment/helpers/comment-target-resolver.js index d89d7930c..79f99bb9c 100644 --- a/packages/super-editor/src/extensions/comment/helpers/comment-target-resolver.js +++ b/packages/super-editor/src/extensions/comment/helpers/comment-target-resolver.js @@ -14,12 +14,30 @@ const resolveMoveIds = ({ commentId, importedId }) => { return { canonicalId, fallbackImportedId }; }; -const collectMarkSegmentsByAttr = (doc, attrName, attrValue) => { +const collectCanonicalMarkSegments = (doc, canonicalId) => { const segments = []; doc.descendants((node, pos) => { if (!node.isInline) return; const commentMark = node.marks?.find( - (mark) => mark.type.name === CommentMarkName && mark.attrs?.[attrName] === attrValue, + (mark) => mark.type.name === CommentMarkName && mark.attrs?.commentId === canonicalId, + ); + if (!commentMark) return; + segments.push({ + from: pos, + to: pos + node.nodeSize, + attrs: commentMark.attrs ?? {}, + mark: commentMark, + }); + }); + return segments; +}; + +const collectImportedMarkSegments = (doc, importedId) => { + const segments = []; + doc.descendants((node, pos) => { + if (!node.isInline) return; + const commentMark = node.marks?.find( + (mark) => mark.type.name === CommentMarkName && mark.attrs?.importedId === importedId, ); if (!commentMark) return; segments.push({ @@ -67,7 +85,7 @@ export const resolveCommentIdentity = (doc, { commentId, importedId }) => { } if (canonicalId) { - const canonicalMarks = collectMarkSegmentsByAttr(doc, 'commentId', canonicalId); + const canonicalMarks = collectCanonicalMarkSegments(doc, canonicalId); const canonicalAnchors = collectAnchorsById(doc, canonicalId); if (canonicalMarks.length > 0 || canonicalAnchors.length > 0) { return { @@ -84,7 +102,7 @@ export const resolveCommentIdentity = (doc, { commentId, importedId }) => { return { status: 'unresolved', reason: 'no-targets' }; } - const fallbackMarks = collectMarkSegmentsByAttr(doc, 'importedId', fallbackImportedId); + const fallbackMarks = collectImportedMarkSegments(doc, fallbackImportedId); const fallbackAnchors = collectAnchorsById(doc, fallbackImportedId); if (fallbackMarks.length === 0 && fallbackAnchors.length === 0) { return { status: 'unresolved', reason: 'no-targets' }; @@ -115,12 +133,13 @@ export const resolveCommentIdentity = (doc, { commentId, importedId }) => { * * @param {import('prosemirror-model').Node} doc - The ProseMirror document * @param {ReturnType} identity - A resolved identity from {@link resolveCommentIdentity} - * @returns {Array<{ from: number, to: number, attrs: Record, mark: import('prosemirror-model').Mark }>} Mark segments, empty when identity is not resolved + * @returns {Array<{ from: number, to: number, attrs: Object, mark: Object }>} Mark segments, empty when identity is not resolved */ export const collectCommentMarkSegments = (doc, identity) => { if (!identity || identity.status !== 'resolved') return []; - const attrName = identity.strategy === 'canonical' ? 'commentId' : 'importedId'; - return collectMarkSegmentsByAttr(doc, attrName, identity.matchId); + return identity.strategy === 'canonical' + ? collectCanonicalMarkSegments(doc, identity.matchId) + : collectImportedMarkSegments(doc, identity.matchId); }; /** @@ -128,7 +147,7 @@ export const collectCommentMarkSegments = (doc, identity) => { * * @param {import('prosemirror-model').Node} doc - The ProseMirror document * @param {ReturnType} identity - A resolved identity from {@link resolveCommentIdentity} - * @returns {Array<{ pos: number, typeName: string, attrs: Record }>} Anchor nodes, empty when identity is not resolved + * @returns {Array<{ pos: number, typeName: string, attrs: Object }>} Anchor nodes, empty when identity is not resolved */ export const collectCommentAnchorNodes = (doc, identity) => { if (!identity || identity.status !== 'resolved') return []; @@ -140,13 +159,27 @@ export const collectCommentAnchorNodes = (doc, identity) => { * * @param {import('prosemirror-model').Node} doc - The ProseMirror document * @param {ReturnType} identity - A resolved identity from {@link resolveCommentIdentity} - * @returns {{ startPos: number, endPos: number, startAttrs: Record } | null} Range positions, or null when anchors are missing/incomplete + * @returns {{ startPos: number, endPos: number, startAttrs: Object } | null} Range positions, or null when anchors are missing/incomplete */ export const collectCommentRangeAnchors = (doc, identity) => { if (!identity || identity.status !== 'resolved') return null; - const anchors = collectAnchorsById(doc, identity.matchId); - const start = anchors.find((a) => a.typeName === 'commentRangeStart'); - const end = anchors.find((a) => a.typeName === 'commentRangeEnd'); - if (!start || !end) return null; - return { startPos: start.pos, endPos: end.pos, startAttrs: start.attrs }; + let startPos = null; + let endPos = null; + let startAttrs = { 'w:id': identity.matchId }; + + doc.descendants((node, pos) => { + if (!COMMENT_RANGE_NODE_TYPES.has(node.type?.name)) return; + if (node.attrs?.['w:id'] !== identity.matchId) return; + if (node.type.name === 'commentRangeStart') { + startPos = pos; + startAttrs = { ...node.attrs }; + return; + } + if (node.type.name === 'commentRangeEnd') { + endPos = pos; + } + }); + + if (startPos == null || endPos == null) return null; + return { startPos, endPos, startAttrs }; }; From 1f5077c4e8464a58a174c15b3d47971003d1a28c Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Feb 2026 15:25:20 -0800 Subject: [PATCH 17/25] test(document-api): align conformance vectors with adapter behavior updates --- .../contract-conformance.test.ts | 1060 +++++++++++++++++ .../__conformance__/schema-validator.ts | 150 +++ .../find-adapter.test.ts | 74 +- .../find/text-strategy.ts | 16 +- 4 files changed, 1289 insertions(+), 11 deletions(-) create mode 100644 packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/__conformance__/schema-validator.ts diff --git a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts new file mode 100644 index 000000000..98dd83f90 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts @@ -0,0 +1,1060 @@ +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; +import { + COMMAND_CATALOG, + MUTATING_OPERATION_IDS, + OPERATION_IDS, + buildInternalContractSchemas, + type OperationId, +} from '@superdoc/document-api'; +import { + TrackDeleteMarkName, + TrackFormatMarkName, + TrackInsertMarkName, +} from '../../extensions/track-changes/constants.js'; +import { ListHelpers } from '../../core/helpers/list-numbering-helpers.js'; +import { createCommentsAdapter } from '../comments-adapter.js'; +import { createParagraphAdapter } from '../create-adapter.js'; +import { formatBoldAdapter } from '../format-adapter.js'; +import { getDocumentApiCapabilities } from '../capabilities-adapter.js'; +import { + listsExitAdapter, + listsIndentAdapter, + listsInsertAdapter, + listsOutdentAdapter, + listsRestartAdapter, + listsSetTypeAdapter, +} from '../lists-adapter.js'; +import { + trackChangesAcceptAdapter, + trackChangesAcceptAllAdapter, + trackChangesRejectAdapter, + trackChangesRejectAllAdapter, +} from '../track-changes-adapter.js'; +import { toCanonicalTrackedChangeId } from '../helpers/tracked-change-resolver.js'; +import { writeAdapter } from '../write-adapter.js'; +import { validateJsonSchema } from './schema-validator.js'; + +const mockedDeps = vi.hoisted(() => ({ + resolveCommentAnchorsById: vi.fn(() => []), + listCommentAnchors: vi.fn(() => []), + getTrackChanges: vi.fn(() => []), +})); + +vi.mock('../helpers/comment-target-resolver.js', () => ({ + resolveCommentAnchorsById: mockedDeps.resolveCommentAnchorsById, + listCommentAnchors: mockedDeps.listCommentAnchors, +})); + +vi.mock('../../extensions/track-changes/trackChangesHelpers/getTrackChanges.js', () => ({ + getTrackChanges: mockedDeps.getTrackChanges, +})); + +const INTERNAL_SCHEMAS = buildInternalContractSchemas(); + +type MutationVector = { + throwCase: () => unknown; + failureCase: () => unknown; + applyCase: () => unknown; +}; + +type NodeOptions = { + attrs?: Record; + text?: string; + isInline?: boolean; + isBlock?: boolean; + isLeaf?: boolean; + inlineContent?: boolean; + nodeSize?: number; +}; + +type MockParagraphNode = { + type: { name: 'paragraph' }; + attrs: Record; + nodeSize: number; + isBlock: true; + textContent: string; +}; + +function createNode(typeName: string, children: ProseMirrorNode[] = [], options: NodeOptions = {}): ProseMirrorNode { + const attrs = options.attrs ?? {}; + const text = options.text ?? ''; + const isText = typeName === 'text'; + const isInline = options.isInline ?? isText; + const isBlock = options.isBlock ?? (!isInline && typeName !== 'doc'); + const inlineContent = options.inlineContent ?? isBlock; + const isLeaf = options.isLeaf ?? (isInline && !isText && children.length === 0); + + const contentSize = children.reduce((sum, child) => sum + child.nodeSize, 0); + const nodeSize = isText ? text.length : options.nodeSize != null ? options.nodeSize : isLeaf ? 1 : contentSize + 2; + + return { + type: { name: typeName }, + attrs, + text: isText ? text : undefined, + content: { size: contentSize }, + nodeSize, + isText, + isInline, + isBlock, + inlineContent, + isTextblock: inlineContent, + isLeaf, + childCount: children.length, + child(index: number) { + return children[index]!; + }, + descendants(callback: (node: ProseMirrorNode, pos: number) => void) { + let offset = 0; + for (const child of children) { + callback(child, offset); + offset += child.nodeSize; + } + }, + } as unknown as ProseMirrorNode; +} + +function makeTextEditor( + text = 'Hello', + overrides: Partial & { + commands?: Record; + schema?: Record; + } = {}, +): { + editor: Editor; + dispatch: ReturnType; + tr: { + insertText: ReturnType; + delete: ReturnType; + addMark: ReturnType; + setMeta: ReturnType; + }; +} { + const textNode = createNode('text', [], { text }); + const paragraph = createNode('paragraph', [textNode], { + attrs: { sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + + const tr = { + insertText: vi.fn(), + delete: vi.fn(), + addMark: vi.fn(), + setMeta: vi.fn(), + }; + tr.insertText.mockReturnValue(tr); + tr.delete.mockReturnValue(tr); + tr.addMark.mockReturnValue(tr); + tr.setMeta.mockReturnValue(tr); + + const dispatch = vi.fn(); + + const baseCommands = { + insertTrackedChange: vi.fn(() => true), + setTextSelection: vi.fn(() => true), + addComment: vi.fn(() => true), + editComment: vi.fn(() => true), + addCommentReply: vi.fn(() => true), + moveComment: vi.fn(() => true), + resolveComment: vi.fn(() => true), + removeComment: vi.fn(() => true), + setCommentInternal: vi.fn(() => true), + setActiveComment: vi.fn(() => true), + setCursorById: vi.fn(() => true), + acceptTrackedChangeById: vi.fn(() => true), + rejectTrackedChangeById: vi.fn(() => true), + acceptAllTrackedChanges: vi.fn(() => true), + rejectAllTrackedChanges: vi.fn(() => true), + insertParagraphAt: vi.fn(() => true), + insertListItemAt: vi.fn(() => true), + setListTypeAt: vi.fn(() => true), + increaseListIndent: vi.fn(() => true), + decreaseListIndent: vi.fn(() => true), + restartNumbering: vi.fn(() => true), + exitListItemAt: vi.fn(() => true), + }; + + const baseSchema = { + marks: { + bold: { + create: vi.fn(() => ({ type: 'bold' })), + }, + [TrackFormatMarkName]: { + create: vi.fn(() => ({ type: TrackFormatMarkName })), + }, + }, + }; + + const editor = { + state: { + doc: { + ...doc, + textBetween: vi.fn((from: number, to: number) => { + const start = Math.max(0, from - 1); + const end = Math.max(start, to - 1); + return text.slice(start, end); + }), + }, + tr, + }, + can: vi.fn(() => ({ + insertParagraphAt: vi.fn(() => true), + insertListItemAt: vi.fn(() => true), + setListTypeAt: vi.fn(() => true), + increaseListIndent: vi.fn(() => true), + decreaseListIndent: vi.fn(() => true), + restartNumbering: vi.fn(() => true), + exitListItemAt: vi.fn(() => true), + })), + dispatch, + ...overrides, + schema: { + ...baseSchema, + ...(overrides.schema ?? {}), + }, + commands: { + ...baseCommands, + ...(overrides.commands ?? {}), + }, + } as unknown as Editor; + + return { editor, dispatch, tr }; +} + +function makeListParagraph(options: { + id: string; + text?: string; + numId?: number; + ilvl?: number; + numberingType?: string; + markerText?: string; + path?: number[]; +}): MockParagraphNode { + const text = options.text ?? ''; + const numberingProperties = + options.numId != null + ? { + numId: options.numId, + ilvl: options.ilvl ?? 0, + } + : undefined; + + return { + type: { name: 'paragraph' }, + attrs: { + paraId: options.id, + sdBlockId: options.id, + paragraphProperties: numberingProperties ? { numberingProperties } : {}, + listRendering: + options.numId != null + ? { + markerText: options.markerText ?? '', + path: options.path ?? [1], + numberingType: options.numberingType ?? 'decimal', + } + : null, + }, + nodeSize: Math.max(2, text.length + 2), + isBlock: true, + textContent: text, + }; +} + +function makeListEditor(children: MockParagraphNode[], commandOverrides: Record = {}): Editor { + const doc = { + get content() { + return { + size: children.reduce((sum, child) => sum + child.nodeSize, 0), + }; + }, + descendants(callback: (node: MockParagraphNode, pos: number) => void) { + let pos = 0; + for (const child of children) { + callback(child, pos); + pos += child.nodeSize; + } + return undefined; + }, + nodesBetween(_from: number, _to: number, callback: (node: unknown) => void) { + for (const child of children) { + callback(child); + } + return undefined; + }, + }; + + const baseCommands = { + insertListItemAt: vi.fn(() => true), + setListTypeAt: vi.fn(() => true), + setTextSelection: vi.fn(() => true), + increaseListIndent: vi.fn(() => true), + decreaseListIndent: vi.fn(() => true), + restartNumbering: vi.fn(() => true), + exitListItemAt: vi.fn(() => true), + insertTrackedChange: vi.fn(() => true), + }; + + return { + state: { doc }, + commands: { + ...baseCommands, + ...commandOverrides, + }, + converter: { + numbering: { definitions: {}, abstracts: {} }, + }, + } as unknown as Editor; +} + +function makeCommentRecord( + commentId: string, + overrides: Record = {}, +): Record & { commentId: string } { + return { + commentId, + commentText: 'Original', + isDone: false, + isInternal: false, + ...overrides, + }; +} + +function makeCommentsEditor( + records: Array> = [], + commandOverrides: Record = {}, +): Editor { + const { editor } = makeTextEditor('Hello', { commands: commandOverrides }); + return { + ...editor, + converter: { + comments: [...records], + }, + options: { + documentId: 'doc-1', + user: { + name: 'Agent', + email: 'agent@example.com', + }, + }, + } as unknown as Editor; +} + +function setTrackChanges(changes: Array>): void { + mockedDeps.getTrackChanges.mockReturnValue(changes as never); +} + +function makeTrackedChange(id = 'tc-1') { + return { + mark: { + type: { name: TrackInsertMarkName }, + attrs: { id }, + }, + from: 1, + to: 3, + }; +} + +function requireCanonicalTrackChangeId(editor: Editor, rawId: string): string { + const canonicalId = toCanonicalTrackedChangeId(editor, rawId); + expect(canonicalId).toBeTruthy(); + return canonicalId!; +} + +function assertSchema(operationId: OperationId, schemaType: 'output' | 'success' | 'failure', value: unknown): void { + const schemaSet = INTERNAL_SCHEMAS.operations[operationId]; + const schema = schemaSet[schemaType]; + expect(schema).toBeDefined(); + + const result = validateJsonSchema(schema as Parameters[0], value); + expect( + result.valid, + `Schema validation failed for ${operationId} (${schemaType}):\n${result.errors.join('\n')}`, + ).toBe(true); +} + +function expectThrowCode(operationId: OperationId, run: () => unknown): void { + let capturedCode: string | null = null; + try { + run(); + } catch (error) { + capturedCode = (error as { code?: string }).code ?? null; + } + + expect(capturedCode).toBeTruthy(); + expect(COMMAND_CATALOG[operationId].throws.preApply).toContain(capturedCode); +} + +const mutationVectors: Partial> = { + insert: { + throwCase: () => { + const { editor } = makeTextEditor(); + return writeAdapter( + editor, + { kind: 'insert', target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 0 } }, text: 'X' }, + { changeMode: 'direct' }, + ); + }, + failureCase: () => { + const { editor } = makeTextEditor(); + return writeAdapter( + editor, + { kind: 'insert', target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 0 } }, text: '' }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => { + const { editor } = makeTextEditor(); + return writeAdapter( + editor, + { kind: 'insert', target: { kind: 'text', blockId: 'p1', range: { start: 1, end: 1 } }, text: 'X' }, + { changeMode: 'direct' }, + ); + }, + }, + replace: { + throwCase: () => { + const { editor } = makeTextEditor(); + return writeAdapter( + editor, + { kind: 'replace', target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 1 } }, text: 'X' }, + { changeMode: 'direct' }, + ); + }, + failureCase: () => { + const { editor } = makeTextEditor('Hello'); + return writeAdapter( + editor, + { kind: 'replace', target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, text: 'Hello' }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => { + const { editor } = makeTextEditor('Hello'); + return writeAdapter( + editor, + { kind: 'replace', target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, text: 'World' }, + { changeMode: 'direct' }, + ); + }, + }, + delete: { + throwCase: () => { + const { editor } = makeTextEditor(); + return writeAdapter( + editor, + { kind: 'delete', target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 1 } } }, + { changeMode: 'direct' }, + ); + }, + failureCase: () => { + const { editor } = makeTextEditor(); + return writeAdapter( + editor, + { kind: 'delete', target: { kind: 'text', blockId: 'p1', range: { start: 2, end: 2 } } }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => { + const { editor } = makeTextEditor(); + return writeAdapter( + editor, + { kind: 'delete', target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } }, + { changeMode: 'direct' }, + ); + }, + }, + 'format.bold': { + throwCase: () => { + const { editor } = makeTextEditor(); + return formatBoldAdapter( + editor, + { + target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 1 } }, + }, + { changeMode: 'direct' }, + ); + }, + failureCase: () => { + const { editor } = makeTextEditor(); + return formatBoldAdapter( + editor, + { + target: { kind: 'text', blockId: 'p1', range: { start: 2, end: 2 } }, + }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => { + const { editor } = makeTextEditor(); + return formatBoldAdapter( + editor, + { + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + }, + { changeMode: 'direct' }, + ); + }, + }, + 'create.paragraph': { + throwCase: () => { + const { editor } = makeTextEditor('Hello', { commands: { insertParagraphAt: undefined } }); + return createParagraphAdapter(editor, { at: { kind: 'documentEnd' }, text: 'X' }, { changeMode: 'direct' }); + }, + failureCase: () => { + const { editor } = makeTextEditor('Hello', { commands: { insertParagraphAt: vi.fn(() => false) } }); + return createParagraphAdapter(editor, { at: { kind: 'documentEnd' }, text: 'X' }, { changeMode: 'direct' }); + }, + applyCase: () => { + const { editor } = makeTextEditor('Hello', { commands: { insertParagraphAt: vi.fn(() => true) } }); + return createParagraphAdapter(editor, { at: { kind: 'documentEnd' }, text: 'X' }, { changeMode: 'direct' }); + }, + }, + 'lists.insert': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, numberingType: 'decimal' })]); + return listsInsertAdapter( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'missing' }, position: 'after', text: 'X' }, + { changeMode: 'direct' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, numberingType: 'decimal' })], { + insertListItemAt: vi.fn(() => false), + }); + return listsInsertAdapter( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, position: 'after', text: 'X' }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, numberingType: 'decimal' })]); + return listsInsertAdapter( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, position: 'after', text: 'X' }, + { changeMode: 'direct' }, + ); + }, + }, + 'lists.setType': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, numberingType: 'bullet' })]); + return listsSetTypeAdapter( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, kind: 'ordered' }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, numberingType: 'bullet' })]); + return listsSetTypeAdapter(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + kind: 'bullet', + }); + }, + applyCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, numberingType: 'bullet' })]); + return listsSetTypeAdapter(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + kind: 'ordered', + }); + }, + }, + 'lists.indent': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsIndentAdapter( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const hasDefinitionSpy = vi.spyOn(ListHelpers, 'hasListDefinition').mockReturnValue(false); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsIndentAdapter(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + hasDefinitionSpy.mockRestore(); + return result; + }, + applyCase: () => { + const hasDefinitionSpy = vi.spyOn(ListHelpers, 'hasListDefinition').mockReturnValue(true); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsIndentAdapter(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + hasDefinitionSpy.mockRestore(); + return result; + }, + }, + 'lists.outdent': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 1, numberingType: 'decimal' })]); + return listsOutdentAdapter( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsOutdentAdapter(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + }, + applyCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 1, numberingType: 'decimal' })]); + return listsOutdentAdapter(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + }, + }, + 'lists.restart': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsRestartAdapter( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsRestartAdapter(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + }, + applyCase: () => { + const editor = makeListEditor([ + makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal', markerText: '1.', path: [1] }), + makeListParagraph({ id: 'li-2', numId: 1, ilvl: 0, numberingType: 'decimal', markerText: '2.', path: [2] }), + ]); + return listsRestartAdapter(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-2' } }); + }, + }, + 'lists.exit': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsExitAdapter( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })], { + exitListItemAt: vi.fn(() => false), + }); + return listsExitAdapter(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + }, + applyCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsExitAdapter(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + }, + }, + 'comments.add': { + throwCase: () => { + const editor = makeCommentsEditor([], { addComment: undefined }); + return createCommentsAdapter(editor).add({ + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 3 } }, + text: 'X', + }); + }, + failureCase: () => { + const editor = makeCommentsEditor(); + return createCommentsAdapter(editor).add({ + target: { kind: 'text', blockId: 'p1', range: { start: 1, end: 1 } }, + text: 'X', + }); + }, + applyCase: () => { + const editor = makeCommentsEditor(); + return createCommentsAdapter(editor).add({ + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 3 } }, + text: 'X', + }); + }, + }, + 'comments.edit': { + throwCase: () => { + const editor = makeCommentsEditor(); + return createCommentsAdapter(editor).edit({ commentId: 'missing', text: 'X' }); + }, + failureCase: () => { + const editor = makeCommentsEditor([makeCommentRecord('c1', { commentText: 'Same' })]); + return createCommentsAdapter(editor).edit({ commentId: 'c1', text: 'Same' }); + }, + applyCase: () => { + const editor = makeCommentsEditor([makeCommentRecord('c1', { commentText: 'Old' })]); + return createCommentsAdapter(editor).edit({ commentId: 'c1', text: 'New' }); + }, + }, + 'comments.reply': { + throwCase: () => { + const editor = makeCommentsEditor(); + return createCommentsAdapter(editor).reply({ parentCommentId: 'missing', text: 'X' }); + }, + failureCase: () => { + const editor = makeCommentsEditor([makeCommentRecord('c1')]); + return createCommentsAdapter(editor).reply({ parentCommentId: '', text: 'X' }); + }, + applyCase: () => { + const editor = makeCommentsEditor([makeCommentRecord('c1')]); + return createCommentsAdapter(editor).reply({ parentCommentId: 'c1', text: 'Reply' }); + }, + }, + 'comments.move': { + throwCase: () => { + const editor = makeCommentsEditor([makeCommentRecord('c1')]); + return createCommentsAdapter(editor).move({ + commentId: 'c1', + target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 2 } }, + }); + }, + failureCase: () => { + mockedDeps.resolveCommentAnchorsById.mockImplementation(() => []); + const editor = makeCommentsEditor([makeCommentRecord('c1')]); + return createCommentsAdapter(editor).move({ + commentId: 'c1', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } }, + }); + }, + applyCase: () => { + mockedDeps.resolveCommentAnchorsById.mockImplementation((_editor, id) => + id === 'c1' + ? [ + { + commentId: 'c1', + status: 'open', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 1 } }, + pos: 1, + end: 2, + attrs: {}, + }, + ] + : [], + ); + const editor = makeCommentsEditor([makeCommentRecord('c1')]); + return createCommentsAdapter(editor).move({ + commentId: 'c1', + target: { kind: 'text', blockId: 'p1', range: { start: 1, end: 3 } }, + }); + }, + }, + 'comments.resolve': { + throwCase: () => { + const editor = makeCommentsEditor(); + return createCommentsAdapter(editor).resolve({ commentId: 'missing' }); + }, + failureCase: () => { + const editor = makeCommentsEditor([makeCommentRecord('c1', { isDone: true })]); + return createCommentsAdapter(editor).resolve({ commentId: 'c1' }); + }, + applyCase: () => { + const editor = makeCommentsEditor([makeCommentRecord('c1', { isDone: false })]); + return createCommentsAdapter(editor).resolve({ commentId: 'c1' }); + }, + }, + 'comments.remove': { + throwCase: () => { + const editor = makeCommentsEditor(); + return createCommentsAdapter(editor).remove({ commentId: 'missing' }); + }, + failureCase: () => { + mockedDeps.resolveCommentAnchorsById.mockImplementation((_editor, id) => + id === 'c1' + ? [ + { + commentId: 'c1', + status: 'open', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 1 } }, + pos: 1, + end: 2, + attrs: {}, + }, + ] + : [], + ); + const editor = makeCommentsEditor([], { removeComment: vi.fn(() => false) }); + return createCommentsAdapter(editor).remove({ commentId: 'c1' }); + }, + applyCase: () => { + const editor = makeCommentsEditor([makeCommentRecord('c1')], { removeComment: vi.fn(() => true) }); + return createCommentsAdapter(editor).remove({ commentId: 'c1' }); + }, + }, + 'comments.setInternal': { + throwCase: () => { + const editor = makeCommentsEditor(); + return createCommentsAdapter(editor).setInternal({ commentId: 'missing', isInternal: true }); + }, + failureCase: () => { + const editor = makeCommentsEditor([makeCommentRecord('c1', { isInternal: true })]); + return createCommentsAdapter(editor).setInternal({ commentId: 'c1', isInternal: true }); + }, + applyCase: () => { + const editor = makeCommentsEditor([makeCommentRecord('c1', { isInternal: false })]); + return createCommentsAdapter(editor).setInternal({ commentId: 'c1', isInternal: true }); + }, + }, + 'comments.setActive': { + throwCase: () => { + const editor = makeCommentsEditor(); + return createCommentsAdapter(editor).setActive({ commentId: 'missing' }); + }, + failureCase: () => { + const editor = makeCommentsEditor([], { setActiveComment: vi.fn(() => false) }); + return createCommentsAdapter(editor).setActive({ commentId: null }); + }, + applyCase: () => { + const editor = makeCommentsEditor([], { setActiveComment: vi.fn(() => true) }); + return createCommentsAdapter(editor).setActive({ commentId: null }); + }, + }, + 'trackChanges.accept': { + throwCase: () => { + setTrackChanges([]); + const { editor } = makeTextEditor(); + return trackChangesAcceptAdapter(editor, { id: 'missing' }); + }, + failureCase: () => { + setTrackChanges([makeTrackedChange('tc-1')]); + const { editor } = makeTextEditor('Hello', { commands: { acceptTrackedChangeById: vi.fn(() => false) } }); + return trackChangesAcceptAdapter(editor, { id: requireCanonicalTrackChangeId(editor, 'tc-1') }); + }, + applyCase: () => { + setTrackChanges([makeTrackedChange('tc-1')]); + const { editor } = makeTextEditor('Hello', { commands: { acceptTrackedChangeById: vi.fn(() => true) } }); + return trackChangesAcceptAdapter(editor, { id: requireCanonicalTrackChangeId(editor, 'tc-1') }); + }, + }, + 'trackChanges.reject': { + throwCase: () => { + setTrackChanges([]); + const { editor } = makeTextEditor(); + return trackChangesRejectAdapter(editor, { id: 'missing' }); + }, + failureCase: () => { + setTrackChanges([makeTrackedChange('tc-1')]); + const { editor } = makeTextEditor('Hello', { commands: { rejectTrackedChangeById: vi.fn(() => false) } }); + return trackChangesRejectAdapter(editor, { id: requireCanonicalTrackChangeId(editor, 'tc-1') }); + }, + applyCase: () => { + setTrackChanges([makeTrackedChange('tc-1')]); + const { editor } = makeTextEditor('Hello', { commands: { rejectTrackedChangeById: vi.fn(() => true) } }); + return trackChangesRejectAdapter(editor, { id: requireCanonicalTrackChangeId(editor, 'tc-1') }); + }, + }, + 'trackChanges.acceptAll': { + throwCase: () => { + const { editor } = makeTextEditor('Hello', { commands: { acceptAllTrackedChanges: undefined } }); + return trackChangesAcceptAllAdapter(editor, {}); + }, + failureCase: () => { + const { editor } = makeTextEditor('Hello', { commands: { acceptAllTrackedChanges: vi.fn(() => false) } }); + return trackChangesAcceptAllAdapter(editor, {}); + }, + applyCase: () => { + setTrackChanges([makeTrackedChange('tc-1')]); + const { editor } = makeTextEditor('Hello', { commands: { acceptAllTrackedChanges: vi.fn(() => true) } }); + return trackChangesAcceptAllAdapter(editor, {}); + }, + }, + 'trackChanges.rejectAll': { + throwCase: () => { + const { editor } = makeTextEditor('Hello', { commands: { rejectAllTrackedChanges: undefined } }); + return trackChangesRejectAllAdapter(editor, {}); + }, + failureCase: () => { + const { editor } = makeTextEditor('Hello', { commands: { rejectAllTrackedChanges: vi.fn(() => false) } }); + return trackChangesRejectAllAdapter(editor, {}); + }, + applyCase: () => { + setTrackChanges([makeTrackedChange('tc-1')]); + const { editor } = makeTextEditor('Hello', { commands: { rejectAllTrackedChanges: vi.fn(() => true) } }); + return trackChangesRejectAllAdapter(editor, {}); + }, + }, +}; + +const dryRunVectors: Partial unknown>> = { + insert: () => { + const { editor, dispatch, tr } = makeTextEditor(); + const result = writeAdapter( + editor, + { kind: 'insert', target: { kind: 'text', blockId: 'p1', range: { start: 1, end: 1 } }, text: 'X' }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + expect(tr.insertText).not.toHaveBeenCalled(); + return result; + }, + replace: () => { + const { editor, dispatch, tr } = makeTextEditor(); + const result = writeAdapter( + editor, + { kind: 'replace', target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, text: 'World' }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + expect(tr.insertText).not.toHaveBeenCalled(); + return result; + }, + delete: () => { + const { editor, dispatch, tr } = makeTextEditor(); + const result = writeAdapter( + editor, + { kind: 'delete', target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + expect(tr.delete).not.toHaveBeenCalled(); + return result; + }, + 'format.bold': () => { + const { editor, dispatch, tr } = makeTextEditor(); + const result = formatBoldAdapter( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + expect(tr.addMark).not.toHaveBeenCalled(); + return result; + }, + 'create.paragraph': () => { + const insertParagraphAt = vi.fn(() => true); + const { editor } = makeTextEditor('Hello', { commands: { insertParagraphAt } }); + const result = createParagraphAdapter( + editor, + { at: { kind: 'documentEnd' }, text: 'Dry run paragraph' }, + { changeMode: 'direct', dryRun: true }, + ); + expect(insertParagraphAt).not.toHaveBeenCalled(); + return result; + }, +}; + +beforeEach(() => { + vi.restoreAllMocks(); + mockedDeps.resolveCommentAnchorsById.mockReset(); + mockedDeps.resolveCommentAnchorsById.mockImplementation(() => []); + mockedDeps.listCommentAnchors.mockReset(); + mockedDeps.listCommentAnchors.mockImplementation(() => []); + mockedDeps.getTrackChanges.mockReset(); + mockedDeps.getTrackChanges.mockImplementation(() => []); +}); + +describe('document-api adapter conformance', () => { + it('has schema coverage for every operation and mutation policy metadata', () => { + for (const operationId of OPERATION_IDS) { + const schema = INTERNAL_SCHEMAS.operations[operationId]; + expect(schema).toBeDefined(); + expect(schema.input).toBeDefined(); + expect(schema.output).toBeDefined(); + + if (!COMMAND_CATALOG[operationId].mutates) continue; + expect(COMMAND_CATALOG[operationId].throws.postApplyForbidden).toBe(true); + expect(schema.success).toBeDefined(); + expect(schema.failure).toBeDefined(); + } + }); + + it('covers every mutating operation with throw/failure/apply vectors', () => { + const vectorKeys = Object.keys(mutationVectors).sort(); + const expectedKeys = [...MUTATING_OPERATION_IDS].sort(); + expect(vectorKeys).toEqual(expectedKeys); + }); + + it('enforces pre-apply throw behavior for every mutating operation', () => { + for (const operationId of MUTATING_OPERATION_IDS) { + const vector = mutationVectors[operationId]; + expect(vector).toBeDefined(); + expectThrowCode(operationId, () => vector!.throwCase()); + } + }); + + it('enforces structured non-applied outcomes for every mutating operation', () => { + for (const operationId of MUTATING_OPERATION_IDS) { + const vector = mutationVectors[operationId]!; + const result = vector.failureCase() as { success?: boolean; failure?: { code: string } }; + expect(result.success).toBe(false); + if (result.success !== false || !result.failure) continue; + expect(COMMAND_CATALOG[operationId].possibleFailureCodes).toContain(result.failure.code); + assertSchema(operationId, 'output', result); + assertSchema(operationId, 'failure', result); + } + }); + + it('enforces no post-apply throws across every mutating operation', () => { + for (const operationId of MUTATING_OPERATION_IDS) { + const vector = mutationVectors[operationId]!; + const apply = () => vector.applyCase(); + expect(apply).not.toThrow(); + const result = apply() as { success?: boolean }; + expect(result.success).toBe(true); + assertSchema(operationId, 'output', result); + assertSchema(operationId, 'success', result); + } + }); + + it('enforces dryRun non-mutation invariants for every dryRun-capable mutation', () => { + const expectedDryRunOperations = MUTATING_OPERATION_IDS.filter( + (operationId) => COMMAND_CATALOG[operationId].supportsDryRun, + ); + const vectorKeys = Object.keys(dryRunVectors).sort(); + expect(vectorKeys).toEqual([...expectedDryRunOperations].sort()); + + for (const operationId of expectedDryRunOperations) { + const run = dryRunVectors[operationId]!; + const result = run() as { success?: boolean }; + expect(result.success).toBe(true); + assertSchema(operationId, 'output', result); + assertSchema(operationId, 'success', result); + } + }); + + it('keeps capabilities tracked/dryRun flags aligned with static contract metadata', () => { + const fullCapabilities = getDocumentApiCapabilities(makeTextEditor('Hello').editor); + + for (const operationId of OPERATION_IDS) { + const metadata = COMMAND_CATALOG[operationId]; + const runtime = fullCapabilities.operations[operationId]; + + if (!metadata.supportsTrackedMode) { + expect(runtime.tracked).toBe(false); + } + + if (!metadata.supportsDryRun) { + expect(runtime.dryRun).toBe(false); + } + } + + const noTrackedEditor = makeTextEditor('Hello', { + commands: { + insertTrackedChange: undefined, + acceptTrackedChangeById: vi.fn(() => true), + rejectTrackedChangeById: vi.fn(() => true), + acceptAllTrackedChanges: vi.fn(() => true), + rejectAllTrackedChanges: vi.fn(() => true), + }, + }).editor; + const noTrackedCapabilities = getDocumentApiCapabilities(noTrackedEditor); + for (const operationId of OPERATION_IDS) { + if (!COMMAND_CATALOG[operationId].supportsTrackedMode) continue; + expect(noTrackedCapabilities.operations[operationId].tracked).toBe(false); + } + }); + + it('keeps tracked change vectors deterministic for accept/reject coverage', () => { + const change = { + mark: { + type: { name: TrackDeleteMarkName }, + attrs: { id: 'tc-delete-1' }, + }, + from: 3, + to: 4, + }; + setTrackChanges([change]); + const { editor } = makeTextEditor(); + const reject = trackChangesRejectAdapter(editor, { id: requireCanonicalTrackChangeId(editor, 'tc-delete-1') }); + expect(reject.success).toBe(true); + assertSchema('trackChanges.reject', 'output', reject); + assertSchema('trackChanges.reject', 'success', reject); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/__conformance__/schema-validator.ts b/packages/super-editor/src/document-api-adapters/__conformance__/schema-validator.ts new file mode 100644 index 000000000..ae9f0f817 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/__conformance__/schema-validator.ts @@ -0,0 +1,150 @@ +type JsonSchema = { + type?: string | string[]; + required?: string[]; + properties?: Record; + additionalProperties?: boolean | JsonSchema; + items?: JsonSchema; + const?: unknown; + enum?: unknown[]; + oneOf?: JsonSchema[]; + anyOf?: JsonSchema[]; +}; + +const SUPPORTED_SCHEMA_KEYWORDS = new Set([ + 'type', + 'required', + 'properties', + 'additionalProperties', + 'items', + 'const', + 'enum', + 'oneOf', + 'anyOf', +]); + +export interface SchemaValidationResult { + valid: boolean; + errors: string[]; +} + +function isType(value: unknown, expectedType: string): boolean { + switch (expectedType) { + case 'array': + return Array.isArray(value); + case 'object': + return typeof value === 'object' && value !== null && !Array.isArray(value); + case 'string': + return typeof value === 'string'; + case 'number': + return typeof value === 'number' && Number.isFinite(value); + case 'integer': + return typeof value === 'number' && Number.isInteger(value); + case 'boolean': + return typeof value === 'boolean'; + case 'null': + return value === null; + default: + return true; + } +} + +function validateInternal(schema: JsonSchema, value: unknown, path: string, errors: string[]): void { + let hasUnsupportedKeyword = false; + for (const key of Object.keys(schema)) { + if (SUPPORTED_SCHEMA_KEYWORDS.has(key)) continue; + errors.push(`${path}: unsupported schema keyword "${key}"`); + hasUnsupportedKeyword = true; + } + + if (hasUnsupportedKeyword) return; + + if (schema.const !== undefined && value !== schema.const) { + errors.push(`${path}: expected const ${JSON.stringify(schema.const)}`); + return; + } + + if (schema.enum && !schema.enum.includes(value)) { + errors.push(`${path}: expected one of ${JSON.stringify(schema.enum)}`); + return; + } + + if (schema.oneOf) { + let matchCount = 0; + for (const nested of schema.oneOf) { + const nestedErrors: string[] = []; + validateInternal(nested, value, path, nestedErrors); + if (nestedErrors.length === 0) matchCount += 1; + } + if (matchCount !== 1) { + errors.push(`${path}: expected exactly one oneOf schema match`); + } + return; + } + + if (schema.anyOf) { + let matched = false; + for (const nested of schema.anyOf) { + const nestedErrors: string[] = []; + validateInternal(nested, value, path, nestedErrors); + if (nestedErrors.length === 0) { + matched = true; + break; + } + } + if (!matched) { + errors.push(`${path}: expected at least one anyOf schema match`); + } + return; + } + + if (schema.type) { + const expectedTypes = Array.isArray(schema.type) ? schema.type : [schema.type]; + const hasTypeMatch = expectedTypes.some((expectedType) => isType(value, expectedType)); + if (!hasTypeMatch) { + errors.push(`${path}: expected type ${expectedTypes.join('|')}`); + return; + } + } + + const types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : []; + if (types.includes('array') && schema.items && Array.isArray(value)) { + value.forEach((item, index) => { + validateInternal(schema.items as JsonSchema, item, `${path}[${index}]`, errors); + }); + return; + } + + const isObjectSchema = schema.type === 'object' || (schema.properties && typeof value === 'object'); + if (!isObjectSchema || typeof value !== 'object' || value === null || Array.isArray(value)) return; + + const objectValue = value as Record; + if (schema.required) { + for (const key of schema.required) { + if (!(key in objectValue) || objectValue[key] === undefined) { + errors.push(`${path}: missing required property "${key}"`); + } + } + } + + if (schema.properties) { + for (const [key, nestedSchema] of Object.entries(schema.properties)) { + if (!(key in objectValue) || objectValue[key] === undefined) continue; + validateInternal(nestedSchema, objectValue[key], `${path}.${key}`, errors); + } + } + + if (schema.additionalProperties === false && schema.properties) { + const allowed = new Set(Object.keys(schema.properties)); + for (const key of Object.keys(objectValue)) { + if (!allowed.has(key)) { + errors.push(`${path}: unexpected property "${key}"`); + } + } + } +} + +export function validateJsonSchema(schema: JsonSchema, value: unknown): SchemaValidationResult { + const errors: string[] = []; + validateInternal(schema, value, '$', errors); + return { valid: errors.length === 0, errors }; +} diff --git a/packages/super-editor/src/document-api-adapters/find-adapter.test.ts b/packages/super-editor/src/document-api-adapters/find-adapter.test.ts index 0a3fe7fff..1413274bb 100644 --- a/packages/super-editor/src/document-api-adapters/find-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/find-adapter.test.ts @@ -594,7 +594,7 @@ describe('findAdapter — text selectors', () => { expect((capturedPattern as RegExp).flags).not.toContain('i'); }); - it('passes string pattern for default contains mode', () => { + it('passes escaped RegExp for default contains mode', () => { let capturedPattern: string | RegExp | undefined; const doc = buildDoc({ typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, nodeSize: 50, offset: 0 }); const search: SearchFn = (pattern) => { @@ -606,11 +606,55 @@ describe('findAdapter — text selectors', () => { findAdapter(editor, query); - expect(typeof capturedPattern).toBe('string'); - expect(capturedPattern).toBe('hello'); + expect(capturedPattern).toBeInstanceOf(RegExp); + expect((capturedPattern as RegExp).source).toBe('hello'); + expect((capturedPattern as RegExp).flags).toContain('i'); + }); + + it('treats slash-delimited contains patterns as literal text', () => { + const text = 'foo /foo/ foo'; + const doc = buildDoc(text, { + typeName: 'paragraph', + attrs: { sdBlockId: 'p1' }, + nodeSize: text.length + 4, + offset: 0, + }); + const search: SearchFn = (pattern, options) => { + const caseSensitive = (options as { caseSensitive?: boolean })?.caseSensitive ?? false; + let effectivePattern: RegExp; + + if (pattern instanceof RegExp) { + const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`; + effectivePattern = new RegExp(pattern.source, flags); + } else if (typeof pattern === 'string' && /^\/(.+)\/([gimsuy]*)$/.test(pattern)) { + const [, body, flags] = pattern.match(/^\/(.+)\/([gimsuy]*)$/) as RegExpMatchArray; + effectivePattern = new RegExp(body, flags.includes('g') ? flags : `${flags}g`); + } else { + const escaped = String(pattern).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + effectivePattern = new RegExp(escaped, caseSensitive ? 'g' : 'gi'); + } + + return Array.from(text.matchAll(effectivePattern)).map((match) => { + const from = match.index ?? 0; + return { + from, + to: from + match[0].length, + text: match[0], + }; + }); + }; + const editor = makeEditor(doc, search); + const query: Query = { select: { type: 'text', pattern: '/foo/' } }; + + const result = findAdapter(editor, query); + + expect(result.total).toBe(1); + expect(result.context).toBeDefined(); + const context = result.context![0]; + expect(context.snippet.slice(context.highlightRange.start, context.highlightRange.end)).toBe('/foo/'); }); - it('passes string pattern for case-sensitive contains mode', () => { + it('passes case-sensitive escaped RegExp for contains mode', () => { let capturedPattern: string | RegExp | undefined; const doc = buildDoc({ typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, nodeSize: 50, offset: 0 }); const search: SearchFn = (pattern) => { @@ -622,8 +666,9 @@ describe('findAdapter — text selectors', () => { findAdapter(editor, query); - expect(typeof capturedPattern).toBe('string'); - expect(capturedPattern).toBe('Hello'); + expect(capturedPattern).toBeInstanceOf(RegExp); + expect((capturedPattern as RegExp).source).toBe('Hello'); + expect((capturedPattern as RegExp).flags).not.toContain('i'); }); it('forwards caseSensitive option to search command for contains mode', () => { @@ -642,6 +687,23 @@ describe('findAdapter — text selectors', () => { expect(capturedOptions!.caseSensitive).toBe(true); }); + it('bounds maxMatches when pagination requests a small page', () => { + let capturedOptions: Record | undefined; + const doc = buildDoc({ typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, nodeSize: 50, offset: 0 }); + const search: SearchFn = (_pattern, options) => { + capturedOptions = options as Record; + return []; + }; + const editor = makeEditor(doc, search); + const query: Query = { select: { type: 'text', pattern: 'a' }, offset: 0, limit: 2 }; + + findAdapter(editor, query); + + expect(capturedOptions).toBeDefined(); + expect(typeof capturedOptions!.maxMatches).toBe('number'); + expect(capturedOptions!.maxMatches as number).toBeLessThanOrEqual(1000); + }); + it('throws when editor has no search command', () => { const doc = buildDoc({ typeName: 'paragraph', attrs: { sdBlockId: 'p1' }, nodeSize: 50, offset: 0 }); const editor = makeEditor(doc); // no search command diff --git a/packages/super-editor/src/document-api-adapters/find/text-strategy.ts b/packages/super-editor/src/document-api-adapters/find/text-strategy.ts index 13928881c..f6af42ac0 100644 --- a/packages/super-editor/src/document-api-adapters/find/text-strategy.ts +++ b/packages/super-editor/src/document-api-adapters/find/text-strategy.ts @@ -39,12 +39,17 @@ function compileRegex(selector: TextSelector, diagnostics: UnknownNodeDiagnostic } } -function buildSearchPattern(selector: TextSelector, diagnostics: UnknownNodeDiagnostic[]): string | RegExp | null { +function buildSearchPattern(selector: TextSelector, diagnostics: UnknownNodeDiagnostic[]): RegExp | null { const mode = selector.mode ?? 'contains'; if (mode === 'regex') { return compileRegex(selector, diagnostics); } - return selector.pattern; + // Compile as an escaped RegExp to guarantee literal matching. Passing a raw + // string can be reinterpreted by the search command (e.g. slash-delimited + // strings like "/foo/" are parsed as regex syntax by some implementations). + const escaped = selector.pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const flags = selector.caseSensitive ? 'g' : 'gi'; + return new RegExp(escaped, flags); } /** @@ -82,11 +87,12 @@ export function executeTextSelector( const search = requireEditorCommand(editor.commands?.search, 'find (search)'); - // Fetch all matches so `total` reflects the true document-wide count. - // Pagination is applied after filtering via paginate(). + // Cap materialized matches to avoid memory pressure on high-frequency queries + // (e.g. single-character patterns). Pagination is applied after filtering. + const MAX_SEARCH_MATCHES = 1000; const rawResult = search(pattern, { highlight: false, - maxMatches: Number.MAX_SAFE_INTEGER, + maxMatches: MAX_SEARCH_MATCHES, caseSensitive: selector.caseSensitive ?? false, }); From a7eb1b6f357be716986837f75f3c56e6814562d6 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Feb 2026 15:45:59 -0800 Subject: [PATCH 18/25] fix(super-editor): harden document-api adapters with offset, capability, and safety fixes --- .../helpers/node-address-resolver.test.ts | 19 ++++++++++++++----- .../helpers/node-address-resolver.ts | 9 ++++++++- .../write-adapter.test.ts | 16 ++++++++++++++++ .../document-api-adapters/write-adapter.ts | 11 +++++------ 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.test.ts b/packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.test.ts index b710bcb7b..4c30e43e9 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.test.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.test.ts @@ -395,8 +395,7 @@ describe('buildBlockIndex', () => { expect(index.candidates.map((c) => c.nodeId)).toEqual(['a', 'b', 'c']); }); - it('last candidate wins in byId when composite keys collide', () => { - // Duplicate IDs are invalid, but if present, the map keeps the last seen node. + it('excludes ambiguous composite keys from byId to prevent silent arbitrary resolution', () => { const p1 = makeNode('paragraph', { sdBlockId: 'dup' }, 10); const p2 = makeNode('paragraph', { sdBlockId: 'dup' }, 10); const doc = makeNode('doc', {}, 24, [ @@ -405,9 +404,10 @@ describe('buildBlockIndex', () => { ]); const index = buildBlockIndex(makeEditor(doc)); - // The map keeps the last one set - const found = index.byId.get('paragraph:dup'); - expect(found?.pos).toBe(12); + // Ambiguous keys are excluded from byId to prevent silent arbitrary resolution. + // Both candidates still appear in the ordered candidates array. + expect(index.byId.get('paragraph:dup')).toBeUndefined(); + expect(index.candidates.filter((c) => c.nodeId === 'dup')).toHaveLength(2); }); it('returns empty index for a document with no block nodes', () => { @@ -459,6 +459,15 @@ describe('findBlockById', () => { }; expect(findBlockById(index, address)).toBeUndefined(); }); + + it('treats duplicate nodeType:nodeId matches as ambiguous and does not resolve an arbitrary block', () => { + const index = indexFromNodes( + { typeName: 'paragraph', attrs: { sdBlockId: 'dup' }, offset: 0 }, + { typeName: 'paragraph', attrs: { sdBlockId: 'dup' }, offset: 12 }, + ); + + expect(findBlockById(index, { kind: 'block', nodeType: 'paragraph', nodeId: 'dup' })).toBeUndefined(); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.ts b/packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.ts index 12d3d89f9..3bf9c210c 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.ts @@ -148,6 +148,7 @@ export function toBlockAddress(candidate: BlockCandidate): BlockNodeAddress { export function buildBlockIndex(editor: Editor): BlockIndex { const candidates: BlockCandidate[] = []; const byId = new Map(); + const ambiguous = new Set(); // This traversal is a hot path for adapter workflows (for example find -> // getNode). Keep this pure snapshot builder so a transaction-invalidated @@ -167,7 +168,13 @@ export function buildBlockIndex(editor: Editor): BlockIndex { }; candidates.push(candidate); - byId.set(`${candidate.nodeType}:${candidate.nodeId}`, candidate); + const key = `${candidate.nodeType}:${candidate.nodeId}`; + if (byId.has(key)) { + ambiguous.add(key); + byId.delete(key); + } else if (!ambiguous.has(key)) { + byId.set(key, candidate); + } }); return { candidates, byId }; diff --git a/packages/super-editor/src/document-api-adapters/write-adapter.test.ts b/packages/super-editor/src/document-api-adapters/write-adapter.test.ts index 0f5232d71..1ce963985 100644 --- a/packages/super-editor/src/document-api-adapters/write-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/write-adapter.test.ts @@ -284,6 +284,22 @@ describe('writeAdapter', () => { expect(dispatch).toHaveBeenCalledTimes(1); }); + it('sets skipTrackChanges metadata for direct writes to preserve direct mutation semantics', () => { + const { editor, tr } = makeEditor('Hello'); + + writeAdapter( + editor, + { + kind: 'replace', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'World', + }, + { changeMode: 'direct' }, + ); + + expect(tr.setMeta).toHaveBeenCalledWith('skipTrackChanges', true); + }); + it('creates tracked changes for tracked writes', () => { const resolverSpy = vi .spyOn(trackedChangeResolver, 'toCanonicalTrackedChangeId') diff --git a/packages/super-editor/src/document-api-adapters/write-adapter.ts b/packages/super-editor/src/document-api-adapters/write-adapter.ts index afccba0c5..0d55fd8db 100644 --- a/packages/super-editor/src/document-api-adapters/write-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/write-adapter.ts @@ -9,6 +9,7 @@ import type { } from '@superdoc/document-api'; import { DocumentApiAdapterError } from './errors.js'; import { ensureTrackedCapability } from './helpers/mutation-helpers.js'; +import { applyDirectMutationMeta } from './helpers/transaction-meta.js'; import { resolveDefaultInsertTarget, resolveTextTarget, type ResolvedTextTarget } from './helpers/adapter-utils.js'; import { buildTextMutationResolution, readTextAtResolvedRange } from './helpers/text-mutation-resolution.js'; import { toCanonicalTrackedChangeId } from './helpers/tracked-change-resolver.js'; @@ -114,17 +115,15 @@ function applyDirectWrite( resolvedTarget: ResolvedWriteTarget, ): TextMutationReceipt { if (request.kind === 'delete') { - const tr = editor.state.tr - .delete(resolvedTarget.range.from, resolvedTarget.range.to) - .setMeta('inputType', 'programmatic'); + const tr = applyDirectMutationMeta(editor.state.tr.delete(resolvedTarget.range.from, resolvedTarget.range.to)); editor.dispatch(tr); return { success: true, resolution: resolvedTarget.resolution }; } // text is guaranteed non-empty for insert/replace after validateWriteRequest - const tr = editor.state.tr - .insertText(request.text ?? '', resolvedTarget.range.from, resolvedTarget.range.to) - .setMeta('inputType', 'programmatic'); + const tr = applyDirectMutationMeta( + editor.state.tr.insertText(request.text ?? '', resolvedTarget.range.from, resolvedTarget.range.to), + ); editor.dispatch(tr); return { success: true, resolution: resolvedTarget.resolution }; } From d79b05f1fdac3ca0493bf7ed158d4bb18940117a Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Feb 2026 16:11:32 -0800 Subject: [PATCH 19/25] chore(document-api): automate contract output sync/check via hooks and CI --- .github/workflows/ci-document-api.yml | 36 +++++++++++++++++++ .../reference/_generated-manifest.json | 23 +++++++++--- lefthook.yml | 3 ++ package.json | 5 ++- packages/document-api/README.md | 9 +++-- .../generated/agent/workflow-playbooks.json | 36 +++++++++++++++---- packages/document-api/scripts/README.md | 16 ++++++--- 7 files changed, 111 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/ci-document-api.yml diff --git a/.github/workflows/ci-document-api.yml b/.github/workflows/ci-document-api.yml new file mode 100644 index 000000000..e2d1f6cef --- /dev/null +++ b/.github/workflows/ci-document-api.yml @@ -0,0 +1,36 @@ +name: CI Document API + +permissions: + contents: read + +on: + pull_request: + paths: + - 'packages/document-api/**' + - 'apps/docs/document-api/**' + - 'package.json' + - '.github/workflows/ci-document-api.yml' + workflow_dispatch: + +concurrency: + group: ci-document-api-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check contract parity and generated outputs + run: pnpm run docapi:check diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 21cccf467..767d29688 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -50,25 +50,40 @@ "groups": [ { "key": "core", - "operationIds": ["find", "getNode", "getNodeById", "getText", "info", "insert", "replace", "delete"], + "operationIds": [ + "find", + "getNode", + "getNodeById", + "getText", + "info", + "insert", + "replace", + "delete" + ], "pagePath": "apps/docs/document-api/reference/core/index.mdx", "title": "Core" }, { "key": "capabilities", - "operationIds": ["capabilities.get"], + "operationIds": [ + "capabilities.get" + ], "pagePath": "apps/docs/document-api/reference/capabilities/index.mdx", "title": "Capabilities" }, { "key": "create", - "operationIds": ["create.paragraph"], + "operationIds": [ + "create.paragraph" + ], "pagePath": "apps/docs/document-api/reference/create/index.mdx", "title": "Create" }, { "key": "format", - "operationIds": ["format.bold"], + "operationIds": [ + "format.bold" + ], "pagePath": "apps/docs/document-api/reference/format/index.mdx", "title": "Format" }, diff --git a/lefthook.yml b/lefthook.yml index 08eda888b..067d56577 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -39,6 +39,9 @@ pre-commit: root: "apps/docs/" glob: "apps/docs/**/*.mdx" run: pnpm run test:examples + docapi-sync: + glob: "{packages/document-api,apps/docs/document-api}/**" + run: pnpm run docapi:sync && git add packages/document-api/generated apps/docs/document-api/reference apps/docs/document-api/overview.mdx commit-msg: commands: diff --git a/package.json b/package.json index a0784bb07..60c99a5db 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,10 @@ "local:publish": "pnpm --prefix packages/superdoc version prerelease --preid=local && pnpm --prefix packages/superdoc publish --registry http://localhost:4873", "update-preset-geometry": "ROOT=$(pwd) && cd ../superdoc-devtools/preset-geometry && pnpm run build && cp ./dist/index.js ./dist/index.js.map ./dist/index.d.ts \"$ROOT/packages/preset-geometry/\"", "manual-tag": "bash scripts/manual-tag.sh", - "manual-clean-tag": "bash scripts/manual-clean-tag.sh" + "manual-clean-tag": "bash scripts/manual-clean-tag.sh", + "docapi:sync": "pnpm exec tsx packages/document-api/scripts/generate-contract-outputs.ts", + "docapi:check": "pnpm exec tsx packages/document-api/scripts/check-contract-parity.ts && pnpm exec tsx packages/document-api/scripts/check-contract-outputs.ts", + "docapi:sync:check": "pnpm run docapi:sync && pnpm run docapi:check" }, "devDependencies": { "@commitlint/cli": "catalog:", diff --git a/packages/document-api/README.md b/packages/document-api/README.md index 0bf7b6fc0..2b6acde13 100644 --- a/packages/document-api/README.md +++ b/packages/document-api/README.md @@ -25,10 +25,15 @@ Generated marker block in overview: From repo root: ```bash -pnpm exec tsx packages/document-api/scripts/generate-contract-outputs.ts -pnpm exec tsx packages/document-api/scripts/check-contract-outputs.ts +pnpm run docapi:sync # regenerate all generated outputs +pnpm run docapi:check # verify parity + output drift (CI runs this) +pnpm run docapi:sync:check # sync then check in one step ``` +These are also enforced automatically: +- **Pre-commit hook** runs `docapi:sync` when document-api sources change and restages generated files. +- **CI workflow** (`ci-document-api.yml`) runs `docapi:check` on every PR touching relevant paths. + ## Related docs - `packages/document-api/src/README.md` for contract semantics and invariants diff --git a/packages/document-api/generated/agent/workflow-playbooks.json b/packages/document-api/generated/agent/workflow-playbooks.json index 7120befea..44be14382 100644 --- a/packages/document-api/generated/agent/workflow-playbooks.json +++ b/packages/document-api/generated/agent/workflow-playbooks.json @@ -4,32 +4,56 @@ "workflows": [ { "id": "find-mutate", - "operations": ["find", "replace"], + "operations": [ + "find", + "replace" + ], "title": "Find + mutate workflow" }, { "id": "tracked-insert", - "operations": ["capabilities.get", "insert"], + "operations": [ + "capabilities.get", + "insert" + ], "title": "Tracked insert workflow" }, { "id": "comment-thread-lifecycle", - "operations": ["comments.add", "comments.reply", "comments.resolve"], + "operations": [ + "comments.add", + "comments.reply", + "comments.resolve" + ], "title": "Comment lifecycle workflow" }, { "id": "list-manipulation", - "operations": ["lists.insert", "lists.setType", "lists.indent", "lists.outdent", "lists.exit"], + "operations": [ + "lists.insert", + "lists.setType", + "lists.indent", + "lists.outdent", + "lists.exit" + ], "title": "List manipulation workflow" }, { "id": "capabilities-aware-branching", - "operations": ["capabilities.get", "replace", "insert"], + "operations": [ + "capabilities.get", + "replace", + "insert" + ], "title": "Capabilities-aware branching workflow" }, { "id": "track-change-review", - "operations": ["trackChanges.list", "trackChanges.accept", "trackChanges.reject"], + "operations": [ + "trackChanges.list", + "trackChanges.accept", + "trackChanges.reject" + ], "title": "Track-change review workflow" } ] diff --git a/packages/document-api/scripts/README.md b/packages/document-api/scripts/README.md index ae7a0a9f3..2da578635 100644 --- a/packages/document-api/scripts/README.md +++ b/packages/document-api/scripts/README.md @@ -6,8 +6,12 @@ This folder contains deterministic generator/check entry points for the Document - `generate-*` scripts write generated artifacts. - `check-*` scripts validate generated artifacts or docs and fail with non-zero exit code on drift. -- In this repository snapshot, these scripts are not directly referenced from root `package.json` scripts or `.github/workflows`. -- Typical caller today: local ad-hoc invocations or higher-level wrappers in feature branches/CI jobs. +- Root `package.json` exposes three canonical entry points: + - `pnpm run docapi:sync` — runs `generate-contract-outputs.ts` + - `pnpm run docapi:check` — runs `check-contract-parity.ts` + `check-contract-outputs.ts` + - `pnpm run docapi:sync:check` — sync then check +- Pre-commit hook (`lefthook.yml`) auto-runs `docapi:sync` when document-api source files are staged. +- CI workflow (`ci-document-api.yml`) runs `docapi:check` on PRs touching document-api paths. ## Manual vs generated boundaries @@ -45,5 +49,9 @@ Do not hand-edit generated output files. Regenerate instead. ## Recommended usage 1. Change contract/docs sources. -2. Run the relevant `generate-*` script (or the all-in-one `generate-contract-outputs.ts`). -3. Run the matching `check-*` script (or the all-in-one `check-contract-outputs.ts`) to verify zero drift. +2. Run `pnpm run docapi:sync` (or the individual `generate-*` script for focused work). +3. Run `pnpm run docapi:check` to verify zero drift. + +Or combine: `pnpm run docapi:sync:check` + +The pre-commit hook handles step 2 automatically when document-api files are staged. CI enforces step 3. From ea7cc47d7a13d753da0e8d8842deffda2bdd5bba Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Feb 2026 16:24:58 -0800 Subject: [PATCH 20/25] fix(super-editor): preserve direct mutations and remove leaked document-api export --- .../src/core/commands/insertListItemAt.js | 5 +++-- .../src/core/commands/insertListItemAt.test.js | 8 ++++++++ .../src/core/commands/insertParagraphAt.js | 7 +++---- .../src/core/commands/insertParagraphAt.test.js | 10 ++++++++++ packages/super-editor/src/index.js | 2 -- packages/super-editor/src/index.public-api.test.js | 12 ++++++++++++ 6 files changed, 36 insertions(+), 8 deletions(-) create mode 100644 packages/super-editor/src/index.public-api.test.js diff --git a/packages/super-editor/src/core/commands/insertListItemAt.js b/packages/super-editor/src/core/commands/insertListItemAt.js index 3fe9b94b4..eab3bc760 100644 --- a/packages/super-editor/src/core/commands/insertListItemAt.js +++ b/packages/super-editor/src/core/commands/insertListItemAt.js @@ -10,7 +10,7 @@ import { getResolvedParagraphProperties } from '@extensions/paragraph/resolvedPr * @returns {import('./types/index.js').Command} */ export const insertListItemAt = - ({ pos, position, text = '', sdBlockId, tracked = false }) => + ({ pos, position, text = '', sdBlockId, tracked }) => ({ state, dispatch }) => { if (!Number.isInteger(pos) || pos < 0 || pos > state.doc.content.size) return false; if (position !== 'before' && position !== 'after') return false; @@ -60,7 +60,8 @@ export const insertListItemAt = try { const tr = state.tr.insert(insertPos, paragraphNode).setMeta('inputType', 'programmatic'); - if (tracked) tr.setMeta('forceTrackChanges', true); + if (tracked === true) tr.setMeta('forceTrackChanges', true); + else if (tracked === false) tr.setMeta('skipTrackChanges', true); dispatch(tr); return true; } catch { diff --git a/packages/super-editor/src/core/commands/insertListItemAt.test.js b/packages/super-editor/src/core/commands/insertListItemAt.test.js index b9e3ed0de..ee9414072 100644 --- a/packages/super-editor/src/core/commands/insertListItemAt.test.js +++ b/packages/super-editor/src/core/commands/insertListItemAt.test.js @@ -126,6 +126,14 @@ describe('insertListItemAt', () => { expect(state.tr.setMeta).toHaveBeenCalledWith('forceTrackChanges', true); }); + it('sets skipTrackChanges meta when tracked is false to preserve direct mode semantics', () => { + const { state, dispatch } = createMockState(); + + insertListItemAt({ pos: 0, position: 'after', tracked: false })({ state, dispatch }); + + expect(state.tr.setMeta).toHaveBeenCalledWith('skipTrackChanges', true); + }); + it('sets inputType programmatic meta', () => { const { state, dispatch } = createMockState(); diff --git a/packages/super-editor/src/core/commands/insertParagraphAt.js b/packages/super-editor/src/core/commands/insertParagraphAt.js index e35f8a239..4c69025d5 100644 --- a/packages/super-editor/src/core/commands/insertParagraphAt.js +++ b/packages/super-editor/src/core/commands/insertParagraphAt.js @@ -8,7 +8,7 @@ * @returns {import('./types/index.js').Command} */ export const insertParagraphAt = - ({ pos, text = '', sdBlockId, tracked = false }) => + ({ pos, text = '', sdBlockId, tracked }) => ({ state, dispatch }) => { const paragraphType = state.schema.nodes.paragraph; if (!paragraphType) return false; @@ -35,9 +35,8 @@ export const insertParagraphAt = const tr = state.tr.insert(pos, paragraphNode); if (!dispatch) return true; tr.setMeta('inputType', 'programmatic'); - if (tracked) { - tr.setMeta('forceTrackChanges', true); - } + if (tracked === true) tr.setMeta('forceTrackChanges', true); + else if (tracked === false) tr.setMeta('skipTrackChanges', true); dispatch(tr); return true; } catch { diff --git a/packages/super-editor/src/core/commands/insertParagraphAt.test.js b/packages/super-editor/src/core/commands/insertParagraphAt.test.js index 870478807..979e3c365 100644 --- a/packages/super-editor/src/core/commands/insertParagraphAt.test.js +++ b/packages/super-editor/src/core/commands/insertParagraphAt.test.js @@ -124,6 +124,16 @@ describe('insertParagraphAt', () => { expect(metaCalls).not.toContain('forceTrackChanges'); }); + it('sets skipTrackChanges meta when tracked is false to preserve direct mode semantics', () => { + const { state, dispatch, paragraphType, tr } = createMockState(); + const mockNode = { type: { name: 'paragraph' } }; + paragraphType.createAndFill.mockReturnValue(mockNode); + + insertParagraphAt({ pos: 0, tracked: false })({ state, dispatch }); + + expect(tr.setMeta).toHaveBeenCalledWith('skipTrackChanges', true); + }); + it('falls back to paragraphType.create when createAndFill returns null', () => { const { state, dispatch, paragraphType } = createMockState(); const mockNode = { type: { name: 'paragraph' } }; diff --git a/packages/super-editor/src/index.js b/packages/super-editor/src/index.js index e08b51ace..f0bdb0ad0 100644 --- a/packages/super-editor/src/index.js +++ b/packages/super-editor/src/index.js @@ -114,5 +114,3 @@ export { defineNode, defineMark, }; - -export { assembleDocumentApiAdapters } from './document-api-adapters/assemble-adapters.js'; diff --git a/packages/super-editor/src/index.public-api.test.js b/packages/super-editor/src/index.public-api.test.js new file mode 100644 index 000000000..b99c921ca --- /dev/null +++ b/packages/super-editor/src/index.public-api.test.js @@ -0,0 +1,12 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('public root exports', () => { + it('does not expose document-api adapter assembly from the package root', () => { + const indexPath = resolve(import.meta.dirname, 'index.js'); + const source = readFileSync(indexPath, 'utf8'); + + expect(source).not.toMatch(/export\s*\{\s*assembleDocumentApiAdapters\s*\}/); + }); +}); From e559581910a3752ae883461dd5046c0c7cb5e181 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Feb 2026 16:57:15 -0800 Subject: [PATCH 21/25] fix(document-api): pipe generated JSON through Prettier to fix sync/format race --- .../reference/_generated-manifest.json | 23 +++--------- .../generated/agent/workflow-playbooks.json | 36 ++++--------------- .../scripts/lib/generation-utils.ts | 19 +++++++--- 3 files changed, 25 insertions(+), 53 deletions(-) diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 767d29688..21cccf467 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -50,40 +50,25 @@ "groups": [ { "key": "core", - "operationIds": [ - "find", - "getNode", - "getNodeById", - "getText", - "info", - "insert", - "replace", - "delete" - ], + "operationIds": ["find", "getNode", "getNodeById", "getText", "info", "insert", "replace", "delete"], "pagePath": "apps/docs/document-api/reference/core/index.mdx", "title": "Core" }, { "key": "capabilities", - "operationIds": [ - "capabilities.get" - ], + "operationIds": ["capabilities.get"], "pagePath": "apps/docs/document-api/reference/capabilities/index.mdx", "title": "Capabilities" }, { "key": "create", - "operationIds": [ - "create.paragraph" - ], + "operationIds": ["create.paragraph"], "pagePath": "apps/docs/document-api/reference/create/index.mdx", "title": "Create" }, { "key": "format", - "operationIds": [ - "format.bold" - ], + "operationIds": ["format.bold"], "pagePath": "apps/docs/document-api/reference/format/index.mdx", "title": "Format" }, diff --git a/packages/document-api/generated/agent/workflow-playbooks.json b/packages/document-api/generated/agent/workflow-playbooks.json index 44be14382..7120befea 100644 --- a/packages/document-api/generated/agent/workflow-playbooks.json +++ b/packages/document-api/generated/agent/workflow-playbooks.json @@ -4,56 +4,32 @@ "workflows": [ { "id": "find-mutate", - "operations": [ - "find", - "replace" - ], + "operations": ["find", "replace"], "title": "Find + mutate workflow" }, { "id": "tracked-insert", - "operations": [ - "capabilities.get", - "insert" - ], + "operations": ["capabilities.get", "insert"], "title": "Tracked insert workflow" }, { "id": "comment-thread-lifecycle", - "operations": [ - "comments.add", - "comments.reply", - "comments.resolve" - ], + "operations": ["comments.add", "comments.reply", "comments.resolve"], "title": "Comment lifecycle workflow" }, { "id": "list-manipulation", - "operations": [ - "lists.insert", - "lists.setType", - "lists.indent", - "lists.outdent", - "lists.exit" - ], + "operations": ["lists.insert", "lists.setType", "lists.indent", "lists.outdent", "lists.exit"], "title": "List manipulation workflow" }, { "id": "capabilities-aware-branching", - "operations": [ - "capabilities.get", - "replace", - "insert" - ], + "operations": ["capabilities.get", "replace", "insert"], "title": "Capabilities-aware branching workflow" }, { "id": "track-change-review", - "operations": [ - "trackChanges.list", - "trackChanges.accept", - "trackChanges.reject" - ], + "operations": ["trackChanges.list", "trackChanges.accept", "trackChanges.reject"], "title": "Track-change review workflow" } ] diff --git a/packages/document-api/scripts/lib/generation-utils.ts b/packages/document-api/scripts/lib/generation-utils.ts index 5ca44278b..17bf22b47 100644 --- a/packages/document-api/scripts/lib/generation-utils.ts +++ b/packages/document-api/scripts/lib/generation-utils.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; +import { format as prettierFormat, resolveConfig as prettierResolveConfig } from 'prettier'; export interface GeneratedFile { path: string; @@ -40,15 +41,23 @@ export function normalizeFileContent(content: string): string { return content.endsWith('\n') ? content : `${content}\n`; } +async function formatGeneratedContent(file: GeneratedFile): Promise { + if (!file.path.endsWith('.json')) return file; + const config = await prettierResolveConfig(resolveWorkspacePath(file.path)); + const formatted = await prettierFormat(file.content, { ...config, parser: 'json' }); + return { ...file, content: formatted }; +} + export function resolveWorkspacePath(path: string): string { return resolve(process.cwd(), path); } export async function writeGeneratedFiles(files: GeneratedFile[]): Promise { for (const file of files) { - const absolutePath = resolveWorkspacePath(file.path); + const formatted = await formatGeneratedContent(file); + const absolutePath = resolveWorkspacePath(formatted.path); await mkdir(dirname(absolutePath), { recursive: true }); - await writeFile(absolutePath, normalizeFileContent(file.content), 'utf8'); + await writeFile(absolutePath, normalizeFileContent(formatted.content), 'utf8'); } } @@ -85,14 +94,16 @@ export async function checkGeneratedFiles( } = {}, ): Promise { const issues: GeneratedCheckIssue[] = []; - const expected = new Map(expectedFiles.map((file) => [file.path, normalizeFileContent(file.content)])); + const expected = new Map(expectedFiles.map((file) => [file.path, file])); - for (const [path, expectedContent] of expected.entries()) { + for (const [path, file] of expected.entries()) { if (!(await pathExists(path))) { issues.push({ kind: 'missing', path }); continue; } + const formatted = await formatGeneratedContent(file); + const expectedContent = normalizeFileContent(formatted.content); const actualContent = await readFile(resolveWorkspacePath(path), 'utf8'); if (actualContent !== expectedContent) { issues.push({ kind: 'content', path }); From 4304a3a1b8f618de5cdb77d2dcd1def1d5a7e6a2 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Feb 2026 17:02:43 -0800 Subject: [PATCH 22/25] fix(document-api): align list mutator catalog flags with implemented dryRun and cicd --- .../reference/_generated-manifest.json | 2 +- apps/docs/document-api/reference/index.mdx | 12 ++-- .../document-api/reference/lists/exit.mdx | 2 +- .../document-api/reference/lists/indent.mdx | 2 +- .../document-api/reference/lists/index.mdx | 12 ++-- .../document-api/reference/lists/insert.mdx | 2 +- .../document-api/reference/lists/outdent.mdx | 2 +- .../document-api/reference/lists/restart.mdx | 2 +- .../document-api/reference/lists/set-type.mdx | 2 +- package.json | 1 + .../generated/agent/compatibility-hints.json | 14 ++-- .../generated/agent/remediation-map.json | 2 +- .../generated/agent/workflow-playbooks.json | 2 +- .../manifests/document-api-tools.json | 14 ++-- .../schemas/document-api-contract.json | 14 ++-- .../src/contract/command-catalog.ts | 12 ++-- .../contract-conformance.test.ts | 71 +++++++++++++++++++ .../capabilities-adapter.test.ts | 18 ++++- pnpm-lock.yaml | 3 + 19 files changed, 140 insertions(+), 49 deletions(-) diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 21cccf467..66211da53 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -120,5 +120,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "50a44d02c22957a93c8ce085776af0197e3200f02d3b402f2625dad31a0dc756" + "sourceHash": "8d875ceb279d213c1e779882f103d39fa2106545ce94c6b1553ae06b6c321e03" } diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index 8c1030bce..9648b8135 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -37,12 +37,12 @@ Document API is currently alpha and subject to breaking changes. | [`create.paragraph`](./create/paragraph) | Create | `create.paragraph` | Yes | `non-idempotent` | Yes | Yes | | [`lists.list`](./lists/list) | Lists | `lists.list` | No | `idempotent` | No | No | | [`lists.get`](./lists/get) | Lists | `lists.get` | No | `idempotent` | No | No | -| [`lists.insert`](./lists/insert) | Lists | `lists.insert` | Yes | `non-idempotent` | Yes | No | -| [`lists.setType`](./lists/set-type) | Lists | `lists.setType` | Yes | `conditional` | No | No | -| [`lists.indent`](./lists/indent) | Lists | `lists.indent` | Yes | `conditional` | No | No | -| [`lists.outdent`](./lists/outdent) | Lists | `lists.outdent` | Yes | `conditional` | No | No | -| [`lists.restart`](./lists/restart) | Lists | `lists.restart` | Yes | `conditional` | No | No | -| [`lists.exit`](./lists/exit) | Lists | `lists.exit` | Yes | `conditional` | No | No | +| [`lists.insert`](./lists/insert) | Lists | `lists.insert` | Yes | `non-idempotent` | Yes | Yes | +| [`lists.setType`](./lists/set-type) | Lists | `lists.setType` | Yes | `conditional` | No | Yes | +| [`lists.indent`](./lists/indent) | Lists | `lists.indent` | Yes | `conditional` | No | Yes | +| [`lists.outdent`](./lists/outdent) | Lists | `lists.outdent` | Yes | `conditional` | No | Yes | +| [`lists.restart`](./lists/restart) | Lists | `lists.restart` | Yes | `conditional` | No | Yes | +| [`lists.exit`](./lists/exit) | Lists | `lists.exit` | Yes | `conditional` | No | Yes | | [`comments.add`](./comments/add) | Comments | `comments.add` | Yes | `non-idempotent` | No | No | | [`comments.edit`](./comments/edit) | Comments | `comments.edit` | Yes | `conditional` | No | No | | [`comments.reply`](./comments/reply) | Comments | `comments.reply` | Yes | `non-idempotent` | No | No | diff --git a/apps/docs/document-api/reference/lists/exit.mdx b/apps/docs/document-api/reference/lists/exit.mdx index 012458a0d..e01820da0 100644 --- a/apps/docs/document-api/reference/lists/exit.mdx +++ b/apps/docs/document-api/reference/lists/exit.mdx @@ -15,7 +15,7 @@ description: Generated reference for lists.exit - Mutates document: `yes` - Idempotency: `conditional` - Supports tracked mode: `no` -- Supports dry run: `no` +- Supports dry run: `yes` - Deterministic target resolution: `yes` ## Pre-apply throws diff --git a/apps/docs/document-api/reference/lists/indent.mdx b/apps/docs/document-api/reference/lists/indent.mdx index 7ee3e0e71..5bc2f3463 100644 --- a/apps/docs/document-api/reference/lists/indent.mdx +++ b/apps/docs/document-api/reference/lists/indent.mdx @@ -15,7 +15,7 @@ description: Generated reference for lists.indent - Mutates document: `yes` - Idempotency: `conditional` - Supports tracked mode: `no` -- Supports dry run: `no` +- Supports dry run: `yes` - Deterministic target resolution: `yes` ## Pre-apply throws diff --git a/apps/docs/document-api/reference/lists/index.mdx b/apps/docs/document-api/reference/lists/index.mdx index ea576a027..46b5679de 100644 --- a/apps/docs/document-api/reference/lists/index.mdx +++ b/apps/docs/document-api/reference/lists/index.mdx @@ -16,9 +16,9 @@ List inspection and list mutations. | --- | --- | --- | --- | --- | --- | | [`lists.list`](./list) | `lists.list` | No | `idempotent` | No | No | | [`lists.get`](./get) | `lists.get` | No | `idempotent` | No | No | -| [`lists.insert`](./insert) | `lists.insert` | Yes | `non-idempotent` | Yes | No | -| [`lists.setType`](./set-type) | `lists.setType` | Yes | `conditional` | No | No | -| [`lists.indent`](./indent) | `lists.indent` | Yes | `conditional` | No | No | -| [`lists.outdent`](./outdent) | `lists.outdent` | Yes | `conditional` | No | No | -| [`lists.restart`](./restart) | `lists.restart` | Yes | `conditional` | No | No | -| [`lists.exit`](./exit) | `lists.exit` | Yes | `conditional` | No | No | +| [`lists.insert`](./insert) | `lists.insert` | Yes | `non-idempotent` | Yes | Yes | +| [`lists.setType`](./set-type) | `lists.setType` | Yes | `conditional` | No | Yes | +| [`lists.indent`](./indent) | `lists.indent` | Yes | `conditional` | No | Yes | +| [`lists.outdent`](./outdent) | `lists.outdent` | Yes | `conditional` | No | Yes | +| [`lists.restart`](./restart) | `lists.restart` | Yes | `conditional` | No | Yes | +| [`lists.exit`](./exit) | `lists.exit` | Yes | `conditional` | No | Yes | diff --git a/apps/docs/document-api/reference/lists/insert.mdx b/apps/docs/document-api/reference/lists/insert.mdx index bd62e6cb3..c7e2d462b 100644 --- a/apps/docs/document-api/reference/lists/insert.mdx +++ b/apps/docs/document-api/reference/lists/insert.mdx @@ -15,7 +15,7 @@ description: Generated reference for lists.insert - Mutates document: `yes` - Idempotency: `non-idempotent` - Supports tracked mode: `yes` -- Supports dry run: `no` +- Supports dry run: `yes` - Deterministic target resolution: `yes` ## Pre-apply throws diff --git a/apps/docs/document-api/reference/lists/outdent.mdx b/apps/docs/document-api/reference/lists/outdent.mdx index f4143eef4..aebde40d1 100644 --- a/apps/docs/document-api/reference/lists/outdent.mdx +++ b/apps/docs/document-api/reference/lists/outdent.mdx @@ -15,7 +15,7 @@ description: Generated reference for lists.outdent - Mutates document: `yes` - Idempotency: `conditional` - Supports tracked mode: `no` -- Supports dry run: `no` +- Supports dry run: `yes` - Deterministic target resolution: `yes` ## Pre-apply throws diff --git a/apps/docs/document-api/reference/lists/restart.mdx b/apps/docs/document-api/reference/lists/restart.mdx index 7b9d82a81..3cc6d5e30 100644 --- a/apps/docs/document-api/reference/lists/restart.mdx +++ b/apps/docs/document-api/reference/lists/restart.mdx @@ -15,7 +15,7 @@ description: Generated reference for lists.restart - Mutates document: `yes` - Idempotency: `conditional` - Supports tracked mode: `no` -- Supports dry run: `no` +- Supports dry run: `yes` - Deterministic target resolution: `yes` ## Pre-apply throws diff --git a/apps/docs/document-api/reference/lists/set-type.mdx b/apps/docs/document-api/reference/lists/set-type.mdx index 5b65b48f7..29bc7054a 100644 --- a/apps/docs/document-api/reference/lists/set-type.mdx +++ b/apps/docs/document-api/reference/lists/set-type.mdx @@ -15,7 +15,7 @@ description: Generated reference for lists.setType - Mutates document: `yes` - Idempotency: `conditional` - Supports tracked mode: `no` -- Supports dry run: `no` +- Supports dry run: `yes` - Deterministic target resolution: `yes` ## Pre-apply throws diff --git a/package.json b/package.json index 60c99a5db..1fdceb3c6 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "semantic-release-commit-filter": "catalog:", "semantic-release-linear-app": "catalog:", "semantic-release-pnpm": "^1.0.2", + "tsx": "catalog:", "typescript": "catalog:", "typescript-eslint": "catalog:", "verdaccio": "catalog:", diff --git a/packages/document-api/generated/agent/compatibility-hints.json b/packages/document-api/generated/agent/compatibility-hints.json index 0ae5e9596..55fc4d83d 100644 --- a/packages/document-api/generated/agent/compatibility-hints.json +++ b/packages/document-api/generated/agent/compatibility-hints.json @@ -196,7 +196,7 @@ "mutates": true, "postApplyThrowForbidden": true, "requiresPreflightCapabilitiesCheck": true, - "supportsDryRun": false, + "supportsDryRun": true, "supportsTrackedMode": false }, "lists.get": { @@ -214,7 +214,7 @@ "mutates": true, "postApplyThrowForbidden": true, "requiresPreflightCapabilitiesCheck": true, - "supportsDryRun": false, + "supportsDryRun": true, "supportsTrackedMode": false }, "lists.insert": { @@ -223,7 +223,7 @@ "mutates": true, "postApplyThrowForbidden": true, "requiresPreflightCapabilitiesCheck": true, - "supportsDryRun": false, + "supportsDryRun": true, "supportsTrackedMode": true }, "lists.list": { @@ -241,7 +241,7 @@ "mutates": true, "postApplyThrowForbidden": true, "requiresPreflightCapabilitiesCheck": true, - "supportsDryRun": false, + "supportsDryRun": true, "supportsTrackedMode": false }, "lists.restart": { @@ -250,7 +250,7 @@ "mutates": true, "postApplyThrowForbidden": true, "requiresPreflightCapabilitiesCheck": true, - "supportsDryRun": false, + "supportsDryRun": true, "supportsTrackedMode": false }, "lists.setType": { @@ -259,7 +259,7 @@ "mutates": true, "postApplyThrowForbidden": true, "requiresPreflightCapabilitiesCheck": true, - "supportsDryRun": false, + "supportsDryRun": true, "supportsTrackedMode": false }, "replace": { @@ -326,5 +326,5 @@ "supportsTrackedMode": false } }, - "sourceHash": "50a44d02c22957a93c8ce085776af0197e3200f02d3b402f2625dad31a0dc756" + "sourceHash": "8d875ceb279d213c1e779882f103d39fa2106545ce94c6b1553ae06b6c321e03" } diff --git a/packages/document-api/generated/agent/remediation-map.json b/packages/document-api/generated/agent/remediation-map.json index 03b72d58e..38fda4f4b 100644 --- a/packages/document-api/generated/agent/remediation-map.json +++ b/packages/document-api/generated/agent/remediation-map.json @@ -288,5 +288,5 @@ ] } ], - "sourceHash": "50a44d02c22957a93c8ce085776af0197e3200f02d3b402f2625dad31a0dc756" + "sourceHash": "8d875ceb279d213c1e779882f103d39fa2106545ce94c6b1553ae06b6c321e03" } diff --git a/packages/document-api/generated/agent/workflow-playbooks.json b/packages/document-api/generated/agent/workflow-playbooks.json index 7120befea..2b6f01785 100644 --- a/packages/document-api/generated/agent/workflow-playbooks.json +++ b/packages/document-api/generated/agent/workflow-playbooks.json @@ -1,6 +1,6 @@ { "contractVersion": "0.1.0", - "sourceHash": "50a44d02c22957a93c8ce085776af0197e3200f02d3b402f2625dad31a0dc756", + "sourceHash": "8d875ceb279d213c1e779882f103d39fa2106545ce94c6b1553ae06b6c321e03", "workflows": [ { "id": "find-mutate", diff --git a/packages/document-api/generated/manifests/document-api-tools.json b/packages/document-api/generated/manifests/document-api-tools.json index 5e83ab394..f372d928c 100644 --- a/packages/document-api/generated/manifests/document-api-tools.json +++ b/packages/document-api/generated/manifests/document-api-tools.json @@ -2,7 +2,7 @@ "contractVersion": "0.1.0", "generatedAt": null, "sourceCommit": null, - "sourceHash": "50a44d02c22957a93c8ce085776af0197e3200f02d3b402f2625dad31a0dc756", + "sourceHash": "8d875ceb279d213c1e779882f103d39fa2106545ce94c6b1553ae06b6c321e03", "tools": [ { "description": "Read Document API data via `find`.", @@ -4268,7 +4268,7 @@ "required": ["success", "item", "insertionPoint"], "type": "object" }, - "supportsDryRun": false, + "supportsDryRun": true, "supportsTrackedMode": true }, { @@ -4416,7 +4416,7 @@ "required": ["success", "item"], "type": "object" }, - "supportsDryRun": false, + "supportsDryRun": true, "supportsTrackedMode": false }, { @@ -4561,7 +4561,7 @@ "required": ["success", "item"], "type": "object" }, - "supportsDryRun": false, + "supportsDryRun": true, "supportsTrackedMode": false }, { @@ -4706,7 +4706,7 @@ "required": ["success", "item"], "type": "object" }, - "supportsDryRun": false, + "supportsDryRun": true, "supportsTrackedMode": false }, { @@ -4851,7 +4851,7 @@ "required": ["success", "item"], "type": "object" }, - "supportsDryRun": false, + "supportsDryRun": true, "supportsTrackedMode": false }, { @@ -4996,7 +4996,7 @@ "required": ["success", "paragraph"], "type": "object" }, - "supportsDryRun": false, + "supportsDryRun": true, "supportsTrackedMode": false }, { diff --git a/packages/document-api/generated/schemas/document-api-contract.json b/packages/document-api/generated/schemas/document-api-contract.json index 95ddafbde..346cd1b8b 100644 --- a/packages/document-api/generated/schemas/document-api-contract.json +++ b/packages/document-api/generated/schemas/document-api-contract.json @@ -7489,7 +7489,7 @@ "idempotency": "conditional", "mutates": true, "possibleFailureCodes": ["INVALID_TARGET"], - "supportsDryRun": false, + "supportsDryRun": true, "supportsTrackedMode": false, "throws": { "postApplyForbidden": true, @@ -7718,7 +7718,7 @@ "idempotency": "conditional", "mutates": true, "possibleFailureCodes": ["NO_OP", "INVALID_TARGET"], - "supportsDryRun": false, + "supportsDryRun": true, "supportsTrackedMode": false, "throws": { "postApplyForbidden": true, @@ -7871,7 +7871,7 @@ "idempotency": "non-idempotent", "mutates": true, "possibleFailureCodes": ["INVALID_TARGET"], - "supportsDryRun": false, + "supportsDryRun": true, "supportsTrackedMode": true, "throws": { "postApplyForbidden": true, @@ -8236,7 +8236,7 @@ "idempotency": "conditional", "mutates": true, "possibleFailureCodes": ["NO_OP", "INVALID_TARGET"], - "supportsDryRun": false, + "supportsDryRun": true, "supportsTrackedMode": false, "throws": { "postApplyForbidden": true, @@ -8383,7 +8383,7 @@ "idempotency": "conditional", "mutates": true, "possibleFailureCodes": ["NO_OP", "INVALID_TARGET"], - "supportsDryRun": false, + "supportsDryRun": true, "supportsTrackedMode": false, "throws": { "postApplyForbidden": true, @@ -8533,7 +8533,7 @@ "idempotency": "conditional", "mutates": true, "possibleFailureCodes": ["NO_OP", "INVALID_TARGET"], - "supportsDryRun": false, + "supportsDryRun": true, "supportsTrackedMode": false, "throws": { "postApplyForbidden": true, @@ -10774,5 +10774,5 @@ } }, "sourceCommit": null, - "sourceHash": "50a44d02c22957a93c8ce085776af0197e3200f02d3b402f2625dad31a0dc756" + "sourceHash": "8d875ceb279d213c1e779882f103d39fa2106545ce94c6b1553ae06b6c321e03" } diff --git a/packages/document-api/src/contract/command-catalog.ts b/packages/document-api/src/contract/command-catalog.ts index 35891c940..9dec04d09 100644 --- a/packages/document-api/src/contract/command-catalog.ts +++ b/packages/document-api/src/contract/command-catalog.ts @@ -127,42 +127,42 @@ export const COMMAND_CATALOG: CommandCatalog = { }), 'lists.insert': mutationOperation({ idempotency: 'non-idempotent', - supportsDryRun: false, + supportsDryRun: true, supportsTrackedMode: true, possibleFailureCodes: ['INVALID_TARGET'], throws: T_NOT_FOUND_COMMAND_TRACKED, }), 'lists.setType': mutationOperation({ idempotency: 'conditional', - supportsDryRun: false, + supportsDryRun: true, supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], throws: T_NOT_FOUND_COMMAND_TRACKED, }), 'lists.indent': mutationOperation({ idempotency: 'conditional', - supportsDryRun: false, + supportsDryRun: true, supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], throws: T_NOT_FOUND_COMMAND_TRACKED, }), 'lists.outdent': mutationOperation({ idempotency: 'conditional', - supportsDryRun: false, + supportsDryRun: true, supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], throws: T_NOT_FOUND_COMMAND_TRACKED, }), 'lists.restart': mutationOperation({ idempotency: 'conditional', - supportsDryRun: false, + supportsDryRun: true, supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], throws: T_NOT_FOUND_COMMAND_TRACKED, }), 'lists.exit': mutationOperation({ idempotency: 'conditional', - supportsDryRun: false, + supportsDryRun: true, supportsTrackedMode: false, possibleFailureCodes: ['INVALID_TARGET'], throws: T_NOT_FOUND_COMMAND_TRACKED, diff --git a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts index 98dd83f90..269ad4ed9 100644 --- a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts +++ b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts @@ -928,6 +928,77 @@ const dryRunVectors: Partial unknown>> = { expect(insertParagraphAt).not.toHaveBeenCalled(); return result; }, + 'lists.insert': () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, numberingType: 'decimal' })]); + const insertListItemAt = editor.commands!.insertListItemAt as ReturnType; + const result = listsInsertAdapter( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, position: 'after', text: 'X' }, + { changeMode: 'direct', dryRun: true }, + ); + expect(insertListItemAt).not.toHaveBeenCalled(); + return result; + }, + 'lists.setType': () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, numberingType: 'bullet' })]); + const setListTypeAt = editor.commands!.setListTypeAt as ReturnType; + const result = listsSetTypeAdapter( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, kind: 'ordered' }, + { changeMode: 'direct', dryRun: true }, + ); + expect(setListTypeAt).not.toHaveBeenCalled(); + return result; + }, + 'lists.indent': () => { + const hasDefinitionSpy = vi.spyOn(ListHelpers, 'hasListDefinition').mockReturnValue(true); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const increaseListIndent = editor.commands!.increaseListIndent as ReturnType; + const result = listsIndentAdapter( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, + { changeMode: 'direct', dryRun: true }, + ); + expect(increaseListIndent).not.toHaveBeenCalled(); + hasDefinitionSpy.mockRestore(); + return result; + }, + 'lists.outdent': () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 1, numberingType: 'decimal' })]); + const decreaseListIndent = editor.commands!.decreaseListIndent as ReturnType; + const result = listsOutdentAdapter( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, + { changeMode: 'direct', dryRun: true }, + ); + expect(decreaseListIndent).not.toHaveBeenCalled(); + return result; + }, + 'lists.restart': () => { + const editor = makeListEditor([ + makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal', markerText: '1.', path: [1] }), + makeListParagraph({ id: 'li-2', numId: 1, ilvl: 0, numberingType: 'decimal', markerText: '2.', path: [2] }), + ]); + const restartNumbering = editor.commands!.restartNumbering as ReturnType; + const result = listsRestartAdapter( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-2' } }, + { changeMode: 'direct', dryRun: true }, + ); + expect(restartNumbering).not.toHaveBeenCalled(); + return result; + }, + 'lists.exit': () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const exitListItemAt = editor.commands!.exitListItemAt as ReturnType; + const result = listsExitAdapter( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, + { changeMode: 'direct', dryRun: true }, + ); + expect(exitListItemAt).not.toHaveBeenCalled(); + return result; + }, }; beforeEach(() => { diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts index e384be339..0515f3070 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts @@ -113,11 +113,27 @@ describe('getDocumentApiCapabilities', () => { expect(capabilities.operations.insert.tracked).toBe(true); expect(capabilities.operations.insert.dryRun).toBe(true); expect(capabilities.operations['lists.setType'].tracked).toBe(false); - expect(capabilities.operations['lists.setType'].dryRun).toBe(false); + expect(capabilities.operations['lists.setType'].dryRun).toBe(true); expect(capabilities.operations['trackChanges.accept'].dryRun).toBe(false); expect(capabilities.operations['create.paragraph'].dryRun).toBe(true); }); + it('advertises dryRun for list mutators that implement dry-run behavior', () => { + const capabilities = getDocumentApiCapabilities(makeEditor()); + const listMutations = [ + 'lists.insert', + 'lists.setType', + 'lists.indent', + 'lists.outdent', + 'lists.restart', + 'lists.exit', + ] as const; + + for (const operationId of listMutations) { + expect(capabilities.operations[operationId].dryRun, `${operationId} should advertise dryRun support`).toBe(true); + } + }); + it('reports tracked mode unavailable when no editor user is configured', () => { const capabilities = getDocumentApiCapabilities( makeEditor({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b99f5a79..b7e7adca4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -387,6 +387,9 @@ importers: semantic-release-pnpm: specifier: ^1.0.2 version: 1.0.2(semantic-release@24.2.9(typescript@5.9.3)) + tsx: + specifier: 'catalog:' + version: 4.21.0 typescript: specifier: 'catalog:' version: 5.9.3 From dc1c158e7fef25257b83c9f0e63a89131771f24a Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Feb 2026 17:34:25 -0800 Subject: [PATCH 23/25] feat(document-api): add invoke dynamic dispatch with typed operation registry Adds `api.invoke({ operationId, input, options })` for dynamic/AI callers alongside type-safe overloads for TypeScript consumers. Includes: - OperationRegistry with bidirectional compile-time completeness checks - Runtime dispatch table mapping all 36 operations to direct API methods - DynamicInvokeRequest overload accepting unknown input - hasOwnProperty guard with clear error for unknown operation IDs - Contract parity check extended to verify dispatch table keys - 13 new tests covering completeness, parity, error handling, and dynamic dispatch --- .../scripts/check-contract-parity.ts | 22 +- packages/document-api/src/contract/index.ts | 1 + .../src/contract/operation-registry.ts | 151 ++++++++++ packages/document-api/src/index.ts | 29 +- .../document-api/src/invoke/invoke.test.ts | 273 ++++++++++++++++++ packages/document-api/src/invoke/invoke.ts | 108 +++++++ 6 files changed, 582 insertions(+), 2 deletions(-) create mode 100644 packages/document-api/src/contract/operation-registry.ts create mode 100644 packages/document-api/src/invoke/invoke.test.ts create mode 100644 packages/document-api/src/invoke/invoke.ts diff --git a/packages/document-api/scripts/check-contract-parity.ts b/packages/document-api/scripts/check-contract-parity.ts index 387138f16..23f7b05ff 100644 --- a/packages/document-api/scripts/check-contract-parity.ts +++ b/packages/document-api/scripts/check-contract-parity.ts @@ -14,6 +14,13 @@ import { isValidOperationIdFormat, type DocumentApiAdapters, } from '../src/index.js'; +import { buildDispatchTable } from '../src/invoke/invoke.js'; + +/** + * Meta-methods on DocumentApi that are not operations. + * These are excluded from operation-to-member-path parity checks. + */ +const META_MEMBER_PATHS = ['invoke'] as const; function collectFunctionMemberPaths(value: unknown, prefix = ''): string[] { if (!value || typeof value !== 'object') return []; @@ -188,7 +195,10 @@ function run(): void { } const api = createDocumentApi(createNoopAdapters()); - const runtimeMemberPaths = collectFunctionMemberPaths(api).sort(); + const metaPathSet = new Set(META_MEMBER_PATHS); + const runtimeMemberPaths = collectFunctionMemberPaths(api) + .filter((path) => !metaPathSet.has(path)) + .sort(); const declaredMemberPaths = [...DOCUMENT_API_MEMBER_PATHS].sort(); const missingRuntimeMembers = diff(declaredMemberPaths, runtimeMemberPaths); @@ -199,6 +209,16 @@ function run(): void { ); } + // Verify invoke dispatch table keys match OPERATION_IDS exactly. + const dispatchKeys = Object.keys(buildDispatchTable(api)).sort(); + const missingDispatch = diff(operationIds, dispatchKeys); + const extraDispatch = diff(dispatchKeys, operationIds); + if (missingDispatch.length > 0 || extraDispatch.length > 0) { + errors.push( + `invoke dispatch table parity failed (missing: ${missingDispatch.join(', ') || 'none'}, extra: ${extraDispatch.join(', ') || 'none'})`, + ); + } + const mappedMemberPaths = Object.values(OPERATION_MEMBER_PATH_MAP).sort(); const missingMapMembers = diff(declaredMemberPaths, mappedMemberPaths); const extraMapMembers = diff(mappedMemberPaths, declaredMemberPaths); diff --git a/packages/document-api/src/contract/index.ts b/packages/document-api/src/contract/index.ts index 45de2e9d5..36556488a 100644 --- a/packages/document-api/src/contract/index.ts +++ b/packages/document-api/src/contract/index.ts @@ -3,3 +3,4 @@ export * from './command-catalog.js'; export * from './schemas.js'; export * from './operation-map.js'; export * from './reference-doc-map.js'; +export * from './operation-registry.js'; diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts new file mode 100644 index 000000000..c86834fea --- /dev/null +++ b/packages/document-api/src/contract/operation-registry.ts @@ -0,0 +1,151 @@ +/** + * Canonical type-level mapping from OperationId to input, options, and output types. + * + * This interface is the single source of truth for the invoke dispatch layer. + * The bidirectional completeness checks at the bottom of this file guarantee + * that every OperationId has a registry entry and vice versa. + */ + +import type { OperationId } from './types.js'; + +import type { NodeAddress, NodeInfo, QueryResult, Selector, Query } from '../types/index.js'; +import type { TextMutationReceipt, Receipt } from '../types/receipt.js'; +import type { DocumentInfo } from '../types/info.types.js'; +import type { CreateParagraphInput, CreateParagraphResult } from '../types/create.types.js'; + +import type { FindOptions } from '../find/find.js'; +import type { GetNodeByIdInput } from '../get-node/get-node.js'; +import type { GetTextInput } from '../get-text/get-text.js'; +import type { InfoInput } from '../info/info.js'; +import type { InsertInput } from '../insert/insert.js'; +import type { ReplaceInput } from '../replace/replace.js'; +import type { DeleteInput } from '../delete/delete.js'; +import type { MutationOptions } from '../write/write.js'; +import type { FormatBoldInput } from '../format/format.js'; +import type { + AddCommentInput, + EditCommentInput, + ReplyToCommentInput, + MoveCommentInput, + ResolveCommentInput, + RemoveCommentInput, + SetCommentInternalInput, + SetCommentActiveInput, + GoToCommentInput, + GetCommentInput, +} from '../comments/comments.js'; +import type { CommentInfo, CommentsListQuery, CommentsListResult } from '../comments/comments.types.js'; +import type { + TrackChangesListInput, + TrackChangesGetInput, + TrackChangesAcceptInput, + TrackChangesRejectInput, + TrackChangesAcceptAllInput, + TrackChangesRejectAllInput, +} from '../track-changes/track-changes.js'; +import type { TrackChangeInfo, TrackChangesListResult } from '../types/track-changes.types.js'; +import type { DocumentApiCapabilities } from '../capabilities/capabilities.js'; +import type { + ListsListQuery, + ListsListResult, + ListsGetInput, + ListItemInfo, + ListInsertInput, + ListsInsertResult, + ListSetTypeInput, + ListsMutateItemResult, + ListTargetInput, + ListsExitResult, +} from '../lists/lists.types.js'; + +export interface OperationRegistry { + // --- Singleton reads --- + find: { input: Selector | Query; options: FindOptions; output: QueryResult }; + getNode: { input: NodeAddress; options: never; output: NodeInfo }; + getNodeById: { input: GetNodeByIdInput; options: never; output: NodeInfo }; + getText: { input: GetTextInput; options: never; output: string }; + info: { input: InfoInput; options: never; output: DocumentInfo }; + + // --- Singleton mutations --- + insert: { input: InsertInput; options: MutationOptions; output: TextMutationReceipt }; + replace: { input: ReplaceInput; options: MutationOptions; output: TextMutationReceipt }; + delete: { input: DeleteInput; options: MutationOptions; output: TextMutationReceipt }; + + // --- format.* --- + 'format.bold': { input: FormatBoldInput; options: MutationOptions; output: TextMutationReceipt }; + + // --- create.* --- + 'create.paragraph': { input: CreateParagraphInput; options: MutationOptions; output: CreateParagraphResult }; + + // --- lists.* --- + 'lists.list': { input: ListsListQuery | undefined; options: never; output: ListsListResult }; + 'lists.get': { input: ListsGetInput; options: never; output: ListItemInfo }; + 'lists.insert': { input: ListInsertInput; options: MutationOptions; output: ListsInsertResult }; + 'lists.setType': { input: ListSetTypeInput; options: MutationOptions; output: ListsMutateItemResult }; + 'lists.indent': { input: ListTargetInput; options: MutationOptions; output: ListsMutateItemResult }; + 'lists.outdent': { input: ListTargetInput; options: MutationOptions; output: ListsMutateItemResult }; + 'lists.restart': { input: ListTargetInput; options: MutationOptions; output: ListsMutateItemResult }; + 'lists.exit': { input: ListTargetInput; options: MutationOptions; output: ListsExitResult }; + + // --- comments.* --- + 'comments.add': { input: AddCommentInput; options: never; output: Receipt }; + 'comments.edit': { input: EditCommentInput; options: never; output: Receipt }; + 'comments.reply': { input: ReplyToCommentInput; options: never; output: Receipt }; + 'comments.move': { input: MoveCommentInput; options: never; output: Receipt }; + 'comments.resolve': { input: ResolveCommentInput; options: never; output: Receipt }; + 'comments.remove': { input: RemoveCommentInput; options: never; output: Receipt }; + 'comments.setInternal': { input: SetCommentInternalInput; options: never; output: Receipt }; + 'comments.setActive': { input: SetCommentActiveInput; options: never; output: Receipt }; + 'comments.goTo': { input: GoToCommentInput; options: never; output: Receipt }; + 'comments.get': { input: GetCommentInput; options: never; output: CommentInfo }; + 'comments.list': { input: CommentsListQuery | undefined; options: never; output: CommentsListResult }; + + // --- trackChanges.* --- + 'trackChanges.list': { input: TrackChangesListInput | undefined; options: never; output: TrackChangesListResult }; + 'trackChanges.get': { input: TrackChangesGetInput; options: never; output: TrackChangeInfo }; + 'trackChanges.accept': { input: TrackChangesAcceptInput; options: never; output: Receipt }; + 'trackChanges.reject': { input: TrackChangesRejectInput; options: never; output: Receipt }; + 'trackChanges.acceptAll': { input: TrackChangesAcceptAllInput; options: never; output: Receipt }; + 'trackChanges.rejectAll': { input: TrackChangesRejectAllInput; options: never; output: Receipt }; + + // --- capabilities --- + 'capabilities.get': { input: undefined; options: never; output: DocumentApiCapabilities }; +} + +// --- Bidirectional completeness checks --- +// If either assertion fails, the `false extends true` branch produces a compile error. + +type Assert<_T extends true> = void; + +/** Fails to compile if OperationRegistry is missing any OperationId key. */ +type _AllOpsHaveRegistryEntry = Assert; + +/** Fails to compile if OperationRegistry has extra keys not in OperationId. */ +type _NoExtraRegistryKeys = Assert; + +// --- Invoke request/result types --- + +/** + * Typed invoke request. TypeScript narrows input and options based on operationId. + */ +export type InvokeRequest = { + operationId: T; + input: OperationRegistry[T]['input']; +} & (OperationRegistry[T]['options'] extends never + ? Record + : { options?: OperationRegistry[T]['options'] }); + +/** + * Typed invoke result, narrowed by operationId. + */ +export type InvokeResult = OperationRegistry[T]['output']; + +/** + * Loose invoke request for dynamic callers who don't know the operation at compile time. + * Invalid inputs will produce adapter-level errors, not input-validation errors. + */ +export type DynamicInvokeRequest = { + operationId: OperationId; + input: unknown; + options?: unknown; +}; diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index 3114899bf..08a47a2e8 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -109,6 +109,9 @@ import { type CapabilitiesAdapter, type DocumentApiCapabilities, } from './capabilities/capabilities.js'; +import type { OperationId } from './contract/types.js'; +import type { DynamicInvokeRequest, InvokeRequest, InvokeResult } from './contract/operation-registry.js'; +import { buildDispatchTable } from './invoke/invoke.js'; export type { FindAdapter, FindOptions } from './find/find.js'; export type { GetNodeAdapter, GetNodeByIdInput } from './get-node/get-node.js'; @@ -243,6 +246,19 @@ export interface DocumentApi { * Callable directly (`capabilities()`) or via `.get()`. */ capabilities: CapabilitiesApi; + /** + * Dynamically dispatch any operation by its operation ID. + * + * For TypeScript consumers, the return type narrows based on the operationId. + * For dynamic callers (AI agents, automation), accepts {@link DynamicInvokeRequest} + * with `unknown` input. Invalid inputs produce adapter-level errors. + * + * @param request - Operation envelope with operationId, input, and optional options. + * @returns The operation-specific result payload from the dispatched handler. + * @throws {Error} When operationId is unknown. + */ + invoke(request: InvokeRequest): InvokeResult; + invoke(request: DynamicInvokeRequest): unknown; } export interface DocumentApiAdapters { @@ -279,7 +295,7 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { const capFn = () => executeCapabilities(adapters.capabilities); const capabilities: CapabilitiesApi = Object.assign(capFn, { get: capFn }); - return { + const api: DocumentApi = { find(selectorOrQuery: Selector | Query, options?: FindOptions): QueryResult { return executeFind(adapters.find, selectorOrQuery, options); }, @@ -396,5 +412,16 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { return executeListsExit(adapters.lists, input, options); }, }, + invoke(request: DynamicInvokeRequest): unknown { + if (!Object.prototype.hasOwnProperty.call(dispatch, request.operationId)) { + throw new Error(`Unknown operationId: "${request.operationId}"`); + } + const handler = dispatch[request.operationId]; + return handler(request.input, request.options); + }, }; + + const dispatch = buildDispatchTable(api); + + return api; } diff --git a/packages/document-api/src/invoke/invoke.test.ts b/packages/document-api/src/invoke/invoke.test.ts new file mode 100644 index 000000000..93493f234 --- /dev/null +++ b/packages/document-api/src/invoke/invoke.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect, vi } from 'vitest'; +import { OPERATION_IDS, type OperationId } from '../contract/types.js'; +import { createDocumentApi, type DocumentApiAdapters } from '../index.js'; +import { buildDispatchTable } from './invoke.js'; +import type { FindAdapter } from '../find/find.js'; +import type { GetNodeAdapter } from '../get-node/get-node.js'; +import type { WriteAdapter } from '../write/write.js'; +import type { FormatAdapter } from '../format/format.js'; +import type { TrackChangesAdapter } from '../track-changes/track-changes.js'; +import type { CreateAdapter } from '../create/create.js'; +import type { ListsAdapter } from '../lists/lists.js'; +import type { CommentsAdapter } from '../comments/comments.js'; +import type { CapabilitiesAdapter, DocumentApiCapabilities } from '../capabilities/capabilities.js'; + +function makeAdapters() { + const findAdapter: FindAdapter = { find: vi.fn(() => ({ matches: [], total: 0 })) }; + const getNodeAdapter: GetNodeAdapter = { + getNode: vi.fn(() => ({ kind: 'block' as const, nodeType: 'paragraph' as const, properties: {} })), + getNodeById: vi.fn(() => ({ kind: 'block' as const, nodeType: 'paragraph' as const, properties: {} })), + }; + const getTextAdapter = { getText: vi.fn(() => 'hello') }; + const infoAdapter = { + info: vi.fn(() => ({ + counts: { words: 1, paragraphs: 1, headings: 0, tables: 0, images: 0, comments: 0 }, + outline: [], + capabilities: { canFind: true, canGetNode: true, canComment: true, canReplace: true }, + })), + }; + const capabilitiesAdapter: CapabilitiesAdapter = { + get: vi.fn( + (): DocumentApiCapabilities => ({ + global: { + trackChanges: { enabled: false }, + comments: { enabled: false }, + lists: { enabled: false }, + dryRun: { enabled: false }, + }, + operations: {} as DocumentApiCapabilities['operations'], + }), + ), + }; + const commentsAdapter: CommentsAdapter = { + add: vi.fn(() => ({ success: true as const })), + edit: vi.fn(() => ({ success: true as const })), + reply: vi.fn(() => ({ success: true as const })), + move: vi.fn(() => ({ success: true as const })), + resolve: vi.fn(() => ({ success: true as const })), + remove: vi.fn(() => ({ success: true as const })), + setInternal: vi.fn(() => ({ success: true as const })), + setActive: vi.fn(() => ({ success: true as const })), + goTo: vi.fn(() => ({ success: true as const })), + get: vi.fn(() => ({ + address: { kind: 'entity' as const, entityType: 'comment' as const, entityId: 'c1' }, + commentId: 'c1', + status: 'open' as const, + })), + list: vi.fn(() => ({ matches: [], total: 0 })), + }; + const writeAdapter: WriteAdapter = { + write: vi.fn(() => ({ + success: true as const, + resolution: { + target: { kind: 'text' as const, blockId: 'p1', range: { start: 0, end: 0 } }, + range: { from: 1, to: 1 }, + text: '', + }, + })), + }; + const formatAdapter: FormatAdapter = { + bold: vi.fn(() => ({ + success: true as const, + resolution: { + target: { kind: 'text' as const, blockId: 'p1', range: { start: 0, end: 2 } }, + range: { from: 1, to: 3 }, + text: 'Hi', + }, + })), + }; + const trackChangesAdapter: TrackChangesAdapter = { + list: vi.fn(() => ({ matches: [], total: 0 })), + get: vi.fn((input: { id: string }) => ({ + address: { kind: 'entity' as const, entityType: 'trackedChange' as const, entityId: input.id }, + id: input.id, + type: 'insert' as const, + })), + accept: vi.fn(() => ({ success: true as const })), + reject: vi.fn(() => ({ success: true as const })), + acceptAll: vi.fn(() => ({ success: true as const })), + rejectAll: vi.fn(() => ({ success: true as const })), + }; + const createAdapter: CreateAdapter = { + paragraph: vi.fn(() => ({ + success: true as const, + paragraph: { kind: 'block' as const, nodeType: 'paragraph' as const, nodeId: 'new-p' }, + insertionPoint: { kind: 'text' as const, blockId: 'new-p', range: { start: 0, end: 0 } }, + })), + }; + const listsAdapter: ListsAdapter = { + list: vi.fn(() => ({ matches: [], total: 0, items: [] })), + get: vi.fn(() => ({ + address: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + })), + insert: vi.fn(() => ({ + success: true as const, + item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-2' }, + insertionPoint: { kind: 'text' as const, blockId: 'li-2', range: { start: 0, end: 0 } }, + })), + setType: vi.fn(() => ({ + success: true as const, + item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + })), + indent: vi.fn(() => ({ + success: true as const, + item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + })), + outdent: vi.fn(() => ({ + success: true as const, + item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + })), + restart: vi.fn(() => ({ + success: true as const, + item: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' }, + })), + exit: vi.fn(() => ({ + success: true as const, + paragraph: { kind: 'block' as const, nodeType: 'paragraph' as const, nodeId: 'p3' }, + })), + }; + + const adapters: DocumentApiAdapters = { + find: findAdapter, + getNode: getNodeAdapter, + getText: getTextAdapter, + info: infoAdapter, + capabilities: capabilitiesAdapter, + comments: commentsAdapter, + write: writeAdapter, + format: formatAdapter, + trackChanges: trackChangesAdapter, + create: createAdapter, + lists: listsAdapter, + }; + + return { adapters, findAdapter, writeAdapter, commentsAdapter, trackChangesAdapter }; +} + +describe('invoke', () => { + describe('dispatch table completeness', () => { + it('has an entry for every OperationId', () => { + const { adapters } = makeAdapters(); + const api = createDocumentApi(adapters); + const dispatchKeys = Object.keys(buildDispatchTable(api)).sort(); + const operationIds = [...OPERATION_IDS].sort(); + expect(dispatchKeys).toEqual(operationIds); + }); + + it('has no extra entries beyond OperationId', () => { + const { adapters } = makeAdapters(); + const api = createDocumentApi(adapters); + const dispatchKeys = Object.keys(buildDispatchTable(api)); + const operationIdSet = new Set(OPERATION_IDS); + const extraKeys = dispatchKeys.filter((key) => !operationIdSet.has(key)); + expect(extraKeys).toEqual([]); + }); + }); + + describe('representative parity (invoke matches direct method)', () => { + it('find: invoke returns same result as direct call', () => { + const { adapters } = makeAdapters(); + const api = createDocumentApi(adapters); + const query = { nodeType: 'paragraph' as const }; + const direct = api.find(query); + const invoked = api.invoke({ operationId: 'find', input: query }); + expect(invoked).toEqual(direct); + }); + + it('insert: invoke returns same result as direct call', () => { + const { adapters } = makeAdapters(); + const api = createDocumentApi(adapters); + const input = { text: 'hello' }; + const direct = api.insert(input); + const invoked = api.invoke({ operationId: 'insert', input }); + expect(invoked).toEqual(direct); + }); + + it('insert: invoke forwards options through to adapter-backed execution', () => { + const { adapters, writeAdapter } = makeAdapters(); + const api = createDocumentApi(adapters); + api.invoke({ operationId: 'insert', input: { text: 'hello' }, options: { changeMode: 'tracked' } }); + expect(writeAdapter.write).toHaveBeenCalledWith( + { kind: 'insert', text: 'hello' }, + { changeMode: 'tracked', dryRun: false }, + ); + }); + + it('comments.add: invoke returns same result as direct call', () => { + const { adapters } = makeAdapters(); + const api = createDocumentApi(adapters); + const input = { + target: { kind: 'text' as const, blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'A comment', + }; + const direct = api.comments.add(input); + const invoked = api.invoke({ operationId: 'comments.add', input }); + expect(invoked).toEqual(direct); + }); + + it('trackChanges.list: invoke returns same result as direct call', () => { + const { adapters } = makeAdapters(); + const api = createDocumentApi(adapters); + const direct = api.trackChanges.list(); + const invoked = api.invoke({ operationId: 'trackChanges.list', input: undefined }); + expect(invoked).toEqual(direct); + }); + + it('capabilities.get: invoke returns same result as direct call', () => { + const { adapters } = makeAdapters(); + const api = createDocumentApi(adapters); + const direct = api.capabilities(); + const invoked = api.invoke({ operationId: 'capabilities.get', input: undefined }); + expect(invoked).toEqual(direct); + }); + + it('lists.get: invoke returns same result as direct call', () => { + const { adapters } = makeAdapters(); + const api = createDocumentApi(adapters); + const input = { address: { kind: 'block' as const, nodeType: 'listItem' as const, nodeId: 'li-1' } }; + const direct = api.lists.get(input); + const invoked = api.invoke({ operationId: 'lists.get', input }); + expect(invoked).toEqual(direct); + }); + }); + + describe('error handling', () => { + it('throws for inherited prototype keys used as operationId', () => { + const { adapters } = makeAdapters(); + const api = createDocumentApi(adapters); + expect(() => { + api.invoke({ operationId: 'toString' as OperationId, input: undefined }); + }).toThrow('Unknown operationId'); + }); + + it('throws for unknown operationId with a clear message', () => { + const { adapters } = makeAdapters(); + const api = createDocumentApi(adapters); + expect(() => { + api.invoke({ operationId: 'nonexistent' as OperationId, input: {} }); + }).toThrow('Unknown operationId: "nonexistent"'); + }); + }); + + describe('DynamicInvokeRequest (untyped input)', () => { + it('accepts unknown input and dispatches to the correct handler', () => { + const { adapters } = makeAdapters(); + const api = createDocumentApi(adapters); + const input: unknown = { nodeType: 'paragraph' }; + const result = api.invoke({ operationId: 'find', input }); + expect(result).toEqual({ matches: [], total: 0 }); + }); + + it('forwards unknown options through to the handler', () => { + const { adapters, writeAdapter } = makeAdapters(); + const api = createDocumentApi(adapters); + const input: unknown = { text: 'dynamic' }; + const options: unknown = { changeMode: 'tracked' }; + api.invoke({ operationId: 'insert', input, options }); + expect(writeAdapter.write).toHaveBeenCalledWith( + { kind: 'insert', text: 'dynamic' }, + { changeMode: 'tracked', dryRun: false }, + ); + }); + }); +}); diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts new file mode 100644 index 000000000..7ba6a7233 --- /dev/null +++ b/packages/document-api/src/invoke/invoke.ts @@ -0,0 +1,108 @@ +/** + * Runtime dispatch table for the invoke API. + * + * Maps every OperationId to a function that delegates to the corresponding + * direct method on DocumentApi. Built once per createDocumentApi call. + */ + +import type { OperationId } from '../contract/types.js'; +import type { DocumentApi } from '../index.js'; + +type DispatchHandler = (input: unknown, options?: unknown) => unknown; + +export type DispatchTable = Record; + +/** + * Builds a dispatch table that maps every OperationId to the corresponding + * direct method call on the given DocumentApi instance. + * + * Each entry delegates to the direct method — no parallel execution path. + */ +export function buildDispatchTable(api: DocumentApi): DispatchTable { + return { + // --- Singleton reads --- + find: (input, options) => + api.find(input as Parameters[0], options as Parameters[1]), + getNode: (input) => api.getNode(input as Parameters[0]), + getNodeById: (input) => api.getNodeById(input as Parameters[0]), + getText: (input) => api.getText(input as Parameters[0]), + info: (input) => api.info(input as Parameters[0]), + + // --- Singleton mutations --- + insert: (input, options) => + api.insert(input as Parameters[0], options as Parameters[1]), + replace: (input, options) => + api.replace(input as Parameters[0], options as Parameters[1]), + delete: (input, options) => + api.delete(input as Parameters[0], options as Parameters[1]), + + // --- format.* --- + 'format.bold': (input, options) => + api.format.bold(input as Parameters[0], options as Parameters[1]), + + // --- create.* --- + 'create.paragraph': (input, options) => + api.create.paragraph( + input as Parameters[0], + options as Parameters[1], + ), + + // --- lists.* --- + 'lists.list': (input) => api.lists.list(input as Parameters[0]), + 'lists.get': (input) => api.lists.get(input as Parameters[0]), + 'lists.insert': (input, options) => + api.lists.insert( + input as Parameters[0], + options as Parameters[1], + ), + 'lists.setType': (input, options) => + api.lists.setType( + input as Parameters[0], + options as Parameters[1], + ), + 'lists.indent': (input, options) => + api.lists.indent( + input as Parameters[0], + options as Parameters[1], + ), + 'lists.outdent': (input, options) => + api.lists.outdent( + input as Parameters[0], + options as Parameters[1], + ), + 'lists.restart': (input, options) => + api.lists.restart( + input as Parameters[0], + options as Parameters[1], + ), + 'lists.exit': (input, options) => + api.lists.exit(input as Parameters[0], options as Parameters[1]), + + // --- comments.* --- + 'comments.add': (input) => api.comments.add(input as Parameters[0]), + 'comments.edit': (input) => api.comments.edit(input as Parameters[0]), + 'comments.reply': (input) => api.comments.reply(input as Parameters[0]), + 'comments.move': (input) => api.comments.move(input as Parameters[0]), + 'comments.resolve': (input) => api.comments.resolve(input as Parameters[0]), + 'comments.remove': (input) => api.comments.remove(input as Parameters[0]), + 'comments.setInternal': (input) => + api.comments.setInternal(input as Parameters[0]), + 'comments.setActive': (input) => api.comments.setActive(input as Parameters[0]), + 'comments.goTo': (input) => api.comments.goTo(input as Parameters[0]), + 'comments.get': (input) => api.comments.get(input as Parameters[0]), + 'comments.list': (input) => api.comments.list(input as Parameters[0]), + + // --- trackChanges.* --- + 'trackChanges.list': (input) => api.trackChanges.list(input as Parameters[0]), + 'trackChanges.get': (input) => api.trackChanges.get(input as Parameters[0]), + 'trackChanges.accept': (input) => api.trackChanges.accept(input as Parameters[0]), + 'trackChanges.reject': (input) => api.trackChanges.reject(input as Parameters[0]), + 'trackChanges.acceptAll': (input) => + api.trackChanges.acceptAll(input as Parameters[0]), + 'trackChanges.rejectAll': (input) => + api.trackChanges.rejectAll(input as Parameters[0]), + + // --- capabilities --- + 'capabilities.get': () => api.capabilities(), + }; +} From ed3024e0e05647f27cdfccce00911c0bad6cfa80 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Feb 2026 18:02:33 -0800 Subject: [PATCH 24/25] fix(document-api): enforce skipTrackChanges for direct-mode bold formatting --- .../document-api-adapters/format-adapter.test.ts | 13 +++++++++++++ .../src/document-api-adapters/format-adapter.ts | 6 ++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/document-api-adapters/format-adapter.test.ts b/packages/super-editor/src/document-api-adapters/format-adapter.test.ts index 85897c7f5..d3ed0518f 100644 --- a/packages/super-editor/src/document-api-adapters/format-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/format-adapter.test.ts @@ -135,6 +135,19 @@ describe('formatBoldAdapter', () => { expect(dispatch).toHaveBeenCalledTimes(1); }); + it('sets skipTrackChanges meta in direct mode to preserve operation-scoped semantics', () => { + const { editor, tr } = makeEditor(); + const receipt = formatBoldAdapter( + editor, + { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, + { changeMode: 'direct' }, + ); + + expect(receipt.success).toBe(true); + expect(tr.setMeta).toHaveBeenCalledWith('skipTrackChanges', true); + expect(tr.setMeta).not.toHaveBeenCalledWith('forceTrackChanges', true); + }); + it('sets forceTrackChanges meta in tracked mode', () => { const { editor, tr } = makeEditor('Hello', { user: { name: 'Test' } }); const receipt = formatBoldAdapter( diff --git a/packages/super-editor/src/document-api-adapters/format-adapter.ts b/packages/super-editor/src/document-api-adapters/format-adapter.ts index 54b6bc65d..45673ef16 100644 --- a/packages/super-editor/src/document-api-adapters/format-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/format-adapter.ts @@ -3,6 +3,7 @@ import type { FormatBoldInput, MutationOptions, TextMutationReceipt } from '@sup import { TrackFormatMarkName } from '../extensions/track-changes/constants.js'; import { DocumentApiAdapterError } from './errors.js'; import { requireSchemaMark, ensureTrackedCapability } from './helpers/mutation-helpers.js'; +import { applyDirectMutationMeta, applyTrackedMutationMeta } from './helpers/transaction-meta.js'; import { resolveTextTarget } from './helpers/adapter-utils.js'; import { buildTextMutationResolution, readTextAtResolvedRange } from './helpers/text-mutation-resolution.js'; @@ -46,8 +47,9 @@ export function formatBoldAdapter( return { success: true, resolution }; } - const tr = editor.state.tr.addMark(range.from, range.to, boldMark.create()).setMeta('inputType', 'programmatic'); - if (mode === 'tracked') tr.setMeta('forceTrackChanges', true); + const tr = editor.state.tr.addMark(range.from, range.to, boldMark.create()); + if (mode === 'tracked') applyTrackedMutationMeta(tr); + else applyDirectMutationMeta(tr); editor.dispatch(tr); return { success: true, resolution }; From e962a89aa1f9ca4d6ffa8dfb4fa9b7cd97620032 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Feb 2026 18:30:11 -0800 Subject: [PATCH 25/25] chore: consolidate operation definitions and enforce typed invoke dispatch --- CLAUDE.md | 14 + packages/document-api/README.md | 26 + .../scripts/check-contract-parity.ts | 25 + .../src/contract/command-catalog.ts | 281 +-------- .../src/contract/contract.test.ts | 42 +- .../src/contract/metadata-types.ts | 37 ++ .../src/contract/operation-definitions.ts | 545 ++++++++++++++++++ .../src/contract/operation-map.ts | 33 +- .../src/contract/reference-doc-map.ts | 144 +---- .../document-api/src/contract/types.test.ts | 9 + packages/document-api/src/contract/types.ts | 95 +-- packages/document-api/src/index.ts | 4 +- packages/document-api/src/invoke/invoke.ts | 117 ++-- 13 files changed, 812 insertions(+), 560 deletions(-) create mode 100644 packages/document-api/src/contract/metadata-types.ts create mode 100644 packages/document-api/src/contract/operation-definitions.ts diff --git a/CLAUDE.md b/CLAUDE.md index f6d065eb1..2cd6d0e91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,8 @@ tests/visual/ Visual regression tests (Playwright + R2 baselines) | Style resolution | `layout-engine/style-engine/` | | Main entry point (Vue) | `superdoc/src/SuperDoc.vue` | | Visual regression tests | `tests/visual/` (see its CLAUDE.md) | +| Document API contract | `packages/document-api/src/contract/operation-definitions.ts` | +| Adding a doc-api operation | See `packages/document-api/README.md` § "Adding a new operation" | ## Style Resolution Boundary @@ -82,6 +84,18 @@ tests/visual/ Visual regression tests (Playwright + R2 baselines) - **Editing commands/behavior**: Modify `super-editor/src/extensions/` - **State bridging**: Modify `PresentationEditor.ts` +## Document API Contract + +The `packages/document-api/` package uses a contract-first pattern with a single source of truth. + +- **`operation-definitions.ts`** — canonical object defining every operation's key, metadata, member path, reference doc path, and group. All downstream maps are projected from this file automatically. +- **`operation-registry.ts`** — type-level registry mapping each operation to its `input`, `options`, and `output` types. +- **`invoke.ts`** — `TypedDispatchTable` validates dispatch wiring against the registry at compile time. + +Adding a new operation touches 4 files: `operation-definitions.ts`, `operation-registry.ts`, `invoke.ts` (dispatch table), and the implementation. See `packages/document-api/README.md` for the full guide. + +Do NOT hand-edit `COMMAND_CATALOG`, `OPERATION_MEMBER_PATH_MAP`, `OPERATION_REFERENCE_DOC_PATH_MAP`, or `REFERENCE_OPERATION_GROUPS` — they are derived from `OPERATION_DEFINITIONS`. + ## JSDoc types Many packages use `.js` files with JSDoc `@typedef` for type definitions (e.g., `packages/superdoc/src/core/types/index.js`). These typedefs ARE the published type declarations — `vite-plugin-dts` generates `.d.ts` files from them. diff --git a/packages/document-api/README.md b/packages/document-api/README.md index 2b6acde13..4bf0e42cc 100644 --- a/packages/document-api/README.md +++ b/packages/document-api/README.md @@ -34,6 +34,32 @@ These are also enforced automatically: - **Pre-commit hook** runs `docapi:sync` when document-api sources change and restages generated files. - **CI workflow** (`ci-document-api.yml`) runs `docapi:check` on every PR touching relevant paths. +## Adding a new operation + +The contract uses a single-source-of-truth pattern. Adding a new operation touches 4 files: + +1. **`src/contract/operation-definitions.ts`** — add an entry to `OPERATION_DEFINITIONS` with `memberPath`, `metadata` (use `readOperation()` or `mutationOperation()`), `referenceDocPath`, and `referenceGroup`. +2. **`src/contract/operation-registry.ts`** — add a type entry (`input`, `options`, `output`). The bidirectional `Assert` checks will fail until this is done. +3. **`src/invoke/invoke.ts`** (`buildDispatchTable`) — add a one-line dispatch entry calling the API method. The `TypedDispatchTable` mapped type will fail until this is done. +4. **Implement** — the API method on `DocumentApi` in `src/index.ts` + its adapter. + +The catalog (`COMMAND_CATALOG`), member-path map (`OPERATION_MEMBER_PATH_MAP`), and reference-doc map (`OPERATION_REFERENCE_DOC_PATH_MAP`) are all derived automatically from `OPERATION_DEFINITIONS` — do not edit them by hand. + +## Contract architecture + +``` +metadata-types.ts (leaf — CommandStaticMetadata, throw codes, idempotency) + ↑ ↑ +operation-definitions.ts types.ts (re-exports + CommandCatalog, guards) + ↑ ↑ + +--- command-catalog.ts, operation-map.ts, reference-doc-map.ts, + operation-registry.ts, schemas.ts +``` + +- `operation-definitions.ts` is the single source of truth for operation keys, metadata, paths, and grouping. +- `operation-registry.ts` is the single source of truth for type signatures (input/options/output per operation). +- `TypedDispatchTable` (in `invoke.ts`) validates at compile time that dispatch wiring conforms to the registry. + ## Related docs - `packages/document-api/src/README.md` for contract semantics and invariants diff --git a/packages/document-api/scripts/check-contract-parity.ts b/packages/document-api/scripts/check-contract-parity.ts index 23f7b05ff..a539d81ff 100644 --- a/packages/document-api/scripts/check-contract-parity.ts +++ b/packages/document-api/scripts/check-contract-parity.ts @@ -14,6 +14,8 @@ import { isValidOperationIdFormat, type DocumentApiAdapters, } from '../src/index.js'; +import { OPERATION_DEFINITIONS } from '../src/contract/operation-definitions.js'; +import { OPERATION_REFERENCE_DOC_PATH_MAP } from '../src/contract/reference-doc-map.js'; import { buildDispatchTable } from '../src/invoke/invoke.js'; /** @@ -238,6 +240,29 @@ function run(): void { } } + // Verify OPERATION_DEFINITIONS keys match OPERATION_IDS exactly. + const definitionKeys = Object.keys(OPERATION_DEFINITIONS).sort(); + const sortedOperationIds = [...operationIds].sort(); + if (definitionKeys.join('|') !== sortedOperationIds.join('|')) { + errors.push( + `OPERATION_DEFINITIONS keys do not match OPERATION_IDS (definitions: ${definitionKeys.length}, ops: ${sortedOperationIds.length})`, + ); + } + + // Value-level projection checks — catches projection bugs, not just key bugs. + for (const id of operationIds) { + const defEntry = OPERATION_DEFINITIONS[id]; + if (COMMAND_CATALOG[id] !== defEntry.metadata) { + errors.push(`COMMAND_CATALOG['${id}'] is not the same object as OPERATION_DEFINITIONS['${id}'].metadata`); + } + if (OPERATION_MEMBER_PATH_MAP[id] !== defEntry.memberPath) { + errors.push(`OPERATION_MEMBER_PATH_MAP['${id}'] !== OPERATION_DEFINITIONS['${id}'].memberPath`); + } + if (OPERATION_REFERENCE_DOC_PATH_MAP[id] !== defEntry.referenceDocPath) { + errors.push(`OPERATION_REFERENCE_DOC_PATH_MAP['${id}'] !== OPERATION_DEFINITIONS['${id}'].referenceDocPath`); + } + } + if (errors.length > 0) { console.error('contract parity check failed:\n'); for (const error of errors) { diff --git a/packages/document-api/src/contract/command-catalog.ts b/packages/document-api/src/contract/command-catalog.ts index 9dec04d09..86adc33d2 100644 --- a/packages/document-api/src/contract/command-catalog.ts +++ b/packages/document-api/src/contract/command-catalog.ts @@ -1,282 +1,7 @@ -import type { ReceiptFailureCode } from '../types/receipt.js'; -import type { CommandCatalog, CommandStaticMetadata, OperationIdempotency, PreApplyThrowCode } from './types.js'; -import { OPERATION_IDS } from './types.js'; +import type { CommandCatalog, CommandStaticMetadata } from './types.js'; +import { OPERATION_IDS, projectFromDefinitions } from './operation-definitions.js'; -const NONE_FAILURES: readonly ReceiptFailureCode[] = []; -const NONE_THROWS: readonly PreApplyThrowCode[] = []; - -function readOperation( - options: { - idempotency?: OperationIdempotency; - throws?: readonly PreApplyThrowCode[]; - deterministicTargetResolution?: boolean; - remediationHints?: readonly string[]; - } = {}, -): CommandStaticMetadata { - return { - mutates: false, - idempotency: options.idempotency ?? 'idempotent', - supportsDryRun: false, - supportsTrackedMode: false, - possibleFailureCodes: NONE_FAILURES, - throws: { - preApply: options.throws ?? NONE_THROWS, - postApplyForbidden: true, - }, - deterministicTargetResolution: options.deterministicTargetResolution ?? true, - remediationHints: options.remediationHints, - }; -} - -function mutationOperation(options: { - idempotency: OperationIdempotency; - supportsDryRun: boolean; - supportsTrackedMode: boolean; - possibleFailureCodes: readonly ReceiptFailureCode[]; - throws: readonly PreApplyThrowCode[]; - deterministicTargetResolution?: boolean; - remediationHints?: readonly string[]; -}): CommandStaticMetadata { - return { - mutates: true, - idempotency: options.idempotency, - supportsDryRun: options.supportsDryRun, - supportsTrackedMode: options.supportsTrackedMode, - possibleFailureCodes: options.possibleFailureCodes, - throws: { - preApply: options.throws, - postApplyForbidden: true, - }, - deterministicTargetResolution: options.deterministicTargetResolution ?? true, - remediationHints: options.remediationHints, - }; -} - -const T_NOT_FOUND = ['TARGET_NOT_FOUND'] as const; -const T_COMMAND = ['COMMAND_UNAVAILABLE', 'CAPABILITY_UNAVAILABLE'] as const; -const T_NOT_FOUND_COMMAND = ['TARGET_NOT_FOUND', 'COMMAND_UNAVAILABLE', 'CAPABILITY_UNAVAILABLE'] as const; -const T_NOT_FOUND_TRACKED = ['TARGET_NOT_FOUND', 'TRACK_CHANGE_COMMAND_UNAVAILABLE', 'CAPABILITY_UNAVAILABLE'] as const; -const T_NOT_FOUND_COMMAND_TRACKED = [ - 'TARGET_NOT_FOUND', - 'COMMAND_UNAVAILABLE', - 'TRACK_CHANGE_COMMAND_UNAVAILABLE', - 'CAPABILITY_UNAVAILABLE', -] as const; - -export const COMMAND_CATALOG: CommandCatalog = { - find: readOperation({ - idempotency: 'idempotent', - deterministicTargetResolution: false, - }), - getNode: readOperation({ - idempotency: 'idempotent', - throws: T_NOT_FOUND, - }), - getNodeById: readOperation({ - idempotency: 'idempotent', - throws: T_NOT_FOUND, - }), - getText: readOperation(), - info: readOperation(), - - insert: mutationOperation({ - idempotency: 'non-idempotent', - supportsDryRun: true, - supportsTrackedMode: true, - possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], - throws: T_NOT_FOUND_TRACKED, - }), - replace: mutationOperation({ - idempotency: 'conditional', - supportsDryRun: true, - supportsTrackedMode: true, - possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], - throws: T_NOT_FOUND_TRACKED, - }), - delete: mutationOperation({ - idempotency: 'conditional', - supportsDryRun: true, - supportsTrackedMode: true, - possibleFailureCodes: ['NO_OP'], - throws: T_NOT_FOUND_TRACKED, - }), - - 'format.bold': mutationOperation({ - idempotency: 'conditional', - supportsDryRun: true, - supportsTrackedMode: true, - possibleFailureCodes: ['INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND_TRACKED, - }), - - 'create.paragraph': mutationOperation({ - idempotency: 'non-idempotent', - supportsDryRun: true, - supportsTrackedMode: true, - possibleFailureCodes: ['INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND_TRACKED, - }), - - 'lists.list': readOperation({ - idempotency: 'idempotent', - throws: T_NOT_FOUND, - }), - 'lists.get': readOperation({ - idempotency: 'idempotent', - throws: T_NOT_FOUND, - }), - 'lists.insert': mutationOperation({ - idempotency: 'non-idempotent', - supportsDryRun: true, - supportsTrackedMode: true, - possibleFailureCodes: ['INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND_TRACKED, - }), - 'lists.setType': mutationOperation({ - idempotency: 'conditional', - supportsDryRun: true, - supportsTrackedMode: false, - possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND_TRACKED, - }), - 'lists.indent': mutationOperation({ - idempotency: 'conditional', - supportsDryRun: true, - supportsTrackedMode: false, - possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND_TRACKED, - }), - 'lists.outdent': mutationOperation({ - idempotency: 'conditional', - supportsDryRun: true, - supportsTrackedMode: false, - possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND_TRACKED, - }), - 'lists.restart': mutationOperation({ - idempotency: 'conditional', - supportsDryRun: true, - supportsTrackedMode: false, - possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND_TRACKED, - }), - 'lists.exit': mutationOperation({ - idempotency: 'conditional', - supportsDryRun: true, - supportsTrackedMode: false, - possibleFailureCodes: ['INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND_TRACKED, - }), - - 'comments.add': mutationOperation({ - idempotency: 'non-idempotent', - supportsDryRun: false, - supportsTrackedMode: false, - possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], - throws: T_NOT_FOUND_COMMAND, - }), - 'comments.edit': mutationOperation({ - idempotency: 'conditional', - supportsDryRun: false, - supportsTrackedMode: false, - possibleFailureCodes: ['NO_OP'], - throws: T_NOT_FOUND_COMMAND, - }), - 'comments.reply': mutationOperation({ - idempotency: 'non-idempotent', - supportsDryRun: false, - supportsTrackedMode: false, - possibleFailureCodes: ['INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND, - }), - 'comments.move': mutationOperation({ - idempotency: 'conditional', - supportsDryRun: false, - supportsTrackedMode: false, - possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], - throws: T_NOT_FOUND_COMMAND, - }), - 'comments.resolve': mutationOperation({ - idempotency: 'conditional', - supportsDryRun: false, - supportsTrackedMode: false, - possibleFailureCodes: ['NO_OP'], - throws: T_NOT_FOUND_COMMAND, - }), - 'comments.remove': mutationOperation({ - idempotency: 'conditional', - supportsDryRun: false, - supportsTrackedMode: false, - possibleFailureCodes: ['NO_OP'], - throws: T_NOT_FOUND_COMMAND, - }), - 'comments.setInternal': mutationOperation({ - idempotency: 'conditional', - supportsDryRun: false, - supportsTrackedMode: false, - possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND, - }), - 'comments.setActive': mutationOperation({ - idempotency: 'conditional', - supportsDryRun: false, - supportsTrackedMode: false, - possibleFailureCodes: ['INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND, - }), - 'comments.goTo': readOperation({ - idempotency: 'conditional', - throws: T_NOT_FOUND_COMMAND, - }), - 'comments.get': readOperation({ - idempotency: 'idempotent', - throws: T_NOT_FOUND, - }), - 'comments.list': readOperation({ - idempotency: 'idempotent', - }), - - 'trackChanges.list': readOperation({ - idempotency: 'idempotent', - }), - 'trackChanges.get': readOperation({ - idempotency: 'idempotent', - throws: T_NOT_FOUND, - }), - 'trackChanges.accept': mutationOperation({ - idempotency: 'conditional', - supportsDryRun: false, - supportsTrackedMode: false, - possibleFailureCodes: ['NO_OP'], - throws: T_NOT_FOUND_COMMAND, - }), - 'trackChanges.reject': mutationOperation({ - idempotency: 'conditional', - supportsDryRun: false, - supportsTrackedMode: false, - possibleFailureCodes: ['NO_OP'], - throws: T_NOT_FOUND_COMMAND, - }), - 'trackChanges.acceptAll': mutationOperation({ - idempotency: 'conditional', - supportsDryRun: false, - supportsTrackedMode: false, - possibleFailureCodes: ['NO_OP'], - throws: T_COMMAND, - }), - 'trackChanges.rejectAll': mutationOperation({ - idempotency: 'conditional', - supportsDryRun: false, - supportsTrackedMode: false, - possibleFailureCodes: ['NO_OP'], - throws: T_COMMAND, - }), - - 'capabilities.get': readOperation({ - idempotency: 'idempotent', - throws: NONE_THROWS, - }), -} as const; +export const COMMAND_CATALOG: CommandCatalog = projectFromDefinitions((_id, entry) => entry.metadata); /** Operation IDs whose catalog entry has `mutates: true`. */ export const MUTATING_OPERATION_IDS = OPERATION_IDS.filter((operationId) => COMMAND_CATALOG[operationId].mutates); diff --git a/packages/document-api/src/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts index 02bb98200..14ee4a473 100644 --- a/packages/document-api/src/contract/contract.test.ts +++ b/packages/document-api/src/contract/contract.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { COMMAND_CATALOG } from './command-catalog.js'; -import { DOCUMENT_API_MEMBER_PATHS, memberPathForOperation } from './operation-map.js'; +import { OPERATION_DEFINITIONS, type ReferenceGroupKey } from './operation-definitions.js'; +import { DOCUMENT_API_MEMBER_PATHS, OPERATION_MEMBER_PATH_MAP, memberPathForOperation } from './operation-map.js'; import { OPERATION_REFERENCE_DOC_PATH_MAP, REFERENCE_OPERATION_GROUPS } from './reference-doc-map.js'; import { buildInternalContractSchemas } from './schemas.js'; import { OPERATION_IDS, PRE_APPLY_THROW_CODES, isValidOperationIdFormat } from './types.js'; @@ -71,4 +72,43 @@ describe('document-api contract catalog', () => { expect(inputSchema.additionalProperties).toBe(false); } }); + + it('derives OPERATION_IDS from OPERATION_DEFINITIONS keys', () => { + const definitionKeys = Object.keys(OPERATION_DEFINITIONS).sort(); + const operationIds = [...OPERATION_IDS].sort(); + expect(definitionKeys).toEqual(operationIds); + }); + + it('ensures every definition entry has a valid referenceGroup', () => { + const validGroups: readonly ReferenceGroupKey[] = [ + 'core', + 'capabilities', + 'create', + 'format', + 'lists', + 'comments', + 'trackChanges', + ]; + for (const id of OPERATION_IDS) { + expect(validGroups, `${id} has invalid referenceGroup`).toContain(OPERATION_DEFINITIONS[id].referenceGroup); + } + }); + + it('projects COMMAND_CATALOG metadata from the same objects in OPERATION_DEFINITIONS', () => { + for (const id of OPERATION_IDS) { + expect(COMMAND_CATALOG[id]).toBe(OPERATION_DEFINITIONS[id].metadata); + } + }); + + it('projects member paths that match OPERATION_DEFINITIONS', () => { + for (const id of OPERATION_IDS) { + expect(OPERATION_MEMBER_PATH_MAP[id]).toBe(OPERATION_DEFINITIONS[id].memberPath); + } + }); + + it('projects reference doc paths that match OPERATION_DEFINITIONS', () => { + for (const id of OPERATION_IDS) { + expect(OPERATION_REFERENCE_DOC_PATH_MAP[id]).toBe(OPERATION_DEFINITIONS[id].referenceDocPath); + } + }); }); diff --git a/packages/document-api/src/contract/metadata-types.ts b/packages/document-api/src/contract/metadata-types.ts new file mode 100644 index 000000000..d6e6abab8 --- /dev/null +++ b/packages/document-api/src/contract/metadata-types.ts @@ -0,0 +1,37 @@ +/** + * Shared leaf types for operation metadata. + * + * This file is the bottom of the contract import DAG — it imports only + * from `../types/receipt.js` and has no contract-internal dependencies. + */ + +import type { ReceiptFailureCode } from '../types/receipt.js'; + +export const OPERATION_IDEMPOTENCY_VALUES = ['idempotent', 'conditional', 'non-idempotent'] as const; +export type OperationIdempotency = (typeof OPERATION_IDEMPOTENCY_VALUES)[number]; + +export const PRE_APPLY_THROW_CODES = [ + 'TARGET_NOT_FOUND', + 'COMMAND_UNAVAILABLE', + 'TRACK_CHANGE_COMMAND_UNAVAILABLE', + 'CAPABILITY_UNAVAILABLE', + 'INVALID_TARGET', +] as const; + +export type PreApplyThrowCode = (typeof PRE_APPLY_THROW_CODES)[number]; + +export interface CommandThrowPolicy { + preApply: readonly PreApplyThrowCode[]; + postApplyForbidden: true; +} + +export interface CommandStaticMetadata { + mutates: boolean; + idempotency: OperationIdempotency; + supportsDryRun: boolean; + supportsTrackedMode: boolean; + possibleFailureCodes: readonly ReceiptFailureCode[]; + throws: CommandThrowPolicy; + deterministicTargetResolution: boolean; + remediationHints?: readonly string[]; +} diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts new file mode 100644 index 000000000..cc1d0cb4f --- /dev/null +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -0,0 +1,545 @@ +/** + * Canonical operation definitions — single source of truth for keys, metadata, and paths. + * + * Every operation in the Document API is defined exactly once here. + * All downstream artifacts (COMMAND_CATALOG, OPERATION_MEMBER_PATH_MAP, + * OPERATION_REFERENCE_DOC_PATH_MAP, REFERENCE_OPERATION_GROUPS) are + * projected from this object. + * + * ## Adding a new operation + * + * 1. **Here** (`operation-definitions.ts`) — add an entry to `OPERATION_DEFINITIONS` + * with `memberPath`, `metadata`, `referenceDocPath`, and `referenceGroup`. + * 2. **`operation-registry.ts`** — add a type entry (`input`, `options`, `output`). + * The bidirectional `Assert` checks will error until this is done. + * 3. **`invoke.ts`** (`buildDispatchTable`) — add a one-line dispatch entry calling + * the API method. `TypedDispatchTable` will error until this is done. + * 4. **Implement** — the API method on `DocumentApi` + its adapter. + * + * That's 4 touch points. The catalog, maps, and reference docs are derived + * automatically. If you forget step 1 or 2, compile-time assertions fail. + * If you forget step 3, the `TypedDispatchTable` mapped type errors. + * + * Import DAG: this file imports only from `metadata-types.ts` and + * `../types/receipt.js` — no contract-internal circular deps. + */ + +import type { ReceiptFailureCode } from '../types/receipt.js'; +import type { CommandStaticMetadata, OperationIdempotency, PreApplyThrowCode } from './metadata-types.js'; + +// --------------------------------------------------------------------------- +// Reference group key +// --------------------------------------------------------------------------- + +export type ReferenceGroupKey = 'core' | 'capabilities' | 'create' | 'format' | 'lists' | 'comments' | 'trackChanges'; + +// --------------------------------------------------------------------------- +// Entry shape +// --------------------------------------------------------------------------- + +export interface OperationDefinitionEntry { + memberPath: string; + metadata: CommandStaticMetadata; + referenceDocPath: string; + referenceGroup: ReferenceGroupKey; +} + +// --------------------------------------------------------------------------- +// Metadata helpers (moved from command-catalog.ts) +// --------------------------------------------------------------------------- + +const NONE_FAILURES: readonly ReceiptFailureCode[] = []; +const NONE_THROWS: readonly PreApplyThrowCode[] = []; + +function readOperation( + options: { + idempotency?: OperationIdempotency; + throws?: readonly PreApplyThrowCode[]; + deterministicTargetResolution?: boolean; + remediationHints?: readonly string[]; + } = {}, +): CommandStaticMetadata { + return { + mutates: false, + idempotency: options.idempotency ?? 'idempotent', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: NONE_FAILURES, + throws: { + preApply: options.throws ?? NONE_THROWS, + postApplyForbidden: true, + }, + deterministicTargetResolution: options.deterministicTargetResolution ?? true, + remediationHints: options.remediationHints, + }; +} + +function mutationOperation(options: { + idempotency: OperationIdempotency; + supportsDryRun: boolean; + supportsTrackedMode: boolean; + possibleFailureCodes: readonly ReceiptFailureCode[]; + throws: readonly PreApplyThrowCode[]; + deterministicTargetResolution?: boolean; + remediationHints?: readonly string[]; +}): CommandStaticMetadata { + return { + mutates: true, + idempotency: options.idempotency, + supportsDryRun: options.supportsDryRun, + supportsTrackedMode: options.supportsTrackedMode, + possibleFailureCodes: options.possibleFailureCodes, + throws: { + preApply: options.throws, + postApplyForbidden: true, + }, + deterministicTargetResolution: options.deterministicTargetResolution ?? true, + remediationHints: options.remediationHints, + }; +} + +// Throw-code shorthand arrays +const T_NOT_FOUND = ['TARGET_NOT_FOUND'] as const; +const T_COMMAND = ['COMMAND_UNAVAILABLE', 'CAPABILITY_UNAVAILABLE'] as const; +const T_NOT_FOUND_COMMAND = ['TARGET_NOT_FOUND', 'COMMAND_UNAVAILABLE', 'CAPABILITY_UNAVAILABLE'] as const; +const T_NOT_FOUND_TRACKED = ['TARGET_NOT_FOUND', 'TRACK_CHANGE_COMMAND_UNAVAILABLE', 'CAPABILITY_UNAVAILABLE'] as const; +const T_NOT_FOUND_COMMAND_TRACKED = [ + 'TARGET_NOT_FOUND', + 'COMMAND_UNAVAILABLE', + 'TRACK_CHANGE_COMMAND_UNAVAILABLE', + 'CAPABILITY_UNAVAILABLE', +] as const; + +// --------------------------------------------------------------------------- +// Canonical definitions +// --------------------------------------------------------------------------- + +export const OPERATION_DEFINITIONS = { + find: { + memberPath: 'find', + metadata: readOperation({ + idempotency: 'idempotent', + deterministicTargetResolution: false, + }), + referenceDocPath: 'find.mdx', + referenceGroup: 'core', + }, + getNode: { + memberPath: 'getNode', + metadata: readOperation({ + idempotency: 'idempotent', + throws: T_NOT_FOUND, + }), + referenceDocPath: 'get-node.mdx', + referenceGroup: 'core', + }, + getNodeById: { + memberPath: 'getNodeById', + metadata: readOperation({ + idempotency: 'idempotent', + throws: T_NOT_FOUND, + }), + referenceDocPath: 'get-node-by-id.mdx', + referenceGroup: 'core', + }, + getText: { + memberPath: 'getText', + metadata: readOperation(), + referenceDocPath: 'get-text.mdx', + referenceGroup: 'core', + }, + info: { + memberPath: 'info', + metadata: readOperation(), + referenceDocPath: 'info.mdx', + referenceGroup: 'core', + }, + + insert: { + memberPath: 'insert', + metadata: mutationOperation({ + idempotency: 'non-idempotent', + supportsDryRun: true, + supportsTrackedMode: true, + possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], + throws: T_NOT_FOUND_TRACKED, + }), + referenceDocPath: 'insert.mdx', + referenceGroup: 'core', + }, + replace: { + memberPath: 'replace', + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: true, + possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], + throws: T_NOT_FOUND_TRACKED, + }), + referenceDocPath: 'replace.mdx', + referenceGroup: 'core', + }, + delete: { + memberPath: 'delete', + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: true, + possibleFailureCodes: ['NO_OP'], + throws: T_NOT_FOUND_TRACKED, + }), + referenceDocPath: 'delete.mdx', + referenceGroup: 'core', + }, + + 'format.bold': { + memberPath: 'format.bold', + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: true, + possibleFailureCodes: ['INVALID_TARGET'], + throws: T_NOT_FOUND_COMMAND_TRACKED, + }), + referenceDocPath: 'format/bold.mdx', + referenceGroup: 'format', + }, + + 'create.paragraph': { + memberPath: 'create.paragraph', + metadata: mutationOperation({ + idempotency: 'non-idempotent', + supportsDryRun: true, + supportsTrackedMode: true, + possibleFailureCodes: ['INVALID_TARGET'], + throws: T_NOT_FOUND_COMMAND_TRACKED, + }), + referenceDocPath: 'create/paragraph.mdx', + referenceGroup: 'create', + }, + + 'lists.list': { + memberPath: 'lists.list', + metadata: readOperation({ + idempotency: 'idempotent', + throws: T_NOT_FOUND, + }), + referenceDocPath: 'lists/list.mdx', + referenceGroup: 'lists', + }, + 'lists.get': { + memberPath: 'lists.get', + metadata: readOperation({ + idempotency: 'idempotent', + throws: T_NOT_FOUND, + }), + referenceDocPath: 'lists/get.mdx', + referenceGroup: 'lists', + }, + 'lists.insert': { + memberPath: 'lists.insert', + metadata: mutationOperation({ + idempotency: 'non-idempotent', + supportsDryRun: true, + supportsTrackedMode: true, + possibleFailureCodes: ['INVALID_TARGET'], + throws: T_NOT_FOUND_COMMAND_TRACKED, + }), + referenceDocPath: 'lists/insert.mdx', + referenceGroup: 'lists', + }, + 'lists.setType': { + memberPath: 'lists.setType', + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], + throws: T_NOT_FOUND_COMMAND_TRACKED, + }), + referenceDocPath: 'lists/set-type.mdx', + referenceGroup: 'lists', + }, + 'lists.indent': { + memberPath: 'lists.indent', + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], + throws: T_NOT_FOUND_COMMAND_TRACKED, + }), + referenceDocPath: 'lists/indent.mdx', + referenceGroup: 'lists', + }, + 'lists.outdent': { + memberPath: 'lists.outdent', + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], + throws: T_NOT_FOUND_COMMAND_TRACKED, + }), + referenceDocPath: 'lists/outdent.mdx', + referenceGroup: 'lists', + }, + 'lists.restart': { + memberPath: 'lists.restart', + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], + throws: T_NOT_FOUND_COMMAND_TRACKED, + }), + referenceDocPath: 'lists/restart.mdx', + referenceGroup: 'lists', + }, + 'lists.exit': { + memberPath: 'lists.exit', + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET'], + throws: T_NOT_FOUND_COMMAND_TRACKED, + }), + referenceDocPath: 'lists/exit.mdx', + referenceGroup: 'lists', + }, + + 'comments.add': { + memberPath: 'comments.add', + metadata: mutationOperation({ + idempotency: 'non-idempotent', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], + throws: T_NOT_FOUND_COMMAND, + }), + referenceDocPath: 'comments/add.mdx', + referenceGroup: 'comments', + }, + 'comments.edit': { + memberPath: 'comments.edit', + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_NOT_FOUND_COMMAND, + }), + referenceDocPath: 'comments/edit.mdx', + referenceGroup: 'comments', + }, + 'comments.reply': { + memberPath: 'comments.reply', + metadata: mutationOperation({ + idempotency: 'non-idempotent', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET'], + throws: T_NOT_FOUND_COMMAND, + }), + referenceDocPath: 'comments/reply.mdx', + referenceGroup: 'comments', + }, + 'comments.move': { + memberPath: 'comments.move', + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], + throws: T_NOT_FOUND_COMMAND, + }), + referenceDocPath: 'comments/move.mdx', + referenceGroup: 'comments', + }, + 'comments.resolve': { + memberPath: 'comments.resolve', + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_NOT_FOUND_COMMAND, + }), + referenceDocPath: 'comments/resolve.mdx', + referenceGroup: 'comments', + }, + 'comments.remove': { + memberPath: 'comments.remove', + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_NOT_FOUND_COMMAND, + }), + referenceDocPath: 'comments/remove.mdx', + referenceGroup: 'comments', + }, + 'comments.setInternal': { + memberPath: 'comments.setInternal', + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], + throws: T_NOT_FOUND_COMMAND, + }), + referenceDocPath: 'comments/set-internal.mdx', + referenceGroup: 'comments', + }, + 'comments.setActive': { + memberPath: 'comments.setActive', + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET'], + throws: T_NOT_FOUND_COMMAND, + }), + referenceDocPath: 'comments/set-active.mdx', + referenceGroup: 'comments', + }, + 'comments.goTo': { + memberPath: 'comments.goTo', + metadata: readOperation({ + idempotency: 'conditional', + throws: T_NOT_FOUND_COMMAND, + }), + referenceDocPath: 'comments/go-to.mdx', + referenceGroup: 'comments', + }, + 'comments.get': { + memberPath: 'comments.get', + metadata: readOperation({ + idempotency: 'idempotent', + throws: T_NOT_FOUND, + }), + referenceDocPath: 'comments/get.mdx', + referenceGroup: 'comments', + }, + 'comments.list': { + memberPath: 'comments.list', + metadata: readOperation({ + idempotency: 'idempotent', + }), + referenceDocPath: 'comments/list.mdx', + referenceGroup: 'comments', + }, + + 'trackChanges.list': { + memberPath: 'trackChanges.list', + metadata: readOperation({ + idempotency: 'idempotent', + }), + referenceDocPath: 'track-changes/list.mdx', + referenceGroup: 'trackChanges', + }, + 'trackChanges.get': { + memberPath: 'trackChanges.get', + metadata: readOperation({ + idempotency: 'idempotent', + throws: T_NOT_FOUND, + }), + referenceDocPath: 'track-changes/get.mdx', + referenceGroup: 'trackChanges', + }, + 'trackChanges.accept': { + memberPath: 'trackChanges.accept', + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_NOT_FOUND_COMMAND, + }), + referenceDocPath: 'track-changes/accept.mdx', + referenceGroup: 'trackChanges', + }, + 'trackChanges.reject': { + memberPath: 'trackChanges.reject', + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_NOT_FOUND_COMMAND, + }), + referenceDocPath: 'track-changes/reject.mdx', + referenceGroup: 'trackChanges', + }, + 'trackChanges.acceptAll': { + memberPath: 'trackChanges.acceptAll', + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_COMMAND, + }), + referenceDocPath: 'track-changes/accept-all.mdx', + referenceGroup: 'trackChanges', + }, + 'trackChanges.rejectAll': { + memberPath: 'trackChanges.rejectAll', + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_COMMAND, + }), + referenceDocPath: 'track-changes/reject-all.mdx', + referenceGroup: 'trackChanges', + }, + + 'capabilities.get': { + memberPath: 'capabilities', + metadata: readOperation({ + idempotency: 'idempotent', + throws: NONE_THROWS, + }), + referenceDocPath: 'capabilities/get.mdx', + referenceGroup: 'capabilities', + }, +} as const satisfies Record; + +// --------------------------------------------------------------------------- +// Derived identities (immutable) +// --------------------------------------------------------------------------- + +export type OperationId = keyof typeof OPERATION_DEFINITIONS; + +export const OPERATION_IDS: readonly OperationId[] = Object.freeze(Object.keys(OPERATION_DEFINITIONS) as OperationId[]); + +export const SINGLETON_OPERATION_IDS: readonly OperationId[] = Object.freeze( + OPERATION_IDS.filter((id) => !id.includes('.')), +); + +export const NAMESPACED_OPERATION_IDS: readonly OperationId[] = Object.freeze( + OPERATION_IDS.filter((id) => id.includes('.')), +); + +// --------------------------------------------------------------------------- +// Typed projection helper (single contained cast) +// --------------------------------------------------------------------------- + +/** + * Projects a value from each operation definition entry into a keyed record. + * + * The cast is needed because `Object.fromEntries` returns `Record`; + * all callers validate the result via explicit type annotations. + */ +export function projectFromDefinitions( + fn: (id: OperationId, entry: OperationDefinitionEntry) => V, +): Record { + return Object.fromEntries(OPERATION_IDS.map((id) => [id, fn(id, OPERATION_DEFINITIONS[id])])) as Record< + OperationId, + V + >; +} diff --git a/packages/document-api/src/contract/operation-map.ts b/packages/document-api/src/contract/operation-map.ts index e371f9c09..ff2c9f3bd 100644 --- a/packages/document-api/src/contract/operation-map.ts +++ b/packages/document-api/src/contract/operation-map.ts @@ -1,23 +1,20 @@ -import { OPERATION_IDS, type OperationId } from './types.js'; +import { + OPERATION_DEFINITIONS, + OPERATION_IDS, + projectFromDefinitions, + type OperationId, +} from './operation-definitions.js'; -/** - * Overrides for operation IDs whose public DocumentApi member path - * differs from the canonical operation ID. - */ -const MEMBER_PATH_OVERRIDES: Partial> = { - // capabilities() is exposed as a top-level getter-like method on DocumentApi. - // The canonical operationId remains capabilities.get for catalog consistency. - 'capabilities.get': 'capabilities', -}; +export type DocumentApiMemberPath = (typeof OPERATION_DEFINITIONS)[OperationId]['memberPath']; -export function memberPathForOperation(operationId: OperationId): string { - return MEMBER_PATH_OVERRIDES[operationId] ?? operationId; +export function memberPathForOperation(operationId: OperationId): DocumentApiMemberPath { + return OPERATION_DEFINITIONS[operationId].memberPath; } -export const DOCUMENT_API_MEMBER_PATHS = [...new Set(OPERATION_IDS.map(memberPathForOperation))] as const; +export const OPERATION_MEMBER_PATH_MAP: Record = projectFromDefinitions( + (_id, entry) => entry.memberPath as DocumentApiMemberPath, +); -export type DocumentApiMemberPath = (typeof DOCUMENT_API_MEMBER_PATHS)[number]; - -export const OPERATION_MEMBER_PATH_MAP = Object.fromEntries( - OPERATION_IDS.map((operationId) => [operationId, memberPathForOperation(operationId)]), -) as Record; +export const DOCUMENT_API_MEMBER_PATHS: readonly DocumentApiMemberPath[] = [ + ...new Set(OPERATION_IDS.map((id) => OPERATION_DEFINITIONS[id].memberPath)), +]; diff --git a/packages/document-api/src/contract/reference-doc-map.ts b/packages/document-api/src/contract/reference-doc-map.ts index b73198fc9..4f29d4123 100644 --- a/packages/document-api/src/contract/reference-doc-map.ts +++ b/packages/document-api/src/contract/reference-doc-map.ts @@ -1,6 +1,12 @@ -import { OPERATION_IDS, type OperationId } from './types.js'; +import { + OPERATION_DEFINITIONS, + OPERATION_IDS, + projectFromDefinitions, + type ReferenceGroupKey, +} from './operation-definitions.js'; +import type { OperationId } from './types.js'; -export type ReferenceGroupKey = 'core' | 'capabilities' | 'create' | 'format' | 'lists' | 'comments' | 'trackChanges'; +export type { ReferenceGroupKey } from './operation-definitions.js'; export interface ReferenceOperationGroupDefinition { key: ReferenceGroupKey; @@ -10,148 +16,52 @@ export interface ReferenceOperationGroupDefinition { operations: readonly OperationId[]; } -export const OPERATION_REFERENCE_DOC_PATH_MAP: Record = { - find: 'find.mdx', - getNode: 'get-node.mdx', - getNodeById: 'get-node-by-id.mdx', - getText: 'get-text.mdx', - info: 'info.mdx', - insert: 'insert.mdx', - replace: 'replace.mdx', - delete: 'delete.mdx', - 'format.bold': 'format/bold.mdx', - 'create.paragraph': 'create/paragraph.mdx', - 'lists.list': 'lists/list.mdx', - 'lists.get': 'lists/get.mdx', - 'lists.insert': 'lists/insert.mdx', - 'lists.setType': 'lists/set-type.mdx', - 'lists.indent': 'lists/indent.mdx', - 'lists.outdent': 'lists/outdent.mdx', - 'lists.restart': 'lists/restart.mdx', - 'lists.exit': 'lists/exit.mdx', - 'comments.add': 'comments/add.mdx', - 'comments.edit': 'comments/edit.mdx', - 'comments.reply': 'comments/reply.mdx', - 'comments.move': 'comments/move.mdx', - 'comments.resolve': 'comments/resolve.mdx', - 'comments.remove': 'comments/remove.mdx', - 'comments.setInternal': 'comments/set-internal.mdx', - 'comments.setActive': 'comments/set-active.mdx', - 'comments.goTo': 'comments/go-to.mdx', - 'comments.get': 'comments/get.mdx', - 'comments.list': 'comments/list.mdx', - 'trackChanges.list': 'track-changes/list.mdx', - 'trackChanges.get': 'track-changes/get.mdx', - 'trackChanges.accept': 'track-changes/accept.mdx', - 'trackChanges.reject': 'track-changes/reject.mdx', - 'trackChanges.acceptAll': 'track-changes/accept-all.mdx', - 'trackChanges.rejectAll': 'track-changes/reject-all.mdx', - 'capabilities.get': 'capabilities/get.mdx', -}; +export const OPERATION_REFERENCE_DOC_PATH_MAP: Record = projectFromDefinitions( + (_id, entry) => entry.referenceDocPath, +); -export const REFERENCE_OPERATION_GROUPS: readonly ReferenceOperationGroupDefinition[] = [ - { - key: 'core', +const GROUP_METADATA: Record = { + core: { title: 'Core', description: 'Primary read and write operations.', pagePath: 'core/index.mdx', - operations: ['find', 'getNode', 'getNodeById', 'getText', 'info', 'insert', 'replace', 'delete'], }, - { - key: 'capabilities', + capabilities: { title: 'Capabilities', description: 'Runtime support discovery for capability-aware branching.', pagePath: 'capabilities/index.mdx', - operations: ['capabilities.get'], }, - { - key: 'create', + create: { title: 'Create', description: 'Structured creation helpers.', pagePath: 'create/index.mdx', - operations: ['create.paragraph'], }, - { - key: 'format', + format: { title: 'Format', description: 'Formatting mutations.', pagePath: 'format/index.mdx', - operations: ['format.bold'], }, - { - key: 'lists', + lists: { title: 'Lists', description: 'List inspection and list mutations.', pagePath: 'lists/index.mdx', - operations: [ - 'lists.list', - 'lists.get', - 'lists.insert', - 'lists.setType', - 'lists.indent', - 'lists.outdent', - 'lists.restart', - 'lists.exit', - ], }, - { - key: 'comments', + comments: { title: 'Comments', description: 'Comment authoring and thread lifecycle operations.', pagePath: 'comments/index.mdx', - operations: [ - 'comments.add', - 'comments.edit', - 'comments.reply', - 'comments.move', - 'comments.resolve', - 'comments.remove', - 'comments.setInternal', - 'comments.setActive', - 'comments.goTo', - 'comments.get', - 'comments.list', - ], }, - { - key: 'trackChanges', + trackChanges: { title: 'Track Changes', description: 'Tracked-change inspection and review operations.', pagePath: 'track-changes/index.mdx', - operations: [ - 'trackChanges.list', - 'trackChanges.get', - 'trackChanges.accept', - 'trackChanges.reject', - 'trackChanges.acceptAll', - 'trackChanges.rejectAll', - ], }, -]; - -/** - * Fail-fast guard that runs at import time to catch stale reference-doc - * mappings before they reach consumers. The same invariants are also covered - * by contract.test.ts; this assertion provides an immediate signal during - * development when a new operation is added but the doc map is not updated. - */ -function assertReferenceMapCoverage(): void { - const operationIds = [...OPERATION_IDS].sort(); - - const docPathKeys = Object.keys(OPERATION_REFERENCE_DOC_PATH_MAP).sort(); - if (docPathKeys.join('|') !== operationIds.join('|')) { - throw new Error('OPERATION_REFERENCE_DOC_PATH_MAP keys must match OPERATION_IDS exactly.'); - } - - const grouped = REFERENCE_OPERATION_GROUPS.flatMap((group) => group.operations); - const groupedSorted = [...grouped].sort(); - if (groupedSorted.join('|') !== operationIds.join('|')) { - throw new Error('REFERENCE_OPERATION_GROUPS operation coverage must match OPERATION_IDS exactly.'); - } - - if (new Set(grouped).size !== grouped.length) { - throw new Error('REFERENCE_OPERATION_GROUPS contains duplicate operations.'); - } -} +}; -assertReferenceMapCoverage(); +export const REFERENCE_OPERATION_GROUPS: readonly ReferenceOperationGroupDefinition[] = ( + Object.keys(GROUP_METADATA) as ReferenceGroupKey[] +).map((key) => ({ + key, + ...GROUP_METADATA[key], + operations: OPERATION_IDS.filter((id) => OPERATION_DEFINITIONS[id].referenceGroup === key), +})); diff --git a/packages/document-api/src/contract/types.test.ts b/packages/document-api/src/contract/types.test.ts index 46bcd2034..cfe11e5f7 100644 --- a/packages/document-api/src/contract/types.test.ts +++ b/packages/document-api/src/contract/types.test.ts @@ -1,4 +1,5 @@ import { assertOperationId, isOperationId, isValidOperationIdFormat, OPERATION_IDS } from './types.js'; +import type { DocumentApiMemberPath } from './operation-map.js'; describe('isValidOperationIdFormat', () => { it('accepts simple camelCase identifiers', () => { @@ -71,3 +72,11 @@ describe('assertOperationId', () => { expect(() => assertOperationId('BAD FORMAT')).toThrow(/Unknown operationId/); }); }); + +describe('DocumentApiMemberPath type safety', () => { + it('is narrower than string', () => { + type IsWideString = string extends DocumentApiMemberPath ? true : false; + const isWideString: IsWideString = false; + expect(isWideString).toBe(false); + }); +}); diff --git a/packages/document-api/src/contract/types.ts b/packages/document-api/src/contract/types.ts index f354870a6..43e18ee97 100644 --- a/packages/document-api/src/contract/types.ts +++ b/packages/document-api/src/contract/types.ts @@ -1,84 +1,27 @@ -import type { ReceiptFailureCode } from '../types/receipt.js'; +export { + OPERATION_IDEMPOTENCY_VALUES, + type OperationIdempotency, + PRE_APPLY_THROW_CODES, + type PreApplyThrowCode, + type CommandThrowPolicy, + type CommandStaticMetadata, +} from './metadata-types.js'; + +export { + type OperationId, + OPERATION_IDS, + SINGLETON_OPERATION_IDS, + NAMESPACED_OPERATION_IDS, +} from './operation-definitions.js'; + +import type { OperationId } from './operation-definitions.js'; +import { OPERATION_IDS } from './operation-definitions.js'; +import type { CommandStaticMetadata } from './metadata-types.js'; export const CONTRACT_VERSION = '0.1.0'; export const JSON_SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema'; -export const SINGLETON_OPERATION_IDS = [ - 'find', - 'getNode', - 'getNodeById', - 'getText', - 'info', - 'insert', - 'replace', - 'delete', -] as const; - -export const NAMESPACED_OPERATION_IDS = [ - 'format.bold', - 'create.paragraph', - 'lists.list', - 'lists.get', - 'lists.insert', - 'lists.setType', - 'lists.indent', - 'lists.outdent', - 'lists.restart', - 'lists.exit', - 'comments.add', - 'comments.edit', - 'comments.reply', - 'comments.move', - 'comments.resolve', - 'comments.remove', - 'comments.setInternal', - 'comments.setActive', - 'comments.goTo', - 'comments.get', - 'comments.list', - 'trackChanges.list', - 'trackChanges.get', - 'trackChanges.accept', - 'trackChanges.reject', - 'trackChanges.acceptAll', - 'trackChanges.rejectAll', - 'capabilities.get', -] as const; - -export const OPERATION_IDS = [...SINGLETON_OPERATION_IDS, ...NAMESPACED_OPERATION_IDS] as const; - -export type OperationId = (typeof OPERATION_IDS)[number]; - -export const OPERATION_IDEMPOTENCY_VALUES = ['idempotent', 'conditional', 'non-idempotent'] as const; -export type OperationIdempotency = (typeof OPERATION_IDEMPOTENCY_VALUES)[number]; - -export const PRE_APPLY_THROW_CODES = [ - 'TARGET_NOT_FOUND', - 'COMMAND_UNAVAILABLE', - 'TRACK_CHANGE_COMMAND_UNAVAILABLE', - 'CAPABILITY_UNAVAILABLE', - 'INVALID_TARGET', -] as const; - -export type PreApplyThrowCode = (typeof PRE_APPLY_THROW_CODES)[number]; - -export interface CommandThrowPolicy { - preApply: readonly PreApplyThrowCode[]; - postApplyForbidden: true; -} - -export interface CommandStaticMetadata { - mutates: boolean; - idempotency: OperationIdempotency; - supportsDryRun: boolean; - supportsTrackedMode: boolean; - possibleFailureCodes: readonly ReceiptFailureCode[]; - throws: CommandThrowPolicy; - deterministicTargetResolution: boolean; - remediationHints?: readonly string[]; -} - export type CommandCatalog = { readonly [K in OperationId]: CommandStaticMetadata; }; diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index 08a47a2e8..6d8507654 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -416,7 +416,9 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { if (!Object.prototype.hasOwnProperty.call(dispatch, request.operationId)) { throw new Error(`Unknown operationId: "${request.operationId}"`); } - const handler = dispatch[request.operationId]; + // Safe: InvokeRequest provides caller-side type safety. + // Dynamic callers accept adapter-level validation. + const handler = dispatch[request.operationId] as unknown as (input: unknown, options?: unknown) => unknown; return handler(request.input, request.options); }, }; diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts index 7ba6a7233..ef54fbb24 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -6,101 +6,80 @@ */ import type { OperationId } from '../contract/types.js'; +import type { OperationRegistry } from '../contract/operation-registry.js'; import type { DocumentApi } from '../index.js'; -type DispatchHandler = (input: unknown, options?: unknown) => unknown; +// --------------------------------------------------------------------------- +// TypedDispatchTable — compile-time contract between registry and dispatch +// --------------------------------------------------------------------------- -export type DispatchTable = Record; +type TypedDispatchHandler = OperationRegistry[K]['options'] extends never + ? (input: OperationRegistry[K]['input']) => OperationRegistry[K]['output'] + : (input: OperationRegistry[K]['input'], options?: OperationRegistry[K]['options']) => OperationRegistry[K]['output']; + +export type TypedDispatchTable = { + [K in OperationId]: TypedDispatchHandler; +}; /** * Builds a dispatch table that maps every OperationId to the corresponding * direct method call on the given DocumentApi instance. * * Each entry delegates to the direct method — no parallel execution path. + * The return type is {@link TypedDispatchTable}, which validates at compile + * time that each handler conforms to the {@link OperationRegistry} contract. */ -export function buildDispatchTable(api: DocumentApi): DispatchTable { +export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { return { // --- Singleton reads --- find: (input, options) => api.find(input as Parameters[0], options as Parameters[1]), - getNode: (input) => api.getNode(input as Parameters[0]), - getNodeById: (input) => api.getNodeById(input as Parameters[0]), - getText: (input) => api.getText(input as Parameters[0]), - info: (input) => api.info(input as Parameters[0]), + getNode: (input) => api.getNode(input), + getNodeById: (input) => api.getNodeById(input), + getText: (input) => api.getText(input), + info: (input) => api.info(input), // --- Singleton mutations --- - insert: (input, options) => - api.insert(input as Parameters[0], options as Parameters[1]), - replace: (input, options) => - api.replace(input as Parameters[0], options as Parameters[1]), - delete: (input, options) => - api.delete(input as Parameters[0], options as Parameters[1]), + insert: (input, options) => api.insert(input, options), + replace: (input, options) => api.replace(input, options), + delete: (input, options) => api.delete(input, options), // --- format.* --- - 'format.bold': (input, options) => - api.format.bold(input as Parameters[0], options as Parameters[1]), + 'format.bold': (input, options) => api.format.bold(input, options), // --- create.* --- - 'create.paragraph': (input, options) => - api.create.paragraph( - input as Parameters[0], - options as Parameters[1], - ), + 'create.paragraph': (input, options) => api.create.paragraph(input, options), // --- lists.* --- - 'lists.list': (input) => api.lists.list(input as Parameters[0]), - 'lists.get': (input) => api.lists.get(input as Parameters[0]), - 'lists.insert': (input, options) => - api.lists.insert( - input as Parameters[0], - options as Parameters[1], - ), - 'lists.setType': (input, options) => - api.lists.setType( - input as Parameters[0], - options as Parameters[1], - ), - 'lists.indent': (input, options) => - api.lists.indent( - input as Parameters[0], - options as Parameters[1], - ), - 'lists.outdent': (input, options) => - api.lists.outdent( - input as Parameters[0], - options as Parameters[1], - ), - 'lists.restart': (input, options) => - api.lists.restart( - input as Parameters[0], - options as Parameters[1], - ), - 'lists.exit': (input, options) => - api.lists.exit(input as Parameters[0], options as Parameters[1]), + 'lists.list': (input) => api.lists.list(input), + 'lists.get': (input) => api.lists.get(input), + 'lists.insert': (input, options) => api.lists.insert(input, options), + 'lists.setType': (input, options) => api.lists.setType(input, options), + 'lists.indent': (input, options) => api.lists.indent(input, options), + 'lists.outdent': (input, options) => api.lists.outdent(input, options), + 'lists.restart': (input, options) => api.lists.restart(input, options), + 'lists.exit': (input, options) => api.lists.exit(input, options), // --- comments.* --- - 'comments.add': (input) => api.comments.add(input as Parameters[0]), - 'comments.edit': (input) => api.comments.edit(input as Parameters[0]), - 'comments.reply': (input) => api.comments.reply(input as Parameters[0]), - 'comments.move': (input) => api.comments.move(input as Parameters[0]), - 'comments.resolve': (input) => api.comments.resolve(input as Parameters[0]), - 'comments.remove': (input) => api.comments.remove(input as Parameters[0]), - 'comments.setInternal': (input) => - api.comments.setInternal(input as Parameters[0]), - 'comments.setActive': (input) => api.comments.setActive(input as Parameters[0]), - 'comments.goTo': (input) => api.comments.goTo(input as Parameters[0]), - 'comments.get': (input) => api.comments.get(input as Parameters[0]), - 'comments.list': (input) => api.comments.list(input as Parameters[0]), + 'comments.add': (input) => api.comments.add(input), + 'comments.edit': (input) => api.comments.edit(input), + 'comments.reply': (input) => api.comments.reply(input), + 'comments.move': (input) => api.comments.move(input), + 'comments.resolve': (input) => api.comments.resolve(input), + 'comments.remove': (input) => api.comments.remove(input), + 'comments.setInternal': (input) => api.comments.setInternal(input), + 'comments.setActive': (input) => api.comments.setActive(input), + 'comments.goTo': (input) => api.comments.goTo(input), + 'comments.get': (input) => api.comments.get(input), + 'comments.list': (input) => api.comments.list(input), // --- trackChanges.* --- - 'trackChanges.list': (input) => api.trackChanges.list(input as Parameters[0]), - 'trackChanges.get': (input) => api.trackChanges.get(input as Parameters[0]), - 'trackChanges.accept': (input) => api.trackChanges.accept(input as Parameters[0]), - 'trackChanges.reject': (input) => api.trackChanges.reject(input as Parameters[0]), - 'trackChanges.acceptAll': (input) => - api.trackChanges.acceptAll(input as Parameters[0]), - 'trackChanges.rejectAll': (input) => - api.trackChanges.rejectAll(input as Parameters[0]), + 'trackChanges.list': (input) => api.trackChanges.list(input), + 'trackChanges.get': (input) => api.trackChanges.get(input), + 'trackChanges.accept': (input) => api.trackChanges.accept(input), + 'trackChanges.reject': (input) => api.trackChanges.reject(input), + 'trackChanges.acceptAll': (input) => api.trackChanges.acceptAll(input), + 'trackChanges.rejectAll': (input) => api.trackChanges.rejectAll(input), // --- capabilities --- 'capabilities.get': () => api.capabilities(),