Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96faa28220 | |||
| 654ba9f249 |
@@ -148,6 +148,53 @@ describe('assistantParts', () => {
|
|||||||
expect(toolPart).not.toHaveProperty('output');
|
expect(toolPart).not.toHaveProperty('output');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('replays the REAL error text for a THROWN tool (tool-error part)', () => {
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
text: '',
|
||||||
|
toolCalls: [
|
||||||
|
{ toolCallId: 'c7', toolName: 'editPageText', input: { id: 'p1' } },
|
||||||
|
],
|
||||||
|
// A thrown tool is a `tool-error` content part; toolResults holds only
|
||||||
|
// successes and stays empty for this call.
|
||||||
|
toolResults: [],
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool-error',
|
||||||
|
toolCallId: 'c7',
|
||||||
|
toolName: 'editPageText',
|
||||||
|
input: { id: 'p1' },
|
||||||
|
error: new Error('page is locked'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const parts = assistantParts(steps, '') as AnyPart[];
|
||||||
|
const toolPart = parts.find((p) => p.type === 'tool-editPageText');
|
||||||
|
expect(toolPart).toBeDefined();
|
||||||
|
expect(toolPart!.state).toBe('output-error');
|
||||||
|
// The REAL error is replayed, NOT the 'Tool call did not complete.' placeholder.
|
||||||
|
expect(toolPart!.errorText).toBe('page is locked');
|
||||||
|
expect(toolPart).not.toHaveProperty('output');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the placeholder ONLY for a call with neither result nor tool-error', () => {
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
text: '',
|
||||||
|
toolCalls: [
|
||||||
|
{ toolCallId: 'c8', toolName: 'insertNode', input: { node: {} } },
|
||||||
|
],
|
||||||
|
toolResults: [],
|
||||||
|
content: [], // aborted mid-step: no result AND no tool-error
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const parts = assistantParts(steps, '') as AnyPart[];
|
||||||
|
const toolPart = parts.find((p) => p.type === 'tool-insertNode');
|
||||||
|
expect(toolPart!.state).toBe('output-error');
|
||||||
|
expect(toolPart!.errorText).toBe('Tool call did not complete.');
|
||||||
|
});
|
||||||
|
|
||||||
it('skips malformed tool-calls (missing toolName or toolCallId)', () => {
|
it('skips malformed tool-calls (missing toolName or toolCallId)', () => {
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
@@ -195,6 +242,45 @@ describe('serializeSteps', () => {
|
|||||||
expect(trace[0]).toEqual({ toolName: 'getPage', input: { id: 'p1' } });
|
expect(trace[0]).toEqual({ toolName: 'getPage', input: { id: 'p1' } });
|
||||||
expect(trace[1]).toEqual({ toolName: 'getPage', output: { title: 'T' } });
|
expect(trace[1]).toEqual({ toolName: 'getPage', output: { title: 'T' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('records a THROWN tool failure (tool-error part) with its error message', () => {
|
||||||
|
const trace = serializeSteps([
|
||||||
|
{
|
||||||
|
toolCalls: [{ toolName: 'editPageText', input: { id: 'p1' } }],
|
||||||
|
toolResults: [],
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool-error',
|
||||||
|
toolName: 'editPageText',
|
||||||
|
error: new Error('page is locked'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]) as Array<Record<string, unknown>>;
|
||||||
|
// The call element is followed by a paired error element (mirroring how a
|
||||||
|
// successful result is appended), so the failure survives in the trace.
|
||||||
|
expect(trace).toHaveLength(2);
|
||||||
|
expect(trace[0]).toEqual({ toolName: 'editPageText', input: { id: 'p1' } });
|
||||||
|
expect(trace[1]).toEqual({
|
||||||
|
toolName: 'editPageText',
|
||||||
|
error: 'page is locked',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates a very long tool-error message to the tool-output limit', () => {
|
||||||
|
const long = 'x'.repeat(5000);
|
||||||
|
const trace = serializeSteps([
|
||||||
|
{
|
||||||
|
toolCalls: [{ toolName: 'editPageText', input: {} }],
|
||||||
|
toolResults: [],
|
||||||
|
content: [{ type: 'tool-error', toolName: 'editPageText', error: long }],
|
||||||
|
},
|
||||||
|
]) as Array<Record<string, unknown>>;
|
||||||
|
const errorText = trace[1].error as string;
|
||||||
|
// Truncated (not the full 5000 chars) and carries the omission marker.
|
||||||
|
expect(errorText.length).toBeLessThan(long.length);
|
||||||
|
expect(errorText).toContain('chars omitted');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('rowToUiMessage', () => {
|
describe('rowToUiMessage', () => {
|
||||||
|
|||||||
@@ -1637,6 +1637,17 @@ type StepLike = {
|
|||||||
toolName?: string;
|
toolName?: string;
|
||||||
output?: unknown;
|
output?: unknown;
|
||||||
}>;
|
}>;
|
||||||
|
// ai@6.0.134: a tool that THREW surfaces as a `tool-error` content part
|
||||||
|
// ({ type:'tool-error', toolCallId, toolName, input, error }), NOT as a
|
||||||
|
// `toolResults` entry (which holds only successes). Read from here so failed
|
||||||
|
// calls are persisted with their real error instead of being dropped.
|
||||||
|
content?: ReadonlyArray<{
|
||||||
|
type?: string;
|
||||||
|
toolCallId?: string;
|
||||||
|
toolName?: string;
|
||||||
|
input?: unknown;
|
||||||
|
error?: unknown;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1739,6 +1750,26 @@ function compactValue(value: unknown, depth: number): unknown {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a bounded string message from a `tool-error` part's `error` field for
|
||||||
|
* persistence and history replay. The field may be an `Error`, a string, or an
|
||||||
|
* arbitrary object, so pull a message robustly. The result is passed through
|
||||||
|
* `compactValue` so a very long error honors the SAME truncation limits the file
|
||||||
|
* already applies to tool outputs (no new limit is introduced here).
|
||||||
|
*/
|
||||||
|
function normalizeToolError(error: unknown): string {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: typeof error === 'string'
|
||||||
|
? error
|
||||||
|
: error != null &&
|
||||||
|
typeof (error as { message?: unknown }).message === 'string'
|
||||||
|
? (error as { message: string }).message
|
||||||
|
: String(error);
|
||||||
|
return compactValue(message, 0) as string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rebuild the FULL UIMessage `parts` for an assistant turn from the SDK steps,
|
* Rebuild the FULL UIMessage `parts` for an assistant turn from the SDK steps,
|
||||||
* so multi-turn history replays prior tool-calls/results to the model (not just
|
* so multi-turn history replays prior tool-calls/results to the model (not just
|
||||||
@@ -1771,6 +1802,14 @@ export function assistantParts(
|
|||||||
for (const r of step.toolResults ?? []) {
|
for (const r of step.toolResults ?? []) {
|
||||||
if (r.toolCallId) resultsById.set(r.toolCallId, r.output);
|
if (r.toolCallId) resultsById.set(r.toolCallId, r.output);
|
||||||
}
|
}
|
||||||
|
// Index this step's THROWN tool failures (ai@6 `tool-error` content parts)
|
||||||
|
// by tool call id, so a call that failed replays with its real error text.
|
||||||
|
const errorsById = new Map<string, unknown>();
|
||||||
|
for (const part of step.content ?? []) {
|
||||||
|
if (part.type === 'tool-error' && part.toolCallId) {
|
||||||
|
errorsById.set(part.toolCallId, part.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const call of step.toolCalls ?? []) {
|
for (const call of step.toolCalls ?? []) {
|
||||||
if (!call.toolName || !call.toolCallId) continue;
|
if (!call.toolName || !call.toolCallId) continue;
|
||||||
const hasResult = resultsById.has(call.toolCallId);
|
const hasResult = resultsById.has(call.toolCallId);
|
||||||
@@ -1783,9 +1822,21 @@ export function assistantParts(
|
|||||||
input: call.input,
|
input: call.input,
|
||||||
output: compactToolOutput(resultsById.get(call.toolCallId)),
|
output: compactToolOutput(resultsById.get(call.toolCallId)),
|
||||||
});
|
});
|
||||||
|
} else if (errorsById.has(call.toolCallId)) {
|
||||||
|
// The tool THREW: replay the REAL error so the model on the next turn
|
||||||
|
// knows WHY the call failed (and does not blindly repeat it). An
|
||||||
|
// output-error round-trips through convertToModelMessages as a balanced
|
||||||
|
// tool-call + tool-result, keeping the rebuilt history valid.
|
||||||
|
parts.push({
|
||||||
|
type: `tool-${call.toolName}`,
|
||||||
|
toolCallId: call.toolCallId,
|
||||||
|
state: 'output-error',
|
||||||
|
input: call.input,
|
||||||
|
errorText: normalizeToolError(errorsById.get(call.toolCallId)),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// No paired result (e.g. aborted mid-step). Persisting a bare
|
// No paired result AND no tool-error (e.g. aborted mid-step). Persisting
|
||||||
// tool-call (input-available) would replay as an unpaired call and
|
// a bare tool-call (input-available) would replay as an unpaired call and
|
||||||
// throw MissingToolResultsError on the next turn (convertToModelMessages
|
// throw MissingToolResultsError on the next turn (convertToModelMessages
|
||||||
// emits no tool-result for it). Emit a SYNTHETIC paired result instead:
|
// emits no tool-result for it). Emit a SYNTHETIC paired result instead:
|
||||||
// an output-error round-trips through convertToModelMessages as a
|
// an output-error round-trips through convertToModelMessages as a
|
||||||
@@ -2021,10 +2072,19 @@ export function serializeSteps(
|
|||||||
steps: ReadonlyArray<{
|
steps: ReadonlyArray<{
|
||||||
toolCalls?: ReadonlyArray<{ toolName?: string; input?: unknown }>;
|
toolCalls?: ReadonlyArray<{ toolName?: string; input?: unknown }>;
|
||||||
toolResults?: ReadonlyArray<{ toolName?: string; output?: unknown }>;
|
toolResults?: ReadonlyArray<{ toolName?: string; output?: unknown }>;
|
||||||
|
content?: ReadonlyArray<{
|
||||||
|
type?: string;
|
||||||
|
toolName?: string;
|
||||||
|
error?: unknown;
|
||||||
|
}>;
|
||||||
}>,
|
}>,
|
||||||
): unknown {
|
): unknown {
|
||||||
const calls: Array<{ toolName?: string; input?: unknown; output?: unknown }> =
|
const calls: Array<{
|
||||||
[];
|
toolName?: string;
|
||||||
|
input?: unknown;
|
||||||
|
output?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}> = [];
|
||||||
for (const step of steps ?? []) {
|
for (const step of steps ?? []) {
|
||||||
for (const call of step.toolCalls ?? []) {
|
for (const call of step.toolCalls ?? []) {
|
||||||
calls.push({ toolName: call.toolName, input: call.input });
|
calls.push({ toolName: call.toolName, input: call.input });
|
||||||
@@ -2032,6 +2092,18 @@ export function serializeSteps(
|
|||||||
for (const r of step.toolResults ?? []) {
|
for (const r of step.toolResults ?? []) {
|
||||||
calls.push({ toolName: r.toolName, output: compactToolOutput(r.output) });
|
calls.push({ toolName: r.toolName, output: compactToolOutput(r.output) });
|
||||||
}
|
}
|
||||||
|
// ai@6 surfaces a THROWN tool failure as a `tool-error` content part, NOT as
|
||||||
|
// a `toolResults` entry. Record it as its own paired element (mirroring how a
|
||||||
|
// successful result is appended) so the failure and its reason survive in the
|
||||||
|
// trace instead of leaving an orphaned call with no result.
|
||||||
|
for (const part of step.content ?? []) {
|
||||||
|
if (part.type === 'tool-error') {
|
||||||
|
calls.push({
|
||||||
|
toolName: part.toolName,
|
||||||
|
error: normalizeToolError(part.error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return calls.length > 0 ? calls : null;
|
return calls.length > 0 ? calls : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
resolveCurrentPageResult,
|
resolveCurrentPageResult,
|
||||||
type SelectionContext,
|
type SelectionContext,
|
||||||
} from './current-page.util';
|
} from './current-page.util';
|
||||||
import { parseNodeArg } from '@docmost/prosemirror-markdown';
|
import { parseNodeArg } from './parse-node-arg';
|
||||||
import { modelFriendlyInput } from './model-friendly-input';
|
import { modelFriendlyInput } from './model-friendly-input';
|
||||||
import { SandboxStore } from '../../../integrations/sandbox/sandbox.store';
|
import { SandboxStore } from '../../../integrations/sandbox/sandbox.store';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { parseNodeArg } from '@docmost/prosemirror-markdown';
|
import { parseNodeArg } from './parse-node-arg';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for the shared `parseNodeArg` helper (#414: now the single copy in
|
* Unit tests for the in-app `parseNodeArg` helper. It mirrors the standalone
|
||||||
* `@docmost/prosemirror-markdown`, imported by both the server tool adapters and
|
* MCP helper (packages/mcp/src/lib/parse-node-arg.ts) and is used by the
|
||||||
* `@docmost/mcp`). Used by the patchNode / insertNode / updatePageJson adapters.
|
* patchNode / insertNode / updatePageJson tool adapters. Behavior must be
|
||||||
* Behavior: object passthrough, valid-string parse, invalid-string throw.
|
* byte-identical: object passthrough, valid-string parse, invalid-string throw.
|
||||||
*/
|
*/
|
||||||
describe('parseNodeArg', () => {
|
describe('parseNodeArg', () => {
|
||||||
it('passes an object through unchanged', () => {
|
it('passes an object through unchanged', () => {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// The model sometimes serializes a ProseMirror node arg as a JSON string
|
||||||
|
// instead of an object. Normalize: parse a string to an object (throwing on
|
||||||
|
// invalid JSON), pass an object through unchanged. Shared by patchNode /
|
||||||
|
// insertNode (and the analogous updatePageJson content parsing).
|
||||||
|
//
|
||||||
|
// This is behaviorally identical to `packages/mcp/src/lib/parse-node-arg.ts`
|
||||||
|
// (the function logic, default/explicit throw messages and branch order match;
|
||||||
|
// only comments and quote style differ). We cannot import that helper here:
|
||||||
|
// `@docmost/mcp` is ESM-only and this server
|
||||||
|
// compiles with module:commonjs, so it is loaded at runtime via the
|
||||||
|
// `new Function('import()')` trick (see docmost-client.loader.ts). Sharing
|
||||||
|
// runtime code across that ESM/CJS boundary by a normal import is impossible,
|
||||||
|
// hence the mirrored copy.
|
||||||
|
export function parseNodeArg(
|
||||||
|
node: unknown,
|
||||||
|
errMsg = 'node was a string but not valid JSON',
|
||||||
|
): unknown {
|
||||||
|
if (typeof node === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(node);
|
||||||
|
} catch {
|
||||||
|
throw new Error(errMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
+77
-39
@@ -13,12 +13,14 @@ Read the **Gotchas** section before you trust any error count.
|
|||||||
- Agent chats live in Postgres, DB `docmost`, tables `ai_chat_*`.
|
- Agent chats live in Postgres, DB `docmost`, tables `ai_chat_*`.
|
||||||
- Each tool invocation is stored as **two** array elements (a `tool-call` part and
|
- Each tool invocation is stored as **two** array elements (a `tool-call` part and
|
||||||
a `tool-result` part), so naive counting double-counts.
|
a `tool-result` part), so naive counting double-counts.
|
||||||
- **A tool that *throws* writes no result part at all.** Its error text is nowhere
|
- **A tool that *throws* writes no result part.** Since the #407 fix its error is
|
||||||
in the DB — not in `tool_calls`, `content`, or `metadata`. It is shown live in
|
persisted as a dedicated `{toolName, error}` element in `tool_calls` (queryable +
|
||||||
the UI only. So `isError` / `success=false` scans under-report by design.
|
replayed to the model). **Rows written before #407 still drop it** — the error is
|
||||||
- To find where agents fail you need **three** sources: (1) soft-failure markers in
|
nowhere in the DB and shows only in the live UI. So `isError` / `success=false`
|
||||||
`tool_calls`, (2) the orphan-gap proxy for thrown errors, (3) server logs / the
|
scans under-report by design, and pre-#407 thrown errors are invisible.
|
||||||
live UI for the actual error text.
|
- To find where agents fail: (1) soft-failure markers in `tool_calls`, (2) the new
|
||||||
|
`error` field for thrown errors (new rows) / the orphan-gap proxy (old rows),
|
||||||
|
(3) server logs / the live UI for full stack traces beyond the truncated message.
|
||||||
|
|
||||||
## Where the data lives
|
## Where the data lives
|
||||||
|
|
||||||
@@ -61,13 +63,17 @@ index 0: { "toolName": "getPage", "input": { "pageId": "…" } } ← tool-ca
|
|||||||
index 1: { "toolName": "getPage", "output": { … } } ← tool-result (has output, NO input)
|
index 1: { "toolName": "getPage", "output": { … } } ← tool-result (has output, NO input)
|
||||||
```
|
```
|
||||||
|
|
||||||
The **only** keys that ever appear on an element are `toolName`, `input`, `output`.
|
The keys that appear on an element are `toolName`, `input`, `output`, and — for a
|
||||||
There is no `state`, no `errorText`, no `type`. Consequences:
|
**thrown** failure on rows written after the #407 fix — `error` (the tool's error
|
||||||
|
message; see the "Hard failures" section below). There is no `state`, no `errorText`,
|
||||||
|
no `type`. On pre-#407 rows a thrown failure has NO paired result element at all
|
||||||
|
(silent orphan). Consequences:
|
||||||
|
|
||||||
1. **Real invocation count = elements that have `output`.** Counting every element
|
1. **Real invocation count = elements that have `output` or `error`.** Counting every
|
||||||
double-counts (you get ~2× and a spurious "~50% of every tool has no output").
|
element double-counts (you get ~2× and a spurious "~50% of every tool has no output").
|
||||||
2. **Pairing:** a successful call = a `tool-call` part followed by its `tool-result`
|
2. **Pairing:** a call = a `tool-call` part followed by its result part. A success
|
||||||
part. Both carry `toolName`, so you can group by tool on either.
|
carries `output`; a thrown failure (post-#407) carries `error` instead. Both carry
|
||||||
|
`toolName`, so you can group by tool on either.
|
||||||
|
|
||||||
## The two classes of failure (and which the DB can see)
|
## The two classes of failure (and which the DB can see)
|
||||||
|
|
||||||
@@ -85,25 +91,37 @@ These are visible in the `tool-result` `output`. The marker differs per tool:
|
|||||||
Note `editPageText` returns `failed: []` on success — filtering on the *presence*
|
Note `editPageText` returns `failed: []` on success — filtering on the *presence*
|
||||||
of the key gives false positives; filter on **non-empty**.
|
of the key gives false positives; filter on **non-empty**.
|
||||||
|
|
||||||
### 2. Hard failures — tool THREW → NOT PERSISTED ❌ (the trap)
|
### 2. Hard failures — tool THREW → NOW PERSISTED ✅ (since the #407 fix)
|
||||||
|
|
||||||
When a tool throws (the classic one is `patchNode` / `insertNode` / `tableUpdateCell`
|
When a tool throws (the classic one is `patchNode` / `insertNode` / `tableUpdateCell`
|
||||||
→ `Failed to encode document to Yjs (fromJSON): Unknown node type: undefined`), the
|
→ `Failed to encode document to Yjs (fromJSON): Unknown node type: undefined`), the
|
||||||
runtime writes **no `tool-result` part**. The orphaned `tool-call` part stays, but
|
runtime still writes **no `tool-result` part** — the failure is an ai@6 `tool-error`
|
||||||
the error text is **nowhere in the DB**. It is streamed to the UI live and (until
|
content part instead. **Since the #407 fix, that error is persisted**: `serializeSteps`
|
||||||
rotation) to server logs — that is it.
|
appends a dedicated element `{toolName, error: "<message>"}` right after the failed
|
||||||
|
call, mirroring how a successful `{toolName, output}` element is appended. So a thrown
|
||||||
|
error now leaves a queryable `error` field carrying its (truncated) reason, and the
|
||||||
|
same real text is replayed to the model on the next turn (an `output-error` part with
|
||||||
|
the real `errorText`, no longer the `'Tool call did not complete.'` placeholder).
|
||||||
|
|
||||||
So any query like `count(*) FILTER (WHERE output.success = false)` will happily
|
**Cutover caveat — old rows keep the old blind shape.** Rows written **before** this
|
||||||
return **0** for `patchNode` even when the chat is visibly full of red failures.
|
change have the two-part shape (`call` + `output` only) and simply **drop** thrown
|
||||||
That is survivorship bias, not reliability.
|
errors, leaving a silent **orphan** (a `call` with no `output` *and* no `error`). Rows
|
||||||
|
written **after** the fix additionally carry the `error` element. So:
|
||||||
|
|
||||||
The only DB-side proxy for a thrown error is an **orphan**: a `tool-call` part with
|
- **New rows:** query the `error` field directly (see the hard-error query below) — no
|
||||||
no matching `tool-result`. Caveat: orphans also appear when a run is **aborted**
|
orphan heuristic needed for thrown failures.
|
||||||
mid-flight (server restart), so a high-volume tool (`createComment`, `searchInPage`,
|
- **Old rows (pre-#407):** the only DB-side proxy is still an **orphan**: a `tool-call`
|
||||||
`Search_web_search`) shows orphans from aborts, not from real errors. Treat the
|
part with no matching `tool-result` *and* no `error`. Orphans also appear when a run
|
||||||
orphan gap as an *upper bound* on hard errors, and cross-check the tool: a gap on a
|
is **aborted** mid-flight (server restart), so a high-volume tool (`createComment`,
|
||||||
structural editor (`patchNode`, `insertNode`, `updatePageJson`, `transformPage`) is
|
`searchInPage`, `Search_web_search`) shows orphans from aborts, not real errors on
|
||||||
almost certainly a thrown Yjs-encode error; a gap on `createComment` is mostly aborts.
|
old rows. Treat the orphan gap as an *upper bound*, and cross-check the tool: a gap on
|
||||||
|
a structural editor (`patchNode`, `insertNode`, `updatePageJson`, `transformPage`) is
|
||||||
|
almost certainly a thrown Yjs-encode error; a gap on `createComment` is mostly aborts.
|
||||||
|
|
||||||
|
A note on the aborted-call fallback: a call with **neither** a result **nor** a
|
||||||
|
`tool-error` (genuinely interrupted mid-step) still replays with the
|
||||||
|
`'Tool call did not complete.'` placeholder and persists as an orphan — that path is
|
||||||
|
unchanged, and is distinct from a real thrown error, which now carries `error`.
|
||||||
|
|
||||||
### 3. Run-level failures → `ai_chat_runs`
|
### 3. Run-level failures → `ai_chat_runs`
|
||||||
|
|
||||||
@@ -164,14 +182,28 @@ WHERE jsonb_typeof(o->'failed') = 'array'
|
|||||||
GROUP BY 1 ORDER BY 2 DESC;
|
GROUP BY 1 ORDER BY 2 DESC;
|
||||||
```
|
```
|
||||||
|
|
||||||
**Hard-error proxy — orphan gap per tool, WITH a spread column** (call parts minus
|
**Hard errors — persisted `error` field per tool (NEW rows, since #407)** — thrown
|
||||||
result parts, plus how many distinct chats the gap is spread across):
|
tool failures now carry their real reason, so query them directly:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT elem->>'toolName' AS tool, count(*) AS thrown_errors,
|
||||||
|
min(elem->>'error') AS sample_error
|
||||||
|
FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
|
||||||
|
WHERE jsonb_typeof(m.tool_calls) = 'array' AND elem ? 'error'
|
||||||
|
GROUP BY 1 ORDER BY 2 DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hard-error proxy for OLD rows (pre-#407) — orphan gap per tool, WITH a spread column**
|
||||||
|
(call parts minus result parts, plus how many distinct chats the gap is spread across).
|
||||||
|
This covers rows written before thrown errors were persisted; on new rows a thrown
|
||||||
|
failure now has its own `error` element (use the query above) and an orphan means only
|
||||||
|
a genuinely aborted mid-step call:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
WITH parts AS (
|
WITH parts AS (
|
||||||
SELECT m.chat_id, elem->>'toolName' AS tool,
|
SELECT m.chat_id, elem->>'toolName' AS tool,
|
||||||
(elem ? 'input' AND NOT (elem ? 'output')) AS is_call,
|
(elem ? 'input' AND NOT (elem ? 'output')) AS is_call,
|
||||||
(elem ? 'output') AS is_result
|
(elem ? 'output' OR elem ? 'error') AS is_result
|
||||||
FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
|
FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
|
||||||
WHERE jsonb_typeof(m.tool_calls) = 'array' AND m.role = 'assistant'
|
WHERE jsonb_typeof(m.tool_calls) = 'array' AND m.role = 'assistant'
|
||||||
),
|
),
|
||||||
@@ -188,8 +220,12 @@ HAVING sum(gap) FILTER (WHERE gap > 0) > 0
|
|||||||
ORDER BY missing_results DESC;
|
ORDER BY missing_results DESC;
|
||||||
```
|
```
|
||||||
|
|
||||||
**`missing_results` mixes thrown errors AND aborted/interrupted runs — you cannot
|
The `is_result` predicate counts an `error` element as a paired result too, so on new
|
||||||
split them from `output` alone** (a positional "what follows the orphan" heuristic
|
rows a persisted thrown error no longer inflates the orphan gap; a remaining gap is an
|
||||||
|
aborted/interrupted call.
|
||||||
|
|
||||||
|
**On OLD rows, `missing_results` mixes thrown errors AND aborted/interrupted runs — you
|
||||||
|
cannot split them from `output` alone** (a positional "what follows the orphan" heuristic
|
||||||
breaks on parallel tool batches, which persist as `call,call,…,result,result`). Use
|
breaks on parallel tool batches, which persist as `call,call,…,result,result`). Use
|
||||||
`chats_spread` to disambiguate:
|
`chats_spread` to disambiguate:
|
||||||
|
|
||||||
@@ -244,18 +280,20 @@ docker compose -p gitmost logs -f --tail=100 # whole stack
|
|||||||
```
|
```
|
||||||
|
|
||||||
Logging is `json-file`, `max-size=10m max-file=5` → ~50 MB retained, then rotated,
|
Logging is `json-file`, `max-size=10m max-file=5` → ~50 MB retained, then rotated,
|
||||||
and **wiped on container recreate**. So thrown-tool error text is only reliably
|
and **wiped on container recreate**. Since the #407 fix, thrown-tool error text is
|
||||||
caught **in real time** (or in the live chat UI, which renders the failed part with
|
**persisted in the `error` field** of `tool_calls` (see the hard-error query above), so
|
||||||
its message). There is no durable, queryable store of hard tool errors today — if you
|
you no longer depend on live logs for it. Logs/live UI remain useful for **pre-#407
|
||||||
need one, that is a feature to add (persist `output-error` parts, or emit a
|
rows** (whose thrown errors were dropped) and for full stack traces beyond the
|
||||||
`tool_calls_total{tool,status}` metric to VictoriaMetrics).
|
truncated stored message. A per-tool `tool_calls_total{tool,status}` metric to
|
||||||
|
VictoriaMetrics is still a possible future add for aggregate dashboards.
|
||||||
|
|
||||||
## Gotchas checklist
|
## Gotchas checklist
|
||||||
|
|
||||||
- [ ] Counting every `tool_calls` element → **2× overcount**. Count elements with `output`.
|
- [ ] Counting every `tool_calls` element → **overcount**. Count `output` elements; add `error` elements for thrown failures (new rows), but don't count both as invocations.
|
||||||
- [ ] `isError` / `success=false` ≈ 0 does **not** mean "no errors" — thrown errors aren't persisted.
|
- [ ] `isError` / `success=false` ≈ 0 does **not** mean "no errors" — thrown errors are a separate `error` element (new rows) or dropped entirely (pre-#407 rows).
|
||||||
|
- [ ] Thrown errors persist only on rows written **after the #407 fix** — pre-#407 rows still drop them (orphan only). Mind the cutover when trending over time.
|
||||||
- [ ] `editPageText.failed` is `[]` on success — test for **non-empty**, not presence.
|
- [ ] `editPageText.failed` is `[]` on success — test for **non-empty**, not presence.
|
||||||
- [ ] Orphan gap mixes thrown errors **and** aborted runs — split by tool before concluding.
|
- [ ] Orphan gap on OLD rows mixes thrown errors **and** aborted runs — split by tool. On NEW rows a thrown error is its own `error` element, so a gap ≈ aborted call.
|
||||||
- [ ] `aborted` runs = server restarts, `failed` runs = provider overload — not agent mistakes.
|
- [ ] `aborted` runs = server restarts, `failed` runs = provider overload — not agent mistakes.
|
||||||
- [ ] Never dump a raw `tool_calls` cell — it can be hundreds of KB.
|
- [ ] Never dump a raw `tool_calls` cell — it can be hundreds of KB.
|
||||||
- [ ] Logs are ephemeral (≤50 MB, wiped on recreate) — grab hard-error text live.
|
- [ ] Logs are ephemeral (≤50 MB, wiped on recreate) — grab hard-error text live.
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import {
|
|||||||
insertTableRow,
|
insertTableRow,
|
||||||
deleteTableRow,
|
deleteTableRow,
|
||||||
updateTableCell,
|
updateTableCell,
|
||||||
} from "@docmost/prosemirror-markdown";
|
} from "./lib/node-ops.js";
|
||||||
import { searchInDoc, SearchOptions } from "./lib/page-search.js";
|
import { searchInDoc, SearchOptions } from "./lib/page-search.js";
|
||||||
import { withPageLock } from "./lib/page-lock.js";
|
import { withPageLock } from "./lib/page-lock.js";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { readFileSync } from "fs";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import { DocmostClient, DocmostMcpConfig } from "./client.js";
|
import { DocmostClient, DocmostMcpConfig } from "./client.js";
|
||||||
import { parseNodeArg } from "@docmost/prosemirror-markdown";
|
import { parseNodeArg } from "./lib/parse-node-arg.js";
|
||||||
import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
|
import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
|
||||||
|
|
||||||
// Re-export the client and its config type so embedding hosts (e.g. the gitmost
|
// Re-export the client and its config type so embedding hosts (e.g. the gitmost
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { JSDOM } from "jsdom";
|
|||||||
import { markdownToProseMirror } from "@docmost/prosemirror-markdown";
|
import { markdownToProseMirror } from "@docmost/prosemirror-markdown";
|
||||||
import { docmostExtensions, docmostSchema } from "./docmost-schema.js";
|
import { docmostExtensions, docmostSchema } from "./docmost-schema.js";
|
||||||
import { withPageLock } from "./page-lock.js";
|
import { withPageLock } from "./page-lock.js";
|
||||||
import { sanitizeForYjs, findUnstorableAttr } from "@docmost/prosemirror-markdown";
|
import { sanitizeForYjs, findUnstorableAttr } from "./node-ops.js";
|
||||||
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
||||||
import { summarizeChange, VerifyReport } from "./diff.js";
|
import { summarizeChange, VerifyReport } from "./diff.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,62 +1,136 @@
|
|||||||
/**
|
/**
|
||||||
* Legacy footnote advisory for imported Markdown (issue #166, reduced in #414).
|
* Legacy footnote diagnostics for imported Markdown (issue #166).
|
||||||
*
|
*
|
||||||
* Since #293 STEP 5 the canonical import form is inline `^[body]` footnotes
|
* A PURE, fence-aware text scan (independent of the Markdown->ProseMirror
|
||||||
* (handled by `@docmost/prosemirror-markdown`). LEGACY reference-style
|
* conversion path, so it reports the same problems for `create_page`,
|
||||||
* `[^id]: …` definition markup is now INERT on import — the importer leaves it as
|
* `update_page` and `import_page_markdown`). It never changes the document — the
|
||||||
* literal text — so authoring it silently produces broken footnotes (the #410
|
* importer still creates the page; this only surfaces footnote problems to the
|
||||||
* incident class). Rather than the old, elaborate diagnostics of every problem
|
* caller so an agent can fix its own markup instead of shipping broken footnotes.
|
||||||
* SHAPE (dangling/duplicate/empty/in-table) that no longer describe what the
|
|
||||||
* importer builds, this module surfaces ONE advisory warning whenever legacy
|
|
||||||
* reference-style definition syntax is present, nudging the author to the inline
|
|
||||||
* form. It never changes the document — the importer still creates the page.
|
|
||||||
*
|
*
|
||||||
* The scan is fence-aware: a `[^id]:` line inside a ``` / ~~~ code block is
|
* SCOPE after #293 STEP 5: the canonical import form is now inline `^[body]`
|
||||||
* example text, not markup, so it never triggers the warning.
|
* footnotes (handled by `@docmost/prosemirror-markdown`), where these problems
|
||||||
|
* cannot arise. This scan therefore targets the LEGACY reference-style
|
||||||
|
* (`[^id]` / `[^id]:`) markup, which is now inert on import (left as literal
|
||||||
|
* text). The warnings remain useful as an advisory nudge when an agent still
|
||||||
|
* authors the old syntax, but they no longer describe what the importer builds.
|
||||||
|
*
|
||||||
|
* Detected problems:
|
||||||
|
* - danglingReferences: a `[^id]` reference with no `[^id]:` definition.
|
||||||
|
* - emptyDefinitions: a `[^id]:` whose (kept) text is empty/whitespace.
|
||||||
|
* - duplicateDefinitions: an id defined by two or more `[^id]:` lines (only the
|
||||||
|
* first would have been kept under the old first-wins import).
|
||||||
|
* - referencesInTables: a `[^id]` marker found in a GFM table row (heuristic:
|
||||||
|
* the line, trimmed, starts with `|`) — footnotes in table cells often do not
|
||||||
|
* render as expected.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** A legacy footnote DEFINITION line: `[^id]:` at the start of a (non-fenced) line. */
|
import {
|
||||||
const FOOTNOTE_DEF_RE = /^\[\^[^\]\s]+\]:/;
|
lexFootnoteLines,
|
||||||
/** Opening/closing code fence marker (``` or ~~~). */
|
forEachFootnoteReference,
|
||||||
const FENCE_RE = /^\s*(`{3,}|~{3,})/;
|
} from "./footnote-lex.js";
|
||||||
|
|
||||||
/** The single advisory shown when legacy reference-style footnotes are present. */
|
export interface FootnoteDiagnostics {
|
||||||
export const LEGACY_FOOTNOTE_WARNING =
|
/** Reference ids (distinct, document order) with no matching definition. */
|
||||||
"Reference-style footnotes (`[^id]: …`) are not parsed on import and will " +
|
danglingReferences: string[];
|
||||||
"appear as literal text. Use inline footnotes instead: `^[footnote text]`.";
|
/** Definition ids whose first (kept) text is empty/whitespace. */
|
||||||
|
emptyDefinitions: string[];
|
||||||
|
/** Ids defined by two or more `[^id]:` lines (only the first is kept). */
|
||||||
|
duplicateDefinitions: string[];
|
||||||
|
/** Reference ids found inside a GFM table row (heuristic). */
|
||||||
|
referencesInTables: string[];
|
||||||
|
/** Human-readable warning lines for the tool result (one per problem class). */
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when `markdown` contains a legacy `[^id]:` definition line OUTSIDE any
|
* Analyze the footnotes in a Markdown string. Pure; safe to call on any body.
|
||||||
* code fence. Pure; safe to call on any body.
|
|
||||||
*/
|
*/
|
||||||
export function hasLegacyFootnoteDefinition(markdown: string): boolean {
|
export function analyzeFootnotes(markdown: string): FootnoteDiagnostics {
|
||||||
if (typeof markdown !== "string" || !markdown.includes("[^")) return false;
|
// Distinct reference ids in first-appearance order, plus the set of ids seen
|
||||||
let fence: string | null = null;
|
// inside a table row.
|
||||||
for (const line of markdown.split("\n")) {
|
const refIds: string[] = [];
|
||||||
const fenceMatch = FENCE_RE.exec(line);
|
const refIdSet = new Set<string>();
|
||||||
if (fenceMatch) {
|
const referencesInTables = new Set<string>();
|
||||||
const marker = fenceMatch[1][0];
|
const addRef = (id: string, inTable: boolean) => {
|
||||||
if (fence === null) fence = marker; // opening fence
|
if (!refIdSet.has(id)) {
|
||||||
else if (marker === fence) fence = null; // matching closing fence
|
refIdSet.add(id);
|
||||||
|
refIds.push(id);
|
||||||
|
}
|
||||||
|
if (inTable) referencesInTables.add(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Definition texts per id, in first-appearance order of the id.
|
||||||
|
const defTextsById = new Map<string, string[]>();
|
||||||
|
|
||||||
|
// Same lexer the importer uses, so the analysis matches exactly what import
|
||||||
|
// keeps/strips (#166): fenced lines are inert, definition lines are pulled.
|
||||||
|
for (const tok of lexFootnoteLines(markdown)) {
|
||||||
|
if (tok.inFence) continue;
|
||||||
|
if (tok.definition) {
|
||||||
|
const { id, text } = tok.definition;
|
||||||
|
const arr = defTextsById.get(id);
|
||||||
|
if (arr) arr.push(text);
|
||||||
|
else defTextsById.set(id, [text]);
|
||||||
|
// A definition's TEXT can itself reference another footnote (`[^a]: see
|
||||||
|
// [^b]`); count those so such a `[^b]` is not falsely reported dangling.
|
||||||
|
forEachFootnoteReference(text, (rid) => addRef(rid, false));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (fence !== null) continue; // inside a fence: inert example text
|
const inTable = tok.line.trimStart().startsWith("|");
|
||||||
if (FOOTNOTE_DEF_RE.test(line)) return true;
|
forEachFootnoteReference(tok.line, (id) => addRef(id, inTable));
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
|
const danglingReferences = refIds.filter((id) => !defTextsById.has(id));
|
||||||
|
const duplicateDefinitions: string[] = [];
|
||||||
|
const emptyDefinitions: string[] = [];
|
||||||
|
for (const [id, texts] of defTextsById) {
|
||||||
|
if (texts.length >= 2) duplicateDefinitions.push(id);
|
||||||
|
// First-wins: the kept definition is the first one; flag it if it is blank.
|
||||||
|
if ((texts[0] ?? "").trim().length === 0) emptyDefinitions.push(id);
|
||||||
|
}
|
||||||
|
const tableRefs = [...referencesInTables];
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const list = (ids: string[]) => ids.map((id) => `[^${id}]`).join(", ");
|
||||||
|
if (danglingReferences.length > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`Footnote reference(s) with no matching definition: ${list(danglingReferences)} (each will render as an empty footnote in the editor).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (emptyDefinitions.length > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`Footnote definition(s) with empty text: ${list(emptyDefinitions)}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (duplicateDefinitions.length > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`Footnote id(s) defined more than once (only the first definition was kept): ${list(duplicateDefinitions)}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tableRefs.length > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`Footnote marker(s) inside a table row (footnotes in table cells may not render as expected): ${list(tableRefs)}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
danglingReferences,
|
||||||
|
emptyDefinitions,
|
||||||
|
duplicateDefinitions,
|
||||||
|
referencesInTables: tableRefs,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The optional `footnoteWarnings` field for a page-write tool result: present
|
* The optional `footnoteWarnings` field for a page-write tool result: present
|
||||||
* (with the single advisory) only when `markdown` uses legacy reference-style
|
* (with the warning lines) only when `markdown` has footnote problems, omitted
|
||||||
* footnote syntax, omitted otherwise. One helper so all three call sites
|
* otherwise. One helper so all three call sites (create/update/import) attach the
|
||||||
* (create/update/import) attach the field identically. Spread into the result:
|
* field identically. Spread into the result: `{ ...result, ...footnoteWarningsField(text) }`.
|
||||||
* `{ ...result, ...footnoteWarningsField(text) }`.
|
|
||||||
*/
|
*/
|
||||||
export function footnoteWarningsField(markdown: string): {
|
export function footnoteWarningsField(markdown: string): {
|
||||||
footnoteWarnings?: string[];
|
footnoteWarnings?: string[];
|
||||||
} {
|
} {
|
||||||
return hasLegacyFootnoteDefinition(markdown)
|
const { warnings } = analyzeFootnotes(markdown);
|
||||||
? { footnoteWarnings: [LEGACY_FOOTNOTE_WARNING] }
|
return warnings.length > 0 ? { footnoteWarnings: warnings } : {};
|
||||||
: {};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Inline-authoring helpers for footnotes (MCP).
|
||||||
|
*
|
||||||
|
* These build/identify footnote DEFINITION nodes for the author-inline tool
|
||||||
|
* (`insertInlineFootnote` in transforms.ts): a content key to de-duplicate notes
|
||||||
|
* by text, a definition-node factory, and a fresh uuidv7-style id generator.
|
||||||
|
*
|
||||||
|
* Split out of `footnote-canonicalize.ts` so that module stays a pure MIRROR of
|
||||||
|
* the editor-ext canonicalizer (compositionally symmetric to the editor-ext
|
||||||
|
* copy, which keeps its authoring helpers in `footnote-util.ts`). The pure
|
||||||
|
* canonicalizer has no dependency on these.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition";
|
||||||
|
|
||||||
|
function cloneJson<T>(v: T): T {
|
||||||
|
if (typeof structuredClone === "function") return structuredClone(v);
|
||||||
|
return JSON.parse(JSON.stringify(v)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized content key for de-duplicating footnote DEFINITIONS by their text.
|
||||||
|
*
|
||||||
|
* Two definitions with the same key are the SAME footnote — so the inline
|
||||||
|
* authoring tool reuses one id (one number, one definition, several references)
|
||||||
|
* instead of minting a second definition. Key = plaintext (whitespace-collapsed,
|
||||||
|
* trimmed) PLUS a signature of the inline mark types in order, so two notes that
|
||||||
|
* read the same but differ in formatting (one bold, one plain) are NOT merged.
|
||||||
|
* Conservative: only an exact match merges.
|
||||||
|
*/
|
||||||
|
export function footnoteContentKey(defNode: any): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const visit = (n: any): void => {
|
||||||
|
if (!n || typeof n !== "object") return;
|
||||||
|
if (n.type === "text" && typeof n.text === "string") {
|
||||||
|
const marks = Array.isArray(n.marks)
|
||||||
|
? n.marks.map((m: any) => m?.type).filter(Boolean).sort().join(",")
|
||||||
|
: "";
|
||||||
|
parts.push(`${n.text}${marks}`);
|
||||||
|
}
|
||||||
|
if (Array.isArray(n.content)) for (const c of n.content) visit(c);
|
||||||
|
};
|
||||||
|
visit(defNode);
|
||||||
|
// Collapse the assembled text's whitespace and trim, keeping the mark
|
||||||
|
// signature attached so formatting differences still distinguish notes.
|
||||||
|
return parts
|
||||||
|
.join("")
|
||||||
|
.replace(/[ \t\r\n]+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a footnoteDefinition node from inline ProseMirror nodes, keyed by id.
|
||||||
|
*/
|
||||||
|
export function makeFootnoteDefinition(id: string, inlineNodes: any[]): any {
|
||||||
|
const content = Array.isArray(inlineNodes) ? cloneJson(inlineNodes) : [];
|
||||||
|
return {
|
||||||
|
type: FOOTNOTE_DEFINITION_NAME,
|
||||||
|
attrs: { id },
|
||||||
|
content: [{ type: "paragraph", content }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a uuidv7-style id (time-ordered), matching editor-ext's
|
||||||
|
* `generateFootnoteId`. Used for a genuinely-new inline footnote id.
|
||||||
|
*/
|
||||||
|
export function generateFootnoteId(): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const timeHex = now.toString(16).padStart(12, "0");
|
||||||
|
const rand = (length: number) => {
|
||||||
|
let s = "";
|
||||||
|
for (let i = 0; i < length; i++)
|
||||||
|
s += Math.floor(Math.random() * 16).toString(16);
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
const versioned = "7" + rand(3);
|
||||||
|
const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16);
|
||||||
|
const variant = variantNibble + rand(3);
|
||||||
|
return (
|
||||||
|
timeHex.slice(0, 8) +
|
||||||
|
"-" +
|
||||||
|
timeHex.slice(8, 12) +
|
||||||
|
"-" +
|
||||||
|
versioned +
|
||||||
|
"-" +
|
||||||
|
variant +
|
||||||
|
"-" +
|
||||||
|
rand(12)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
* `canonicalizeFootnotes(doc)` is a pure ProseMirror-JSON port of the editor's
|
* `canonicalizeFootnotes(doc)` is a pure ProseMirror-JSON port of the editor's
|
||||||
* `footnoteSyncPlugin` end-state, identical in behaviour to
|
* `footnoteSyncPlugin` end-state, identical in behaviour to
|
||||||
* `@docmost/editor-ext`'s `canonicalizeFootnotes`. It is mirrored here — rather
|
* `@docmost/editor-ext`'s `canonicalizeFootnotes`. It is mirrored here — rather
|
||||||
* than imported from editor-ext — for the SAME reason the `docmost-schema.ts`
|
* than imported from editor-ext — for the SAME reason `footnote-lex.ts` and the
|
||||||
* nodes are mirrored: the MCP package is deliberately
|
* `docmost-schema.ts` nodes are mirrored: the MCP package is deliberately
|
||||||
* decoupled from the browser/React-heavy editor barrel and operates on plain
|
* decoupled from the browser/React-heavy editor barrel and operates on plain
|
||||||
* JSON. The editor-ext copy owns the golden test against the live plugin; this
|
* JSON. The editor-ext copy owns the golden test against the live plugin; this
|
||||||
* copy must stay behaviourally identical (a SHARED golden corpus, exercised by
|
* copy must stay behaviourally identical (a SHARED golden corpus, exercised by
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
*
|
*
|
||||||
* This module is the pure MIRROR only. The inline-authoring helpers
|
* This module is the pure MIRROR only. The inline-authoring helpers
|
||||||
* (`footnoteContentKey`, `makeFootnoteDefinition`, `generateFootnoteId`) used by
|
* (`footnoteContentKey`, `makeFootnoteDefinition`, `generateFootnoteId`) used by
|
||||||
* `insertInlineFootnote` live in `@docmost/prosemirror-markdown` (next to the
|
* `insertInlineFootnote` live in the sibling `footnote-authoring.ts`, so this
|
||||||
* importer's `assembleFootnotes`, #414), so this file stays a pure mirror.
|
* file is compositionally symmetric to the editor-ext copy.
|
||||||
*
|
*
|
||||||
* Why it exists: every NON-editor write path (markdown import, update_page_json,
|
* Why it exists: every NON-editor write path (markdown import, update_page_json,
|
||||||
* docmost_transform, insert_footnote) builds ProseMirror JSON directly, so the
|
* docmost_transform, insert_footnote) builds ProseMirror JSON directly, so the
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Shared, fence-aware line lexer for legacy footnote markdown (MCP-internal).
|
||||||
|
*
|
||||||
|
* Since #293 STEP 5 the markdown -> ProseMirror IMPORT path lives in the shared
|
||||||
|
* `@docmost/prosemirror-markdown` package (inline `^[body]` footnotes), so this
|
||||||
|
* lexer no longer backs an mcp importer. It now backs ONLY the import-time
|
||||||
|
* diagnostics (`analyzeFootnotes` in footnote-analyze.ts), which still scan the
|
||||||
|
* raw markdown for legacy reference-style `[^id]:` definition lines and surface
|
||||||
|
* advisory warnings (duplicate/orphan definitions) about content that is now
|
||||||
|
* inert on import. Fence-awareness (a `[^id]:` line inside a ``` / ~~~ block is
|
||||||
|
* NOT a definition) is the property the analyzer relies on.
|
||||||
|
*
|
||||||
|
* NOTE: this is deliberately NOT shared with editor-ext's
|
||||||
|
* `extractFootnoteDefinitions` — that lives in a different package and the
|
||||||
|
* decoupling between the editor and the MCP mirror is intentional.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A footnote DEFINITION line: `[^id]: text` (id + text captured). */
|
||||||
|
export const FOOTNOTE_DEF_RE = /^\[\^([^\]\s]+)\]:[ \t]*(.*)$/;
|
||||||
|
/** Every footnote REFERENCE `[^id]` in a line (global; id captured). */
|
||||||
|
export const FOOTNOTE_REF_RE_G = /\[\^([^\]\s]+)\]/g;
|
||||||
|
/** Opening/closing code fence marker (``` or ~~~). */
|
||||||
|
const FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
|
||||||
|
|
||||||
|
export interface FootnoteLine {
|
||||||
|
/** The raw line, verbatim. */
|
||||||
|
line: string;
|
||||||
|
/**
|
||||||
|
* True for a code-fence marker line AND every line inside a fence — footnote
|
||||||
|
* syntax on such lines is inert (example text, not real markup). The importer
|
||||||
|
* keeps these in the body; the analyzer skips them.
|
||||||
|
*/
|
||||||
|
inFence: boolean;
|
||||||
|
/** The parsed definition, when this is a `[^id]: text` line OUTSIDE any fence. */
|
||||||
|
definition: { id: string; text: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Classify every line of `markdown`, tracking fenced-code state. Pure. */
|
||||||
|
export function lexFootnoteLines(markdown: string): FootnoteLine[] {
|
||||||
|
const out: FootnoteLine[] = [];
|
||||||
|
let fence: string | null = null;
|
||||||
|
for (const line of markdown.split("\n")) {
|
||||||
|
const fenceMatch = FENCE_RE.exec(line);
|
||||||
|
if (fenceMatch) {
|
||||||
|
const marker = fenceMatch[2][0];
|
||||||
|
if (fence === null) fence = marker; // opening fence
|
||||||
|
else if (marker === fence) fence = null; // matching closing fence
|
||||||
|
out.push({ line, inFence: true, definition: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fence !== null) {
|
||||||
|
out.push({ line, inFence: true, definition: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const m = FOOTNOTE_DEF_RE.exec(line);
|
||||||
|
out.push({
|
||||||
|
line,
|
||||||
|
inFence: false,
|
||||||
|
definition: m ? { id: m[1], text: m[2] } : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scan a line for every `[^id]` reference, invoking `onRef(id)` for each. */
|
||||||
|
export function forEachFootnoteReference(
|
||||||
|
line: string,
|
||||||
|
onRef: (id: string) => void,
|
||||||
|
): void {
|
||||||
|
FOOTNOTE_REF_RE_G.lastIndex = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = FOOTNOTE_REF_RE_G.exec(line)) !== null) onRef(m[1]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,963 @@
|
|||||||
|
/**
|
||||||
|
* Pure, network-free helpers for manipulating a ProseMirror/TipTap document
|
||||||
|
* tree by node id.
|
||||||
|
*
|
||||||
|
* A ProseMirror node here is a plain JSON object of the shape produced by
|
||||||
|
* Docmost: `{ type, attrs?, content?, text?, marks? }`. Children live in the
|
||||||
|
* `content` array; a node carries a stable id in `attrs.id`. Callouts and
|
||||||
|
* table cells hold their children in `content` just like any other block, so a
|
||||||
|
* single recursive walk reaches them all.
|
||||||
|
*
|
||||||
|
* Every exported function operates on a DEEP CLONE of the input document and
|
||||||
|
* returns the new document. The input doc and any `newNode`/`node` argument are
|
||||||
|
* never mutated. All functions are defensively null-safe: missing/!Array
|
||||||
|
* `content`, non-object nodes, and absent `attrs` are tolerated.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { stripInlineMarkdown } from "./text-normalize.js";
|
||||||
|
|
||||||
|
/** Deep-clone a JSON-serializable value without mutating the original. */
|
||||||
|
function clone<T>(value: T): T {
|
||||||
|
if (typeof structuredClone === "function") {
|
||||||
|
return structuredClone(value);
|
||||||
|
}
|
||||||
|
// Fallback for environments without structuredClone.
|
||||||
|
return JSON.parse(JSON.stringify(value)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if `value` is a non-null object (and not an array). */
|
||||||
|
function isObject(value: any): value is Record<string, any> {
|
||||||
|
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if `node` carries the given id in `node.attrs.id`. */
|
||||||
|
function matchesId(node: any, nodeId: string): boolean {
|
||||||
|
return isObject(node) && isObject(node.attrs) && node.attrs.id === nodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively concatenate all text contained in a node.
|
||||||
|
*
|
||||||
|
* Text nodes contribute their `text` string; container nodes contribute the
|
||||||
|
* joined `blockPlainText` of their `content` children. Returns "" for nullish
|
||||||
|
* or non-object inputs.
|
||||||
|
*/
|
||||||
|
export function blockPlainText(node: any): string {
|
||||||
|
if (!isObject(node)) return "";
|
||||||
|
let out = "";
|
||||||
|
if (typeof node.text === "string") {
|
||||||
|
out += node.text;
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
for (const child of node.content) {
|
||||||
|
out += blockPlainText(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Truncate `text` to at most `n` chars, appending an ellipsis when cut. */
|
||||||
|
function truncate(text: string, n: number): string {
|
||||||
|
return text.length > n ? text.slice(0, n) + "…" : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One compact outline entry for a single top-level block. */
|
||||||
|
export interface OutlineEntry {
|
||||||
|
index: number;
|
||||||
|
type: string | undefined;
|
||||||
|
id: string | null;
|
||||||
|
firstText: string;
|
||||||
|
/** Present for headings only. */
|
||||||
|
level?: number | null;
|
||||||
|
/** Present for tables only. */
|
||||||
|
rows?: number;
|
||||||
|
cols?: number;
|
||||||
|
header?: string[];
|
||||||
|
/** Present for list blocks only (bulletList/orderedList/taskList). */
|
||||||
|
items?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a COMPACT outline of the TOP-LEVEL blocks of `doc` (the entries in
|
||||||
|
* `doc.content`). Deliberately does NOT recurse into paragraphs, list items, or
|
||||||
|
* table cells — compactness is the point; use `getNodeByRef` to drill into a
|
||||||
|
* specific block.
|
||||||
|
*
|
||||||
|
* Each entry carries `{ index, type, id, firstText }`, plus type-specific
|
||||||
|
* extras: headings add `level`; tables add `rows`/`cols` and the first row's
|
||||||
|
* cell texts as `header`; list blocks (types ending in "List") add `items`.
|
||||||
|
* `firstText` is the block's plain text truncated to 100 chars. Null-safe:
|
||||||
|
* a missing or non-object doc/content yields `[]`.
|
||||||
|
*/
|
||||||
|
export function buildOutline(doc: any): OutlineEntry[] {
|
||||||
|
if (!isObject(doc) || !Array.isArray(doc.content)) return [];
|
||||||
|
|
||||||
|
const out: OutlineEntry[] = [];
|
||||||
|
for (let i = 0; i < doc.content.length; i++) {
|
||||||
|
const block = doc.content[i];
|
||||||
|
const type = isObject(block) ? block.type : undefined;
|
||||||
|
const entry: OutlineEntry = {
|
||||||
|
index: i,
|
||||||
|
type,
|
||||||
|
id:
|
||||||
|
isObject(block) && isObject(block.attrs)
|
||||||
|
? (block.attrs.id ?? null)
|
||||||
|
: null,
|
||||||
|
firstText: truncate(blockPlainText(block), 100),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === "heading") {
|
||||||
|
entry.level = isObject(block.attrs) ? (block.attrs.level ?? null) : null;
|
||||||
|
} else if (type === "table") {
|
||||||
|
const headerRow = block.content?.[0]?.content ?? [];
|
||||||
|
entry.rows = block.content?.length ?? 0;
|
||||||
|
entry.cols = block.content?.[0]?.content?.length ?? 0;
|
||||||
|
entry.header = headerRow.map((cell: any) =>
|
||||||
|
truncate(blockPlainText(cell), 40),
|
||||||
|
);
|
||||||
|
} else if (typeof type === "string" && type.endsWith("List")) {
|
||||||
|
entry.items = block.content?.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push(entry);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a single node by reference and return `{ node, path, type }`, or
|
||||||
|
* `null` when nothing matches.
|
||||||
|
*
|
||||||
|
* - `ref` of the form `#<n>` (e.g. `#2`) selects the TOP-LEVEL block at index
|
||||||
|
* `n` in `doc.content`. This is the only way to address table/tableRow/
|
||||||
|
* tableCell nodes, which carry no `attrs.id`.
|
||||||
|
* - Otherwise `ref` is treated as a block id: the FIRST node anywhere in the
|
||||||
|
* tree with `attrs.id === ref` is returned.
|
||||||
|
*
|
||||||
|
* `path` is the array of child indices from the doc root down to the node
|
||||||
|
* (so a top-level block is `[index]`). The returned `node` is a DEEP CLONE,
|
||||||
|
* so callers can mutate it without touching the input doc. Null-safe.
|
||||||
|
*/
|
||||||
|
export function getNodeByRef(
|
||||||
|
doc: any,
|
||||||
|
ref: string,
|
||||||
|
): { node: any; path: number[]; type: string | undefined } | null {
|
||||||
|
if (!isObject(doc)) return null;
|
||||||
|
|
||||||
|
// "#<n>": index into the top-level content array.
|
||||||
|
const indexMatch = typeof ref === "string" ? ref.match(/^#(\d+)$/) : null;
|
||||||
|
if (indexMatch) {
|
||||||
|
const index = Number(indexMatch[1]);
|
||||||
|
const block = Array.isArray(doc.content) ? doc.content[index] : undefined;
|
||||||
|
if (!isObject(block)) return null;
|
||||||
|
return { node: clone(block), path: [index], type: block.type };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise: depth-first search for the first node with attrs.id === ref.
|
||||||
|
const search = (
|
||||||
|
node: any,
|
||||||
|
trail: number[],
|
||||||
|
): { node: any; path: number[]; type: string } | null => {
|
||||||
|
if (!isObject(node)) return null;
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
for (let i = 0; i < node.content.length; i++) {
|
||||||
|
const child = node.content[i];
|
||||||
|
const path = [...trail, i];
|
||||||
|
if (matchesId(child, ref)) {
|
||||||
|
return { node: clone(child), path, type: child.type };
|
||||||
|
}
|
||||||
|
const hit = search(child, path);
|
||||||
|
if (hit != null) return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return search(doc, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace EVERY node whose `attrs.id === nodeId` with a deep clone of
|
||||||
|
* `newNode`, anywhere in the tree (including inside callouts and table cells).
|
||||||
|
*
|
||||||
|
* Operates on a clone of `doc`; returns `{ doc, replaced }` where `replaced`
|
||||||
|
* is the number of nodes substituted. A fresh clone of `newNode` is used for
|
||||||
|
* each match so they do not share references.
|
||||||
|
*/
|
||||||
|
export function replaceNodeById(
|
||||||
|
doc: any,
|
||||||
|
nodeId: string,
|
||||||
|
newNode: any,
|
||||||
|
): { doc: any; replaced: number } {
|
||||||
|
const out = clone(doc);
|
||||||
|
let replaced = 0;
|
||||||
|
|
||||||
|
// Walk a content array, replacing direct matches and recursing into the
|
||||||
|
// (possibly new) children of non-matching nodes.
|
||||||
|
const walkContent = (content: any[]): void => {
|
||||||
|
for (let i = 0; i < content.length; i++) {
|
||||||
|
const child = content[i];
|
||||||
|
if (matchesId(child, nodeId)) {
|
||||||
|
content[i] = clone(newNode);
|
||||||
|
replaced++;
|
||||||
|
// Do not recurse into a freshly substituted node.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isObject(child) && Array.isArray(child.content)) {
|
||||||
|
walkContent(child.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isObject(out) && Array.isArray(out.content)) {
|
||||||
|
walkContent(out.content);
|
||||||
|
}
|
||||||
|
return { doc: out, replaced };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove EVERY node whose `attrs.id === nodeId` from its parent `content`
|
||||||
|
* array, anywhere in the tree (recursive, including callouts and tables).
|
||||||
|
*
|
||||||
|
* Operates on a clone of `doc`; returns `{ doc, deleted }` where `deleted` is
|
||||||
|
* the number of nodes removed.
|
||||||
|
*/
|
||||||
|
export function deleteNodeById(
|
||||||
|
doc: any,
|
||||||
|
nodeId: string,
|
||||||
|
): { doc: any; deleted: number } {
|
||||||
|
const out = clone(doc);
|
||||||
|
let deleted = 0;
|
||||||
|
|
||||||
|
// Filter a content array in place, dropping matches and recursing into the
|
||||||
|
// surviving children.
|
||||||
|
const walkContent = (content: any[]): any[] => {
|
||||||
|
const kept: any[] = [];
|
||||||
|
for (const child of content) {
|
||||||
|
if (matchesId(child, nodeId)) {
|
||||||
|
deleted++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isObject(child) && Array.isArray(child.content)) {
|
||||||
|
child.content = walkContent(child.content);
|
||||||
|
}
|
||||||
|
kept.push(child);
|
||||||
|
}
|
||||||
|
return kept;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isObject(out) && Array.isArray(out.content)) {
|
||||||
|
out.content = walkContent(out.content);
|
||||||
|
}
|
||||||
|
return { doc: out, deleted };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throw a clear, model-actionable error when a node-id write op did NOT match
|
||||||
|
* exactly one node (#159). `count === 0` -> "no node found"; `count > 1` ->
|
||||||
|
* "ambiguous, refused" — Docmost duplicates block ids on copy/paste, so a write
|
||||||
|
* by id could clobber/remove EVERY duplicate. The caller skips the write for any
|
||||||
|
* `count !== 1` (the transform returns null), so this only REPORTS; nothing was
|
||||||
|
* changed. No-op for the unambiguous single-match case.
|
||||||
|
*/
|
||||||
|
export function assertUnambiguousMatch(
|
||||||
|
op: "patch_node" | "delete_node",
|
||||||
|
verb: "replace" | "delete",
|
||||||
|
count: number,
|
||||||
|
nodeId: string,
|
||||||
|
pageId: string,
|
||||||
|
): void {
|
||||||
|
if (count === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`${op}: no node with id "${nodeId}" found on page ${pageId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (count > 1) {
|
||||||
|
throw new Error(
|
||||||
|
`${op}: id "${nodeId}" is ambiguous — ${count} nodes on page ${pageId} share it (block ids are duplicated on copy/paste). Refusing to ${verb} all of them; nothing was changed. Re-target with a more specific anchor.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep-clone `doc` and strip every node/mark attribute whose value is strictly
|
||||||
|
* `undefined`, so the result is safe to hand to Yjs (which throws an opaque
|
||||||
|
* "Unexpected content type" when asked to store an `undefined` attribute value).
|
||||||
|
*
|
||||||
|
* Only `undefined` keys are removed; `null`, `false`, `0`, and `""` are all
|
||||||
|
* legitimate JSON-storable values and are preserved. Operates on a clone and
|
||||||
|
* returns it; the input is never mutated. Defensively null-safe like the rest
|
||||||
|
* of the file.
|
||||||
|
*/
|
||||||
|
export function sanitizeForYjs(doc: any): any {
|
||||||
|
const out = clone(doc);
|
||||||
|
|
||||||
|
// Drop every key whose value is strictly `undefined` from an attrs object.
|
||||||
|
const stripUndefined = (attrs: any): void => {
|
||||||
|
if (!isObject(attrs)) return;
|
||||||
|
for (const key of Object.keys(attrs)) {
|
||||||
|
if (attrs[key] === undefined) {
|
||||||
|
delete attrs[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const walk = (node: any): void => {
|
||||||
|
if (!isObject(node)) return;
|
||||||
|
stripUndefined(node.attrs);
|
||||||
|
if (Array.isArray(node.marks)) {
|
||||||
|
for (const mark of node.marks) {
|
||||||
|
if (isObject(mark)) stripUndefined(mark.attrs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
for (const child of node.content) {
|
||||||
|
walk(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diagnostics helper: walk the tree and return a human-readable path string for
|
||||||
|
* the FIRST attribute value (in any `node.attrs` or `mark.attrs`) that Yjs
|
||||||
|
* cannot store — i.e. `undefined`, a `function`, a `symbol`, or a `bigint`
|
||||||
|
* (e.g. `content[3].content[0].attrs.indent (undefined)`). Returns `null` when
|
||||||
|
* every attribute is storable. Null-safe.
|
||||||
|
*/
|
||||||
|
export function findUnstorableAttr(doc: any): string | null {
|
||||||
|
const isUnstorable = (value: any): string | null => {
|
||||||
|
if (value === undefined) return "undefined";
|
||||||
|
const t = typeof value;
|
||||||
|
if (t === "function") return "function";
|
||||||
|
if (t === "symbol") return "symbol";
|
||||||
|
if (t === "bigint") return "bigint";
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check an attrs object; return the offending sub-path or null.
|
||||||
|
const checkAttrs = (attrs: any, basePath: string): string | null => {
|
||||||
|
if (!isObject(attrs)) return null;
|
||||||
|
for (const key of Object.keys(attrs)) {
|
||||||
|
const kind = isUnstorable(attrs[key]);
|
||||||
|
if (kind != null) return `${basePath}.${key} (${kind})`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const walk = (node: any, path: string): string | null => {
|
||||||
|
if (!isObject(node)) return null;
|
||||||
|
const attrHit = checkAttrs(node.attrs, `${path}.attrs`);
|
||||||
|
if (attrHit != null) return attrHit;
|
||||||
|
if (Array.isArray(node.marks)) {
|
||||||
|
for (let i = 0; i < node.marks.length; i++) {
|
||||||
|
const markHit = checkAttrs(
|
||||||
|
node.marks[i]?.attrs,
|
||||||
|
`${path}.marks[${i}].attrs`,
|
||||||
|
);
|
||||||
|
if (markHit != null) return markHit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
for (let i = 0; i < node.content.length; i++) {
|
||||||
|
const childHit = walk(node.content[i], `${path}.content[${i}]`);
|
||||||
|
if (childHit != null) return childHit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The root doc node carries no useful index, so start the path at "doc".
|
||||||
|
if (!isObject(doc)) return null;
|
||||||
|
const attrHit = checkAttrs(doc.attrs, "attrs");
|
||||||
|
if (attrHit != null) return attrHit;
|
||||||
|
if (Array.isArray(doc.content)) {
|
||||||
|
for (let i = 0; i < doc.content.length; i++) {
|
||||||
|
const childHit = walk(doc.content[i], `content[${i}]`);
|
||||||
|
if (childHit != null) return childHit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table structural node types and the container each must live directly inside.
|
||||||
|
* Used by `insertNodeRelative` to splice rows/cells into the correct ancestor
|
||||||
|
* rather than blindly into the anchor's direct parent (which would corrupt the
|
||||||
|
* table's nesting).
|
||||||
|
*/
|
||||||
|
const STRUCTURAL_TYPES = new Set(["tableRow", "tableCell", "tableHeader"]);
|
||||||
|
const REQUIRED_CONTAINER: Record<string, string> = {
|
||||||
|
tableRow: "table",
|
||||||
|
tableCell: "tableRow",
|
||||||
|
tableHeader: "tableRow",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the index of the first TOP-LEVEL block whose plain text includes the
|
||||||
|
* anchor, with a markdown-stripping FALLBACK. Returns -1 when none matches.
|
||||||
|
*
|
||||||
|
* Two passes preserve "exact wins globally":
|
||||||
|
* - Pass 1: first block containing the verbatim `anchorText`.
|
||||||
|
* - Pass 2 (only if pass 1 found nothing): first block containing the
|
||||||
|
* markdown-stripped anchor, when stripping actually changed it.
|
||||||
|
*/
|
||||||
|
function findAnchorTextIndex(content: any[], anchorText: string): number {
|
||||||
|
if (!Array.isArray(content)) return -1;
|
||||||
|
// Pass 1: exact.
|
||||||
|
for (let i = 0; i < content.length; i++) {
|
||||||
|
if (blockPlainText(content[i]).includes(anchorText)) return i;
|
||||||
|
}
|
||||||
|
// Pass 2: markdown-stripped fallback.
|
||||||
|
const a = stripInlineMarkdown(anchorText);
|
||||||
|
if (a !== anchorText && a.length > 0) {
|
||||||
|
for (let i = 0; i < content.length; i++) {
|
||||||
|
if (blockPlainText(content[i]).includes(a)) return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locate an anchor and return its ancestor chain (from `doc` down to and
|
||||||
|
* including the matched node). Each chain entry is `{ node, index }` where
|
||||||
|
* `index` is the node's position inside its parent's `content` array (the root
|
||||||
|
* doc has index -1). Returns `null` when the anchor cannot be resolved.
|
||||||
|
*/
|
||||||
|
function findAnchorChain(
|
||||||
|
doc: any,
|
||||||
|
opts: InsertOptions,
|
||||||
|
): { node: any; index: number }[] | null {
|
||||||
|
if (!isObject(doc)) return null;
|
||||||
|
|
||||||
|
// DFS by id anywhere in the tree, accumulating the path.
|
||||||
|
if (opts.anchorNodeId != null) {
|
||||||
|
const targetId = opts.anchorNodeId;
|
||||||
|
const search = (
|
||||||
|
node: any,
|
||||||
|
index: number,
|
||||||
|
trail: { node: any; index: number }[],
|
||||||
|
): { node: any; index: number }[] | null => {
|
||||||
|
if (!isObject(node)) return null;
|
||||||
|
const here = [...trail, { node, index }];
|
||||||
|
if (matchesId(node, targetId)) return here;
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
for (let i = 0; i < node.content.length; i++) {
|
||||||
|
const hit = search(node.content[i], i, here);
|
||||||
|
if (hit != null) return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return search(doc, -1, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// By text: only top-level blocks are scanned (same rule as the JSON path).
|
||||||
|
// Exact match wins; a markdown-stripped fallback is tried only on a miss.
|
||||||
|
if (opts.anchorText != null && Array.isArray(doc.content)) {
|
||||||
|
const i = findAnchorTextIndex(doc.content, opts.anchorText);
|
||||||
|
if (i !== -1) {
|
||||||
|
return [
|
||||||
|
{ node: doc, index: -1 },
|
||||||
|
{ node: doc.content[i], index: i },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options controlling where `insertNodeRelative` places the new node. */
|
||||||
|
export interface InsertOptions {
|
||||||
|
position: "before" | "after" | "append";
|
||||||
|
/** Resolve the anchor by node id anywhere in the tree (preferred). */
|
||||||
|
anchorNodeId?: string;
|
||||||
|
/** Fallback: first TOP-LEVEL block whose plain text includes this string. */
|
||||||
|
anchorText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a deep clone of `node` relative to an anchor.
|
||||||
|
*
|
||||||
|
* - position "append": push the node onto the top-level `doc.content`.
|
||||||
|
* - position "before"/"after": locate the anchor and splice the node into the
|
||||||
|
* anchor's parent `content` array immediately before / after it.
|
||||||
|
*
|
||||||
|
* Anchor resolution for before/after:
|
||||||
|
* - if `anchorNodeId` is given, find the node with `attrs.id === anchorNodeId`
|
||||||
|
* anywhere in the tree (recursive);
|
||||||
|
* - otherwise, if `anchorText` is given, scan only TOP-LEVEL `doc.content`
|
||||||
|
* blocks and pick the first whose `blockPlainText` includes `anchorText`.
|
||||||
|
*
|
||||||
|
* Operates on a clone of `doc`; returns `{ doc, inserted }`. `inserted` is
|
||||||
|
* false when the anchor could not be resolved (the doc is returned unchanged
|
||||||
|
* apart from being cloned).
|
||||||
|
*/
|
||||||
|
export function insertNodeRelative(
|
||||||
|
doc: any,
|
||||||
|
node: any,
|
||||||
|
opts: InsertOptions,
|
||||||
|
): { doc: any; inserted: boolean } {
|
||||||
|
const out = clone(doc);
|
||||||
|
const fresh = clone(node);
|
||||||
|
|
||||||
|
// Defensive: stay null-safe like the other exports — a missing opts means
|
||||||
|
// there is nothing actionable to do.
|
||||||
|
if (!isObject(opts)) return { doc: out, inserted: false };
|
||||||
|
|
||||||
|
const isStructural = isObject(node) && STRUCTURAL_TYPES.has(node.type);
|
||||||
|
|
||||||
|
// "append": top-level push.
|
||||||
|
if (opts.position === "append") {
|
||||||
|
// Structural table nodes (tableRow/tableCell/tableHeader) cannot live at the
|
||||||
|
// top level — appending one would produce invalid nesting.
|
||||||
|
if (isStructural) {
|
||||||
|
throw new Error(
|
||||||
|
`insert_node: cannot append a ${node.type} at the top level; use ` +
|
||||||
|
`position before/after with an anchor inside the target table`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isObject(out)) {
|
||||||
|
if (!Array.isArray(out.content)) out.content = [];
|
||||||
|
out.content.push(fresh);
|
||||||
|
return { doc: out, inserted: true };
|
||||||
|
}
|
||||||
|
return { doc: out, inserted: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = opts.position === "after" ? 1 : 0;
|
||||||
|
|
||||||
|
// Structural insert (before/after a tableRow/tableCell/tableHeader): splice
|
||||||
|
// into the nearest enclosing table/tableRow rather than the anchor's direct
|
||||||
|
// parent, so the row/cell lands at the correct level of the table.
|
||||||
|
if (isStructural) {
|
||||||
|
const containerType = REQUIRED_CONTAINER[node.type];
|
||||||
|
const chain = findAnchorChain(out, opts);
|
||||||
|
// Anchor not resolved at all — keep the existing "anchor not found" path.
|
||||||
|
if (chain == null) return { doc: out, inserted: false };
|
||||||
|
|
||||||
|
// Find the DEEPEST ancestor (including the anchor itself) of the required
|
||||||
|
// container type.
|
||||||
|
let containerIdx = -1;
|
||||||
|
for (let i = chain.length - 1; i >= 0; i--) {
|
||||||
|
if (isObject(chain[i].node) && chain[i].node.type === containerType) {
|
||||||
|
containerIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containerIdx === -1) {
|
||||||
|
throw new Error(
|
||||||
|
`insert_node: cannot insert a ${node.type} here — the anchor is not ` +
|
||||||
|
`inside a ${containerType}. Anchor on a cell's text or a block id ` +
|
||||||
|
`that lives inside the target table.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = chain[containerIdx].node;
|
||||||
|
if (!Array.isArray(container.content)) container.content = [];
|
||||||
|
|
||||||
|
if (containerIdx === chain.length - 1) {
|
||||||
|
// The matched container IS the anchor node itself (e.g. anchorText
|
||||||
|
// resolved to the table block): append/prepend within it.
|
||||||
|
const at = opts.position === "after" ? container.content.length : 0;
|
||||||
|
container.content.splice(at, 0, fresh);
|
||||||
|
} else {
|
||||||
|
// The immediate child on the path leading to the anchor is the row/cell
|
||||||
|
// to splice next to.
|
||||||
|
const enclosingChildIndex = chain[containerIdx + 1].index;
|
||||||
|
container.content.splice(enclosingChildIndex + offset, 0, fresh);
|
||||||
|
}
|
||||||
|
return { doc: out, inserted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve by id anywhere in the tree: splice into the parent content array.
|
||||||
|
if (opts.anchorNodeId != null) {
|
||||||
|
let inserted = false;
|
||||||
|
const walkContent = (content: any[]): void => {
|
||||||
|
for (let i = 0; i < content.length; i++) {
|
||||||
|
const child = content[i];
|
||||||
|
if (matchesId(child, opts.anchorNodeId as string)) {
|
||||||
|
content.splice(i + offset, 0, fresh);
|
||||||
|
inserted = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isObject(child) && Array.isArray(child.content)) {
|
||||||
|
walkContent(child.content);
|
||||||
|
if (inserted) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (isObject(out) && Array.isArray(out.content)) {
|
||||||
|
walkContent(out.content);
|
||||||
|
}
|
||||||
|
return { doc: out, inserted };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve by text: only top-level doc.content blocks are scanned. Exact
|
||||||
|
// match wins; a markdown-stripped fallback is tried only on a miss.
|
||||||
|
if (opts.anchorText != null && isObject(out) && Array.isArray(out.content)) {
|
||||||
|
const i = findAnchorTextIndex(out.content, opts.anchorText);
|
||||||
|
if (i !== -1) {
|
||||||
|
out.content.splice(i + offset, 0, fresh);
|
||||||
|
return { doc: out, inserted: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { doc: out, inserted: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Table editing helpers
|
||||||
|
//
|
||||||
|
// A Docmost table is a ProseMirror subtree with NO ids on the structural nodes:
|
||||||
|
// table -> { type:"table", content:[tableRow...] }
|
||||||
|
// row -> { type:"tableRow", content:[tableCell|tableHeader...] }
|
||||||
|
// cell -> { type:"tableCell"|"tableHeader", attrs:{colspan,rowspan,colwidth},
|
||||||
|
// content:[paragraph...] }
|
||||||
|
// para -> { type:"paragraph", attrs:{id,indent}, content:[textNode...] }
|
||||||
|
// Only paragraphs/headings carry an `attrs.id`, so a cell is addressed via the
|
||||||
|
// id of the paragraph inside it. The helpers below all operate on a DEEP CLONE
|
||||||
|
// of the input doc (via `clone`) and never mutate their inputs.
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect EVERY `attrs.id` present anywhere in `node` into `used`. Used to seed
|
||||||
|
* `makeFreshId` so generated paragraph ids never collide with existing ones.
|
||||||
|
*/
|
||||||
|
function collectIds(node: any, used: Set<string>): void {
|
||||||
|
if (!isObject(node)) return;
|
||||||
|
if (isObject(node.attrs) && typeof node.attrs.id === "string") {
|
||||||
|
used.add(node.attrs.id);
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
for (const child of node.content) collectIds(child, used);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fresh-id generator: returns a random Docmost-style id (12 chars from
|
||||||
|
* lowercase `a-z0-9`) that is not already in `used`, and records it. On the
|
||||||
|
* rare collision the id is regenerated. Callers rely on uniqueness, not on the
|
||||||
|
* exact string, so randomness is fine — and unlike a module-local counter it
|
||||||
|
* needs no reset and cannot become predictable across calls.
|
||||||
|
*/
|
||||||
|
function makeFreshId(used: Set<string>): string {
|
||||||
|
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
let id: string;
|
||||||
|
do {
|
||||||
|
id = "";
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
id += alphabet[Math.floor(Math.random() * alphabet.length)];
|
||||||
|
}
|
||||||
|
} while (used.has(id) || id === "");
|
||||||
|
used.add(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a table reference against an ALREADY-CLONED doc and return the LIVE
|
||||||
|
* table node (a reference inside `rootClone`, so the caller may mutate it) plus
|
||||||
|
* its index path. Returns null when no table matches.
|
||||||
|
*
|
||||||
|
* - `#<n>`: the top-level block at index `n`, only if its `type === "table"`.
|
||||||
|
* - otherwise: DFS for the node with `attrs.id === tableRef`, then walk UP its
|
||||||
|
* ancestor chain to the nearest `type === "table"` ancestor.
|
||||||
|
*/
|
||||||
|
function locateTable(
|
||||||
|
rootClone: any,
|
||||||
|
tableRef: string,
|
||||||
|
): { table: any; path: number[] } | null {
|
||||||
|
if (!isObject(rootClone)) return null;
|
||||||
|
|
||||||
|
// "#<n>": index into the top-level content array; must be a table.
|
||||||
|
const indexMatch =
|
||||||
|
typeof tableRef === "string" ? tableRef.match(/^#(\d+)$/) : null;
|
||||||
|
if (indexMatch) {
|
||||||
|
const index = Number(indexMatch[1]);
|
||||||
|
const block = Array.isArray(rootClone.content)
|
||||||
|
? rootClone.content[index]
|
||||||
|
: undefined;
|
||||||
|
if (isObject(block) && block.type === "table") {
|
||||||
|
return { table: block, path: [index] };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise: DFS for attrs.id === tableRef, tracking the ancestor chain, then
|
||||||
|
// climb to the nearest enclosing table.
|
||||||
|
const search = (
|
||||||
|
node: any,
|
||||||
|
trail: { node: any; index: number }[],
|
||||||
|
): { table: any; path: number[] } | null => {
|
||||||
|
if (!isObject(node)) return null;
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
for (let i = 0; i < node.content.length; i++) {
|
||||||
|
const child = node.content[i];
|
||||||
|
const here = [...trail, { node: child, index: i }];
|
||||||
|
if (matchesId(child, tableRef)) {
|
||||||
|
// Walk UP to the nearest table ancestor (including the match itself).
|
||||||
|
for (let j = here.length - 1; j >= 0; j--) {
|
||||||
|
if (isObject(here[j].node) && here[j].node.type === "table") {
|
||||||
|
return {
|
||||||
|
table: here[j].node,
|
||||||
|
path: here.slice(0, j + 1).map((e) => e.index),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null; // id found but no enclosing table
|
||||||
|
}
|
||||||
|
const hit = search(child, here);
|
||||||
|
if (hit != null) return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return search(rootClone, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the plain-text → single-paragraph cell content used by all writers. */
|
||||||
|
function makeCellParagraph(id: string, text: string): any {
|
||||||
|
return {
|
||||||
|
type: "paragraph",
|
||||||
|
attrs: { id, indent: 0 },
|
||||||
|
// Empty string → a paragraph with an empty content array.
|
||||||
|
content: text ? [{ type: "text", text }] : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a table as a matrix. Returns null when `tableRef` resolves to no table.
|
||||||
|
*
|
||||||
|
* - `rows`/`cols`: the table's row count and the column count of its FIRST row.
|
||||||
|
* Tables may be ragged (rows of differing length), so `cols` reflects only
|
||||||
|
* row 0; use the per-row length of `cells`/`cellIds` for each row's actual
|
||||||
|
* width.
|
||||||
|
* - `cells`: `string[][]` of each cell's `blockPlainText`.
|
||||||
|
* - `cellIds`: `(string|null)[][]` of each cell's FIRST paragraph id (or null),
|
||||||
|
* so callers can `patch_node` a cell for rich-formatted edits.
|
||||||
|
* - `path`: index path of the table within the doc.
|
||||||
|
*/
|
||||||
|
export function readTable(
|
||||||
|
doc: any,
|
||||||
|
tableRef: string,
|
||||||
|
): {
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
cells: string[][];
|
||||||
|
cellIds: (string | null)[][];
|
||||||
|
path: number[];
|
||||||
|
} | null {
|
||||||
|
const root = clone(doc);
|
||||||
|
const located = locateTable(root, tableRef);
|
||||||
|
if (located == null) return null;
|
||||||
|
const { table, path } = located;
|
||||||
|
|
||||||
|
const rowNodes = Array.isArray(table.content) ? table.content : [];
|
||||||
|
const rows = rowNodes.length;
|
||||||
|
const cols = rowNodes[0]?.content?.length ?? 0;
|
||||||
|
|
||||||
|
const cells: string[][] = [];
|
||||||
|
const cellIds: (string | null)[][] = [];
|
||||||
|
for (const rowNode of rowNodes) {
|
||||||
|
const cellNodes = Array.isArray(rowNode?.content) ? rowNode.content : [];
|
||||||
|
const rowText: string[] = [];
|
||||||
|
const rowIds: (string | null)[] = [];
|
||||||
|
for (const cellNode of cellNodes) {
|
||||||
|
rowText.push(blockPlainText(cellNode));
|
||||||
|
// The cell's first paragraph carries the id used for patch_node.
|
||||||
|
const firstPara = Array.isArray(cellNode?.content)
|
||||||
|
? cellNode.content[0]
|
||||||
|
: undefined;
|
||||||
|
const id =
|
||||||
|
isObject(firstPara) && isObject(firstPara.attrs)
|
||||||
|
? (firstPara.attrs.id ?? null)
|
||||||
|
: null;
|
||||||
|
rowIds.push(id);
|
||||||
|
}
|
||||||
|
cells.push(rowText);
|
||||||
|
cellIds.push(rowIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rows, cols, cells, cellIds, path };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a row of plain-text cells into a table. Returns `{ doc, inserted }`.
|
||||||
|
*
|
||||||
|
* The row is padded to the table's column count (`cells[i] ?? ""`); supplying
|
||||||
|
* MORE cells than columns throws. Each new cell copies `colwidth` for its
|
||||||
|
* column from the header row when present, gets a fresh-id paragraph, and a
|
||||||
|
* `colspan:1, rowspan:1` attrs. `index` (when an integer in `[0, rows]`) splices
|
||||||
|
* the row there; otherwise the row is appended at the end.
|
||||||
|
*/
|
||||||
|
export function insertTableRow(
|
||||||
|
doc: any,
|
||||||
|
tableRef: string,
|
||||||
|
cells: string[],
|
||||||
|
index?: number,
|
||||||
|
): { doc: any; inserted: boolean } {
|
||||||
|
const out = clone(doc);
|
||||||
|
const located = locateTable(out, tableRef);
|
||||||
|
if (located == null) return { doc: out, inserted: false };
|
||||||
|
const { table } = located;
|
||||||
|
|
||||||
|
if (!Array.isArray(table.content)) table.content = [];
|
||||||
|
const rows = table.content.length;
|
||||||
|
const headerRow = table.content[0];
|
||||||
|
const headerCells = Array.isArray(headerRow?.content)
|
||||||
|
? headerRow.content
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Column count is the WIDEST existing row, so the guard below stays
|
||||||
|
// meaningful for ragged tables and the new row matches the table's width.
|
||||||
|
// Fall back to the supplied cell count only when the table has no rows.
|
||||||
|
let colCount = 0;
|
||||||
|
for (const r of table.content) {
|
||||||
|
if (isObject(r) && Array.isArray(r.content))
|
||||||
|
colCount = Math.max(colCount, r.content.length);
|
||||||
|
}
|
||||||
|
if (colCount === 0) colCount = Array.isArray(cells) ? cells.length : 0;
|
||||||
|
|
||||||
|
if (Array.isArray(cells) && cells.length > colCount) {
|
||||||
|
throw new Error(
|
||||||
|
`table_insert_row: got ${cells.length} cell(s) but the table has ${colCount} column(s)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the landing index up front so the cell-type decision and the splice
|
||||||
|
// below agree: a valid integer in [0, rows] splices there, else we append.
|
||||||
|
const landingIndex =
|
||||||
|
typeof index === "number" &&
|
||||||
|
Number.isInteger(index) &&
|
||||||
|
index >= 0 &&
|
||||||
|
index <= rows
|
||||||
|
? index
|
||||||
|
: rows;
|
||||||
|
|
||||||
|
// Seed the id generator with every id already in the doc so the new cell
|
||||||
|
// paragraph ids are unique within the whole document.
|
||||||
|
const used = new Set<string>();
|
||||||
|
collectIds(out, used);
|
||||||
|
|
||||||
|
const newCells: any[] = [];
|
||||||
|
for (let i = 0; i < colCount; i++) {
|
||||||
|
const text = (Array.isArray(cells) ? cells[i] : undefined) ?? "";
|
||||||
|
const attrs: Record<string, any> = { colspan: 1, rowspan: 1 };
|
||||||
|
// Copy this column's colwidth from the header row's cell when present.
|
||||||
|
const colwidth = headerCells[i]?.attrs?.colwidth;
|
||||||
|
if (colwidth !== undefined) attrs.colwidth = colwidth;
|
||||||
|
// A row landing at index 0 becomes the new header row, so inherit the
|
||||||
|
// current header cell's type per column (Docmost uses "tableHeader" there);
|
||||||
|
// every other position is a plain data cell.
|
||||||
|
const cellType =
|
||||||
|
landingIndex === 0 ? (headerCells[i]?.type ?? "tableCell") : "tableCell";
|
||||||
|
newCells.push({
|
||||||
|
type: cellType,
|
||||||
|
attrs,
|
||||||
|
content: [makeCellParagraph(makeFreshId(used), text)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRow = { type: "tableRow", content: newCells };
|
||||||
|
|
||||||
|
// Splice at the resolved landing index (append when index was omitted/invalid).
|
||||||
|
table.content.splice(landingIndex, 0, newRow);
|
||||||
|
|
||||||
|
return { doc: out, inserted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the row at 0-based `index` from a table. Returns `{ doc, deleted }`.
|
||||||
|
* `deleted` is false only when the table cannot be located. Throws on an
|
||||||
|
* out-of-range index, and refuses to delete the table's only row.
|
||||||
|
*/
|
||||||
|
export function deleteTableRow(
|
||||||
|
doc: any,
|
||||||
|
tableRef: string,
|
||||||
|
index: number,
|
||||||
|
): { doc: any; deleted: boolean } {
|
||||||
|
const out = clone(doc);
|
||||||
|
const located = locateTable(out, tableRef);
|
||||||
|
if (located == null) return { doc: out, deleted: false };
|
||||||
|
const { table } = located;
|
||||||
|
|
||||||
|
if (!Array.isArray(table.content)) table.content = [];
|
||||||
|
const rows = table.content.length;
|
||||||
|
|
||||||
|
if (!Number.isInteger(index) || index < 0 || index >= rows) {
|
||||||
|
throw new Error(
|
||||||
|
`table_delete_row: row index ${index} out of range (table has ${rows} row(s))`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rows <= 1) {
|
||||||
|
throw new Error(
|
||||||
|
"table_delete_row: refusing to delete the only row of the table",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
table.content.splice(index, 1);
|
||||||
|
return { doc: out, deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the plain-text content of cell `[row, col]` (0-based) to `text`. Returns
|
||||||
|
* `{ doc, updated }`; `updated` is false only when the table cannot be located.
|
||||||
|
* Throws when `row`/`col` is out of range. The cell's own attrs (colspan/
|
||||||
|
* rowspan/colwidth) are preserved; its content becomes a single text paragraph
|
||||||
|
* that reuses the cell's existing first-paragraph id when present, else a fresh
|
||||||
|
* one.
|
||||||
|
*/
|
||||||
|
export function updateTableCell(
|
||||||
|
doc: any,
|
||||||
|
tableRef: string,
|
||||||
|
row: number,
|
||||||
|
col: number,
|
||||||
|
text: string,
|
||||||
|
): { doc: any; updated: boolean } {
|
||||||
|
const out = clone(doc);
|
||||||
|
const located = locateTable(out, tableRef);
|
||||||
|
if (located == null) return { doc: out, updated: false };
|
||||||
|
const { table } = located;
|
||||||
|
|
||||||
|
const rowNodes = Array.isArray(table.content) ? table.content : [];
|
||||||
|
const rows = rowNodes.length;
|
||||||
|
const rowNode = rowNodes[row];
|
||||||
|
const cols =
|
||||||
|
isObject(rowNode) && Array.isArray(rowNode.content)
|
||||||
|
? rowNode.content.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!Number.isInteger(row) ||
|
||||||
|
row < 0 ||
|
||||||
|
row >= rows ||
|
||||||
|
!Number.isInteger(col) ||
|
||||||
|
col < 0 ||
|
||||||
|
col >= cols
|
||||||
|
) {
|
||||||
|
throw new Error(`table_update_cell: cell [${row},${col}] out of range`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cellNode = rowNode.content[col];
|
||||||
|
// Reuse the cell's existing first-paragraph id, or mint a fresh unique one.
|
||||||
|
const existingPara = Array.isArray(cellNode?.content)
|
||||||
|
? cellNode.content[0]
|
||||||
|
: undefined;
|
||||||
|
let id =
|
||||||
|
isObject(existingPara) && isObject(existingPara.attrs)
|
||||||
|
? existingPara.attrs.id
|
||||||
|
: undefined;
|
||||||
|
if (typeof id !== "string" || id.length === 0) {
|
||||||
|
const used = new Set<string>();
|
||||||
|
collectIds(out, used);
|
||||||
|
id = makeFreshId(used);
|
||||||
|
}
|
||||||
|
|
||||||
|
cellNode.content = [makeCellParagraph(id, text)];
|
||||||
|
return { doc: out, updated: true };
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
import RE2 from "re2";
|
import RE2 from "re2";
|
||||||
|
|
||||||
import { blockPlainText } from "@docmost/prosemirror-markdown";
|
import { blockPlainText } from "./node-ops.js";
|
||||||
|
|
||||||
/** An RE2 regex instance (RE2 extends `RegExp`, so it is usable as one). */
|
/** An RE2 regex instance (RE2 extends `RegExp`, so it is usable as one). */
|
||||||
type Re2Regex = InstanceType<typeof RE2>;
|
type Re2Regex = InstanceType<typeof RE2>;
|
||||||
|
|||||||
-5
@@ -2,11 +2,6 @@
|
|||||||
// instead of an object. Normalize: parse a string to an object (throwing on
|
// instead of an object. Normalize: parse a string to an object (throwing on
|
||||||
// invalid JSON), pass an object through unchanged. Shared by patch_node /
|
// invalid JSON), pass an object through unchanged. Shared by patch_node /
|
||||||
// insert_node (and the analogous update_page_json content parsing).
|
// insert_node (and the analogous update_page_json content parsing).
|
||||||
//
|
|
||||||
// This lives in the converter package (#414) so BOTH consumers import the ONE
|
|
||||||
// copy: `@docmost/mcp` (ESM) and the CommonJS server app. The server cannot
|
|
||||||
// import `@docmost/mcp` directly (ESM-only, no declaration files), but it does
|
|
||||||
// import `@docmost/prosemirror-markdown` natively — so this is the shared home.
|
|
||||||
export function parseNodeArg(
|
export function parseNodeArg(
|
||||||
node: unknown,
|
node: unknown,
|
||||||
errMsg = "node was a string but not valid JSON",
|
errMsg = "node was a string but not valid JSON",
|
||||||
@@ -14,13 +14,13 @@
|
|||||||
* - `marks` arrays are preserved verbatim when fragments are split/reordered.
|
* - `marks` arrays are preserved verbatim when fragments are split/reordered.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { blockPlainText } from "./node-ops.js";
|
||||||
|
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
||||||
import {
|
import {
|
||||||
blockPlainText,
|
|
||||||
footnoteContentKey,
|
footnoteContentKey,
|
||||||
makeFootnoteDefinition,
|
makeFootnoteDefinition,
|
||||||
generateFootnoteId,
|
generateFootnoteId,
|
||||||
} from "@docmost/prosemirror-markdown";
|
} from "./footnote-authoring.js";
|
||||||
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
|
||||||
|
|
||||||
export { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
export { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
||||||
|
|
||||||
@@ -365,7 +365,7 @@ export function noteItem(inlineNodes: any[]): any {
|
|||||||
* { type:"footnoteDefinition", attrs:{id}, content:[{ type:"paragraph", content }] }
|
* { type:"footnoteDefinition", attrs:{id}, content:[{ type:"paragraph", content }] }
|
||||||
* (mirrors the editor-ext / docmost-schema FootnoteDefinition node).
|
* (mirrors the editor-ext / docmost-schema FootnoteDefinition node).
|
||||||
*
|
*
|
||||||
* Built on the shared `makeFootnoteDefinition` factory (`@docmost/prosemirror-markdown`);
|
* Built on the shared `makeFootnoteDefinition` factory (footnote-authoring.ts);
|
||||||
* the only extra is a fresh block id on the inner paragraph (Docmost stamps one,
|
* the only extra is a fresh block id on the inner paragraph (Docmost stamps one,
|
||||||
* and the canonicalizer preserves attrs as-is). Single factory, one place to
|
* and the canonicalizer preserves attrs as-is). Single factory, one place to
|
||||||
* change the definition shape.
|
* change the definition shape.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Mock-HTTP test for the footnoteWarnings plumbing (#166). createPage is the
|
// Mock-HTTP test for the footnoteWarnings plumbing (#166). createPage is the
|
||||||
// representative path that is fully plain-HTTP (import + getPage) and so is
|
// representative path that is fully plain-HTTP (import + getPage) and so is
|
||||||
// mockable here; updatePage / importPageMarkdown attach footnoteWarnings with the
|
// mockable here; updatePage / importPageMarkdown attach footnoteWarnings with the
|
||||||
// IDENTICAL wiring (`footnoteWarningsField(...)` spread-when-non-empty) but run their
|
// IDENTICAL wiring (`analyzeFootnotes(...)` + spread-when-non-empty) but run their
|
||||||
// mutation over the Hocuspocus collab WebSocket, which this plain-HTTP harness
|
// mutation over the Hocuspocus collab WebSocket, which this plain-HTTP harness
|
||||||
// does not stand up. The analyzer itself is unit-tested in footnote-analyze.test.
|
// does not stand up. The analyzer itself is unit-tested in footnote-analyze.test.
|
||||||
import { test, after } from "node:test";
|
import { test, after } from "node:test";
|
||||||
@@ -76,29 +76,35 @@ function pageHandler() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test("createPage attaches footnoteWarnings when the content uses legacy footnote syntax", async () => {
|
test("createPage attaches footnoteWarnings when the content has footnote problems", async () => {
|
||||||
const baseURL = await spawn(pageHandler());
|
const baseURL = await spawn(pageHandler());
|
||||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
// Legacy reference-style `[^id]:` definitions — inert on import since #293.
|
// A dangling reference + a duplicate definition + a table marker.
|
||||||
const content = ["Intro[^a].", "", "[^a]: a definition"].join("\n");
|
const content = [
|
||||||
|
"Intro[^missing] and| cell[^t] |.",
|
||||||
|
"",
|
||||||
|
"[^d]: one",
|
||||||
|
"[^d]: two",
|
||||||
|
"[^t]: in table",
|
||||||
|
].join("\n");
|
||||||
const result = await client.createPage("T", content, "sp-1");
|
const result = await client.createPage("T", content, "sp-1");
|
||||||
assert.ok(Array.isArray(result.footnoteWarnings), "footnoteWarnings present");
|
assert.ok(Array.isArray(result.footnoteWarnings), "footnoteWarnings present");
|
||||||
const joined = result.footnoteWarnings.join("\n");
|
const joined = result.footnoteWarnings.join("\n");
|
||||||
assert.match(joined, /reference-style footnotes/i);
|
assert.match(joined, /no matching definition/); // dangling [^missing]
|
||||||
assert.match(joined, /\^\[footnote text\]/); // nudge to the inline form
|
assert.match(joined, /defined more than once/); // duplicate [^d]
|
||||||
// The page itself is still returned.
|
// The page itself is still returned.
|
||||||
assert.equal(result.success, true);
|
assert.equal(result.success, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("createPage omits footnoteWarnings when the content uses the inline form", async () => {
|
test("createPage omits footnoteWarnings when the content is clean", async () => {
|
||||||
const baseURL = await spawn(pageHandler());
|
const baseURL = await spawn(pageHandler());
|
||||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
const content = "A note.^[the body] and reuse.^[the body]";
|
const content = ["A[^a] and reuse[^a].", "", "[^a]: fine"].join("\n");
|
||||||
const result = await client.createPage("T", content, "sp-1");
|
const result = await client.createPage("T", content, "sp-1");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
"footnoteWarnings" in result,
|
"footnoteWarnings" in result,
|
||||||
false,
|
false,
|
||||||
"no footnoteWarnings field on inline-footnote input",
|
"no footnoteWarnings field on clean input",
|
||||||
);
|
);
|
||||||
assert.equal(result.success, true);
|
assert.equal(result.success, true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,45 +1,64 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import {
|
import { analyzeFootnotes } from "../../build/lib/footnote-analyze.js";
|
||||||
footnoteWarningsField,
|
|
||||||
hasLegacyFootnoteDefinition,
|
|
||||||
} from "../../build/lib/footnote-analyze.js";
|
|
||||||
|
|
||||||
// #414: the legacy footnote diagnostics were reduced to ONE advisory that fires
|
test("clean footnotes produce no diagnostics", () => {
|
||||||
// on the PRESENCE of legacy reference-style `[^id]:` definition syntax (inert on
|
const md = ["A[^a] and B[^b].", "", "[^a]: first", "[^b]: second"].join("\n");
|
||||||
// import since #293), nudging the author to inline `^[...]` footnotes.
|
const d = analyzeFootnotes(md);
|
||||||
|
assert.deepEqual(d.danglingReferences, []);
|
||||||
test("inline `^[...]` footnotes produce no warning", () => {
|
assert.deepEqual(d.emptyDefinitions, []);
|
||||||
const md = "A note here.^[the body] and reuse elsewhere.^[the body]";
|
assert.deepEqual(d.duplicateDefinitions, []);
|
||||||
assert.equal(hasLegacyFootnoteDefinition(md), false);
|
assert.deepEqual(d.referencesInTables, []);
|
||||||
assert.deepEqual(footnoteWarningsField(md), {});
|
assert.deepEqual(d.warnings, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("no footnotes at all produce no warning", () => {
|
test("reuse (repeated references to one definition) is NOT a warning", () => {
|
||||||
const md = "Just a paragraph with [a link](https://x) and no footnotes.";
|
const md = ["A[^a] B[^a] C[^a].", "", "[^a]: shared"].join("\n");
|
||||||
assert.equal(hasLegacyFootnoteDefinition(md), false);
|
const d = analyzeFootnotes(md);
|
||||||
assert.deepEqual(footnoteWarningsField(md), {});
|
assert.deepEqual(d.danglingReferences, []);
|
||||||
|
assert.deepEqual(d.warnings, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a legacy `[^id]:` definition triggers the single advisory", () => {
|
test("dangling reference (no definition) is reported", () => {
|
||||||
const md = ["See[^a].", "", "[^a]: defined"].join("\n");
|
const md = ["See[^missing] and[^a].", "", "[^a]: defined"].join("\n");
|
||||||
assert.equal(hasLegacyFootnoteDefinition(md), true);
|
const d = analyzeFootnotes(md);
|
||||||
const field = footnoteWarningsField(md);
|
assert.deepEqual(d.danglingReferences, ["missing"]);
|
||||||
assert.equal(field.footnoteWarnings.length, 1);
|
assert.equal(d.warnings.length, 1);
|
||||||
assert.match(field.footnoteWarnings[0], /reference-style footnotes/i);
|
assert.match(d.warnings[0], /no matching definition/);
|
||||||
assert.match(field.footnoteWarnings[0], /\^\[footnote text\]/);
|
assert.match(d.warnings[0], /\[\^missing\]/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a bare `[^id]` reference (no definition line) is not flagged", () => {
|
test("empty definition text is reported", () => {
|
||||||
// Only the definition syntax `[^id]:` is a reliable signal of legacy authoring;
|
const md = ["See[^a].", "", "[^a]: "].join("\n");
|
||||||
// a lone `[^x]` in prose is too ambiguous to warn on.
|
const d = analyzeFootnotes(md);
|
||||||
const md = "A sentence mentioning [^x] with no definition.";
|
assert.deepEqual(d.emptyDefinitions, ["a"]);
|
||||||
assert.equal(hasLegacyFootnoteDefinition(md), false);
|
assert.match(d.warnings.join("\n"), /empty text/);
|
||||||
assert.deepEqual(footnoteWarningsField(md), {});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("legacy syntax inside a code fence is ignored (fence-aware)", () => {
|
test("duplicate definition id is reported (first-wins)", () => {
|
||||||
|
const md = ["See[^d].", "", "[^d]: first", "[^d]: second"].join("\n");
|
||||||
|
const d = analyzeFootnotes(md);
|
||||||
|
assert.deepEqual(d.duplicateDefinitions, ["d"]);
|
||||||
|
assert.match(d.warnings.join("\n"), /defined more than once/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reference inside a GFM table row is reported (heuristic)", () => {
|
||||||
|
const md = [
|
||||||
|
"| Col |",
|
||||||
|
"| --- |",
|
||||||
|
"| cell[^t] |",
|
||||||
|
"",
|
||||||
|
"[^t]: table note",
|
||||||
|
].join("\n");
|
||||||
|
const d = analyzeFootnotes(md);
|
||||||
|
assert.deepEqual(d.referencesInTables, ["t"]);
|
||||||
|
assert.match(d.warnings.join("\n"), /table/);
|
||||||
|
// It is defined, so it is NOT also dangling.
|
||||||
|
assert.deepEqual(d.danglingReferences, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("footnote syntax inside a code fence is ignored", () => {
|
||||||
const md = [
|
const md = [
|
||||||
"Intro.",
|
"Intro.",
|
||||||
"",
|
"",
|
||||||
@@ -48,22 +67,40 @@ test("legacy syntax inside a code fence is ignored (fence-aware)", () => {
|
|||||||
"[^demo]: not a real definition",
|
"[^demo]: not a real definition",
|
||||||
"```",
|
"```",
|
||||||
"",
|
"",
|
||||||
"Outro with an inline note.^[real]",
|
"Outro[^a].",
|
||||||
|
"",
|
||||||
|
"[^a]: real",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
assert.equal(hasLegacyFootnoteDefinition(md), false);
|
const d = analyzeFootnotes(md);
|
||||||
assert.deepEqual(footnoteWarningsField(md), {});
|
// `[^demo]` lives only in the fenced block, so it is neither a reference nor a
|
||||||
|
// dangling one, and `[^demo]:` is not counted as a definition.
|
||||||
|
assert.deepEqual(d.danglingReferences, []);
|
||||||
|
assert.deepEqual(d.duplicateDefinitions, []);
|
||||||
|
assert.deepEqual(d.warnings, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a legacy definition OUTSIDE a fence still warns even with a fenced sample", () => {
|
test("a reference that only appears inside a definition's text is not dangling", () => {
|
||||||
const md = [
|
// `[^b]` is referenced from within [^a]'s text and has its own definition.
|
||||||
"```",
|
const md = ["See[^a].", "", "[^a]: see also [^b]", "[^b]: the other"].join(
|
||||||
"[^demo]: example inside a fence",
|
"\n",
|
||||||
"```",
|
);
|
||||||
"",
|
const d = analyzeFootnotes(md);
|
||||||
"See[^a].",
|
assert.deepEqual(d.danglingReferences, []);
|
||||||
"",
|
});
|
||||||
"[^a]: real definition outside the fence",
|
|
||||||
].join("\n");
|
test("multiple problem classes accumulate distinct warnings", () => {
|
||||||
assert.equal(hasLegacyFootnoteDefinition(md), true);
|
const md = [
|
||||||
assert.equal(footnoteWarningsField(md).footnoteWarnings.length, 1);
|
"Ref[^x] and[^dup].",
|
||||||
|
"",
|
||||||
|
"[^dup]: one",
|
||||||
|
"[^dup]: two",
|
||||||
|
"[^empty]:",
|
||||||
|
].join("\n");
|
||||||
|
const d = analyzeFootnotes(md);
|
||||||
|
// x has no definition; dup is defined twice; empty is empty AND has no ref.
|
||||||
|
assert.ok(d.danglingReferences.includes("x"));
|
||||||
|
assert.deepEqual(d.duplicateDefinitions, ["dup"]);
|
||||||
|
assert.deepEqual(d.emptyDefinitions, ["empty"]);
|
||||||
|
// One warning line per problem class present.
|
||||||
|
assert.ok(d.warnings.length >= 3);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { canonicalizeFootnotes } from "../../build/lib/footnote-canonicalize.js"
|
|||||||
import {
|
import {
|
||||||
footnoteContentKey,
|
footnoteContentKey,
|
||||||
generateFootnoteId,
|
generateFootnoteId,
|
||||||
} from "@docmost/prosemirror-markdown";
|
} from "../../build/lib/footnote-authoring.js";
|
||||||
import { insertInlineFootnote } from "../../build/lib/transforms.js";
|
import { insertInlineFootnote } from "../../build/lib/transforms.js";
|
||||||
import { markdownToProseMirrorCanonical } from "../../build/lib/collaboration.js";
|
import { markdownToProseMirrorCanonical } from "../../build/lib/collaboration.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,39 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import { footnoteWarningsField } from "../../build/lib/footnote-analyze.js";
|
import {
|
||||||
|
analyzeFootnotes,
|
||||||
|
footnoteWarningsField,
|
||||||
|
} from "../../build/lib/footnote-analyze.js";
|
||||||
import {
|
import {
|
||||||
serializeDocmostMarkdown,
|
serializeDocmostMarkdown,
|
||||||
parseDocmostMarkdown,
|
parseDocmostMarkdown,
|
||||||
} from "../../build/lib/markdown-document.js";
|
} from "../../build/lib/markdown-document.js";
|
||||||
|
|
||||||
// Pins the footnoteWarnings PLUMBING contract (#169 review; reduced in #414): the
|
// Pins the footnoteWarnings PLUMBING contract (#169 review): the field is
|
||||||
// field is present only when legacy reference-style `[^id]:` syntax is used and
|
// present only on problems and omitted on clean input, AND `import_page_markdown`
|
||||||
// omitted otherwise, AND `import_page_markdown` analyzes the BODY (after the
|
// analyzes the BODY (after the docmost:meta / docmost:comments blocks) — so a
|
||||||
// docmost:meta / docmost:comments blocks) — so a footnote-like token inside those
|
// footnote-like token inside those JSON blocks never warns, while a real marker
|
||||||
// JSON blocks never warns, while a real definition in the body does.
|
// in the body does. importPageMarkdown does exactly
|
||||||
// importPageMarkdown does exactly `footnoteWarningsField(parseDocmostMarkdown(full).body)`
|
// `footnoteWarningsField(parseDocmostMarkdown(full).body)` over a collab socket
|
||||||
// over a collab socket this harness does not stand up, so we test the same pure
|
// this harness does not stand up, so we test the same pure composition directly.
|
||||||
// composition directly.
|
|
||||||
|
|
||||||
test("footnoteWarningsField is present on legacy syntax and omitted on the inline form", () => {
|
test("footnoteWarningsField is present on problems and omitted on clean input", () => {
|
||||||
const legacy = footnoteWarningsField("See[^a].\n\n[^a]: defined");
|
const problem = footnoteWarningsField("See[^missing].\n\n[^a]: defined");
|
||||||
assert.ok(Array.isArray(legacy.footnoteWarnings));
|
assert.ok(Array.isArray(problem.footnoteWarnings));
|
||||||
assert.match(legacy.footnoteWarnings.join("\n"), /reference-style footnotes/i);
|
assert.match(problem.footnoteWarnings.join("\n"), /no matching definition/);
|
||||||
|
|
||||||
const inline = footnoteWarningsField("A note.^[the body] reused.^[the body]");
|
const clean = footnoteWarningsField("A[^a] and reuse[^a].\n\n[^a]: fine");
|
||||||
assert.deepEqual(inline, {}); // no key at all on inline-footnote input
|
assert.deepEqual(clean, {}); // no key at all on clean input
|
||||||
});
|
});
|
||||||
|
|
||||||
test("import analyzes the BODY only — tokens inside meta/comments never warn", () => {
|
test("import analyzes the BODY only — tokens inside meta/comments never warn", () => {
|
||||||
// meta + comments JSON carry `[^metaonly]:` / `[^commentonly]:`-looking text;
|
// meta + comments JSON carry `[^metaonly]` / `[^commentonly]`-looking text; the
|
||||||
// the BODY has a genuine legacy `[^bodyref]:` definition.
|
// BODY has a genuinely dangling `[^bodyref]`.
|
||||||
const full = serializeDocmostMarkdown(
|
const full = serializeDocmostMarkdown(
|
||||||
{ pageId: "p1", note: "front-matter mentions [^metaonly]: in text" },
|
{ pageId: "p1", note: "front-matter mentions [^metaonly] in text" },
|
||||||
"Body with a legacy[^bodyref] marker.\n\n[^bodyref]: the definition",
|
"Body with a dangling[^bodyref] marker.",
|
||||||
[{ id: "c1", content: "a comment that says [^commentonly]: text" }],
|
[{ id: "c1", content: "a comment that says [^commentonly]" }],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { body } = parseDocmostMarkdown(full);
|
const { body } = parseDocmostMarkdown(full);
|
||||||
@@ -40,19 +42,20 @@ test("import analyzes the BODY only — tokens inside meta/comments never warn",
|
|||||||
assert.ok(!body.includes("[^commentonly]"));
|
assert.ok(!body.includes("[^commentonly]"));
|
||||||
|
|
||||||
const field = footnoteWarningsField(body);
|
const field = footnoteWarningsField(body);
|
||||||
// ONLY the body's legacy definition triggers the advisory.
|
const joined = (field.footnoteWarnings ?? []).join("\n");
|
||||||
assert.ok(Array.isArray(field.footnoteWarnings));
|
// ONLY the body's dangling reference is flagged.
|
||||||
assert.match(field.footnoteWarnings.join("\n"), /reference-style footnotes/i);
|
assert.match(joined, /\[\^bodyref\]/);
|
||||||
|
assert.ok(!joined.includes("metaonly"));
|
||||||
|
assert.ok(!joined.includes("commentonly"));
|
||||||
|
|
||||||
// The meta/comments tokens, analyzed on their own, would NOT have warned in a
|
// Cross-check against analyzeFootnotes directly (same composition the importer uses).
|
||||||
// way that leaks here — the field is computed over the body only.
|
assert.deepEqual(analyzeFootnotes(body).danglingReferences, ["bodyref"]);
|
||||||
assert.deepEqual(footnoteWarningsField("front-matter mentions text"), {});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("import on an inline-footnote body yields no footnoteWarnings field", () => {
|
test("import on a clean body yields no footnoteWarnings field", () => {
|
||||||
const full = serializeDocmostMarkdown(
|
const full = serializeDocmostMarkdown(
|
||||||
{ pageId: "p1" },
|
{ pageId: "p1" },
|
||||||
"Clean body.^[a note] reusing.^[a note]",
|
"Clean body[^a] reusing[^a].\n\n[^a]: ok",
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const { body } = parseDocmostMarkdown(full);
|
const { body } = parseDocmostMarkdown(full);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
insertNodeRelative,
|
insertNodeRelative,
|
||||||
sanitizeForYjs,
|
sanitizeForYjs,
|
||||||
findUnstorableAttr,
|
findUnstorableAttr,
|
||||||
} from "@docmost/prosemirror-markdown";
|
} from "../../build/lib/node-ops.js";
|
||||||
|
|
||||||
// ProseMirror builders. Blocks carry a stable id in attrs.id.
|
// ProseMirror builders. Blocks carry a stable id in attrs.id.
|
||||||
const textNode = (text) => ({ type: "text", text });
|
const textNode = (text) => ({ type: "text", text });
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
deleteNodeById,
|
deleteNodeById,
|
||||||
assertUnambiguousMatch,
|
assertUnambiguousMatch,
|
||||||
insertNodeRelative,
|
insertNodeRelative,
|
||||||
} from "@docmost/prosemirror-markdown";
|
} from "../../build/lib/node-ops.js";
|
||||||
|
|
||||||
// ProseMirror builders. Blocks carry a stable id in attrs.id.
|
// ProseMirror builders. Blocks carry a stable id in attrs.id.
|
||||||
const textNode = (text) => ({ type: "text", text });
|
const textNode = (text) => ({ type: "text", text });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import { buildOutline, getNodeByRef } from "@docmost/prosemirror-markdown";
|
import { buildOutline, getNodeByRef } from "../../build/lib/node-ops.js";
|
||||||
|
|
||||||
// Helpers to build the small fixture doc.
|
// Helpers to build the small fixture doc.
|
||||||
const textNode = (text) => ({ type: "text", text });
|
const textNode = (text) => ({ type: "text", text });
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { test } from "node:test";
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import { searchInDoc } from "../../build/lib/page-search.js";
|
import { searchInDoc } from "../../build/lib/page-search.js";
|
||||||
import { getNodeByRef } from "@docmost/prosemirror-markdown";
|
import { getNodeByRef } from "../../build/lib/node-ops.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Document builders. Mirror the Docmost ProseMirror shape: paragraphs/headings
|
// Document builders. Mirror the Docmost ProseMirror shape: paragraphs/headings
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import { parseNodeArg } from "@docmost/prosemirror-markdown";
|
import { parseNodeArg } from "../../build/lib/parse-node-arg.js";
|
||||||
|
|
||||||
test("parseNodeArg passes an object through unchanged", () => {
|
test("parseNodeArg passes an object through unchanged", () => {
|
||||||
const obj = { type: "paragraph", content: [] };
|
const obj = { type: "paragraph", content: [] };
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
insertTableRow,
|
insertTableRow,
|
||||||
deleteTableRow,
|
deleteTableRow,
|
||||||
updateTableCell,
|
updateTableCell,
|
||||||
} from "@docmost/prosemirror-markdown";
|
} from "../../build/lib/node-ops.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Builders. Tables/rows/cells carry NO attrs.id — only the paragraph inside a
|
// Builders. Tables/rows/cells carry NO attrs.id — only the paragraph inside a
|
||||||
|
|||||||
@@ -59,89 +59,3 @@ export function splitFootnoteParagraphs(encoded: string): string[] {
|
|||||||
paragraphs.push(current);
|
paragraphs.push(current);
|
||||||
return paragraphs;
|
return paragraphs;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Inline-authoring helpers (#414: moved here from the mcp `footnote-authoring.ts`
|
|
||||||
// fork so the dedup convention — content-key + definition factory + id gen —
|
|
||||||
// has ONE home next to the importer that shares the convention). Used by the
|
|
||||||
// mcp author-inline tool (`insertInlineFootnote` in transforms.ts).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition";
|
|
||||||
|
|
||||||
function cloneJson<T>(v: T): T {
|
|
||||||
if (typeof structuredClone === "function") return structuredClone(v);
|
|
||||||
return JSON.parse(JSON.stringify(v)) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalized content key for de-duplicating footnote DEFINITIONS by their text.
|
|
||||||
*
|
|
||||||
* Two definitions with the same key are the SAME footnote — so the inline
|
|
||||||
* authoring tool reuses one id (one number, one definition, several references)
|
|
||||||
* instead of minting a second definition. Key = plaintext (whitespace-collapsed,
|
|
||||||
* trimmed) PLUS a signature of the inline mark types in order, so two notes that
|
|
||||||
* read the same but differ in formatting (one bold, one plain) are NOT merged.
|
|
||||||
* Conservative: only an exact match merges.
|
|
||||||
*/
|
|
||||||
export function footnoteContentKey(defNode: any): string {
|
|
||||||
const parts: string[] = [];
|
|
||||||
const visit = (n: any): void => {
|
|
||||||
if (!n || typeof n !== "object") return;
|
|
||||||
if (n.type === "text" && typeof n.text === "string") {
|
|
||||||
const marks = Array.isArray(n.marks)
|
|
||||||
? n.marks.map((m: any) => m?.type).filter(Boolean).sort().join(",")
|
|
||||||
: "";
|
|
||||||
parts.push(`${n.text}${marks}`);
|
|
||||||
}
|
|
||||||
if (Array.isArray(n.content)) for (const c of n.content) visit(c);
|
|
||||||
};
|
|
||||||
visit(defNode);
|
|
||||||
// Collapse the assembled text's whitespace and trim, keeping the mark
|
|
||||||
// signature attached so formatting differences still distinguish notes.
|
|
||||||
return parts
|
|
||||||
.join("")
|
|
||||||
.replace(/[ \t\r\n]+/g, " ")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a footnoteDefinition node from inline ProseMirror nodes, keyed by id.
|
|
||||||
*/
|
|
||||||
export function makeFootnoteDefinition(id: string, inlineNodes: any[]): any {
|
|
||||||
const content = Array.isArray(inlineNodes) ? cloneJson(inlineNodes) : [];
|
|
||||||
return {
|
|
||||||
type: FOOTNOTE_DEFINITION_NAME,
|
|
||||||
attrs: { id },
|
|
||||||
content: [{ type: "paragraph", content }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a uuidv7-style id (time-ordered), matching editor-ext's
|
|
||||||
* `generateFootnoteId`. Used for a genuinely-new inline footnote id.
|
|
||||||
*/
|
|
||||||
export function generateFootnoteId(): string {
|
|
||||||
const now = Date.now();
|
|
||||||
const timeHex = now.toString(16).padStart(12, "0");
|
|
||||||
const rand = (length: number) => {
|
|
||||||
let s = "";
|
|
||||||
for (let i = 0; i < length; i++)
|
|
||||||
s += Math.floor(Math.random() * 16).toString(16);
|
|
||||||
return s;
|
|
||||||
};
|
|
||||||
const versioned = "7" + rand(3);
|
|
||||||
const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16);
|
|
||||||
const variant = variantNibble + rand(3);
|
|
||||||
return (
|
|
||||||
timeHex.slice(0, 8) +
|
|
||||||
"-" +
|
|
||||||
timeHex.slice(8, 12) +
|
|
||||||
"-" +
|
|
||||||
versioned +
|
|
||||||
"-" +
|
|
||||||
variant +
|
|
||||||
"-" +
|
|
||||||
rand(12)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -44,35 +44,3 @@ export {
|
|||||||
docsCanonicallyEqual,
|
docsCanonicallyEqual,
|
||||||
} from "./canonicalize.js";
|
} from "./canonicalize.js";
|
||||||
export { parsePageFile, serializePageFile } from "./page-file.js";
|
export { parsePageFile, serializePageFile } from "./page-file.js";
|
||||||
|
|
||||||
// Pure, network-free helpers for manipulating a ProseMirror/TipTap document
|
|
||||||
// tree by node id (#414: the single canonical copy, formerly forked into mcp).
|
|
||||||
// Consumed by `@docmost/mcp` (patch/insert/delete node, table tools, outline).
|
|
||||||
export {
|
|
||||||
blockPlainText,
|
|
||||||
buildOutline,
|
|
||||||
getNodeByRef,
|
|
||||||
replaceNodeById,
|
|
||||||
deleteNodeById,
|
|
||||||
sanitizeForYjs,
|
|
||||||
findUnstorableAttr,
|
|
||||||
insertNodeRelative,
|
|
||||||
readTable,
|
|
||||||
insertTableRow,
|
|
||||||
deleteTableRow,
|
|
||||||
updateTableCell,
|
|
||||||
assertUnambiguousMatch,
|
|
||||||
} from "./node-ops.js";
|
|
||||||
export type { OutlineEntry } from "./node-ops.js";
|
|
||||||
|
|
||||||
// Normalize a ProseMirror node arg that the model may have serialized as a JSON
|
|
||||||
// string (#414: single copy shared by mcp and the CommonJS server app).
|
|
||||||
export { parseNodeArg } from "./parse-node-arg.js";
|
|
||||||
|
|
||||||
// Inline-footnote authoring convention (#414: single copy, formerly the mcp
|
|
||||||
// `footnote-authoring.ts` fork), shared with the importer's `assembleFootnotes`.
|
|
||||||
export {
|
|
||||||
footnoteContentKey,
|
|
||||||
makeFootnoteDefinition,
|
|
||||||
generateFootnoteId,
|
|
||||||
} from "./footnote.js";
|
|
||||||
|
|||||||
@@ -14,8 +14,6 @@
|
|||||||
* `content`, non-object nodes, and absent `attrs` are tolerated.
|
* `content`, non-object nodes, and absent `attrs` are tolerated.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { stripInlineMarkdown } from "./text-normalize.js";
|
|
||||||
|
|
||||||
/** Deep-clone a JSON-serializable value without mutating the original. */
|
/** Deep-clone a JSON-serializable value without mutating the original. */
|
||||||
function clone<T>(value: T): T {
|
function clone<T>(value: T): T {
|
||||||
if (typeof structuredClone === "function") {
|
if (typeof structuredClone === "function") {
|
||||||
@@ -99,15 +97,12 @@ export function buildOutline(doc: any): OutlineEntry[] {
|
|||||||
const entry: OutlineEntry = {
|
const entry: OutlineEntry = {
|
||||||
index: i,
|
index: i,
|
||||||
type,
|
type,
|
||||||
id:
|
id: isObject(block) && isObject(block.attrs) ? block.attrs.id ?? null : null,
|
||||||
isObject(block) && isObject(block.attrs)
|
|
||||||
? (block.attrs.id ?? null)
|
|
||||||
: null,
|
|
||||||
firstText: truncate(blockPlainText(block), 100),
|
firstText: truncate(blockPlainText(block), 100),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (type === "heading") {
|
if (type === "heading") {
|
||||||
entry.level = isObject(block.attrs) ? (block.attrs.level ?? null) : null;
|
entry.level = isObject(block.attrs) ? block.attrs.level ?? null : null;
|
||||||
} else if (type === "table") {
|
} else if (type === "table") {
|
||||||
const headerRow = block.content?.[0]?.content ?? [];
|
const headerRow = block.content?.[0]?.content ?? [];
|
||||||
entry.rows = block.content?.length ?? 0;
|
entry.rows = block.content?.length ?? 0;
|
||||||
@@ -252,33 +247,6 @@ export function deleteNodeById(
|
|||||||
return { doc: out, deleted };
|
return { doc: out, deleted };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Throw a clear, model-actionable error when a node-id write op did NOT match
|
|
||||||
* exactly one node (#159). `count === 0` -> "no node found"; `count > 1` ->
|
|
||||||
* "ambiguous, refused" — Docmost duplicates block ids on copy/paste, so a write
|
|
||||||
* by id could clobber/remove EVERY duplicate. The caller skips the write for any
|
|
||||||
* `count !== 1` (the transform returns null), so this only REPORTS; nothing was
|
|
||||||
* changed. No-op for the unambiguous single-match case.
|
|
||||||
*/
|
|
||||||
export function assertUnambiguousMatch(
|
|
||||||
op: "patch_node" | "delete_node",
|
|
||||||
verb: "replace" | "delete",
|
|
||||||
count: number,
|
|
||||||
nodeId: string,
|
|
||||||
pageId: string,
|
|
||||||
): void {
|
|
||||||
if (count === 0) {
|
|
||||||
throw new Error(
|
|
||||||
`${op}: no node with id "${nodeId}" found on page ${pageId}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (count > 1) {
|
|
||||||
throw new Error(
|
|
||||||
`${op}: id "${nodeId}" is ambiguous — ${count} nodes on page ${pageId} share it (block ids are duplicated on copy/paste). Refusing to ${verb} all of them; nothing was changed. Re-target with a more specific anchor.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deep-clone `doc` and strip every node/mark attribute whose value is strictly
|
* Deep-clone `doc` and strip every node/mark attribute whose value is strictly
|
||||||
* `undefined`, so the result is safe to hand to Yjs (which throws an opaque
|
* `undefined`, so the result is safe to hand to Yjs (which throws an opaque
|
||||||
@@ -396,31 +364,6 @@ const REQUIRED_CONTAINER: Record<string, string> = {
|
|||||||
tableHeader: "tableRow",
|
tableHeader: "tableRow",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the index of the first TOP-LEVEL block whose plain text includes the
|
|
||||||
* anchor, with a markdown-stripping FALLBACK. Returns -1 when none matches.
|
|
||||||
*
|
|
||||||
* Two passes preserve "exact wins globally":
|
|
||||||
* - Pass 1: first block containing the verbatim `anchorText`.
|
|
||||||
* - Pass 2 (only if pass 1 found nothing): first block containing the
|
|
||||||
* markdown-stripped anchor, when stripping actually changed it.
|
|
||||||
*/
|
|
||||||
function findAnchorTextIndex(content: any[], anchorText: string): number {
|
|
||||||
if (!Array.isArray(content)) return -1;
|
|
||||||
// Pass 1: exact.
|
|
||||||
for (let i = 0; i < content.length; i++) {
|
|
||||||
if (blockPlainText(content[i]).includes(anchorText)) return i;
|
|
||||||
}
|
|
||||||
// Pass 2: markdown-stripped fallback.
|
|
||||||
const a = stripInlineMarkdown(anchorText);
|
|
||||||
if (a !== anchorText && a.length > 0) {
|
|
||||||
for (let i = 0; i < content.length; i++) {
|
|
||||||
if (blockPlainText(content[i]).includes(a)) return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Locate an anchor and return its ancestor chain (from `doc` down to and
|
* Locate an anchor and return its ancestor chain (from `doc` down to and
|
||||||
* including the matched node). Each chain entry is `{ node, index }` where
|
* including the matched node). Each chain entry is `{ node, index }` where
|
||||||
@@ -456,14 +399,14 @@ function findAnchorChain(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// By text: only top-level blocks are scanned (same rule as the JSON path).
|
// By text: only top-level blocks are scanned (same rule as the JSON path).
|
||||||
// Exact match wins; a markdown-stripped fallback is tried only on a miss.
|
|
||||||
if (opts.anchorText != null && Array.isArray(doc.content)) {
|
if (opts.anchorText != null && Array.isArray(doc.content)) {
|
||||||
const i = findAnchorTextIndex(doc.content, opts.anchorText);
|
for (let i = 0; i < doc.content.length; i++) {
|
||||||
if (i !== -1) {
|
if (blockPlainText(doc.content[i]).includes(opts.anchorText)) {
|
||||||
return [
|
return [
|
||||||
{ node: doc, index: -1 },
|
{ node: doc, index: -1 },
|
||||||
{ node: doc.content[i], index: i },
|
{ node: doc.content[i], index: i },
|
||||||
];
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,13 +540,13 @@ export function insertNodeRelative(
|
|||||||
return { doc: out, inserted };
|
return { doc: out, inserted };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve by text: only top-level doc.content blocks are scanned. Exact
|
// Resolve by text: only top-level doc.content blocks are scanned.
|
||||||
// match wins; a markdown-stripped fallback is tried only on a miss.
|
|
||||||
if (opts.anchorText != null && isObject(out) && Array.isArray(out.content)) {
|
if (opts.anchorText != null && isObject(out) && Array.isArray(out.content)) {
|
||||||
const i = findAnchorTextIndex(out.content, opts.anchorText);
|
for (let i = 0; i < out.content.length; i++) {
|
||||||
if (i !== -1) {
|
if (blockPlainText(out.content[i]).includes(opts.anchorText)) {
|
||||||
out.content.splice(i + offset, 0, fresh);
|
out.content.splice(i + offset, 0, fresh);
|
||||||
return { doc: out, inserted: true };
|
return { doc: out, inserted: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,8 +617,7 @@ function locateTable(
|
|||||||
if (!isObject(rootClone)) return null;
|
if (!isObject(rootClone)) return null;
|
||||||
|
|
||||||
// "#<n>": index into the top-level content array; must be a table.
|
// "#<n>": index into the top-level content array; must be a table.
|
||||||
const indexMatch =
|
const indexMatch = typeof tableRef === "string" ? tableRef.match(/^#(\d+)$/) : null;
|
||||||
typeof tableRef === "string" ? tableRef.match(/^#(\d+)$/) : null;
|
|
||||||
if (indexMatch) {
|
if (indexMatch) {
|
||||||
const index = Number(indexMatch[1]);
|
const index = Number(indexMatch[1]);
|
||||||
const block = Array.isArray(rootClone.content)
|
const block = Array.isArray(rootClone.content)
|
||||||
@@ -775,7 +717,7 @@ export function readTable(
|
|||||||
: undefined;
|
: undefined;
|
||||||
const id =
|
const id =
|
||||||
isObject(firstPara) && isObject(firstPara.attrs)
|
isObject(firstPara) && isObject(firstPara.attrs)
|
||||||
? (firstPara.attrs.id ?? null)
|
? firstPara.attrs.id ?? null
|
||||||
: null;
|
: null;
|
||||||
rowIds.push(id);
|
rowIds.push(id);
|
||||||
}
|
}
|
||||||
@@ -809,17 +751,14 @@ export function insertTableRow(
|
|||||||
if (!Array.isArray(table.content)) table.content = [];
|
if (!Array.isArray(table.content)) table.content = [];
|
||||||
const rows = table.content.length;
|
const rows = table.content.length;
|
||||||
const headerRow = table.content[0];
|
const headerRow = table.content[0];
|
||||||
const headerCells = Array.isArray(headerRow?.content)
|
const headerCells = Array.isArray(headerRow?.content) ? headerRow.content : [];
|
||||||
? headerRow.content
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Column count is the WIDEST existing row, so the guard below stays
|
// Column count is the WIDEST existing row, so the guard below stays
|
||||||
// meaningful for ragged tables and the new row matches the table's width.
|
// meaningful for ragged tables and the new row matches the table's width.
|
||||||
// Fall back to the supplied cell count only when the table has no rows.
|
// Fall back to the supplied cell count only when the table has no rows.
|
||||||
let colCount = 0;
|
let colCount = 0;
|
||||||
for (const r of table.content) {
|
for (const r of table.content) {
|
||||||
if (isObject(r) && Array.isArray(r.content))
|
if (isObject(r) && Array.isArray(r.content)) colCount = Math.max(colCount, r.content.length);
|
||||||
colCount = Math.max(colCount, r.content.length);
|
|
||||||
}
|
}
|
||||||
if (colCount === 0) colCount = Array.isArray(cells) ? cells.length : 0;
|
if (colCount === 0) colCount = Array.isArray(cells) ? cells.length : 0;
|
||||||
|
|
||||||
@@ -832,10 +771,7 @@ export function insertTableRow(
|
|||||||
// Resolve the landing index up front so the cell-type decision and the splice
|
// Resolve the landing index up front so the cell-type decision and the splice
|
||||||
// below agree: a valid integer in [0, rows] splices there, else we append.
|
// below agree: a valid integer in [0, rows] splices there, else we append.
|
||||||
const landingIndex =
|
const landingIndex =
|
||||||
typeof index === "number" &&
|
typeof index === "number" && Number.isInteger(index) && index >= 0 && index <= rows
|
||||||
Number.isInteger(index) &&
|
|
||||||
index >= 0 &&
|
|
||||||
index <= rows
|
|
||||||
? index
|
? index
|
||||||
: rows;
|
: rows;
|
||||||
|
|
||||||
@@ -854,8 +790,7 @@ export function insertTableRow(
|
|||||||
// A row landing at index 0 becomes the new header row, so inherit the
|
// A row landing at index 0 becomes the new header row, so inherit the
|
||||||
// current header cell's type per column (Docmost uses "tableHeader" there);
|
// current header cell's type per column (Docmost uses "tableHeader" there);
|
||||||
// every other position is a plain data cell.
|
// every other position is a plain data cell.
|
||||||
const cellType =
|
const cellType = landingIndex === 0 ? headerCells[i]?.type ?? "tableCell" : "tableCell";
|
||||||
landingIndex === 0 ? (headerCells[i]?.type ?? "tableCell") : "tableCell";
|
|
||||||
newCells.push({
|
newCells.push({
|
||||||
type: cellType,
|
type: cellType,
|
||||||
attrs,
|
attrs,
|
||||||
@@ -927,10 +862,9 @@ export function updateTableCell(
|
|||||||
const rowNodes = Array.isArray(table.content) ? table.content : [];
|
const rowNodes = Array.isArray(table.content) ? table.content : [];
|
||||||
const rows = rowNodes.length;
|
const rows = rowNodes.length;
|
||||||
const rowNode = rowNodes[row];
|
const rowNode = rowNodes[row];
|
||||||
const cols =
|
const cols = isObject(rowNode) && Array.isArray(rowNode.content)
|
||||||
isObject(rowNode) && Array.isArray(rowNode.content)
|
? rowNode.content.length
|
||||||
? rowNode.content.length
|
: 0;
|
||||||
: 0;
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!Number.isInteger(row) ||
|
!Number.isInteger(row) ||
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
/**
|
|
||||||
* Locator normalization: strip inline markdown wrappers and trailing
|
|
||||||
* decoration from a LOCATOR string so a find/anchor that the model wrote with
|
|
||||||
* markdown (or a stray emoji) can still match the document's plain text.
|
|
||||||
*
|
|
||||||
* This is used ONLY as a fallback for LOCATING (after an exact match fails);
|
|
||||||
* it is never applied to replacement text or inserted node content, so no
|
|
||||||
* formatting is ever lost.
|
|
||||||
*
|
|
||||||
* Scope note (#414): this package-local copy exists so `node-ops.ts` — which
|
|
||||||
* lives here now (the single canonical copy) — can resolve its markdown-tolerant
|
|
||||||
* anchor fallback without a circular dependency back on `@docmost/mcp`. It
|
|
||||||
* intentionally carries ONLY `stripInlineMarkdown` (the primitive `node-ops`
|
|
||||||
* needs); the mcp-side `text-normalize.ts` (which additionally serves
|
|
||||||
* `json-edit.ts` via `stripBalancedWrappers`) is the subject of a separate
|
|
||||||
* dedup task and is left untouched here.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Maximum unwrap passes, so pathological/nested input cannot loop forever. */
|
|
||||||
const MAX_PASSES = 8;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inline emphasis/code/strikethrough wrappers, strong BEFORE emphasis so
|
|
||||||
* `**x**` collapses to `x` rather than leaving a stray `*x*`. Each pattern is
|
|
||||||
* non-greedy and capture group 1 is the inner text. Applied repeatedly until
|
|
||||||
* the string stops changing (nested wrappers like `**_x_**`).
|
|
||||||
*/
|
|
||||||
const WRAPPER_PATTERNS: RegExp[] = [
|
|
||||||
/\*\*([^*]+?)\*\*/g, // **x**
|
|
||||||
/__([^_]+?)__/g, // __x__
|
|
||||||
/~~([^~]+?)~~/g, // ~~x~~
|
|
||||||
/\*([^*]+?)\*/g, // *x*
|
|
||||||
/_([^_]+?)_/g, // _x_
|
|
||||||
/``([^`]+?)``/g, // ``x``
|
|
||||||
/`([^`]+?)`/g, // `x`
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Links/images -> their visible text. `!?` covers both `[t](u)` and ``. */
|
|
||||||
const LINK_IMAGE_RE = /!?\[([^\]]*)\]\([^)]*\)/g;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply the two balanced/link passes: first collapse links/images to their
|
|
||||||
* visible text, then collapse balanced inline wrappers repeatedly until stable.
|
|
||||||
* Does NOT trim decoration, does NOT guard against an empty result — it returns
|
|
||||||
* exactly the transformed string.
|
|
||||||
*/
|
|
||||||
function stripWrappersAndLinks(s: string): string {
|
|
||||||
// 1. Links/images -> their visible text.
|
|
||||||
let out = s.replace(LINK_IMAGE_RE, "$1");
|
|
||||||
|
|
||||||
// 2. Strip balanced wrappers, repeating until the string is stable so nested
|
|
||||||
// wrappers (`**_x_**`) and adjacent runs both collapse.
|
|
||||||
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
|
||||||
const before = out;
|
|
||||||
for (const re of WRAPPER_PATTERNS) {
|
|
||||||
out = out.replace(re, "$1");
|
|
||||||
}
|
|
||||||
if (out === before) break;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Conservatively strip inline markdown from a locator string.
|
|
||||||
*
|
|
||||||
* Deterministic, order-fixed steps:
|
|
||||||
* 1. Links/images: `[text](url)` -> `text`, `` -> `alt`.
|
|
||||||
* 2. Balanced inline wrappers (strong before emphasis, code, strikethrough),
|
|
||||||
* applied repeatedly until stable for nested cases.
|
|
||||||
* 3. Trim leading/trailing decoration only: whitespace, leftover marker chars
|
|
||||||
* (`* _ ~ \``) and emoji. Letters/digits and sentence punctuation (`.`/`,`
|
|
||||||
* etc.) are NEVER trimmed.
|
|
||||||
*
|
|
||||||
* If the result is empty (e.g. the input was only markers like `***`), the
|
|
||||||
* ORIGINAL string is returned so a locator can never normalize down to "" and
|
|
||||||
* match everything.
|
|
||||||
*/
|
|
||||||
export function stripInlineMarkdown(s: string): string {
|
|
||||||
if (typeof s !== "string" || s.length === 0) return s;
|
|
||||||
|
|
||||||
// 1 + 2. Shared link/image and balanced-wrapper passes.
|
|
||||||
let out = stripWrappersAndLinks(s);
|
|
||||||
|
|
||||||
// 3. Trim leading/trailing decoration: whitespace, leftover markdown markers,
|
|
||||||
// and emoji (Extended_Pictographic plus the VS16 / ZWJ joiners, plus the
|
|
||||||
// regional-indicator range U+1F1E6–U+1F1FF for flag emoji, which are NOT
|
|
||||||
// Extended_Pictographic). The `u` flag enables the Unicode property escape.
|
|
||||||
// Anchored runs only — interior text and sentence punctuation are untouched.
|
|
||||||
const DECORATION =
|
|
||||||
"[\\s*_~\\x60\\p{Extended_Pictographic}\\u{1F1E6}-\\u{1F1FF}\\u{FE0F}\\u{200D}]+";
|
|
||||||
out = out
|
|
||||||
.replace(new RegExp("^" + DECORATION, "u"), "")
|
|
||||||
.replace(new RegExp(DECORATION + "$", "u"), "");
|
|
||||||
|
|
||||||
// 4. Never normalize a locator down to nothing.
|
|
||||||
if (out.length === 0) return s;
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user