14d7b21df0
client.ts был god-object'ом на 5206 строк / ~65 методов / 5 ответственностей — любая правка рисковала всем write-path. Разнесли на доменные модули; DocmostClient остаётся ТОНКИМ ФАСАДОМ с прежним внешним контрактом. Чистый рефакторинг, поведение не меняется. closes #450 - Фасад client.ts (93 строки) композирует 10 миксинов над общим абстрактным базовым классом. Паттерн МИКСИНЫ (не context-object): тесты субклассируют DocmostClient и переопределяют seam'ы; единая цепочка прототипов сохраняет виртуальную диспетчеризацию this.<method> сквозь модули. - client/context.ts (база): общее состояние (axios, apiUrl, токены, кэши), конструктор + оба интерсептора, login/ensureAuthenticated/paginateAll/ resolvePageId/mutateLiveContentUnlocked + write-seam'ы mutatePage/replacePage. private→protected (внутреннее, не в публичном контракте). - Модули (каждый ≤730): read, pages, nodes-write, media (images/attachments/ drawio), comments, transforms, tables, stash, doc-validate. errors.ts — единый REST error-mapping (довершение #437; тексты сообщений без изменений). - Внешний контракт СОХРАНЁН (доказано компиляцией с обеих сторон): 53 публичных метода через `implements IXMixin` (без ручного зеркала, #446); оба Pick (in-app loader + tool-specs) резолвятся; множество async-методов 59==59. Нагруженные seam'ы (replaceImage один-лок #425, self-resolve #449, единый error-путь) байт-идентичны; ни одного дубля публичного метода. - zod v3→v4: investigate-only, отложено — SDK 1.29 поддерживает v4, но мажор- бамп трогает всю схема-поверхность; отдельным follow-up. Стоит на #449 (#475). Тесты: mcp node --test 800/800 (== база), tsc чисто в client/. Ни одной строки кода не потеряно; убраны 52 дублированных JSDoc-блока. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
705 lines
28 KiB
TypeScript
705 lines
28 KiB
TypeScript
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
|
|
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
|
|
// changed to a mixin factory. See client/context.ts for the shared base.
|
|
import type { GConstructor, DocmostClientContext } from "./context.js";
|
|
import { assertFullUuid } from "./errors.js";
|
|
import {
|
|
filterWorkspace,
|
|
filterSpace,
|
|
filterPage,
|
|
filterComment,
|
|
filterSearchResult,
|
|
} from "../lib/filters.js";
|
|
import { convertProseMirrorToMarkdown } from "../lib/markdown-converter.js";
|
|
import {
|
|
updatePageContentRealtime,
|
|
replacePageContent,
|
|
markdownToProseMirror,
|
|
markdownToProseMirrorCanonical,
|
|
mutatePageContent,
|
|
assertYjsEncodable,
|
|
MutationResult,
|
|
} from "../lib/collaboration.js";
|
|
import {
|
|
replaceNodeById,
|
|
replaceNodeByIdWithMany,
|
|
reassignCollidingBlockIds,
|
|
deleteNodeById,
|
|
assertUnambiguousMatch,
|
|
insertNodeRelative,
|
|
insertNodesRelative,
|
|
blockPlainText,
|
|
buildOutline,
|
|
getNodeByRef,
|
|
readTable,
|
|
insertTableRow,
|
|
deleteTableRow,
|
|
updateTableCell,
|
|
findInvalidNode,
|
|
} from "@docmost/prosemirror-markdown";
|
|
import {
|
|
applyAnchorInDoc,
|
|
countAnchorMatches,
|
|
getAnchoredText,
|
|
resolveAnchorSelection,
|
|
normalizeForMatch,
|
|
} from "../lib/comment-anchor.js";
|
|
import { closestBlockHint } from "../lib/text-normalize.js";
|
|
import {
|
|
blockText,
|
|
walk,
|
|
getList,
|
|
insertMarkerAfter,
|
|
setCalloutRange,
|
|
noteItem,
|
|
mdToInlineNodes,
|
|
commentsToFootnotes,
|
|
canonicalizeFootnotes,
|
|
insertInlineFootnote,
|
|
mergeFootnoteDefinitions,
|
|
} from "../lib/transforms.js";
|
|
|
|
// Public method surface of CommentsMixin (issue #450) — a NAMED type so the factory
|
|
// return type is expressible in the emitted .d.ts (the anonymous mixin class
|
|
// carries the base's protected shared state, which would otherwise trip TS4094).
|
|
// Derived from the class below; `implements ICommentsMixin` fails to compile on drift.
|
|
export interface ICommentsMixin {
|
|
listComments(pageId: string, includeResolved?: boolean): any;
|
|
getComment(commentId: string): any;
|
|
createComment(pageId: string, content: string, type?: "page" | "inline", selection?: string, parentCommentId?: string, suggestedText?: string): any;
|
|
updateComment(commentId: string, content: string): any;
|
|
deleteComment(commentId: string): any;
|
|
resolveComment(commentId: string, resolved: boolean): any;
|
|
checkNewComments(spaceId: string, since: string, parentPageId?: string): any;
|
|
}
|
|
|
|
export function CommentsMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & ICommentsMixin> & TBase {
|
|
abstract class CommentsMixin extends Base implements ICommentsMixin {
|
|
// --- Comment methods (ported from upstream PR #3 by Max Nikitin) ---
|
|
|
|
/**
|
|
* Normalize a comment's `content` into a ProseMirror doc object before
|
|
* markdown conversion. createComment/updateComment send content as a
|
|
* JSON.stringify(...) STRING, and the server stores it as-is, so on read it
|
|
* comes back as a string. convertProseMirrorToMarkdown returns "" for a
|
|
* string, so parse it first (guarded — fall back to the raw value on any
|
|
* parse failure so a non-JSON legacy value is still handled gracefully).
|
|
*/
|
|
protected parseCommentContent(content: any): any {
|
|
if (typeof content !== "string") return content;
|
|
try {
|
|
return JSON.parse(content);
|
|
} catch {
|
|
return content;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List comments on a page (cursor-paginated), content as markdown.
|
|
*
|
|
* DEFAULT (`includeResolved = false`) hides RESOLVED THREADS WHOLESALE so the
|
|
* agent sees only active discussions: a top-level comment with `resolvedAt`
|
|
* set AND every reply under it (a reply of a closed thread is part of the
|
|
* closed thread) are dropped from `items`. `resolvedThreadsHidden` reports how
|
|
* many resolved top-level threads were hidden so the agent can re-query with
|
|
* `includeResolved: true` to see everything. Active threads always stay.
|
|
*
|
|
* Returns `{ items, resolvedThreadsHidden }` (NOT a bare array) — callers that
|
|
* need the full feed (lossless export, transformPage, checkNewComments) pass
|
|
* `includeResolved: true` and read `.items`.
|
|
*/
|
|
async listComments(pageId: string, includeResolved = false) {
|
|
await this.ensureAuthenticated();
|
|
let allComments: any[] = [];
|
|
let cursor: string | null = null;
|
|
|
|
// Hard ceiling + immovable-cursor guard (mirrors paginateAll): if /comments
|
|
// ever stops advancing the cursor (the exact #442 drift scenario) this loop
|
|
// would otherwise spin forever accumulating duplicates.
|
|
const MAX_PAGES = 50;
|
|
let truncated = false;
|
|
|
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
const payload: Record<string, any> = { pageId, limit: 100 };
|
|
if (cursor) payload.cursor = cursor;
|
|
|
|
const response = await this.client.post("/comments", payload);
|
|
const data = response.data.data || response.data;
|
|
const items = data.items || [];
|
|
allComments = allComments.concat(items);
|
|
|
|
// Advance strictly via the server-issued cursor. A missing nextCursor or a
|
|
// cursor identical to the one we just sent means the end (or a server that
|
|
// ignores our pagination param) — stop instead of re-fetching page one.
|
|
const next: string | null = data.meta?.nextCursor || null;
|
|
if (!next || next === cursor) break;
|
|
cursor = next;
|
|
|
|
// Reaching the ceiling with a still-advancing cursor means truncation.
|
|
if (page === MAX_PAGES - 1) truncated = true;
|
|
}
|
|
|
|
if (truncated) {
|
|
console.warn(
|
|
`listComments: comments for "${pageId}" truncated at the ${MAX_PAGES}-page cap; more pages exist on the server`,
|
|
);
|
|
}
|
|
|
|
const mapped = allComments.map((comment: any) => {
|
|
const markdown = comment.content
|
|
? convertProseMirrorToMarkdown(
|
|
this.parseCommentContent(comment.content),
|
|
)
|
|
: "";
|
|
return filterComment(comment, markdown);
|
|
});
|
|
|
|
if (includeResolved) {
|
|
return { items: mapped, resolvedThreadsHidden: 0 };
|
|
}
|
|
|
|
// Ids of RESOLVED top-level threads (a top-level comment has no
|
|
// parentCommentId). A whole thread is hidden when its root is resolved.
|
|
const resolvedRootIds = new Set(
|
|
mapped
|
|
.filter((c) => !c.parentCommentId && c.resolvedAt != null)
|
|
.map((c) => c.id),
|
|
);
|
|
|
|
const items = mapped.filter((c) => {
|
|
// Hide the resolved root itself and every reply anchored to it. A reply's
|
|
// own resolvedAt is irrelevant — its membership follows the parent thread.
|
|
// ASSUMPTION: Docmost's comment model is FLAT — a reply's parentCommentId
|
|
// always points at the thread ROOT (no reply-of-reply nesting), so a single
|
|
// level of parent lookup covers a whole thread. If nested replies are ever
|
|
// introduced, a deep reply of a resolved thread would need a root-walk here.
|
|
if (!c.parentCommentId) return !resolvedRootIds.has(c.id);
|
|
return !resolvedRootIds.has(c.parentCommentId);
|
|
});
|
|
|
|
return { items, resolvedThreadsHidden: resolvedRootIds.size };
|
|
}
|
|
|
|
|
|
async getComment(commentId: string) {
|
|
// Fail fast (#436): reject a truncated id before any network call.
|
|
assertFullUuid("get_comment", "commentId", commentId);
|
|
await this.ensureAuthenticated();
|
|
const response = await this.client.post("/comments/info", { commentId });
|
|
const comment = response.data.data || response.data;
|
|
const markdown = comment.content
|
|
? convertProseMirrorToMarkdown(this.parseCommentContent(comment.content))
|
|
: "";
|
|
return {
|
|
data: filterComment(comment, markdown),
|
|
success: true,
|
|
};
|
|
}
|
|
|
|
/** Plain text of each TOP-LEVEL block of `doc`, for anchor-failure hints. */
|
|
protected topLevelBlockTexts(doc: any): string[] {
|
|
const content = doc && Array.isArray(doc.content) ? doc.content : [];
|
|
return content
|
|
.map((b: any) => blockPlainText(b))
|
|
.filter((t: string) => t.length > 0);
|
|
}
|
|
|
|
/**
|
|
* True when per-block anchoring failed but the (normalized) selection DOES
|
|
* appear in the blocks' joined plain text — i.e. it straddles a block
|
|
* boundary. Blocks are joined with a newline (collapsed to one space by
|
|
* normalizeForMatch) so a selection whose parts are separated by a paragraph
|
|
* break still matches. Callers only reach here after single-block anchoring
|
|
* (incl. the markdown-strip fallback) has already failed.
|
|
*/
|
|
protected selectionSpansMultipleBlocks(
|
|
blockTexts: string[],
|
|
selection: string,
|
|
): boolean {
|
|
const normSel = normalizeForMatch(selection).norm.trim();
|
|
if (normSel.length === 0) return false;
|
|
const joined = normalizeForMatch(blockTexts.join("\n")).norm;
|
|
return joined.indexOf(normSel) !== -1;
|
|
}
|
|
|
|
/**
|
|
* Build the actionable error for a createComment anchor MISS, porting
|
|
* editPageText's self-correction affordances: an explicit "spans multiple
|
|
* blocks" message when the selection straddles a block boundary, otherwise a
|
|
* "closest block text" hint quoting the block that holds the selection's
|
|
* longest token. `live` switches the wording between the pre-check (reading the
|
|
* persisted page) and the post-create live-anchor failure (which rolls back).
|
|
*/
|
|
protected anchorNotFoundError(
|
|
doc: any,
|
|
selection: string,
|
|
live: boolean,
|
|
): Error {
|
|
const blockTexts = this.topLevelBlockTexts(doc);
|
|
const rolled = live ? " The comment was rolled back." : "";
|
|
if (this.selectionSpansMultipleBlocks(blockTexts, selection)) {
|
|
return new Error(
|
|
"createComment: the selection spans multiple blocks; anchor on a " +
|
|
"contiguous fragment within a SINGLE paragraph/block (<=250 chars)." +
|
|
rolled,
|
|
);
|
|
}
|
|
const where = live ? "in the live document" : "in the page";
|
|
return new Error(
|
|
`createComment: could not find the selection text ${where} to anchor ` +
|
|
"the comment. Provide the EXACT contiguous text from a single " +
|
|
"paragraph/block (<=250 chars)." +
|
|
closestBlockHint(blockTexts, selection) +
|
|
rolled,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create an inline comment anchored to its `selection` text, or a reply.
|
|
*
|
|
* Top-level comments (no `parentCommentId`) are ALWAYS inline and MUST carry a
|
|
* `selection`: the `type` argument is kept for interface compatibility but the
|
|
* effective type is coerced to "inline". The selection has to anchor in the
|
|
* document; if it cannot, the comment is rolled back and an error is thrown so
|
|
* the caller is forced to supply a proper inline selection rather than leaving
|
|
* an orphan, unanchored comment behind. Replies (parentCommentId set) inherit
|
|
* their parent's anchor: they take NO selection and are not anchored.
|
|
*/
|
|
async createComment(
|
|
pageId: string,
|
|
content: string,
|
|
type: "page" | "inline" = "page",
|
|
selection?: string,
|
|
parentCommentId?: string,
|
|
suggestedText?: string,
|
|
) {
|
|
// Fail fast (#436): a provided parent id must be a full UUID before any
|
|
// network call. Validate only when truthy — a falsy parentCommentId means
|
|
// "top-level comment" (mirrors the isReply computation below), not a reply.
|
|
if (parentCommentId) {
|
|
assertFullUuid("createComment", "parentCommentId", parentCommentId);
|
|
}
|
|
await this.ensureAuthenticated();
|
|
|
|
const isReply = !!parentCommentId;
|
|
const hasSuggestion =
|
|
suggestedText !== undefined && suggestedText !== null;
|
|
// Defense in depth mirroring the server DTO/service: a suggested edit rewrites
|
|
// the exact anchored text, so it is only meaningful on a top-level inline
|
|
// comment that carries a selection.
|
|
if (hasSuggestion) {
|
|
if (isReply) {
|
|
throw new Error(
|
|
"createComment: a suggested edit (suggestedText) cannot be attached to a reply; it applies only to a top-level inline comment.",
|
|
);
|
|
}
|
|
if (!selection || !selection.trim()) {
|
|
throw new Error(
|
|
"createComment: a suggested edit (suggestedText) requires a 'selection' to anchor and rewrite.",
|
|
);
|
|
}
|
|
}
|
|
// Only top-level comments are inline-anchored, so they are stored as
|
|
// "inline". Replies carry no inline selection, so they keep the historical
|
|
// general ("page") type — both backward-compatible and semantically correct.
|
|
// The `type` argument is kept for interface compatibility; createComment
|
|
// normalizes the effective type internally, so callers may pass "inline".
|
|
const effectiveType: "page" | "inline" = isReply ? "page" : "inline";
|
|
if (!isReply && (!selection || !selection.trim())) {
|
|
throw new Error(
|
|
"createComment: an inline 'selection' (exact text to anchor on) is required for a top-level comment",
|
|
);
|
|
}
|
|
|
|
// For a SUGGESTION, the value we store as the comment's `selection` must be
|
|
// the RAW document substring the mark lands on (typographic quotes/dashes,
|
|
// nbsp, collapsed whitespace), NOT the agent's ASCII input. The anchor is
|
|
// placed via normalization, so when the doc was auto-converted to
|
|
// typographic the raw substring differs from the agent input; apply-time
|
|
// compares the stored selection to the marked doc text STRICTLY, so storing
|
|
// the raw substring is what makes "Apply" succeed instead of a spurious 409.
|
|
// Captured in the pre-check below (which already reads the page) and used as
|
|
// payload.selection. Ordinary comments keep sending the raw agent selection.
|
|
let anchoredSelection: string | null = null;
|
|
// Set when the anchor matched only after stripping markdown from the
|
|
// selection (the strip fallback); surfaced as a soft warning like
|
|
// editPageText does, so a stale-markdown selection is flagged.
|
|
let anchorNormalized = false;
|
|
|
|
// For a top-level comment, fail BEFORE creating anything when the selection
|
|
// is not present in the persisted document — this avoids leaving an orphan
|
|
// comment + notification behind. A read failure (network) is non-fatal: the
|
|
// live anchor step below still enforces the anchoring invariant.
|
|
if (!isReply && selection) {
|
|
try {
|
|
const page = await this.getPageJson(pageId);
|
|
if (hasSuggestion) {
|
|
// A suggestion's anchor MUST be unambiguous: applying it rewrites the
|
|
// exact anchored text, and ordinary anchoring silently takes the first
|
|
// occurrence, so 0 matches -> not found and >=2 -> ambiguous, both
|
|
// rejected BEFORE creating the comment.
|
|
const matches = countAnchorMatches(page.content, selection);
|
|
if (matches === 0) {
|
|
throw this.anchorNotFoundError(page.content, selection, false);
|
|
}
|
|
if (matches >= 2) {
|
|
throw new Error(
|
|
`createComment: the suggestion's selection is ambiguous — it occurs ${matches} times in the page. ` +
|
|
"A suggested edit must anchor to a UNIQUE location; expand the selection with surrounding context " +
|
|
"(still <=250 chars) so it appears exactly once.",
|
|
);
|
|
}
|
|
// Exactly one match: capture the RAW anchored substring to store as the
|
|
// comment selection (so apply-time equality holds). If this returns
|
|
// null despite countAnchorMatches===1 (shouldn't happen), fall back to
|
|
// the raw agent selection below rather than crash.
|
|
anchoredSelection = getAnchoredText(page.content, selection);
|
|
anchorNormalized = resolveAnchorSelection(
|
|
page.content,
|
|
selection,
|
|
).normalized;
|
|
} else {
|
|
const resolved = resolveAnchorSelection(page.content, selection);
|
|
if (!resolved.found) {
|
|
throw this.anchorNotFoundError(page.content, selection, false);
|
|
}
|
|
anchorNormalized = resolved.normalized;
|
|
}
|
|
} catch (e) {
|
|
// Rethrow our own "not found"/"ambiguous"/"spans multiple blocks" errors;
|
|
// swallow read/network errors so the live anchor step can still try (and
|
|
// enforce) anchoring.
|
|
if (
|
|
e instanceof Error &&
|
|
(e.message.startsWith("createComment: could not find the selection") ||
|
|
e.message.startsWith(
|
|
"createComment: the selection spans multiple blocks",
|
|
) ||
|
|
e.message.startsWith(
|
|
"createComment: the suggestion's selection is ambiguous",
|
|
))
|
|
) {
|
|
throw e;
|
|
}
|
|
if (process.env.DEBUG) {
|
|
console.error(
|
|
"Pre-check getPageJson failed; deferring to live anchor step:",
|
|
e,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert through the full Docmost schema. Deliberately the NON-canonicalizing
|
|
// variant: a comment body may carry a footnote definition with no matching
|
|
// reference, and canonicalization would drop it (data loss). See
|
|
// markdownToProseMirror vs markdownToProseMirrorCanonical.
|
|
const jsonContent = await markdownToProseMirror(content);
|
|
const payload: Record<string, any> = {
|
|
pageId,
|
|
content: JSON.stringify(jsonContent),
|
|
type: effectiveType,
|
|
};
|
|
// For a suggestion, store the RAW anchored substring (anchoredSelection) so
|
|
// the stored selection === the text under the mark === apply-time
|
|
// expectedText. Ordinary comments (and the null fallback) keep the raw
|
|
// agent selection — their selection is only display/anchor and never used
|
|
// by apply, so their behavior is unchanged.
|
|
if (!isReply && selection)
|
|
payload.selection = anchoredSelection ?? selection;
|
|
if (parentCommentId) payload.parentCommentId = parentCommentId;
|
|
// Only a top-level inline comment (with a selection) may carry a suggestion.
|
|
if (!isReply && selection && hasSuggestion) {
|
|
payload.suggestedText = suggestedText;
|
|
}
|
|
|
|
const response = await this.client.post("/comments/create", payload);
|
|
const comment = response.data.data || response.data;
|
|
const markdown = comment.content
|
|
? convertProseMirrorToMarkdown(this.parseCommentContent(comment.content))
|
|
: content;
|
|
const result: any = {
|
|
data: filterComment(comment, markdown),
|
|
success: true,
|
|
};
|
|
|
|
// Replies inherit the parent's anchor: no selection, no anchoring.
|
|
if (isReply) {
|
|
return result;
|
|
}
|
|
|
|
// Anchor the comment in the document. The /comments/create API records the
|
|
// comment + its `selection` text, but it does NOT insert the comment MARK
|
|
// into the page content, so without this the inline comment has no
|
|
// highlight/anchor and is not clickable. If anchoring fails the comment is
|
|
// rolled back (deleted) and an error is thrown — never an orphan comment.
|
|
const newCommentId: string = comment.id;
|
|
// Guard: a create response without an id would mean writing a comment mark
|
|
// with commentId: undefined and a later delete of a falsy id. We have no id
|
|
// to roll back here (nothing was created with an id), so just fail loudly.
|
|
if (!newCommentId) {
|
|
throw new Error(
|
|
"createComment: the server returned no comment id, so the comment could not be anchored",
|
|
);
|
|
}
|
|
let anchored = false;
|
|
// Set inside the transform when a suggestion's live anchor is ambiguous
|
|
// (>=2 occurrences), so the rollback path can surface the right error.
|
|
let ambiguousInLiveDoc = false;
|
|
// Captured inside the transform on a not-found abort, so the rollback path
|
|
// can surface the closest-block / spans-multiple-blocks hint built from the
|
|
// LIVE document (the pre-check page is not in scope there).
|
|
let liveNotFoundError: Error | null = null;
|
|
try {
|
|
const collabToken = await this.getCollabTokenWithReauth();
|
|
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
|
// /comments/create REST call above keeps the agent-supplied id.
|
|
const pageUuid = await this.resolvePageId(pageId);
|
|
// Route through the mutatePage seam (not the free function) so this
|
|
// wrapper's uniqueness gate + rollback can be unit-tested without a live
|
|
// Hocuspocus collab socket.
|
|
const mutation = await this.mutatePage(
|
|
pageUuid,
|
|
collabToken,
|
|
this.apiUrl,
|
|
(liveDoc) => {
|
|
const doc =
|
|
liveDoc && liveDoc.type === "doc"
|
|
? liveDoc
|
|
: { type: "doc", content: [] };
|
|
if (hasSuggestion) {
|
|
// Authoritative uniqueness check against the LIVE document: a
|
|
// suggestion must anchor to EXACTLY ONE occurrence, otherwise
|
|
// "Apply" would rewrite the wrong/ambiguous text. If the live doc
|
|
// no longer has exactly one occurrence (it changed since the
|
|
// pre-check), abort so the just-created comment is rolled back
|
|
// rather than mis-anchored to the first occurrence.
|
|
const liveCount = countAnchorMatches(doc, selection as string);
|
|
if (liveCount !== 1) {
|
|
ambiguousInLiveDoc = liveCount >= 2;
|
|
if (liveCount === 0) {
|
|
liveNotFoundError = this.anchorNotFoundError(
|
|
doc,
|
|
selection as string,
|
|
true,
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
if (applyAnchorInDoc(doc, selection as string, newCommentId)) {
|
|
anchored = true;
|
|
return doc;
|
|
}
|
|
// Selection text not found in the LIVE document: abort the write. The
|
|
// rollback + throw below turns this into a hard error.
|
|
liveNotFoundError = this.anchorNotFoundError(
|
|
doc,
|
|
selection as string,
|
|
true,
|
|
);
|
|
return null;
|
|
},
|
|
);
|
|
result.verify = mutation.verify;
|
|
} catch (e) {
|
|
// The comment record already exists; roll it back so we never leave an
|
|
// orphan, then rethrow the original anchoring error.
|
|
await this.safeDeleteComment(newCommentId);
|
|
throw e;
|
|
}
|
|
|
|
if (!anchored) {
|
|
// Mutation aborted because the selection was not found (or, for a
|
|
// suggestion, was ambiguous) in the live document. Roll back the comment
|
|
// and surface a hard error.
|
|
await this.safeDeleteComment(newCommentId);
|
|
if (ambiguousInLiveDoc) {
|
|
throw new Error(
|
|
"createComment: the suggestion's selection is ambiguous in the live document (multiple occurrences); the comment was rolled back. Expand the selection with surrounding context so it is unique.",
|
|
);
|
|
}
|
|
throw (
|
|
liveNotFoundError ??
|
|
new Error(
|
|
"createComment: failed to anchor the comment (selection not found in the live document); the comment was rolled back",
|
|
)
|
|
);
|
|
}
|
|
|
|
// Soft warning (like editPageText): the selection only matched after
|
|
// stripping markdown, so the caller likely quoted a styled fragment.
|
|
if (anchorNormalized) {
|
|
result.warning =
|
|
"The selection matched only after stripping markdown syntax; the comment " +
|
|
"was anchored on the document's plain text. Copy the selection verbatim " +
|
|
"from getPage / searchInPage output to avoid this.";
|
|
}
|
|
|
|
result.anchored = true;
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Best-effort rollback of a just-created comment. Swallows any delete failure
|
|
* (logging under DEBUG) so a failed cleanup never masks the original error.
|
|
*/
|
|
protected async safeDeleteComment(commentId: string): Promise<void> {
|
|
// Defense in depth: never call the delete API with a falsy id — there is
|
|
// nothing to roll back, and deleteComment(undefined) would hit a bad route.
|
|
if (!commentId) return;
|
|
try {
|
|
await this.deleteComment(commentId);
|
|
} catch (delErr) {
|
|
if (process.env.DEBUG) {
|
|
console.error(
|
|
"Failed to roll back comment after anchoring error:",
|
|
delErr,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
async updateComment(commentId: string, content: string) {
|
|
// Fail fast (#436): reject a truncated id before any network call.
|
|
assertFullUuid("updateComment", "commentId", commentId);
|
|
await this.ensureAuthenticated();
|
|
// NON-canonicalizing on purpose (comment body — see createComment).
|
|
const jsonContent = await markdownToProseMirror(content);
|
|
await this.client.post("/comments/update", {
|
|
commentId,
|
|
content: JSON.stringify(jsonContent),
|
|
});
|
|
return {
|
|
success: true,
|
|
commentId,
|
|
message: "Comment updated successfully.",
|
|
};
|
|
}
|
|
|
|
|
|
async deleteComment(commentId: string) {
|
|
// Fail fast (#436): reject a truncated id before any network call.
|
|
assertFullUuid("deleteComment", "commentId", commentId);
|
|
await this.ensureAuthenticated();
|
|
return this.client
|
|
.post("/comments/delete", { commentId })
|
|
.then((res) => res.data);
|
|
}
|
|
|
|
/**
|
|
* Resolve or reopen a top-level comment thread (reversible — `resolved`
|
|
* toggles the state). Only top-level comments can be resolved; the server
|
|
* rejects resolving a reply. Hits POST /comments/resolve.
|
|
*/
|
|
async resolveComment(commentId: string, resolved: boolean) {
|
|
// Fail fast (#436): reject a truncated id before any network call.
|
|
assertFullUuid("resolveComment", "commentId", commentId);
|
|
await this.ensureAuthenticated();
|
|
const response = await this.client.post("/comments/resolve", {
|
|
commentId,
|
|
resolved,
|
|
});
|
|
const comment = response.data?.data ?? response.data;
|
|
return {
|
|
success: true,
|
|
commentId,
|
|
resolved,
|
|
comment,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check for new comments across pages in a space (optionally scoped to a
|
|
* subtree): pages updated after `since` are scanned and their comments
|
|
* filtered by createdAt > since.
|
|
*/
|
|
async checkNewComments(
|
|
spaceId: string,
|
|
since: string,
|
|
parentPageId?: string,
|
|
) {
|
|
await this.ensureAuthenticated();
|
|
|
|
const sinceDate = new Date(since);
|
|
|
|
// Reject an unparseable `since`: comparing against an Invalid Date silently
|
|
// yields zero new comments (every `>` against NaN is false), which would
|
|
// mask a malformed input as "nothing new" instead of erroring.
|
|
if (Number.isNaN(sinceDate.getTime())) {
|
|
throw new Error(
|
|
`checkNewComments: invalid "since" date "${since}"; expected an ISO-8601 timestamp`,
|
|
);
|
|
}
|
|
|
|
// 1. Enumerate the FULL set of pages in scope via the page tree (a complete
|
|
// page index), NOT the bounded "/pages/recent" feed which caps at ~5000
|
|
// recent items and silently misses comments on older pages.
|
|
//
|
|
// Subtree scope: when parentPageId is given, the scope is that page ITSELF
|
|
// plus every descendant. Otherwise the scope is the whole space (all roots
|
|
// and their descendants).
|
|
//
|
|
// NOTE: do NOT pre-filter by page.updatedAt — creating a comment does not
|
|
// bump it (verified on a live server), so such a filter silently misses
|
|
// comments on pages that were not otherwise edited. The complete tree walk
|
|
// already restricts the scope correctly, so no recent-feed allow-list is
|
|
// needed any more.
|
|
//
|
|
// The subtree scope (parentPageId given) already INCLUDES the root node
|
|
// itself: /pages/tree seeds getPageAndDescendants with id = parentPageId, so
|
|
// no separate getPageRaw fetch for the parent is needed.
|
|
const { pages: pagesInScope, truncated } = await this.enumerateSpacePages(
|
|
spaceId,
|
|
parentPageId,
|
|
);
|
|
|
|
// 2. Fetch comments for each page, keep ones created after since
|
|
const results: any[] = [];
|
|
for (const page of pagesInScope) {
|
|
try {
|
|
// Full feed (incl. resolved): a "new comments since" scan reports all
|
|
// recent activity; the active-only filter is scoped to listComments.
|
|
const comments = (await this.listComments(page.id, true)).items;
|
|
const newComments = comments.filter(
|
|
(c: any) => new Date(c.createdAt) > sinceDate,
|
|
);
|
|
if (newComments.length > 0) {
|
|
results.push({
|
|
pageId: page.id,
|
|
pageTitle: page.title,
|
|
comments: newComments,
|
|
});
|
|
}
|
|
} catch (e: any) {
|
|
// Skip pages with errors (e.g. deleted between calls)
|
|
}
|
|
}
|
|
|
|
const totalNewComments = results.reduce(
|
|
(sum, r) => sum + r.comments.length,
|
|
0,
|
|
);
|
|
|
|
// `truncated` is reported by enumerateSpacePages: it is true ONLY when the
|
|
// stdio fallback BFS hit its node cap. The primary /pages/tree path is
|
|
// uncapped, so a space with legitimately many pages is not falsely flagged.
|
|
return {
|
|
since,
|
|
scope: parentPageId ? `subtree of ${parentPageId}` : `space ${spaceId}`,
|
|
checkedPages: pagesInScope.length,
|
|
pagesWithNewComments: results.length,
|
|
totalNewComments,
|
|
truncated,
|
|
comments: results,
|
|
};
|
|
}
|
|
|
|
// --- Image upload / embedding ---
|
|
|
|
/** Map a Content-Type string to a supported MIME type, or null if unsupported. */
|
|
}
|
|
return CommentsMixin;
|
|
}
|