feat(mcp): скрыть resolved-комментарии (якоря + list_comments) от агента (#328) #337
@@ -649,13 +649,21 @@ export class AiChatToolsService {
|
||||
|
||||
listComments: tool({
|
||||
description:
|
||||
'List ALL comments on a page in one call, including RESOLVED ' +
|
||||
'threads — filter by resolvedAt when you need only open ones. ' +
|
||||
'Content is returned as Markdown.',
|
||||
'List comments on a page in one call. By DEFAULT only ACTIVE ' +
|
||||
'threads are returned; resolved threads (a resolved top-level ' +
|
||||
'comment and all its replies) are hidden and their count reported ' +
|
||||
'as `resolvedThreadsHidden` so you can re-query with ' +
|
||||
'`includeResolved: true` to see everything. Returns ' +
|
||||
'`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
includeResolved: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('default only active threads; true — include resolved'),
|
||||
}),
|
||||
execute: async ({ pageId }) => await client.listComments(pageId),
|
||||
execute: async ({ pageId, includeResolved }) =>
|
||||
await client.listComments(pageId, includeResolved),
|
||||
}),
|
||||
|
||||
getComment: tool({
|
||||
|
||||
@@ -56,7 +56,12 @@ export interface DocmostClientLike {
|
||||
getPageJson(pageId: string): Promise<Record<string, unknown>>;
|
||||
getNode(pageId: string, nodeId: string): Promise<Record<string, unknown>>;
|
||||
getTable(pageId: string, tableRef: string): Promise<Record<string, unknown>>;
|
||||
listComments(pageId: string): Promise<unknown[]>;
|
||||
// Returns `{ items, resolvedThreadsHidden }`. DEFAULT (includeResolved unset/
|
||||
// false) hides resolved threads wholesale; pass true for the full feed.
|
||||
listComments(
|
||||
pageId: string,
|
||||
includeResolved?: boolean,
|
||||
): Promise<{ items: unknown[]; resolvedThreadsHidden: number }>;
|
||||
getComment(
|
||||
commentId: string,
|
||||
): Promise<{ data: Record<string, unknown>; success: boolean }>;
|
||||
|
||||
@@ -807,8 +807,14 @@ export class DocmostClient {
|
||||
await this.ensureAuthenticated();
|
||||
const resultData = await this.getPageRaw(pageId);
|
||||
|
||||
// Agent read: hide resolved-comment anchors so the agent sees only active
|
||||
// discussions. Active anchors are kept. (The lossless export_page_markdown
|
||||
// round-trip deliberately does NOT pass this flag — resolved anchors there
|
||||
// must be preserved.)
|
||||
let content = resultData.content
|
||||
? convertProseMirrorToMarkdown(resultData.content)
|
||||
? convertProseMirrorToMarkdown(resultData.content, {
|
||||
dropResolvedCommentAnchors: true,
|
||||
})
|
||||
: "";
|
||||
|
||||
// Always fetch subpages to provide context to the agent
|
||||
@@ -1774,7 +1780,10 @@ export class DocmostClient {
|
||||
const body = page.content ? convertProseMirrorToMarkdown(page.content) : "";
|
||||
let comments: any[] = [];
|
||||
try {
|
||||
comments = await this.listComments(pageId);
|
||||
// Lossless export: include RESOLVED threads so the export -> import
|
||||
// round-trip preserves every comment. This is exactly why the active-only
|
||||
// filter is an opt-in (default false) on listComments.
|
||||
comments = (await this.listComments(pageId, true)).items;
|
||||
} catch (e) {
|
||||
// A comments fetch failure must not lose the body; export with [] and let
|
||||
// the caller see the (empty) comments block. Log under DEBUG only.
|
||||
@@ -2343,8 +2352,21 @@ export class DocmostClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** List all comments on a page (cursor-paginated), content as markdown. */
|
||||
async listComments(pageId: string) {
|
||||
/**
|
||||
* 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;
|
||||
@@ -2360,7 +2382,7 @@ export class DocmostClient {
|
||||
cursor = data.meta?.nextCursor || null;
|
||||
} while (cursor);
|
||||
|
||||
return allComments.map((comment: any) => {
|
||||
const mapped = allComments.map((comment: any) => {
|
||||
const markdown = comment.content
|
||||
? convertProseMirrorToMarkdown(
|
||||
this.parseCommentContent(comment.content),
|
||||
@@ -2368,6 +2390,31 @@ export class DocmostClient {
|
||||
: "";
|
||||
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) {
|
||||
@@ -2742,7 +2789,9 @@ export class DocmostClient {
|
||||
const results: any[] = [];
|
||||
for (const page of pagesInScope) {
|
||||
try {
|
||||
const comments = await this.listComments(page.id);
|
||||
// Full feed (incl. resolved): a "new comments since" scan reports all
|
||||
// recent activity; the active-only filter is scoped to list_comments.
|
||||
const comments = (await this.listComments(page.id, true)).items;
|
||||
const newComments = comments.filter(
|
||||
(c: any) => new Date(c.createdAt) > sinceDate,
|
||||
);
|
||||
@@ -3488,7 +3537,9 @@ export class DocmostClient {
|
||||
const deleteComments = opts.deleteComments ?? false;
|
||||
|
||||
await this.ensureAuthenticated();
|
||||
const comments = await this.listComments(pageId);
|
||||
// Full feed (incl. resolved): a page transform (e.g. comments -> footnotes)
|
||||
// must operate on every comment, so it opts into the unfiltered feed.
|
||||
const comments = (await this.listComments(pageId, true)).items;
|
||||
|
||||
// ctx handed to the sandbox. consume() records ids; helpers are the pure
|
||||
// transform primitives. log is captured from console.log inside the sandbox.
|
||||
|
||||
@@ -712,15 +712,24 @@ server.registerTool(
|
||||
"list_comments",
|
||||
{
|
||||
description:
|
||||
"List ALL comments on a page in one call (pagination is handled " +
|
||||
"internally), including RESOLVED threads — filter by resolvedAt when you " +
|
||||
"need only open ones. Content is returned as Markdown.",
|
||||
"List comments on a page in one call (pagination is handled " +
|
||||
"internally). By DEFAULT only ACTIVE threads are returned; resolved " +
|
||||
"threads (a resolved top-level comment and all its replies) are hidden " +
|
||||
"and their count reported as `resolvedThreadsHidden` so you can re-query " +
|
||||
"with `includeResolved: true` to see everything. Returns " +
|
||||
"`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.",
|
||||
inputSchema: {
|
||||
pageId: z.string().describe("ID of the page"),
|
||||
includeResolved: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"default only active threads; true — include resolved",
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ pageId }) => {
|
||||
const comments = await docmostClient.listComments(pageId);
|
||||
async ({ pageId, includeResolved }) => {
|
||||
const comments = await docmostClient.listComments(pageId, includeResolved);
|
||||
return jsonContent(comments);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -9,4 +9,7 @@
|
||||
* many existing `./markdown-converter.js` importers (client.ts, tests) do not
|
||||
* have to move.
|
||||
*/
|
||||
export { convertProseMirrorToMarkdown } from "@docmost/prosemirror-markdown";
|
||||
export {
|
||||
convertProseMirrorToMarkdown,
|
||||
type ConvertProseMirrorToMarkdownOptions,
|
||||
} from "@docmost/prosemirror-markdown";
|
||||
|
||||
@@ -462,7 +462,7 @@ async function main() {
|
||||
check("create_comment: markdown round-trip", c1.data.content.includes("**комментарий**"), c1.data.content);
|
||||
const reply = await client.createComment(pageId, "Ответ на комментарий.", "page", undefined, c1.data.id);
|
||||
check("create_comment: reply has parent", reply.data.parentCommentId === c1.data.id);
|
||||
const list = await client.listComments(pageId);
|
||||
const list = (await client.listComments(pageId)).items;
|
||||
check("list_comments: both visible", list.length === 2, `count=${list.length}`);
|
||||
await client.updateComment(c1.data.id, "Обновлённый текст комментария.");
|
||||
const got = await client.getComment(c1.data.id);
|
||||
@@ -472,17 +472,19 @@ async function main() {
|
||||
// resolve_comment: close the top-level thread, verify resolvedAt surfaces, then reopen
|
||||
const resolvedRes = await client.resolveComment(c1.data.id, true);
|
||||
check("resolve_comment: marks resolved", resolvedRes.success === true && resolvedRes.resolved === true);
|
||||
const listResolved = await client.listComments(pageId);
|
||||
// c1 is now resolved; the default feed hides resolved threads, so pass
|
||||
// includeResolved:true to still see it and assert its resolvedAt (#328).
|
||||
const listResolved = (await client.listComments(pageId, true)).items;
|
||||
const c1Resolved = listResolved.find((c) => c.id === c1.data.id);
|
||||
check("resolve_comment: resolvedAt set in list", !!c1Resolved?.resolvedAt, `resolvedAt=${c1Resolved?.resolvedAt}`);
|
||||
const reopenedRes = await client.resolveComment(c1.data.id, false);
|
||||
check("resolve_comment: reopen succeeds", reopenedRes.resolved === false);
|
||||
const listReopened = await client.listComments(pageId);
|
||||
const listReopened = (await client.listComments(pageId)).items;
|
||||
const c1Reopened = listReopened.find((c) => c.id === c1.data.id);
|
||||
check("resolve_comment: resolvedAt cleared on reopen", !c1Reopened?.resolvedAt, `resolvedAt=${c1Reopened?.resolvedAt}`);
|
||||
await client.deleteComment(reply.data.id);
|
||||
await client.deleteComment(c1.data.id);
|
||||
const listAfter = await client.listComments(pageId);
|
||||
const listAfter = (await client.listComments(pageId)).items;
|
||||
check("delete_comment: comments removed", listAfter.length === 0, `count=${listAfter.length}`);
|
||||
} finally {
|
||||
if (pageId) {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// gitmost #328 Channel 2: DocmostClient.listComments hides RESOLVED THREADS
|
||||
// wholesale by default (a resolved top-level comment AND every reply under it),
|
||||
// returning `{ items, resolvedThreadsHidden }`. `includeResolved: true` returns
|
||||
// the full feed. These tests stand a local http.createServer in for Docmost and
|
||||
// mock the /auth/login + /comments (paginated) routes.
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import { DocmostClient } from "../../build/client.js";
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let raw = "";
|
||||
req.on("data", (c) => (raw += c));
|
||||
req.on("end", () => resolve(raw));
|
||||
});
|
||||
}
|
||||
|
||||
function startServer(handler) {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer(handler);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server.address();
|
||||
resolve({ server, baseURL: `http://127.0.0.1:${port}/api` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
|
||||
function sendJson(res, status, obj, extraHeaders = {}) {
|
||||
res.writeHead(status, { "Content-Type": "application/json", ...extraHeaders });
|
||||
res.end(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
const openServers = [];
|
||||
async function spawn(handler) {
|
||||
const { server, baseURL } = await startServer(handler);
|
||||
openServers.push(server);
|
||||
return { server, baseURL };
|
||||
}
|
||||
|
||||
after(async () => {
|
||||
await Promise.all(openServers.map((s) => closeServer(s)));
|
||||
});
|
||||
|
||||
// A minimal ProseMirror comment body (a paragraph of text).
|
||||
const body = (t) => ({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
|
||||
});
|
||||
|
||||
// Feed: an ACTIVE thread whose REPLY is resolved (root active, reply resolved —
|
||||
// the thread must STAY, because a thread is gated only by its ROOT's resolvedAt)
|
||||
// and a RESOLVED thread (root + reply).
|
||||
const FEED = [
|
||||
{
|
||||
id: "a",
|
||||
pageId: "page-1",
|
||||
parentCommentId: null,
|
||||
resolvedAt: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
creatorId: "u1",
|
||||
content: body("active root"),
|
||||
},
|
||||
{
|
||||
id: "a1",
|
||||
pageId: "page-1",
|
||||
parentCommentId: "a",
|
||||
// A RESOLVED reply under an ACTIVE root: the thread is NOT hidden (only a
|
||||
// resolved ROOT hides a thread), so this reply survives the default filter.
|
||||
resolvedAt: "2026-02-15T00:00:00.000Z",
|
||||
createdAt: "2026-01-01T01:00:00.000Z",
|
||||
creatorId: "u1",
|
||||
content: body("resolved reply of an active thread"),
|
||||
},
|
||||
{
|
||||
id: "r",
|
||||
pageId: "page-1",
|
||||
parentCommentId: null,
|
||||
resolvedAt: "2026-02-01T00:00:00.000Z",
|
||||
createdAt: "2026-01-02T00:00:00.000Z",
|
||||
creatorId: "u1",
|
||||
content: body("resolved root"),
|
||||
},
|
||||
{
|
||||
id: "r1",
|
||||
pageId: "page-1",
|
||||
parentCommentId: "r",
|
||||
resolvedAt: null,
|
||||
createdAt: "2026-01-02T01:00:00.000Z",
|
||||
creatorId: "u1",
|
||||
content: body("resolved reply"),
|
||||
},
|
||||
];
|
||||
|
||||
function commentsServer() {
|
||||
return spawn(async (req, res) => {
|
||||
await readBody(req);
|
||||
if (req.url === "/api/auth/login") {
|
||||
sendJson(res, 200, { success: true }, {
|
||||
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/comments") {
|
||||
// Single page, no cursor.
|
||||
sendJson(res, 200, { data: { items: FEED, meta: { nextCursor: null } } });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, { message: "not found" });
|
||||
});
|
||||
}
|
||||
|
||||
test("default hides the resolved thread (root + its reply) and counts it", async () => {
|
||||
const { baseURL } = await commentsServer();
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
const result = await client.listComments("page-1");
|
||||
assert.equal(Array.isArray(result.items), true, "returns { items, ... }");
|
||||
const ids = result.items.map((c) => c.id).sort();
|
||||
assert.deepEqual(ids, ["a", "a1"], "only the active thread remains");
|
||||
assert.equal(result.resolvedThreadsHidden, 1, "one resolved thread hidden");
|
||||
});
|
||||
|
||||
test("includeResolved:true returns EVERYTHING with zero hidden", async () => {
|
||||
const { baseURL } = await commentsServer();
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
const result = await client.listComments("page-1", true);
|
||||
const ids = result.items.map((c) => c.id).sort();
|
||||
assert.deepEqual(ids, ["a", "a1", "r", "r1"], "all four comments returned");
|
||||
assert.equal(result.resolvedThreadsHidden, 0, "nothing hidden with the flag");
|
||||
});
|
||||
|
||||
test("the reply of a resolved thread is hidden with the thread", async () => {
|
||||
const { baseURL } = await commentsServer();
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
const result = await client.listComments("page-1");
|
||||
const ids = result.items.map((c) => c.id);
|
||||
assert.equal(ids.includes("r1"), false, "the resolved thread's reply is gone");
|
||||
assert.equal(ids.includes("r"), false, "the resolved root is gone");
|
||||
});
|
||||
|
||||
test("an ACTIVE thread whose REPLY is resolved is NOT hidden", async () => {
|
||||
// A thread is gated only by its ROOT's resolvedAt. `a1` is a resolved reply
|
||||
// under the active root `a`, so both must survive the default filter and the
|
||||
// thread must not be counted as hidden.
|
||||
const { baseURL } = await commentsServer();
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
const result = await client.listComments("page-1");
|
||||
const ids = result.items.map((c) => c.id).sort();
|
||||
assert.equal(ids.includes("a"), true, "active root stays");
|
||||
assert.equal(ids.includes("a1"), true, "its resolved reply stays with the thread");
|
||||
assert.equal(result.resolvedThreadsHidden, 1, "only the resolved-root thread is hidden");
|
||||
});
|
||||
@@ -16,6 +16,7 @@ export {
|
||||
export type { DocmostMdMeta } from "./markdown-document.js";
|
||||
|
||||
export { convertProseMirrorToMarkdown } from "./markdown-converter.js";
|
||||
export type { ConvertProseMirrorToMarkdownOptions } from "./markdown-converter.js";
|
||||
|
||||
export { markdownToProseMirror } from "./markdown-to-prosemirror.js";
|
||||
|
||||
|
||||
@@ -33,13 +33,36 @@ import {
|
||||
*/
|
||||
const MAX_NODE_DEPTH = 400;
|
||||
|
||||
/**
|
||||
* Options for {@link convertProseMirrorToMarkdown}.
|
||||
*/
|
||||
export interface ConvertProseMirrorToMarkdownOptions {
|
||||
/**
|
||||
* When true, an inline comment anchor whose Comment mark is `resolved`
|
||||
* emits its BARE text (no `<span data-comment-id …>` wrapper), so an agent
|
||||
* reading the page never sees resolved-comment anchors. ACTIVE (unresolved)
|
||||
* anchors still emit their wrapper. Defaults to false — a zero-behavior
|
||||
* change for every existing caller, including the lossless git-sync export
|
||||
* path where resolved anchors MUST be preserved for round-tripping.
|
||||
*/
|
||||
dropResolvedCommentAnchors?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ProseMirror/TipTap JSON content to Markdown
|
||||
* Supports all Docmost-specific node types and extensions
|
||||
*/
|
||||
export function convertProseMirrorToMarkdown(content: any): string {
|
||||
export function convertProseMirrorToMarkdown(
|
||||
content: any,
|
||||
options: ConvertProseMirrorToMarkdownOptions = {},
|
||||
): string {
|
||||
if (!content || !content.content) return "";
|
||||
|
||||
// Closure flag read by both `case "comment"` emitters (the top-level marks
|
||||
// loop and the raw-HTML inlineToHtml path). Off by default; the agent-read
|
||||
// callers (mcp getPage / in-app AI chat) pass it true.
|
||||
const dropResolvedCommentAnchors = options.dropResolvedCommentAnchors === true;
|
||||
|
||||
// Escape a value interpolated into an HTML double-quoted attribute value
|
||||
// (textAlign, colors, image src, math `text`, all data-* attrs, etc.). In the
|
||||
// ATTRIBUTE context only the quote that delimits the value and the ampersand
|
||||
@@ -508,6 +531,11 @@ export function convertProseMirrorToMarkdown(content: any): string {
|
||||
// commentId/resolved).
|
||||
const cid = mark.attrs?.commentId;
|
||||
if (cid) {
|
||||
// Hide resolved anchors from agent reads: drop the wrapper and
|
||||
// keep only the bare text. Active anchors keep their wrapper.
|
||||
if (mark.attrs?.resolved && dropResolvedCommentAnchors) {
|
||||
break;
|
||||
}
|
||||
const resolvedAttr = mark.attrs?.resolved
|
||||
? ` data-resolved="true"`
|
||||
: "";
|
||||
@@ -1177,6 +1205,11 @@ export function convertProseMirrorToMarkdown(content: any): string {
|
||||
// Inline comment anchor inside a raw-HTML container (columns /
|
||||
// spanned table cells), so commented text there also round-trips.
|
||||
if (mark.attrs?.commentId) {
|
||||
// Hide resolved anchors from agent reads: drop the wrapper and
|
||||
// keep only the bare text. Active anchors keep their wrapper.
|
||||
if (mark.attrs?.resolved && dropResolvedCommentAnchors) {
|
||||
break;
|
||||
}
|
||||
const r = mark.attrs?.resolved ? ` data-resolved="true"` : "";
|
||||
t = `<span data-comment-id="${escapeAttr(mark.attrs.commentId)}"${r}>${t}</span>`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
// Import the converter DIRECTLY from src (NOT the docmost-client barrel, which
|
||||
// pulls in collaboration.ts and mutates the global DOM at import time), matching
|
||||
// the other converter unit tests (see markdown-converter-html-marks.test.ts).
|
||||
import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js';
|
||||
|
||||
// gitmost #328 Channel 1: the `dropResolvedCommentAnchors` converter option
|
||||
// hides RESOLVED comment anchors from agent reads while keeping ACTIVE anchors.
|
||||
// The option defaults to false (zero behavior change for the lossless git-sync
|
||||
// export path). Two emitters read it: the top-level marks loop and the raw-HTML
|
||||
// inlineToHtml path (inside columns / spanned table cells).
|
||||
|
||||
const text = (t: string, marks?: any[]) =>
|
||||
marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
|
||||
const para = (...inline: any[]) => ({ type: 'paragraph', content: inline });
|
||||
const doc = (...nodes: any[]) => ({ type: 'doc', content: nodes });
|
||||
|
||||
const commentMark = (commentId: string, resolved: boolean) => ({
|
||||
type: 'comment',
|
||||
attrs: { commentId, resolved },
|
||||
});
|
||||
|
||||
// A columns node (raw-HTML container) so its children render via the
|
||||
// blockToHtml -> inlineToHtml path (the SECOND `case "comment"` emitter).
|
||||
const oneColumn = (...blocks: any[]) => ({
|
||||
type: 'columns',
|
||||
attrs: { layout: 'two' },
|
||||
content: [{ type: 'column', content: blocks }],
|
||||
});
|
||||
|
||||
describe('#328 Channel 1 — top-level emitter: dropResolvedCommentAnchors', () => {
|
||||
const resolvedDoc = doc(
|
||||
para(text('kept '), text('resolved', [commentMark('r1', true)])),
|
||||
);
|
||||
const activeDoc = doc(
|
||||
para(text('kept '), text('active', [commentMark('a1', false)])),
|
||||
);
|
||||
|
||||
it('drops a RESOLVED anchor (bare text) WITH the flag', () => {
|
||||
const out = convertProseMirrorToMarkdown(resolvedDoc, {
|
||||
dropResolvedCommentAnchors: true,
|
||||
});
|
||||
expect(out).toBe('kept resolved');
|
||||
expect(out).not.toContain('data-comment-id');
|
||||
});
|
||||
|
||||
it('PRESERVES a RESOLVED anchor WITHOUT the flag (default off)', () => {
|
||||
const out = convertProseMirrorToMarkdown(resolvedDoc);
|
||||
expect(out).toContain(
|
||||
'<span data-comment-id="r1" data-resolved="true">resolved</span>',
|
||||
);
|
||||
});
|
||||
|
||||
it('KEEPS an ACTIVE anchor in BOTH cases', () => {
|
||||
const withFlag = convertProseMirrorToMarkdown(activeDoc, {
|
||||
dropResolvedCommentAnchors: true,
|
||||
});
|
||||
const withoutFlag = convertProseMirrorToMarkdown(activeDoc);
|
||||
expect(withFlag).toContain('<span data-comment-id="a1">active</span>');
|
||||
expect(withoutFlag).toContain('<span data-comment-id="a1">active</span>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#328 Channel 1 — raw-HTML inlineToHtml emitter (columns)', () => {
|
||||
const resolvedCol = doc(
|
||||
oneColumn(para(text('resolved', [commentMark('r1', true)]))),
|
||||
);
|
||||
const activeCol = doc(
|
||||
oneColumn(para(text('active', [commentMark('a1', false)]))),
|
||||
);
|
||||
|
||||
it('drops a RESOLVED anchor (bare text) WITH the flag', () => {
|
||||
const out = convertProseMirrorToMarkdown(resolvedCol, {
|
||||
dropResolvedCommentAnchors: true,
|
||||
});
|
||||
expect(out).toContain('<p>resolved</p>');
|
||||
expect(out).not.toContain('data-comment-id');
|
||||
});
|
||||
|
||||
it('PRESERVES a RESOLVED anchor WITHOUT the flag', () => {
|
||||
const out = convertProseMirrorToMarkdown(resolvedCol);
|
||||
expect(out).toContain(
|
||||
'<span data-comment-id="r1" data-resolved="true">resolved</span>',
|
||||
);
|
||||
});
|
||||
|
||||
it('KEEPS an ACTIVE anchor in BOTH cases', () => {
|
||||
const withFlag = convertProseMirrorToMarkdown(activeCol, {
|
||||
dropResolvedCommentAnchors: true,
|
||||
});
|
||||
const withoutFlag = convertProseMirrorToMarkdown(activeCol);
|
||||
expect(withFlag).toContain('<span data-comment-id="a1">active</span>');
|
||||
expect(withoutFlag).toContain('<span data-comment-id="a1">active</span>');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user