Files
markdown-converter/src/plugins/built-in/collaboration/comment-store.js
T
amitwh 7ab5a0ddb4 feat(ai): add Anthropic-compatible provider; fix async plugin backends
Anthropic-compatible provider:
- New 'anthropic-compatible' option for any base URL speaking the Anthropic
  messages schema (LiteLLM proxies, Bedrock gateways, local servers)
- Sends x-api-key AND Bearer auth when a key is set (gateway-friendly,
  harmless for the official API); keyless proxies supported
- Tolerates base URLs with or without a trailing /v1 segment
- Settings modal, manifest, and provider docs updated

Runtime bug fixes found by booting the app (run-to-verify pass):
- PDF editor: File > Open PDF sends operation=null which matched no switch
  case and crashed on getElementById(undefined); now defaults to the merge
  section
- backlinks-panel: wrong require depth (../../utils -> ../utils) threw at
  panel registration time
- writing-studio stack was written against a synchronous settings backend but
  the real one is IPC-backed: GoalTracker/SnapshotManager/ProjectManager and
  all four panels now await; JSON.parse(Promise) crashes eliminated
- manuscript panel used window.prompt (unavailable in Electron); replaced
  with an inline dialog
- collaboration comment-store/save-load made async to match its IPC IO

617/617 tests green; 4 consecutive clean app boots (no uncaught errors)
2026-09-05 21:53:34 +05:30

180 lines
5.9 KiB
JavaScript

/**
* Comment store for the Collaboration plugin.
*
* Inline document comments live in a sidecar folder (`.comments/`) next to the
* document they annotate, one JSON file per document (`<name>.json`), matching
* the v5 collaboration design. Keeping comments out of the document itself
* means they never ship in exports or leak into Git commits of the prose.
*
* A comment anchors to a line number plus the anchor line's text snippet; the
* snippet lets the UI detect drift ("line moved / text changed") after edits
* without storing full document positions.
*
* All IO is injected, so the module is unit-testable and works from both the
* renderer (via IPC file helpers) and tests (via in-memory fakes).
*
* @module CommentStore
*/
const COMMENT_FOLDER = '.comments';
/**
* Path of the sidecar comments file for a document path.
* Pure string surgery — no IO, easy to test.
*
* @param {string} docPath Absolute path to the markdown document
* @param {Function} pathUtil path module (or equivalent) — injected for tests
* @returns {string} e.g. /docs/.comments/notes.md.json
*/
function commentsFilePathFor(docPath, pathUtil) {
const p = pathUtil;
return p.join(p.dirname(docPath), COMMENT_FOLDER, p.basename(docPath) + '.json');
}
/**
* Generate a reasonably unique comment id without external deps.
*/
function newCommentId(now = Date.now()) {
return 'c' + now.toString(36) + Math.random().toString(36).slice(2, 8);
}
/**
* Load all comments for a document. Missing file/corrupt JSON → [] (a fresh
* document simply has no comments yet; a corrupt store must not crash the app).
*
* Async: `io.readFile`/`io.fileExists` are IPC-backed Promises in the app
* (plain values in tests — `await` handles both).
*
* @param {string} docPath Document path
* @param {object} io - { readFile: (path) => string|null, fileExists: (path) => bool }
* @param {Function} pathUtil injected path module
* @returns {Promise<Array<object>>} comments sorted by line then createdAt
*/
async function loadComments(docPath, io, pathUtil) {
if (!docPath) return [];
const file = commentsFilePathFor(docPath, pathUtil);
try {
if (!(await io.fileExists(file))) return [];
const raw = await io.readFile(file);
const data = JSON.parse(raw);
if (!Array.isArray(data.comments)) return [];
return normalizeComments(data.comments);
} catch {
return [];
}
}
/**
* Persist comments for a document (creates the sidecar structure on demand).
*
* @param {string} docPath Document path
* @param {Array<object>} comments
* @param {object} io - { writeFile, ensureDirectory }
* @param {Function} pathUtil injected path module
*/
async function saveComments(docPath, comments, io, pathUtil) {
const file = commentsFilePathFor(docPath, pathUtil);
await io.ensureDirectory(pathUtil.dirname(file));
await io.writeFile(
file,
JSON.stringify({ version: 1, doc: pathUtil.basename(docPath), comments }, null, 2)
);
}
/** Coerce/validate raw entries into the canonical comment shape. */
function normalizeComments(raw) {
return raw
.filter((c) => c && typeof c === 'object' && typeof c.line === 'number')
.map((c) => ({
id: typeof c.id === 'string' ? c.id : newCommentId(),
line: Math.max(1, Math.floor(c.line)),
anchorText: typeof c.anchorText === 'string' ? c.anchorText : '',
author: typeof c.author === 'string' ? c.author : 'anonymous',
text: typeof c.text === 'string' ? c.text : '',
createdAt: typeof c.createdAt === 'number' ? c.createdAt : Date.now(),
resolved: c.resolved === true,
}))
.sort((a, b) => a.line - b.line || a.createdAt - b.createdAt);
}
/**
* Add a comment anchored to a document line.
*
* @param {Array<object>} comments Existing list (mutated copy returned)
* @param {{line:number, anchorText?:string, author?:string, text:string}} input
* @returns {object} the created comment
*/
function addComment(comments, input) {
const comment = {
id: newCommentId(),
line: Math.max(1, Math.floor(input.line || 1)),
anchorText: String(input.anchorText || '').slice(0, 200),
author: String(input.author || 'me'),
text: String(input.text || '').slice(0, 4000),
createdAt: Date.now(),
resolved: false,
};
comments.push(comment);
return comment;
}
/**
* Toggle a comment's resolved flag by id.
* @returns {boolean} new resolved state, or null when the id is unknown
*/
function toggleResolved(comments, id) {
const comment = comments.find((c) => c.id === id);
if (!comment) return null;
comment.resolved = !comment.resolved;
return comment.resolved;
}
/**
* Remove a comment by id. @returns {boolean} whether anything was removed.
*/
function deleteComment(comments, id) {
const index = comments.findIndex((c) => c.id === id);
if (index === -1) return false;
comments.splice(index, 1);
return true;
}
/**
* Find the next unresolved comment for F8-style navigation (wraps around).
* "Next" is relative to the given line: the first unresolved comment on a
* line > fromLine, wrapping to the top when none exist below.
*
* @returns {object|null} the comment to jump to, or null when none are open
*/
function nextUnresolved(comments, fromLine) {
const open = comments.filter((c) => !c.resolved);
if (open.length === 0) return null;
return open.find((c) => c.line > fromLine) || open[0];
}
/**
* Drift check for one comment against the live document lines.
* @returns {'ok'|'moved'|'changed'|'missing'} anchor status
*/
function anchorStatus(comment, lines) {
if (!Array.isArray(lines) || lines.length === 0) return 'missing';
if (comment.line > lines.length) return 'missing';
const currentText = lines[comment.line - 1].trim();
if (!comment.anchorText) return 'moved';
return currentText === comment.anchorText.trim() ? 'ok' : 'changed';
}
module.exports = {
COMMENT_FOLDER,
commentsFilePathFor,
loadComments,
saveComments,
addComment,
toggleResolved,
deleteComment,
nextUnresolved,
anchorStatus,
newCommentId,
normalizeComments,
};