Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e04afee629 | |||
| 3b80285d57 | |||
| c4842367af | |||
| 96b9ec11d6 | |||
| 24b802baa3 | |||
| f8d26420eb | |||
| 5c1187b864 | |||
| 14f83abe78 | |||
| 22ea387495 | |||
| b56a1629d2 | |||
| 7e6dd457a4 | |||
| ad08458ac4 | |||
| f9d8a6ede1 | |||
| 3c7b69d6d4 | |||
| 188c5f506c | |||
| 888deba891 | |||
| 57308bc3f3 | |||
| 4c7b671950 | |||
| 1ddb386214 | |||
| 43af3dd5f1 | |||
| b02101b58a | |||
| 932bfce1d9 | |||
| 4131deaabb | |||
| d39b7ae67c | |||
| c124fb1f2c | |||
| d3ebae48cf | |||
| 607aed5997 | |||
| 5b88e3dddf | |||
| d0ca127d83 | |||
| dc14a9a540 | |||
| 2aa482f62d |
@@ -75,7 +75,9 @@ jobs:
|
||||
APP_URL: http://localhost:3000
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg18
|
||||
# via mirror.gcr.io (Docker Hub pull-through cache; avoids Hub anonymous
|
||||
# pull rate-limit that randomly fails on shared GitHub runner IPs).
|
||||
image: mirror.gcr.io/pgvector/pgvector:pg18
|
||||
env:
|
||||
POSTGRES_DB: docmost
|
||||
POSTGRES_USER: docmost
|
||||
@@ -88,7 +90,8 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
redis:
|
||||
image: redis:7
|
||||
# via mirror.gcr.io (see postgres note above).
|
||||
image: mirror.gcr.io/library/redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
@@ -135,7 +138,9 @@ jobs:
|
||||
NODE_ENV: production
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg18
|
||||
# via mirror.gcr.io (Docker Hub pull-through cache; avoids Hub anonymous
|
||||
# pull rate-limit that randomly fails on shared GitHub runner IPs).
|
||||
image: mirror.gcr.io/pgvector/pgvector:pg18
|
||||
env:
|
||||
POSTGRES_DB: docmost
|
||||
POSTGRES_USER: docmost
|
||||
@@ -148,7 +153,8 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
redis:
|
||||
image: redis:7
|
||||
# via mirror.gcr.io (see postgres note above).
|
||||
image: mirror.gcr.io/library/redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
|
||||
@@ -27,7 +27,9 @@ jobs:
|
||||
# TEST_*_URL overrides are needed.
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg18
|
||||
# via mirror.gcr.io (Docker Hub pull-through cache; avoids Hub anonymous
|
||||
# pull rate-limit that randomly fails on shared GitHub runner IPs).
|
||||
image: mirror.gcr.io/pgvector/pgvector:pg18
|
||||
env:
|
||||
POSTGRES_USER: docmost
|
||||
POSTGRES_PASSWORD: docmost_dev_pw
|
||||
@@ -40,7 +42,8 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:7
|
||||
# via mirror.gcr.io (see postgres note above).
|
||||
image: mirror.gcr.io/library/redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
|
||||
+12
-1
@@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- **Editable captions for images.** Images gain an optional caption shown
|
||||
below them, edited inline from the image bubble menu and stored as a `caption` attribute. Captions round-trip
|
||||
losslessly through markdown as a `data-caption` attribute on the image, so
|
||||
they survive export/import unchanged. (#221)
|
||||
|
||||
- **Quick-create regular and temporary notes from the Home and Space screens.**
|
||||
The Home screen now shows a second action next to "New note" that creates a
|
||||
*temporary* note (one that auto-moves to Trash after the workspace lifetime),
|
||||
@@ -67,6 +72,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
`nosniff` + restrictive CSP + attachment disposition for non-image mimes) and
|
||||
are RAM-only, bound to the instance that created them. Tunable via five
|
||||
`SANDBOX_*` env vars (see `.env.example`). (#243)
|
||||
- **Inline spoiler mark — hide text behind click-to-reveal blur.** Selected text
|
||||
can be marked as a spoiler from a new bubble-menu toggle, or typed Discord-style
|
||||
with the `||text||` input rule; the rendered span blurs until clicked to reveal.
|
||||
The mark is preserved losslessly through Markdown export/import (as a raw
|
||||
`<span data-spoiler="true">…</span>`) and on public shares. (#259)
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -504,6 +514,7 @@ knowledge layer, an embedded MCP server, and the Gitmost rebrand.
|
||||
- Build: drop the private EE submodule, retarget CI to GHCR, and update the
|
||||
Docker image to the GHCR registry.
|
||||
|
||||
[Unreleased]: https://github.com/vvzvlad/gitmost/compare/v0.93.0...HEAD
|
||||
[Unreleased]: https://github.com/vvzvlad/gitmost/compare/v0.94.0...HEAD
|
||||
[0.94.0]: https://github.com/vvzvlad/gitmost/compare/v0.93.0...v0.94.0
|
||||
[0.93.0]: https://github.com/vvzvlad/gitmost/compare/v0.91.0...v0.93.0
|
||||
[0.91.0]: https://github.com/vvzvlad/gitmost/compare/v0.90.1...v0.91.0
|
||||
|
||||
@@ -286,6 +286,9 @@
|
||||
"Alt text": "Alt text",
|
||||
"Describe this for accessibility.": "Describe this for accessibility.",
|
||||
"Add a description": "Add a description",
|
||||
"Caption": "Caption",
|
||||
"Add a caption": "Add a caption",
|
||||
"Shown below the image.": "Shown below the image.",
|
||||
"Justify": "Justify",
|
||||
"Merge cells": "Merge cells",
|
||||
"Split cell": "Split cell",
|
||||
@@ -352,6 +355,7 @@
|
||||
"Underline": "Underline",
|
||||
"Strike": "Strike",
|
||||
"Code": "Code",
|
||||
"Spoiler": "Spoiler",
|
||||
"Comment": "Comment",
|
||||
"Text": "Text",
|
||||
"Heading 1": "Heading 1",
|
||||
|
||||
@@ -351,6 +351,7 @@
|
||||
"Underline": "Подчёркнутый",
|
||||
"Strike": "Перечёркнутый",
|
||||
"Code": "Код",
|
||||
"Spoiler": "Спойлер",
|
||||
"Comment": "Комментарий",
|
||||
"Text": "Текст",
|
||||
"Heading 1": "Заголовок 1",
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
IconStrikethrough,
|
||||
IconUnderline,
|
||||
IconMessage,
|
||||
IconEyeOff,
|
||||
IconClearFormatting,
|
||||
} from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import classes from "./bubble-menu.module.css";
|
||||
@@ -74,6 +76,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
isStrike: ctx.editor.isActive("strike"),
|
||||
isCode: ctx.editor.isActive("code"),
|
||||
isComment: ctx.editor.isActive("comment"),
|
||||
isSpoiler: ctx.editor.isActive("spoiler"),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -109,6 +112,20 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
command: () => props.editor.chain().focus().toggleCode().run(),
|
||||
icon: IconCode,
|
||||
},
|
||||
{
|
||||
name: "Spoiler",
|
||||
isActive: () => editorState?.isSpoiler,
|
||||
command: () => props.editor.chain().focus().toggleSpoiler().run(),
|
||||
icon: IconEyeOff,
|
||||
},
|
||||
{
|
||||
name: "Clear formatting",
|
||||
// Action, not a toggle — never show an active/highlighted state.
|
||||
isActive: () => false,
|
||||
// Mirror the fixed-toolbar behavior: strip all inline marks from the selection.
|
||||
command: () => props.editor.chain().focus().unsetAllMarks().run(),
|
||||
icon: IconClearFormatting,
|
||||
},
|
||||
];
|
||||
|
||||
const commentItem: BubbleMenuItem = {
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconAlt } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useImageTextFieldControl } from "@/features/editor/components/common/use-image-text-field-control.tsx";
|
||||
|
||||
const ALT_MAX_LENGTH = 300;
|
||||
|
||||
@@ -27,113 +18,25 @@ type UseAltTextControlArgs = {
|
||||
currentAlt: string;
|
||||
};
|
||||
|
||||
// Thin wrapper over the shared image text-field popover; see
|
||||
// useImageTextFieldControl. The t("...") literals stay here so they remain
|
||||
// statically extractable for i18n.
|
||||
export function useAltTextControl({
|
||||
editor,
|
||||
nodeName,
|
||||
currentAlt,
|
||||
}: UseAltTextControlArgs) {
|
||||
const { t } = useTranslation();
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const open = useCallback(() => {
|
||||
setDraft(currentAlt || "");
|
||||
setShowInput(true);
|
||||
}, [currentAlt]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (!editor.isActive(nodeName)) {
|
||||
setShowInput(false);
|
||||
}
|
||||
};
|
||||
editor.on("selectionUpdate", handler);
|
||||
return () => {
|
||||
editor.off("selectionUpdate", handler);
|
||||
};
|
||||
}, [editor, nodeName]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setShowInput(false);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.updateAttributes(nodeName, { alt: sanitizeAlt(draft) || undefined })
|
||||
.run();
|
||||
setShowInput(false);
|
||||
}, [editor, nodeName, draft]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
},
|
||||
[save, cancel],
|
||||
);
|
||||
|
||||
const button = (
|
||||
<Tooltip position="top" label={t("Alt text")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={open}
|
||||
size="lg"
|
||||
aria-label={t("Alt text")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconAlt size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const panel = showInput ? (
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="md"
|
||||
radius={6}
|
||||
p="sm"
|
||||
w={320}
|
||||
style={{ position: "relative", zIndex: 100 }}
|
||||
>
|
||||
<Text size="sm" fw={600} mb={2}>
|
||||
{t("Alt text")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t("Describe this for accessibility.")}
|
||||
</Text>
|
||||
<Textarea
|
||||
size="xs"
|
||||
placeholder={t("Add a description")}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoFocus
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={5}
|
||||
maxLength={ALT_MAX_LENGTH}
|
||||
/>
|
||||
<Group justify="space-between" align="center" mt="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{draft.length}/{ALT_MAX_LENGTH}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button size="compact-xs" variant="default" onClick={cancel}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="compact-xs" onClick={save}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null;
|
||||
|
||||
return { button, panel, isEditing: showInput };
|
||||
return useImageTextFieldControl({
|
||||
editor,
|
||||
nodeName,
|
||||
currentValue: currentAlt,
|
||||
attrName: "alt",
|
||||
sanitize: sanitizeAlt,
|
||||
maxLength: ALT_MAX_LENGTH,
|
||||
icon: <IconAlt size={18} />,
|
||||
label: t("Alt text"),
|
||||
description: t("Describe this for accessibility."),
|
||||
placeholder: t("Add a description"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { sanitizeCaption } from "@/features/editor/components/common/use-caption-control.tsx";
|
||||
|
||||
/**
|
||||
* `sanitizeCaption` = collapse every whitespace run to a single space + trim +
|
||||
* cap at 500 chars. Captions are plain visible text, so this is a softer
|
||||
* normalization than alt-text sanitization.
|
||||
*/
|
||||
describe("sanitizeCaption", () => {
|
||||
it("trims leading and trailing whitespace", () => {
|
||||
expect(sanitizeCaption(" hello ")).toBe("hello");
|
||||
});
|
||||
|
||||
it("collapses internal whitespace runs to a single space", () => {
|
||||
expect(sanitizeCaption("a b c")).toBe("a b c");
|
||||
});
|
||||
|
||||
it("treats tab, newline and CRLF as whitespace", () => {
|
||||
expect(sanitizeCaption("a\tb")).toBe("a b");
|
||||
expect(sanitizeCaption("a\nb")).toBe("a b");
|
||||
expect(sanitizeCaption("a\r\nb")).toBe("a b");
|
||||
expect(sanitizeCaption("line1\n\n\nline2")).toBe("line1 line2");
|
||||
});
|
||||
|
||||
it("treats unicode whitespace (no-break space) as a separator", () => {
|
||||
// U+00A0 NO-BREAK SPACE is matched by the \s class.
|
||||
expect(sanitizeCaption("a b")).toBe("a b");
|
||||
});
|
||||
|
||||
it("returns empty string for whitespace-only input", () => {
|
||||
expect(sanitizeCaption(" ")).toBe("");
|
||||
expect(sanitizeCaption("")).toBe("");
|
||||
});
|
||||
|
||||
it("keeps a caption at the 500-char limit unchanged", () => {
|
||||
const exact = "x".repeat(500);
|
||||
expect(sanitizeCaption(exact)).toHaveLength(500);
|
||||
expect(sanitizeCaption(exact)).toBe(exact);
|
||||
});
|
||||
|
||||
it("slices a caption longer than 500 chars down to 500", () => {
|
||||
const tooLong = "y".repeat(600);
|
||||
const result = sanitizeCaption(tooLong);
|
||||
expect(result).toHaveLength(500);
|
||||
expect(result).toBe("y".repeat(500));
|
||||
});
|
||||
|
||||
it("collapses whitespace before applying the 500-char cap", () => {
|
||||
// 120 "a b " groups (600 raw chars) collapse to "a b a b ..." = 479 chars
|
||||
// after trimming the trailing space, which stays under the 500 cap — so only
|
||||
// the collapse is exercised here, no slice. (See the dedicated >500 test
|
||||
// above for the slice boundary.)
|
||||
const input = "a b ".repeat(120); // lots of double spaces
|
||||
const result = sanitizeCaption(input);
|
||||
expect(result).toHaveLength(479);
|
||||
expect(result.length).toBeLessThanOrEqual(500);
|
||||
expect(result).not.toMatch(/\s{2,}/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { IconTextCaption } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useImageTextFieldControl } from "@/features/editor/components/common/use-image-text-field-control.tsx";
|
||||
|
||||
const CAPTION_MAX_LENGTH = 500;
|
||||
|
||||
// Caption is plain visible text (not a markdown link target like alt), so it is
|
||||
// sanitized more softly than alt: collapse runs of whitespace/newlines into a
|
||||
// single space and trim, keeping the limit generous.
|
||||
export function sanitizeCaption(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim().slice(0, CAPTION_MAX_LENGTH);
|
||||
}
|
||||
|
||||
type UseCaptionControlArgs = {
|
||||
editor: Editor;
|
||||
nodeName: string;
|
||||
currentCaption: string;
|
||||
};
|
||||
|
||||
// Thin wrapper over the shared image text-field popover; see
|
||||
// useImageTextFieldControl. The t("...") literals stay here so they remain
|
||||
// statically extractable for i18n.
|
||||
export function useCaptionControl({
|
||||
editor,
|
||||
nodeName,
|
||||
currentCaption,
|
||||
}: UseCaptionControlArgs) {
|
||||
const { t } = useTranslation();
|
||||
return useImageTextFieldControl({
|
||||
editor,
|
||||
nodeName,
|
||||
currentValue: currentCaption,
|
||||
attrName: "caption",
|
||||
sanitize: sanitizeCaption,
|
||||
maxLength: CAPTION_MAX_LENGTH,
|
||||
icon: <IconTextCaption size={18} />,
|
||||
label: t("Caption"),
|
||||
description: t("Shown below the image."),
|
||||
placeholder: t("Add a caption"),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// Shared logic+UI for the image bubble-menu text-field popovers (alt text,
|
||||
// caption, ...). Each field is the same popover — an ActionIcon that opens a
|
||||
// titled Paper with a counted Textarea and Cancel/Save — differing only in the
|
||||
// node attribute it writes, its sanitizer, length cap, icon and labels. The
|
||||
// label/description/placeholder are passed already translated so the literal
|
||||
// t("...") calls stay in the thin wrappers and remain extractable; the shared
|
||||
// Cancel/Save strings are translated here.
|
||||
type UseImageTextFieldControlArgs = {
|
||||
editor: Editor;
|
||||
nodeName: string;
|
||||
currentValue: string;
|
||||
attrName: string;
|
||||
sanitize: (value: string) => string;
|
||||
maxLength: number;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
description: string;
|
||||
placeholder: string;
|
||||
};
|
||||
|
||||
export function useImageTextFieldControl({
|
||||
editor,
|
||||
nodeName,
|
||||
currentValue,
|
||||
attrName,
|
||||
sanitize,
|
||||
maxLength,
|
||||
icon,
|
||||
label,
|
||||
description,
|
||||
placeholder,
|
||||
}: UseImageTextFieldControlArgs) {
|
||||
const { t } = useTranslation();
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const open = useCallback(() => {
|
||||
setDraft(currentValue || "");
|
||||
setShowInput(true);
|
||||
}, [currentValue]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (!editor.isActive(nodeName)) {
|
||||
setShowInput(false);
|
||||
}
|
||||
};
|
||||
editor.on("selectionUpdate", handler);
|
||||
return () => {
|
||||
editor.off("selectionUpdate", handler);
|
||||
};
|
||||
}, [editor, nodeName]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setShowInput(false);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.updateAttributes(nodeName, { [attrName]: sanitize(draft) || undefined })
|
||||
.run();
|
||||
setShowInput(false);
|
||||
}, [editor, nodeName, attrName, sanitize, draft]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
},
|
||||
[save, cancel],
|
||||
);
|
||||
|
||||
const button = (
|
||||
<Tooltip position="top" label={label} withinPortal={false}>
|
||||
<ActionIcon onClick={open} size="lg" aria-label={label} variant="subtle">
|
||||
{icon}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const panel = showInput ? (
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="md"
|
||||
radius={6}
|
||||
p="sm"
|
||||
w={320}
|
||||
style={{ position: "relative", zIndex: 100 }}
|
||||
>
|
||||
<Text size="sm" fw={600} mb={2}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{description}
|
||||
</Text>
|
||||
<Textarea
|
||||
size="xs"
|
||||
placeholder={placeholder}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoFocus
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={5}
|
||||
maxLength={maxLength}
|
||||
/>
|
||||
<Group justify="space-between" align="center" mt="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{draft.length}/{maxLength}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button size="compact-xs" variant="default" onClick={cancel}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="compact-xs" onClick={save}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null;
|
||||
|
||||
return { button, panel, isEditing: showInput };
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
|
||||
import { useCaptionControl } from "@/features/editor/components/common/use-caption-control.tsx";
|
||||
import classes from "../common/toolbar-menu.module.css";
|
||||
|
||||
export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
@@ -47,6 +48,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
isFloatRight: ctx.editor.isActive("image", { align: "floatRight" }),
|
||||
src: imageAttrs?.src || null,
|
||||
alt: imageAttrs?.alt || "",
|
||||
caption: imageAttrs?.caption || "",
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -168,6 +170,16 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
currentAlt: editorState?.alt || "",
|
||||
});
|
||||
|
||||
const {
|
||||
button: captionButton,
|
||||
panel: captionPanel,
|
||||
isEditing: isEditingCaption,
|
||||
} = useCaptionControl({
|
||||
editor,
|
||||
nodeName: "image",
|
||||
currentCaption: editorState?.caption || "",
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
@@ -183,6 +195,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
>
|
||||
{isEditingAlt ? (
|
||||
altTextPanel
|
||||
) : isEditingCaption ? (
|
||||
captionPanel
|
||||
) : (
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
|
||||
@@ -249,6 +263,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
|
||||
{altTextButton}
|
||||
|
||||
{captionButton}
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
||||
|
||||
@@ -9,7 +9,9 @@ import { useTranslation } from "react-i18next";
|
||||
export default function ImageView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { editor, node, selected } = props;
|
||||
const { src, width, align, alt, aspectRatio, placeholder } = node.attrs;
|
||||
const { src, width, align, alt, caption, aspectRatio, placeholder } =
|
||||
node.attrs;
|
||||
const captionText = (caption || "").trim();
|
||||
const alignClass = useMemo(() => {
|
||||
if (align === "left") return "alignLeft";
|
||||
if (align === "right") return "alignRight";
|
||||
@@ -29,6 +31,7 @@ export default function ImageView(props: NodeViewProps) {
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<figure style={{ margin: 0 }}>
|
||||
<div
|
||||
className={clsx(
|
||||
selected && "ProseMirror-selectednode",
|
||||
@@ -66,6 +69,15 @@ export default function ImageView(props: NodeViewProps) {
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
{captionText && (
|
||||
<Text
|
||||
component="figcaption"
|
||||
className="image-caption"
|
||||
>
|
||||
{captionText}
|
||||
</Text>
|
||||
)}
|
||||
</figure>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { MarkViewContent, MarkViewProps } from "@tiptap/react";
|
||||
import { useState } from "react";
|
||||
|
||||
// Click-to-reveal spoiler. The revealed state is UI-only and is never written to
|
||||
// the document: toggling only adds/removes the `is-revealed` class (CSS removes
|
||||
// the blur). renderHTML never emits `is-revealed`, so it can't leak into the
|
||||
// doc/clipboard. Works the same in editor, read-only and public-share views.
|
||||
export default function SpoilerView(_props: MarkViewProps) {
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={revealed ? "spoiler is-revealed" : "spoiler"}
|
||||
data-spoiler="true"
|
||||
onClick={() => setRevealed((v) => !v)}
|
||||
>
|
||||
<MarkViewContent />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
Subpages,
|
||||
Heading,
|
||||
Highlight,
|
||||
Spoiler,
|
||||
Indent,
|
||||
UniqueID,
|
||||
SharedStorage,
|
||||
@@ -116,6 +117,7 @@ import mentionRenderItems from "@/features/editor/components/mention/mention-sug
|
||||
import { ReactNodeViewRenderer, ReactMarkViewRenderer } from "@tiptap/react";
|
||||
import MentionView from "@/features/editor/components/mention/mention-view.tsx";
|
||||
import LinkView from "@/features/editor/components/link/link-view.tsx";
|
||||
import SpoilerView from "@/features/editor/components/spoiler/spoiler-view.tsx";
|
||||
import i18n from "@/i18n.ts";
|
||||
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
|
||||
import EmojiCommand from "./emoji-command";
|
||||
@@ -238,6 +240,11 @@ export const mainExtensions = [
|
||||
Highlight.configure({
|
||||
multicolor: true,
|
||||
}),
|
||||
Spoiler.configure({}).extend({
|
||||
addMarkView() {
|
||||
return ReactMarkViewRenderer(SpoilerView);
|
||||
},
|
||||
}),
|
||||
Typography,
|
||||
TrailingNode,
|
||||
GlobalDragHandle.configure({
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
@import "./mention.css";
|
||||
@import "./ordered-list.css";
|
||||
@import "./highlight.css";
|
||||
@import "./spoiler.css";
|
||||
@import "./indent.css";
|
||||
@import "./columns.css";
|
||||
@import "./status.css";
|
||||
|
||||
@@ -33,6 +33,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.image-caption {
|
||||
text-align: center;
|
||||
font-size: 0.875em;
|
||||
color: var(--mantine-color-dimmed);
|
||||
margin-top: 0.4em;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.uploading-text {
|
||||
font-size: var(--mantine-font-size-md);
|
||||
line-height: var(--mantine-line-height-md);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
.spoiler {
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
border-radius: 0.25em;
|
||||
cursor: pointer;
|
||||
filter: blur(0.3em);
|
||||
transition: filter 0.15s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.spoiler.is-revealed {
|
||||
filter: none;
|
||||
background: rgba(125, 125, 125, 0.18);
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.spoiler {
|
||||
filter: none;
|
||||
background: rgba(125, 125, 125, 0.18);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
Mention,
|
||||
Subpages,
|
||||
Highlight,
|
||||
Spoiler,
|
||||
Indent,
|
||||
UniqueID,
|
||||
Columns,
|
||||
@@ -82,6 +83,7 @@ export const tiptapExtensions = [
|
||||
Superscript,
|
||||
SubScript,
|
||||
Highlight,
|
||||
Spoiler,
|
||||
Typography,
|
||||
TrailingNode,
|
||||
TextStyle,
|
||||
|
||||
@@ -422,4 +422,51 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
||||
expect(historyQueue.add).not.toHaveBeenCalled();
|
||||
expect(aiQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// #260 — when the collab doc name carries a SLUGID (`page.<slugId>`) the
|
||||
// post-store side effects must use the resolved page.id (a UUID), NOT the
|
||||
// slugId. The transclusion sync + embedding reindex write uuid-typed columns,
|
||||
// so a slugId there threw Postgres 22P02; the contributors key must also match
|
||||
// the PAGE_HISTORY job, which is enqueued with page.id.
|
||||
it('uses the canonical page.id (not the slugId doc name) for post-store side effects (#260)', async () => {
|
||||
const SLUG = 'slug-1'; // persistedHumanPage.slugId; findById resolves it
|
||||
const document = ydocFor(doc('NEW AGENT CONTENT'));
|
||||
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW AGENT CONTENT'));
|
||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
||||
|
||||
// A `page.<slugId>` document name (the bug's smoking gun), agent store over
|
||||
// a human page so the in-tx history-boundary read is also exercised.
|
||||
await ext.onStoreDocument({
|
||||
documentName: `page.${SLUG}`,
|
||||
document,
|
||||
context: { user: { id: USER_ID, name: 'Alice' }, actor: 'agent' },
|
||||
} as any);
|
||||
|
||||
// findById was queried with the slugId (it resolves either id or slugId).
|
||||
expect(pageRepo.findById).toHaveBeenCalledWith(SLUG, expect.anything());
|
||||
|
||||
// The in-tx history-boundary read uses the canonical UUID, never the slugId.
|
||||
expect(pageHistoryRepo.findPageLastHistory).toHaveBeenCalledWith(
|
||||
PAGE_ID,
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
// Transclusion sync (uuid-typed columns) must receive the UUID.
|
||||
expect(transclusionService.syncPageTransclusions.mock.calls[0][0]).toBe(
|
||||
PAGE_ID,
|
||||
);
|
||||
expect(transclusionService.syncPageReferences.mock.calls[0][0]).toBe(
|
||||
PAGE_ID,
|
||||
);
|
||||
expect(
|
||||
transclusionService.syncPageTemplateReferences.mock.calls[0][0],
|
||||
).toBe(PAGE_ID);
|
||||
|
||||
// Embedding reindex job keyed by the UUID (slugId there threw 22P02).
|
||||
expect(aiQueue.add).toHaveBeenCalledTimes(1);
|
||||
expect(aiQueue.add.mock.calls[0][1].pageIds).toEqual([PAGE_ID]);
|
||||
|
||||
// Contributors keyed by the UUID so they match the PAGE_HISTORY job (page.id).
|
||||
expect(collabHistory.addContributors.mock.calls[0][0]).toBe(PAGE_ID);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -329,8 +329,10 @@ export class PersistenceExtension implements Extension {
|
||||
lastUpdatedSource === 'agent' &&
|
||||
page.lastUpdatedSource !== 'agent'
|
||||
) {
|
||||
// pageHistory.pageId is uuid-typed; use page.id (never the doc-name
|
||||
// slugId) so a `page.<slugId>` doc cannot throw 22P02 here (#260).
|
||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
||||
pageId,
|
||||
page.id,
|
||||
{ includeContent: true, trx },
|
||||
);
|
||||
const humanBaselineMissing =
|
||||
@@ -398,11 +400,16 @@ export class PersistenceExtension implements Extension {
|
||||
}),
|
||||
);
|
||||
|
||||
await this.syncTransclusion(pageId, page.workspaceId, tiptapJson);
|
||||
// Use the canonical page UUID (page.id), not the doc-name id, which may be
|
||||
// a slugId for a `page.<slugId>` doc (#260). The transclusion/reference
|
||||
// syncs write uuid-typed columns, so a slugId here threw Postgres 22P02.
|
||||
await this.syncTransclusion(page.id, page.workspaceId, tiptapJson);
|
||||
}
|
||||
|
||||
if (page) {
|
||||
await this.collabHistory.addContributors(pageId, editingUserIds);
|
||||
// Key contributors by the page UUID so they MATCH the PAGE_HISTORY job,
|
||||
// which is enqueued with page.id and pops contributors by page.id (#260).
|
||||
await this.collabHistory.addContributors(page.id, editingUserIds);
|
||||
|
||||
const mentions = extractMentions(tiptapJson);
|
||||
|
||||
@@ -420,14 +427,17 @@ export class PersistenceExtension implements Extension {
|
||||
creatorId: m.creatorId,
|
||||
})),
|
||||
oldMentionedUserIds,
|
||||
pageId,
|
||||
// Canonical UUID, never the doc-name slugId (#260).
|
||||
pageId: page.id,
|
||||
spaceId: page.spaceId,
|
||||
workspaceId: page.workspaceId,
|
||||
} as IPageMentionNotificationJob);
|
||||
}
|
||||
|
||||
await this.aiQueue.add(QueueJob.PAGE_CONTENT_UPDATED, {
|
||||
pageIds: [pageId],
|
||||
// Canonical UUID: the embedding reindex resolves pages by uuid, so a
|
||||
// slugId here threw Postgres 22P02 invalid-uuid (#260).
|
||||
pageIds: [page.id],
|
||||
workspaceId: page.workspaceId,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,34 @@ import { pathToFileURL } from 'node:url';
|
||||
* ESM-only `@docmost/mcp` package. We only need the constructor + the read/write
|
||||
* methods used by the per-user tool adapter; the full client surface lives in
|
||||
* `packages/mcp/src/client.ts`. Signatures here mirror that file exactly.
|
||||
*
|
||||
* DRIFT GUARD: the method NAMES below are runtime-checked against the real
|
||||
* `DocmostClient` by `packages/mcp/test/unit/client-host-contract.test.mjs`
|
||||
* (which can import the ESM class directly). If you rename/remove a method here
|
||||
* or in client.ts, that test fails — so a stale mirror cannot silently ship a
|
||||
* runtime "x is not a function" into an agent tool call. Keep the two in sync.
|
||||
*
|
||||
* STAGED PLAN — full derivation `DocmostClientLike = <real DocmostClient type>`
|
||||
* (issue #193, layer 3) is intentionally NOT done; it stays a hand-mirror for
|
||||
* now because of two verified blockers across the ESM(mcp)/CJS(server) boundary:
|
||||
* 1. `@docmost/mcp` emits NO declaration files (its tsconfig has no
|
||||
* `declaration`, package.json has no `types`/types-export) and the server
|
||||
* tsconfig has no path mapping for it — the server only loads it via the
|
||||
* runtime `import()` trick below, so there is no type to import today.
|
||||
* 2. The real client methods have inferred, CONCRETE return types; the in-app
|
||||
* tool adapter reads results through loose `Record<string,unknown>` returns
|
||||
* + `as` casts (e.g. `(result?.data ?? {}) as { title?: string }`).
|
||||
* Deriving the exact type would make those casts non-overlapping ("may be a
|
||||
* mistake") and break the build, and `Partial<DocmostClientLike>` test stubs
|
||||
* would have to satisfy the full concrete surface.
|
||||
* To do it safely later (incrementally): (a) turn on `declaration: true` in
|
||||
* packages/mcp/tsconfig.json + add a `types` export condition and commit the
|
||||
* emitted `.d.ts`; (b) `import type { DocmostClient } from '@docmost/mcp'` here
|
||||
* and replace this interface with a `Pick<DocmostClient, ...>` of the consumed
|
||||
* methods; (c) audit every `as` cast in ai-chat-tools.service.ts against the now
|
||||
* concrete return types (double-cast through `unknown` only where genuinely
|
||||
* needed); (d) keep the runtime guard test as a belt-and-braces check. Until
|
||||
* then the guard test above is the cheap, behaviour-neutral protection.
|
||||
*/
|
||||
export interface DocmostClientLike {
|
||||
// --- read ---
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ShareService } from './share.service';
|
||||
|
||||
// Sibling of share-comment-strip.spec.ts. The public-share sanitizer strips ONLY
|
||||
// `comment` marks (internal-team metadata) via removeMarkTypeFromDoc(doc,
|
||||
// 'comment'). The `spoiler` mark is legitimate authored content (hidden text the
|
||||
// reader clicks to reveal) and MUST survive the share-strip — otherwise public
|
||||
// readers would see the secret in plain text or lose it entirely.
|
||||
//
|
||||
// We drive the SAME real seam the comment-strip test uses:
|
||||
// updatePublicAttachments -> prepareContentForShare -> removeMarkTypeFromDoc.
|
||||
|
||||
const WS = 'ws-1';
|
||||
const PAGE = 'page-1';
|
||||
|
||||
function buildService() {
|
||||
const shareRepo = { findById: jest.fn() };
|
||||
const pageRepo = { findById: jest.fn() };
|
||||
const pagePermissionRepo = {
|
||||
hasRestrictedAncestor: jest.fn(async () => false),
|
||||
};
|
||||
const tokenService = {
|
||||
generateAttachmentToken: jest.fn(async () => 'tok'),
|
||||
};
|
||||
const workspaceRepo = {
|
||||
findById: jest.fn(async () => ({ id: WS, settings: { htmlEmbed: true } })),
|
||||
};
|
||||
|
||||
return new ShareService(
|
||||
shareRepo as any,
|
||||
pageRepo as any,
|
||||
pagePermissionRepo as any,
|
||||
{} as any, // db (unused on this path)
|
||||
tokenService as any,
|
||||
{} as any, // transclusionService (unused)
|
||||
workspaceRepo as any,
|
||||
);
|
||||
}
|
||||
|
||||
// Text carrying a `spoiler` mark (no attributes; revealed state is UI-only).
|
||||
function spoilerText(text: string) {
|
||||
return {
|
||||
type: 'text',
|
||||
text,
|
||||
marks: [{ type: 'spoiler' }],
|
||||
};
|
||||
}
|
||||
|
||||
// Text carrying a `comment` mark with an id (the thing that DOES get stripped).
|
||||
function commentedText(text: string, commentId: string) {
|
||||
return {
|
||||
type: 'text',
|
||||
text,
|
||||
marks: [{ type: 'comment', attrs: { commentId, resolved: false } }],
|
||||
};
|
||||
}
|
||||
|
||||
async function sanitize(content: any) {
|
||||
const service = buildService();
|
||||
return service.updatePublicAttachments({
|
||||
id: PAGE,
|
||||
workspaceId: WS,
|
||||
content,
|
||||
} as any);
|
||||
}
|
||||
|
||||
function countMarks(doc: any, type: string): number {
|
||||
let count = 0;
|
||||
const walk = (node: any) => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (Array.isArray(node.marks)) {
|
||||
for (const mark of node.marks) {
|
||||
if (mark?.type === type) count++;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(node.content)) node.content.forEach(walk);
|
||||
};
|
||||
walk(doc);
|
||||
return count;
|
||||
}
|
||||
|
||||
describe('ShareService keeps spoiler marks on public shares (real code)', () => {
|
||||
it('does NOT strip a spoiler mark', async () => {
|
||||
const content = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'visible ' }, spoilerText('hidden')],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(countMarks(content, 'spoiler')).toBe(1);
|
||||
|
||||
const out = await sanitize(content);
|
||||
|
||||
// The spoiler mark survives the share-strip.
|
||||
expect(countMarks(out, 'spoiler')).toBe(1);
|
||||
expect(JSON.stringify(out)).toContain('hidden');
|
||||
});
|
||||
|
||||
it('strips comment marks but keeps spoiler marks in the same doc', async () => {
|
||||
const content = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
commentedText('reviewed', 'cmt-1'),
|
||||
{ type: 'text', text: ' and ' },
|
||||
spoilerText('secret'),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(countMarks(content, 'comment')).toBe(1);
|
||||
expect(countMarks(content, 'spoiler')).toBe(1);
|
||||
|
||||
const out = await sanitize(content);
|
||||
|
||||
// comment is removed, spoiler is preserved.
|
||||
expect(countMarks(out, 'comment')).toBe(0);
|
||||
expect(countMarks(out, 'spoiler')).toBe(1);
|
||||
const serialized = JSON.stringify(out);
|
||||
expect(serialized).not.toContain('cmt-1');
|
||||
expect(serialized).toContain('secret');
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ export * from "./lib/subpages";
|
||||
export * from "./lib/transclusion";
|
||||
export * from "./lib/page-embed";
|
||||
export * from "./lib/highlight";
|
||||
export * from "./lib/spoiler/spoiler";
|
||||
export * from "./lib/indent";
|
||||
export * from "./lib/heading/heading";
|
||||
export * from "./lib/unique-id";
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { generateJSON } from "@tiptap/html";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
import { htmlToMarkdown } from "../markdown/utils/turndown.utils";
|
||||
import { markdownToHtml } from "../markdown/utils/marked.utils";
|
||||
import { TiptapImage } from "./image";
|
||||
|
||||
// Minimal schema for parsing markdownToHtml output back to JSON (mirrors
|
||||
// image.spec.ts), so we can assert the recovered caption EXACTLY.
|
||||
const parseExtensions = [Document, Paragraph, Text, TiptapImage];
|
||||
|
||||
// Lossless markdown round-trip for image captions (issue #221). An image WITH a
|
||||
// caption can't be expressed as ``, so it is emitted as a raw <img>
|
||||
// (carrying data-caption) wrapped in a block <div>, the same trick the <video>
|
||||
// rule uses. marked passes the raw HTML through, so markdownToHtml keeps the
|
||||
// data-caption, and the image extension's parseHTML restores the attribute.
|
||||
describe("image caption markdown round-trip", () => {
|
||||
it("HTML -> Markdown emits a raw <img data-caption> for captioned images", () => {
|
||||
const html = `<p><img src="/files/a.png" alt="cat" data-caption="A grey cat"></p>`;
|
||||
const md = htmlToMarkdown(html);
|
||||
expect(md).toContain("data-caption=\"A grey cat\"");
|
||||
expect(md).toContain('src="/files/a.png"');
|
||||
expect(md).toContain('alt="cat"');
|
||||
// It must NOT degrade to the lossy ![]() form.
|
||||
expect(md).not.toContain("![cat]");
|
||||
});
|
||||
|
||||
it("Markdown -> HTML restores data-caption on the <img>", async () => {
|
||||
const html = `<p><img src="/files/a.png" alt="cat" data-caption="A grey cat"></p>`;
|
||||
const md = htmlToMarkdown(html);
|
||||
const back = await markdownToHtml(md);
|
||||
expect(back).toContain('data-caption="A grey cat"');
|
||||
expect(back).toContain('src="/files/a.png"');
|
||||
});
|
||||
|
||||
it("special characters in the caption survive the round-trip (escaped)", async () => {
|
||||
// The source caption is the decoded string `Tom & "Jerry"` (both an `&` and
|
||||
// a `"`). escapeHtmlAttr must encode `&` -> `&` and `"` -> `"`.
|
||||
const html = `<p><img src="/files/a.png" data-caption='Tom & "Jerry"'></p>`;
|
||||
const md = htmlToMarkdown(html);
|
||||
|
||||
// (a) The intermediate Markdown must carry the EXACT escaped attribute. This
|
||||
// fails if escapeHtmlAttr stopped escaping `"` (attribute break-out:
|
||||
// data-caption="Tom & "Jerry"") or double-encoded `&` (`&amp;`).
|
||||
expect(md).toContain('data-caption="Tom & "Jerry""');
|
||||
|
||||
const back = await markdownToHtml(md);
|
||||
expect(back).toContain("data-caption=");
|
||||
expect(back).toContain("Jerry");
|
||||
expect(back).toContain("Tom");
|
||||
|
||||
// (b) Re-parse the rendered HTML through the image extension's parseHTML and
|
||||
// assert the recovered caption is EXACTLY the original (no corruption, loss,
|
||||
// or double-encoding).
|
||||
const json = generateJSON(back, parseExtensions);
|
||||
expect(json.content?.[0]?.attrs?.caption).toBe('Tom & "Jerry"');
|
||||
});
|
||||
|
||||
it("caption-less images stay a clean  with no raw HTML", () => {
|
||||
const html = `<p><img src="/files/a.png" alt="cat"></p>`;
|
||||
const md = htmlToMarkdown(html);
|
||||
expect(md).toContain("");
|
||||
expect(md).not.toContain("data-caption");
|
||||
expect(md).not.toContain("<img");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,16 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { applyAlignment } from "./image";
|
||||
import { getSchema } from "@tiptap/core";
|
||||
import { generateHTML, generateJSON } from "@tiptap/html";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
import { applyAlignment, TiptapImage } from "./image";
|
||||
|
||||
// CONTRACT tests for the image node's `caption` attribute (issue #221). The
|
||||
// caption is a plain-text string stored on the image atom and serialized as
|
||||
// `data-caption` on the <img>. If this mapping drifts, captions saved to HTML
|
||||
// (and thus to native storage / search / markdown) are silently lost.
|
||||
const extensions = [Document, Paragraph, Text, TiptapImage];
|
||||
|
||||
// applyAlignment is a pure DOM mutation: it sets the float / padding /
|
||||
// justify-content / data-image-align on an image node-view container per the
|
||||
@@ -65,3 +76,56 @@ describe("applyAlignment", () => {
|
||||
expect(el.style.justifyContent).toBe("flex-start");
|
||||
});
|
||||
});
|
||||
|
||||
describe("image schema", () => {
|
||||
it("registers the image node and keeps it an atom", () => {
|
||||
const schema = getSchema(extensions);
|
||||
expect(schema.nodes.image).toBeTruthy();
|
||||
expect(schema.nodes.image.spec.atom).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("image caption parse/render round-trip", () => {
|
||||
it("recovers caption from data-caption on parse (HTML -> JSON)", () => {
|
||||
const html = `<img src="/files/a.png" alt="cat" data-caption="A grey cat">`;
|
||||
const json = generateJSON(html, extensions);
|
||||
|
||||
const node = json.content?.[0];
|
||||
expect(node?.type).toBe("image");
|
||||
expect(node?.attrs?.caption).toBe("A grey cat");
|
||||
expect(node?.attrs?.alt).toBe("cat");
|
||||
});
|
||||
|
||||
it("emits data-caption on render when set (JSON -> HTML)", () => {
|
||||
const json = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
attrs: { src: "/files/a.png", alt: "cat", caption: "A grey cat" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const html = generateHTML(json, extensions);
|
||||
expect(html).toContain('data-caption="A grey cat"');
|
||||
});
|
||||
|
||||
it("omits data-caption when there is no caption (caption-less images stay clean)", () => {
|
||||
const json = {
|
||||
type: "doc",
|
||||
content: [{ type: "image", attrs: { src: "/files/a.png", alt: "cat" } }],
|
||||
};
|
||||
const html = generateHTML(json, extensions);
|
||||
expect(html).not.toContain("data-caption");
|
||||
});
|
||||
|
||||
it("full HTML -> JSON -> HTML round-trip preserves the caption", () => {
|
||||
const html = `<img src="/files/a.png" alt="cat" data-caption="Caption with & "quotes"">`;
|
||||
const json = generateJSON(html, extensions);
|
||||
expect(json.content?.[0]?.attrs?.caption).toBe('Caption with & "quotes"');
|
||||
|
||||
const out = generateHTML(json, extensions);
|
||||
const back = generateJSON(out, extensions);
|
||||
expect(back.content?.[0]?.attrs?.caption).toBe('Caption with & "quotes"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface ImageOptions extends DefaultImageOptions {
|
||||
export interface ImageAttributes {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
align?: string;
|
||||
attachmentId?: string;
|
||||
size?: number;
|
||||
@@ -125,6 +126,13 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
||||
alt: attributes.alt,
|
||||
}),
|
||||
},
|
||||
caption: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-caption") || undefined,
|
||||
// Emit data-caption only when set, so caption-less images stay clean.
|
||||
renderHTML: (attributes: ImageAttributes) =>
|
||||
attributes.caption ? { "data-caption": attributes.caption } : {},
|
||||
},
|
||||
attachmentId: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-id"),
|
||||
@@ -304,6 +312,10 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
||||
el.alt = updatedNode.attrs.alt || "";
|
||||
}
|
||||
|
||||
if (updatedNode.attrs.caption !== currentNode.attrs.caption) {
|
||||
applyCaption(updatedNode.attrs.caption);
|
||||
}
|
||||
|
||||
const w = updatedNode.attrs.width;
|
||||
const h = updatedNode.attrs.height;
|
||||
if (w != null) {
|
||||
@@ -335,6 +347,28 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
||||
|
||||
const dom = nodeView.dom as HTMLElement;
|
||||
|
||||
// Re-parent the resizable wrapper into a <figure> so the caption sits BELOW
|
||||
// the image, OUTSIDE nodeView.wrapper. onCommit measures the img's
|
||||
// offsetHeight for the persisted height/aspectRatio, and the left/right
|
||||
// resize handles span the wrapper — both must cover the image only. The
|
||||
// <figure> stays the single flex child of the container, so applyAlignment
|
||||
// and the float modes keep working. This path also drives read-only/share.
|
||||
const figure = document.createElement("figure");
|
||||
figure.style.margin = "0";
|
||||
figure.style.display = "inline-block"; // shrink-to-fit to image width
|
||||
figure.appendChild(nodeView.wrapper);
|
||||
dom.appendChild(figure);
|
||||
|
||||
const figcaption = document.createElement("figcaption");
|
||||
figcaption.className = "image-caption";
|
||||
const applyCaption = (text?: string) => {
|
||||
const value = (text || "").trim();
|
||||
figcaption.textContent = value;
|
||||
figcaption.style.display = value ? "block" : "none";
|
||||
};
|
||||
applyCaption(node.attrs.caption);
|
||||
figure.appendChild(figcaption);
|
||||
|
||||
// Apply initial alignment
|
||||
applyAlignment(dom, node.attrs.align || "center");
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getSchema } from "@tiptap/core";
|
||||
import { generateHTML, generateJSON } from "@tiptap/html";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
import { Bold } from "@tiptap/extension-bold";
|
||||
import { htmlToMarkdown } from "./turndown.utils";
|
||||
import { markdownToHtml } from "./marked.utils";
|
||||
import { Spoiler } from "../../spoiler/spoiler";
|
||||
|
||||
// The spoiler mark has no native Markdown syntax, so it is preserved losslessly
|
||||
// as raw inline HTML (`<span data-spoiler="true">…</span>`), the same approach
|
||||
// htmlEmbed uses. This test drives the full editor round-trip:
|
||||
// JSON -> HTML -> Markdown -> HTML -> JSON
|
||||
// and asserts the `spoiler` mark survives end to end. We use the same
|
||||
// getSchema + @tiptap/html generateHTML/generateJSON utilities the other
|
||||
// editor-ext schema tests use.
|
||||
|
||||
const extensions = [Document, Paragraph, Text, Bold, Spoiler];
|
||||
|
||||
function html(md: string): string {
|
||||
const out = markdownToHtml(md);
|
||||
if (typeof out !== "string") throw new Error("expected sync string output");
|
||||
return out;
|
||||
}
|
||||
|
||||
// Count text nodes carrying a `spoiler` mark anywhere in a ProseMirror JSON doc.
|
||||
function countSpoilerMarks(doc: any): number {
|
||||
let count = 0;
|
||||
const walk = (node: any) => {
|
||||
if (!node || typeof node !== "object") return;
|
||||
if (Array.isArray(node.marks)) {
|
||||
for (const mark of node.marks) {
|
||||
if (mark?.type === "spoiler") count++;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(node.content)) node.content.forEach(walk);
|
||||
};
|
||||
walk(doc);
|
||||
return count;
|
||||
}
|
||||
|
||||
describe("Spoiler mark schema", () => {
|
||||
it("registers the spoiler mark in the schema", () => {
|
||||
const schema = getSchema(extensions);
|
||||
expect(schema.marks.spoiler).toBeTruthy();
|
||||
});
|
||||
|
||||
it("recovers the spoiler mark from span[data-spoiler] (HTML -> JSON)", () => {
|
||||
const json = generateJSON(
|
||||
'<p>before <span data-spoiler="true">hidden</span> after</p>',
|
||||
extensions,
|
||||
);
|
||||
expect(countSpoilerMarks(json)).toBe(1);
|
||||
});
|
||||
|
||||
it("emits data-spoiler + class on render (JSON -> HTML)", () => {
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "hidden",
|
||||
marks: [{ type: "spoiler" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const out = generateHTML(doc, extensions);
|
||||
expect(out).toContain('data-spoiler="true"');
|
||||
expect(out).toContain('class="spoiler"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Spoiler Markdown round-trip is lossless", () => {
|
||||
const docWith = (textNode: any) => ({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "before " }, textNode, { type: "text", text: " after" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it("preserves the spoiler mark through JSON -> MD -> HTML -> JSON", () => {
|
||||
const startDoc = docWith({
|
||||
type: "text",
|
||||
text: "hidden",
|
||||
marks: [{ type: "spoiler" }],
|
||||
});
|
||||
|
||||
// JSON -> HTML
|
||||
const html1 = generateHTML(startDoc, extensions);
|
||||
expect(html1).toContain('data-spoiler="true"');
|
||||
|
||||
// HTML -> Markdown (raw inline HTML, lossless)
|
||||
const md = htmlToMarkdown(html1);
|
||||
expect(md).toContain('<span data-spoiler="true">hidden</span>');
|
||||
|
||||
// MD -> HTML -> JSON (mark restored via parseHTML)
|
||||
const endJson = generateJSON(html(md), extensions);
|
||||
expect(countSpoilerMarks(endJson)).toBe(1);
|
||||
// The visible text survives.
|
||||
expect(JSON.stringify(endJson)).toContain("hidden");
|
||||
});
|
||||
|
||||
it("keeps the spoiler intact when it intersects a bold mark", () => {
|
||||
const startDoc = docWith({
|
||||
type: "text",
|
||||
text: "secret",
|
||||
marks: [{ type: "bold" }, { type: "spoiler" }],
|
||||
});
|
||||
|
||||
const md = htmlToMarkdown(generateHTML(startDoc, extensions));
|
||||
expect(md).toContain("data-spoiler=\"true\"");
|
||||
|
||||
const endJson = generateJSON(html(md), extensions);
|
||||
expect(countSpoilerMarks(endJson)).toBe(1);
|
||||
// Bold survives alongside the spoiler.
|
||||
expect(JSON.stringify(endJson)).toContain('"bold"');
|
||||
});
|
||||
});
|
||||
@@ -113,6 +113,7 @@ export function htmlToMarkdown(html: string): string {
|
||||
mathBlock,
|
||||
iframeEmbed,
|
||||
htmlEmbed,
|
||||
spoiler,
|
||||
image,
|
||||
video,
|
||||
footnoteReference,
|
||||
@@ -220,6 +221,29 @@ function htmlEmbed(turndownService: _TurndownService) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the `spoiler` inline mark to lossless raw inline HTML.
|
||||
*
|
||||
* Markdown has no native spoiler syntax, so we emit the same `<span
|
||||
* data-spoiler="true">…</span>` the mark renders. `marked` passes inline raw HTML
|
||||
* through untouched, and `generateJSON` restores the mark via its parseHTML, so
|
||||
* the round-trip MD -> HTML -> JSON keeps the spoiler intact. The UI-only
|
||||
* `is-revealed` state is never serialized.
|
||||
*/
|
||||
function spoiler(turndownService: _TurndownService) {
|
||||
turndownService.addRule('spoiler', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return (
|
||||
node.nodeName === 'SPAN' &&
|
||||
node.getAttribute('data-spoiler') === 'true'
|
||||
);
|
||||
},
|
||||
replacement: function (content: string) {
|
||||
return `<span data-spoiler="true">${content}</span>`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function listParagraph(turndownService: _TurndownService) {
|
||||
turndownService.addRule('paragraph', {
|
||||
filter: ['p'],
|
||||
@@ -377,6 +401,17 @@ function image(turndownService: _TurndownService) {
|
||||
replacement: function (_content: string, node: HTMLInputElement) {
|
||||
const src = node.getAttribute('src') || '';
|
||||
if (!src) return '';
|
||||
const caption = node.getAttribute('data-caption') || '';
|
||||
if (caption) {
|
||||
// ![]() can't carry a caption, so emit a raw <img> wrapped in a block
|
||||
// <div>. marked passes it through and the image extension's parseHTML
|
||||
// restores the caption from data-caption.
|
||||
const parts = [`src="${escapeHtmlAttr(src)}"`];
|
||||
const alt = node.getAttribute('alt') || '';
|
||||
if (alt) parts.push(`alt="${escapeHtmlAttr(alt)}"`);
|
||||
parts.push(`data-caption="${escapeHtmlAttr(caption)}"`);
|
||||
return `<div><img ${parts.join(' ')}></div>`;
|
||||
}
|
||||
const alt = sanitizeMdLinkText(node.getAttribute('alt') || '');
|
||||
const title = node.getAttribute('title') || '';
|
||||
const titlePart = title ? ' "' + title.replace(/"/g, '\\"') + '"' : '';
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Mark, markInputRule, mergeAttributes } from "@tiptap/core";
|
||||
|
||||
export interface SpoilerOptions {
|
||||
HTMLAttributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
spoiler: {
|
||||
setSpoiler: () => ReturnType;
|
||||
toggleSpoiler: () => ReturnType;
|
||||
unsetSpoiler: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Discord-style `||text||` input rule. Requires a non-space right after the
|
||||
// opening `||` and a non-space right before the closing `||` so empty/padded
|
||||
// markers don't match.
|
||||
const inputRegex = /(?:^|\s)(\|\|(?!\s)([^|]+)(?<!\s)\|\|)$/;
|
||||
|
||||
export const Spoiler = Mark.create<SpoilerOptions>({
|
||||
name: "spoiler",
|
||||
|
||||
// Don't bleed onto text typed at the boundary (mirrors link).
|
||||
inclusive: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "span[data-spoiler]" }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
"data-spoiler": "true",
|
||||
class: "spoiler",
|
||||
}),
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setSpoiler:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.setMark(this.name),
|
||||
toggleSpoiler:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.toggleMark(this.name),
|
||||
unsetSpoiler:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.unsetMark(this.name),
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [markInputRule({ find: inputRegex, type: this.type })];
|
||||
},
|
||||
|
||||
// No addKeyboardShortcuts: the issue's proposed `Mod-Shift-s` is already taken
|
||||
// by the built-in Strike mark (and `Mod-Shift-h` by Highlight). The `||text||`
|
||||
// input rule plus the bubble-menu button cover ergonomics, so we omit a hotkey
|
||||
// rather than collide with an existing one.
|
||||
});
|
||||
+106
-19
@@ -37,6 +37,15 @@ const MIME_TO_EXT = {
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
};
|
||||
// Canonical UUID shape (versions 1–8, matching the `uuid` package's `validate`
|
||||
// that the server's isValidUUID uses). page.repo.ts treats any non-UUID pageId
|
||||
// as a slugId, so the MCP detects a UUID locally and skips a /pages/info
|
||||
// round-trip in resolvePageId. A 10-char nanoid slugId never contains dashes,
|
||||
// so it can never be misread as a UUID here.
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
function isUuid(value) {
|
||||
return typeof value === "string" && UUID_RE.test(value);
|
||||
}
|
||||
export class DocmostClient {
|
||||
client;
|
||||
token = null;
|
||||
@@ -64,6 +73,11 @@ export class DocmostClient {
|
||||
// can all call login() at once. Memoizing a single promise collapses that
|
||||
// thundering herd into ONE /auth/login request that everyone awaits.
|
||||
loginPromise = null;
|
||||
// Canonical-UUID cache for resolvePageId: maps an agent-supplied slugId to the
|
||||
// page's canonical UUID, so repeated collab edits on the same page do not
|
||||
// re-fetch /pages/info. A UUID input short-circuits before this cache (see
|
||||
// resolvePageId), so only slugId->uuid entries are stored/read here.
|
||||
pageIdCache = new Map();
|
||||
constructor(configOrBaseURL, email, password) {
|
||||
// Normalize the legacy positional form into the object union.
|
||||
const config = typeof configOrBaseURL === "string"
|
||||
@@ -572,6 +586,35 @@ export class DocmostClient {
|
||||
const response = await this.client.post("/pages/info", { pageId });
|
||||
return response.data?.data ?? response.data;
|
||||
}
|
||||
/**
|
||||
* Resolve an agent-supplied pageId to the page's CANONICAL UUID (`page.id`),
|
||||
* so every collaboration document the MCP opens is named `page.<uuid>` — the
|
||||
* SAME name the web editor always uses (`page.${page.id}`).
|
||||
*
|
||||
* The agent commonly passes a 10-char public slugId (from URLs/listings) as
|
||||
* the pageId. The web editor opens the collab doc by UUID, but the MCP used to
|
||||
* pass that slugId straight into the collab doc name (`page.<slugId>`). For one
|
||||
* DB row that produced TWO independent Yjs documents whose debounced stores
|
||||
* clobbered each other — the agent's edit was silently lost (#260).
|
||||
*
|
||||
* A UUID input short-circuits with no network round-trip. A slugId is resolved
|
||||
* once via getPageRaw and cached (both slugId->uuid and uuid->uuid), so
|
||||
* repeated edits on the same page add no extra request.
|
||||
*/
|
||||
async resolvePageId(pageId) {
|
||||
if (isUuid(pageId))
|
||||
return pageId;
|
||||
const cached = this.pageIdCache.get(pageId);
|
||||
if (cached)
|
||||
return cached;
|
||||
const data = await this.getPageRaw(pageId);
|
||||
const uuid = data?.id;
|
||||
if (typeof uuid !== "string" || !uuid) {
|
||||
throw new Error(`Could not resolve a canonical page id for "${pageId}"`);
|
||||
}
|
||||
this.pageIdCache.set(pageId, uuid);
|
||||
return uuid;
|
||||
}
|
||||
async getPage(pageId) {
|
||||
await this.ensureAuthenticated();
|
||||
const resultData = await this.getPageRaw(pageId);
|
||||
@@ -863,10 +906,12 @@ export class DocmostClient {
|
||||
async tableInsertRow(pageId, tableRef, cells, index) {
|
||||
await this.ensureAuthenticated();
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
// Track insertion in an outer var, reset per-transform, so a collab retry
|
||||
// recomputes it cleanly (mirrors insertNode's pattern).
|
||||
let inserted = false;
|
||||
const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
|
||||
inserted = false;
|
||||
const { doc: nd, inserted: ins } = insertTableRow(liveDoc, tableRef, cells, index);
|
||||
inserted = ins;
|
||||
@@ -892,8 +937,10 @@ export class DocmostClient {
|
||||
async tableDeleteRow(pageId, tableRef, index) {
|
||||
await this.ensureAuthenticated();
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
let deleted = false;
|
||||
const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
|
||||
deleted = false;
|
||||
const { doc: nd, deleted: del } = deleteTableRow(liveDoc, tableRef, index);
|
||||
deleted = del;
|
||||
@@ -921,8 +968,10 @@ export class DocmostClient {
|
||||
async tableUpdateCell(pageId, tableRef, row, col, text) {
|
||||
await this.ensureAuthenticated();
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
let updated = false;
|
||||
const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
|
||||
updated = false;
|
||||
const { doc: nd, updated: upd } = updateTableCell(liveDoc, tableRef, row, col, text);
|
||||
updated = upd;
|
||||
@@ -1034,6 +1083,10 @@ export class DocmostClient {
|
||||
*/
|
||||
async updatePage(pageId, content, title) {
|
||||
await this.ensureAuthenticated();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
// REST /pages/update title write below keeps the agent-supplied id (the
|
||||
// server resolves a slugId there).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
// Write the BODY first, then the title (#159 split-brain). If the collab
|
||||
// body write fails (e.g. a persist timeout), the title must be left
|
||||
// UNTOUCHED so the page never ends up with a new title over its old body.
|
||||
@@ -1043,7 +1096,7 @@ export class DocmostClient {
|
||||
let mutation;
|
||||
try {
|
||||
collabToken = await this.getCollabTokenWithReauth();
|
||||
mutation = await updatePageContentRealtime(pageId, content, collabToken, this.apiUrl);
|
||||
mutation = await updatePageContentRealtime(pageUuid, content, collabToken, this.apiUrl);
|
||||
}
|
||||
catch (error) {
|
||||
// Verbose diagnostics (incl. anything that could expose a token prefix)
|
||||
@@ -1259,7 +1312,9 @@ export class DocmostClient {
|
||||
// Write the BODY first, then the title (#159 split-brain): a failed body
|
||||
// write (e.g. persist timeout) must not leave a new title over the old body.
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
const mutation = await this.replacePage(pageId, doc, collabToken, this.apiUrl);
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
const mutation = await this.replacePage(pageUuid, doc, collabToken, this.apiUrl);
|
||||
// Body persisted successfully — now it is safe to set the title.
|
||||
if (title) {
|
||||
await this.client.post("/pages/update", { pageId, title });
|
||||
@@ -1294,8 +1349,10 @@ export class DocmostClient {
|
||||
throw new Error("insert_footnote: text is required");
|
||||
}
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
let result = null;
|
||||
const mutation = await this.mutatePage(pageId, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const mutation = await this.mutatePage(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const r = insertInlineFootnote(liveDoc, { anchorText, text });
|
||||
if (!r.inserted) {
|
||||
// Abort the page-locked write by throwing: mutatePageContent does not
|
||||
@@ -1383,7 +1440,9 @@ export class DocmostClient {
|
||||
// PAGE import: canonicalize footnotes (see markdownToProseMirrorCanonical).
|
||||
const doc = await markdownToProseMirrorCanonical(body);
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
const mutation = await replacePageContent(pageId, doc, collabToken, this.apiUrl);
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
const mutation = await replacePageContent(pageUuid, doc, collabToken, this.apiUrl);
|
||||
// Collect distinct comment ids that actually became comment marks in the doc.
|
||||
const collectCommentIds = (node, acc) => {
|
||||
if (!node || typeof node !== "object")
|
||||
@@ -1467,7 +1526,9 @@ export class DocmostClient {
|
||||
// to the target (parity with the other full-doc write paths).
|
||||
const canonical = canonicalizeFootnotes(content);
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
const mutation = await this.replacePage(targetPageId, canonical, collabToken, this.apiUrl);
|
||||
// Open the TARGET collab doc by its canonical UUID, never the slugId (#260).
|
||||
const targetUuid = await this.resolvePageId(targetPageId);
|
||||
const mutation = await this.replacePage(targetUuid, canonical, collabToken, this.apiUrl);
|
||||
return {
|
||||
success: true,
|
||||
sourcePageId,
|
||||
@@ -1483,6 +1544,8 @@ export class DocmostClient {
|
||||
async editPageText(pageId, edits) {
|
||||
await this.ensureAuthenticated();
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
// Apply the edits against the LIVE synced document, not the debounced REST
|
||||
// snapshot, so concurrent human edits/comments are preserved. applyTextEdits
|
||||
// records per-edit match problems in `failed` instead of throwing, and
|
||||
@@ -1495,7 +1558,7 @@ export class DocmostClient {
|
||||
// we must NOT write (no spurious history version) and must not claim a write
|
||||
// happened.
|
||||
let wrote = false;
|
||||
const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
|
||||
wrote = false;
|
||||
const r = applyTextEdits(liveDoc, edits);
|
||||
results = r.results;
|
||||
@@ -1580,10 +1643,12 @@ export class DocmostClient {
|
||||
target.attrs.id = nodeId;
|
||||
}
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
// Track the replacement count in an outer var, reset per-transform, so a
|
||||
// collab retry recomputes it cleanly (mirrors replaceImage's pattern).
|
||||
let replaced = 0;
|
||||
const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
|
||||
replaced = 0;
|
||||
const { doc: nd, replaced: r } = replaceNodeById(liveDoc, nodeId, target);
|
||||
replaced = r;
|
||||
@@ -1636,10 +1701,12 @@ export class DocmostClient {
|
||||
}
|
||||
}
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
// Track insertion in an outer var, reset per-transform, so a collab retry
|
||||
// recomputes it cleanly (mirrors replaceImage's pattern).
|
||||
let inserted = false;
|
||||
const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
|
||||
inserted = false;
|
||||
const { doc: nd, inserted: ins } = insertNodeRelative(liveDoc, node, opts);
|
||||
inserted = ins;
|
||||
@@ -1675,10 +1742,12 @@ export class DocmostClient {
|
||||
async deleteNode(pageId, nodeId) {
|
||||
await this.ensureAuthenticated();
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
// Track the deletion count in an outer var, reset per-transform, so a
|
||||
// collab retry recomputes it cleanly (mirrors replaceImage's pattern).
|
||||
let deleted = 0;
|
||||
const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
|
||||
deleted = 0;
|
||||
const { doc: nd, deleted: d } = deleteNodeById(liveDoc, nodeId);
|
||||
deleted = d;
|
||||
@@ -1921,7 +1990,10 @@ export class DocmostClient {
|
||||
let anchored = false;
|
||||
try {
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
|
||||
// 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);
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const doc = liveDoc && liveDoc.type === "doc"
|
||||
? liveDoc
|
||||
: { type: "doc", content: [] };
|
||||
@@ -2324,6 +2396,9 @@ export class DocmostClient {
|
||||
if (opts.alt)
|
||||
node.attrs.alt = opts.alt;
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
// uploadImage /files/upload call above keeps the agent-supplied id.
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
// Recursively collect the plain text of a top-level block.
|
||||
const blockText = (n) => {
|
||||
let out = "";
|
||||
@@ -2337,7 +2412,7 @@ export class DocmostClient {
|
||||
// concurrent edits/comments/images are preserved and parallel insert_image
|
||||
// calls (serialized by the per-page lock) each see the previous insertion.
|
||||
let placement;
|
||||
const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const doc = liveDoc && liveDoc.type === "doc"
|
||||
? liveDoc
|
||||
: { type: "doc", content: [] };
|
||||
@@ -2424,6 +2499,13 @@ export class DocmostClient {
|
||||
*/
|
||||
async replaceImage(pageId, oldAttachmentId, url, opts = {}) {
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
// page lock must ALSO key on the UUID so this operation serializes against
|
||||
// other writes to the same page (mutatePageContent now locks by the resolved
|
||||
// UUID too); locking by the raw slugId here would desync the mutex key and
|
||||
// reopen the TOCTOU/orphan-attachment window the lock closes. uploadImage
|
||||
// keeps the agent-supplied id (it hits REST, not the collab doc).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
// Hold ONE per-page lock for the WHOLE operation (scan -> upload -> write).
|
||||
// Previously the scan and the write were two separate mutatePageContent
|
||||
// calls, each acquiring + releasing the lock, with the upload happening in
|
||||
@@ -2435,7 +2517,7 @@ export class DocmostClient {
|
||||
// reentrant, so the self-locking mutatePageContent would deadlock here)
|
||||
// closes that TOCTOU window. uploadImage hits /files/upload over plain HTTP
|
||||
// and does not touch the page lock, so it is safe to call while held.
|
||||
return withPageLock(pageId, async () => {
|
||||
return withPageLock(pageUuid, async () => {
|
||||
// STEP 1: read-only live check. Scan the live document for any image node
|
||||
// matching oldAttachmentId BEFORE uploading anything, so a wrong/stale id
|
||||
// throws without ever creating an orphan attachment.
|
||||
@@ -2453,7 +2535,7 @@ export class DocmostClient {
|
||||
scan(node.content);
|
||||
}
|
||||
};
|
||||
await this.mutateLiveContentUnlocked(pageId, collabToken, (liveDoc) => {
|
||||
await this.mutateLiveContentUnlocked(pageUuid, collabToken, (liveDoc) => {
|
||||
matchFound = false; // reset per-transform (collab may retry the read).
|
||||
const doc = liveDoc && liveDoc.type === "doc"
|
||||
? liveDoc
|
||||
@@ -2501,7 +2583,7 @@ export class DocmostClient {
|
||||
walk(node.content);
|
||||
}
|
||||
};
|
||||
const mutation = await this.mutateLiveContentUnlocked(pageId, collabToken, (liveDoc) => {
|
||||
const mutation = await this.mutateLiveContentUnlocked(pageUuid, collabToken, (liveDoc) => {
|
||||
// Reset per-transform so collab retries recompute cleanly (no double-count).
|
||||
replaced = 0;
|
||||
const doc = liveDoc && liveDoc.type === "doc"
|
||||
@@ -2598,7 +2680,10 @@ export class DocmostClient {
|
||||
// JSON write path) before writing it back.
|
||||
this.validateDocUrls(version.content);
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
const mutation = await mutatePageContent(version.pageId, collabToken, this.apiUrl, () => version.content);
|
||||
// version.pageId is the page entity id (already a UUID); resolvePageId
|
||||
// short-circuits a UUID with no round-trip, so this is defensive only (#260).
|
||||
const pageUuid = await this.resolvePageId(version.pageId);
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, () => version.content);
|
||||
return {
|
||||
pageId: version.pageId,
|
||||
restoredFrom: historyId,
|
||||
@@ -2767,7 +2852,9 @@ export class DocmostClient {
|
||||
}
|
||||
// Apply atomically against the live doc.
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, runTransform);
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, runTransform);
|
||||
// Optionally delete consumed comments (best-effort; a delete failure must
|
||||
// not undo the successful write).
|
||||
const deletedComments = [];
|
||||
|
||||
@@ -271,6 +271,25 @@ const TextStyle = Mark.create({
|
||||
return ["span", HTMLAttributes, 0];
|
||||
},
|
||||
});
|
||||
/**
|
||||
* Inline spoiler mark. Mirrors the @docmost/editor-ext `spoiler` mark so a
|
||||
* document carrying a spoiler survives the MCP read -> transform -> write path
|
||||
* (and markdown export) instead of silently dropping the unrecognized mark.
|
||||
* packages/mcp does NOT depend on editor-ext, so the definition is kept local;
|
||||
* it parses span[data-spoiler] and renders the same span[data-spoiler][class]
|
||||
* the editor-ext mark emits.
|
||||
*/
|
||||
const Spoiler = Mark.create({
|
||||
name: "spoiler",
|
||||
// Don't bleed onto text typed at the boundary (mirrors editor-ext).
|
||||
inclusive: false,
|
||||
parseHTML() {
|
||||
return [{ tag: "span[data-spoiler]" }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["span", { "data-spoiler": "true", class: "spoiler", ...HTMLAttributes }, 0];
|
||||
},
|
||||
});
|
||||
/**
|
||||
* Passthrough definitions for the remaining Docmost-specific nodes.
|
||||
*
|
||||
@@ -1070,7 +1089,24 @@ export const docmostExtensions = [
|
||||
heading: {},
|
||||
link: { openOnClick: false },
|
||||
}),
|
||||
Image.configure({ inline: false }),
|
||||
// Stock @tiptap/extension-image has no caption attribute, so a round-trip
|
||||
// through this schema would drop the data-caption the client TiptapImage
|
||||
// emits. Mirror editor-ext image.ts: add a caption attribute that parses
|
||||
// data-caption and re-renders it only when set (caption-less images stay
|
||||
// clean), keeping the MCP markdown round-trip lossless.
|
||||
Image.extend({
|
||||
addAttributes() {
|
||||
const parent = this.parent?.() ?? {};
|
||||
return {
|
||||
...parent,
|
||||
caption: {
|
||||
default: undefined,
|
||||
parseHTML: (el) => el.getAttribute("data-caption") || undefined,
|
||||
renderHTML: (attrs) => attrs.caption ? { "data-caption": attrs.caption } : {},
|
||||
},
|
||||
};
|
||||
},
|
||||
}).configure({ inline: false }),
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
// Highlight stores its color unescaped and Docmost interpolates it into
|
||||
@@ -1097,6 +1133,7 @@ export const docmostExtensions = [
|
||||
// generateJSON drops <span style="color: ...">, defeating the color import.
|
||||
TextStyle,
|
||||
Comment,
|
||||
Spoiler,
|
||||
Callout,
|
||||
Table,
|
||||
TableRow,
|
||||
|
||||
@@ -160,6 +160,12 @@ export function convertProseMirrorToMarkdown(content) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "spoiler":
|
||||
// Markdown has no native spoiler syntax, so emit the same
|
||||
// lossless raw HTML the editor-ext turndown rule produces; the
|
||||
// schema's Spoiler mark parses span[data-spoiler] back on import.
|
||||
textContent = `<span data-spoiler="true">${textContent}</span>`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,16 +213,27 @@ export function convertProseMirrorToMarkdown(content) {
|
||||
// Two trailing spaces before the newline encode a markdown hard break;
|
||||
// a bare "\n" would be reimported as a soft break and lost.
|
||||
return " \n";
|
||||
case "image":
|
||||
case "image": {
|
||||
const imgAlt = node.attrs?.alt || "";
|
||||
const imgCaption = node.attrs?.caption || "";
|
||||
if (imgCaption) {
|
||||
// ![]() can't carry a caption, so (symmetric to video) emit a raw
|
||||
// <img> wrapped in a block <div>. On import marked.parse keeps the raw
|
||||
// HTML and generateJSON runs the image extension's parseHTML, which
|
||||
// restores the caption from data-caption.
|
||||
const parts = [`src="${escapeAttr(node.attrs?.src ?? "")}"`];
|
||||
if (imgAlt)
|
||||
parts.push(`alt="${escapeAttr(imgAlt)}"`);
|
||||
parts.push(`data-caption="${escapeAttr(imgCaption)}"`);
|
||||
return `<div><img ${parts.join(" ")}></div>`;
|
||||
}
|
||||
// Neutralize characters that could break out of the markdown image
|
||||
// URL: spaces/newlines and parentheses would terminate the (...) target
|
||||
// and let a stored src inject following markdown/HTML. Percent-encode
|
||||
// them so the URL stays a single inert token.
|
||||
const imgSrc = encodeMdUrl(node.attrs?.src);
|
||||
// No "caption" attribute exists in the Docmost image schema, so we do
|
||||
// not emit one (the previous caption branch was dead).
|
||||
return ``;
|
||||
}
|
||||
case "video": {
|
||||
// Emit the schema-matching <video> element so generateJSON rebuilds the
|
||||
// node with its attrs intact. The schema's parseHTML reads src/aria-label
|
||||
@@ -618,6 +635,8 @@ export function convertProseMirrorToMarkdown(content) {
|
||||
const parts = [`src="${escapeAttr(attrs.src ?? "")}"`];
|
||||
if (attrs.alt)
|
||||
parts.push(`alt="${escapeAttr(attrs.alt)}"`);
|
||||
if (attrs.caption)
|
||||
parts.push(`data-caption="${escapeAttr(attrs.caption)}"`);
|
||||
if (attrs.title)
|
||||
parts.push(`title="${escapeAttr(attrs.title)}"`);
|
||||
if (attrs.width != null)
|
||||
|
||||
+110
-19
@@ -133,6 +133,18 @@ export type DocmostMcpConfig = { apiUrl: string } & (
|
||||
};
|
||||
};
|
||||
|
||||
// Canonical UUID shape (versions 1–8, matching the `uuid` package's `validate`
|
||||
// that the server's isValidUUID uses). page.repo.ts treats any non-UUID pageId
|
||||
// as a slugId, so the MCP detects a UUID locally and skips a /pages/info
|
||||
// round-trip in resolvePageId. A 10-char nanoid slugId never contains dashes,
|
||||
// so it can never be misread as a UUID here.
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
function isUuid(value: string): boolean {
|
||||
return typeof value === "string" && UUID_RE.test(value);
|
||||
}
|
||||
|
||||
export class DocmostClient {
|
||||
private client: AxiosInstance;
|
||||
private token: string | null = null;
|
||||
@@ -160,6 +172,11 @@ export class DocmostClient {
|
||||
// can all call login() at once. Memoizing a single promise collapses that
|
||||
// thundering herd into ONE /auth/login request that everyone awaits.
|
||||
private loginPromise: Promise<void> | null = null;
|
||||
// Canonical-UUID cache for resolvePageId: maps an agent-supplied slugId to the
|
||||
// page's canonical UUID, so repeated collab edits on the same page do not
|
||||
// re-fetch /pages/info. A UUID input short-circuits before this cache (see
|
||||
// resolvePageId), so only slugId->uuid entries are stored/read here.
|
||||
private pageIdCache = new Map<string, string>();
|
||||
|
||||
// Two construction forms:
|
||||
// - new DocmostClient(config) // discriminated union (current)
|
||||
@@ -751,6 +768,36 @@ export class DocmostClient {
|
||||
return response.data?.data ?? response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an agent-supplied pageId to the page's CANONICAL UUID (`page.id`),
|
||||
* so every collaboration document the MCP opens is named `page.<uuid>` — the
|
||||
* SAME name the web editor always uses (`page.${page.id}`).
|
||||
*
|
||||
* The agent commonly passes a 10-char public slugId (from URLs/listings) as
|
||||
* the pageId. The web editor opens the collab doc by UUID, but the MCP used to
|
||||
* pass that slugId straight into the collab doc name (`page.<slugId>`). For one
|
||||
* DB row that produced TWO independent Yjs documents whose debounced stores
|
||||
* clobbered each other — the agent's edit was silently lost (#260).
|
||||
*
|
||||
* A UUID input short-circuits with no network round-trip. A slugId is resolved
|
||||
* once via getPageRaw and cached (both slugId->uuid and uuid->uuid), so
|
||||
* repeated edits on the same page add no extra request.
|
||||
*/
|
||||
private async resolvePageId(pageId: string): Promise<string> {
|
||||
if (isUuid(pageId)) return pageId;
|
||||
const cached = this.pageIdCache.get(pageId);
|
||||
if (cached) return cached;
|
||||
const data = await this.getPageRaw(pageId);
|
||||
const uuid = data?.id;
|
||||
if (typeof uuid !== "string" || !uuid) {
|
||||
throw new Error(
|
||||
`Could not resolve a canonical page id for "${pageId}"`,
|
||||
);
|
||||
}
|
||||
this.pageIdCache.set(pageId, uuid);
|
||||
return uuid;
|
||||
}
|
||||
|
||||
async getPage(pageId: string) {
|
||||
await this.ensureAuthenticated();
|
||||
const resultData = await this.getPageRaw(pageId);
|
||||
@@ -1083,12 +1130,14 @@ export class DocmostClient {
|
||||
) {
|
||||
await this.ensureAuthenticated();
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Track insertion in an outer var, reset per-transform, so a collab retry
|
||||
// recomputes it cleanly (mirrors insertNode's pattern).
|
||||
let inserted = false;
|
||||
const mutation = await mutatePageContent(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
@@ -1126,10 +1175,12 @@ export class DocmostClient {
|
||||
async tableDeleteRow(pageId: string, tableRef: string, index: number) {
|
||||
await this.ensureAuthenticated();
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
let deleted = false;
|
||||
const mutation = await mutatePageContent(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
@@ -1174,10 +1225,12 @@ export class DocmostClient {
|
||||
) {
|
||||
await this.ensureAuthenticated();
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
let updated = false;
|
||||
const mutation = await mutatePageContent(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
@@ -1313,6 +1366,10 @@ export class DocmostClient {
|
||||
*/
|
||||
async updatePage(pageId: string, content: string, title?: string) {
|
||||
await this.ensureAuthenticated();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
// REST /pages/update title write below keeps the agent-supplied id (the
|
||||
// server resolves a slugId there).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Write the BODY first, then the title (#159 split-brain). If the collab
|
||||
// body write fails (e.g. a persist timeout), the title must be left
|
||||
@@ -1324,7 +1381,7 @@ export class DocmostClient {
|
||||
try {
|
||||
collabToken = await this.getCollabTokenWithReauth();
|
||||
mutation = await updatePageContentRealtime(
|
||||
pageId,
|
||||
pageUuid,
|
||||
content,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
@@ -1587,8 +1644,10 @@ export class DocmostClient {
|
||||
// Write the BODY first, then the title (#159 split-brain): a failed body
|
||||
// write (e.g. persist timeout) must not leave a new title over the old body.
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
const mutation = await this.replacePage(
|
||||
pageId,
|
||||
pageUuid,
|
||||
doc,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
@@ -1630,9 +1689,11 @@ export class DocmostClient {
|
||||
throw new Error("insert_footnote: text is required");
|
||||
}
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
let result: { footnoteId: string; reused: boolean } | null = null;
|
||||
const mutation = await this.mutatePage(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc: any) => {
|
||||
@@ -1740,8 +1801,10 @@ export class DocmostClient {
|
||||
// PAGE import: canonicalize footnotes (see markdownToProseMirrorCanonical).
|
||||
const doc = await markdownToProseMirrorCanonical(body);
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
const mutation = await replacePageContent(
|
||||
pageId,
|
||||
pageUuid,
|
||||
doc,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
@@ -1840,8 +1903,10 @@ export class DocmostClient {
|
||||
const canonical = canonicalizeFootnotes(content);
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the TARGET collab doc by its canonical UUID, never the slugId (#260).
|
||||
const targetUuid = await this.resolvePageId(targetPageId);
|
||||
const mutation = await this.replacePage(
|
||||
targetPageId,
|
||||
targetUuid,
|
||||
canonical,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
@@ -1864,6 +1929,8 @@ export class DocmostClient {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Apply the edits against the LIVE synced document, not the debounced REST
|
||||
// snapshot, so concurrent human edits/comments are preserved. applyTextEdits
|
||||
@@ -1878,7 +1945,7 @@ export class DocmostClient {
|
||||
// happened.
|
||||
let wrote = false;
|
||||
const mutation = await mutatePageContent(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
@@ -1978,12 +2045,14 @@ export class DocmostClient {
|
||||
}
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Track the replacement count in an outer var, reset per-transform, so a
|
||||
// collab retry recomputes it cleanly (mirrors replaceImage's pattern).
|
||||
let replaced = 0;
|
||||
const mutation = await mutatePageContent(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
@@ -2066,12 +2135,14 @@ export class DocmostClient {
|
||||
}
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Track insertion in an outer var, reset per-transform, so a collab retry
|
||||
// recomputes it cleanly (mirrors replaceImage's pattern).
|
||||
let inserted = false;
|
||||
const mutation = await mutatePageContent(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
@@ -2120,12 +2191,14 @@ export class DocmostClient {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Track the deletion count in an outer var, reset per-transform, so a
|
||||
// collab retry recomputes it cleanly (mirrors replaceImage's pattern).
|
||||
let deleted = 0;
|
||||
const mutation = await mutatePageContent(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
@@ -2414,8 +2487,11 @@ export class DocmostClient {
|
||||
let anchored = false;
|
||||
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);
|
||||
const mutation = await mutatePageContent(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
@@ -2893,6 +2969,9 @@ export class DocmostClient {
|
||||
if (opts.alt) node.attrs.alt = opts.alt;
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
// uploadImage /files/upload call above keeps the agent-supplied id.
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Recursively collect the plain text of a top-level block.
|
||||
const blockText = (n: any): string => {
|
||||
@@ -2907,7 +2986,7 @@ export class DocmostClient {
|
||||
// calls (serialized by the per-page lock) each see the previous insertion.
|
||||
let placement: "replaced" | "after" | "appended" | undefined;
|
||||
const mutation = await mutatePageContent(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
@@ -3019,6 +3098,13 @@ export class DocmostClient {
|
||||
opts: { align?: "left" | "center" | "right"; alt?: string } = {},
|
||||
) {
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
// page lock must ALSO key on the UUID so this operation serializes against
|
||||
// other writes to the same page (mutatePageContent now locks by the resolved
|
||||
// UUID too); locking by the raw slugId here would desync the mutex key and
|
||||
// reopen the TOCTOU/orphan-attachment window the lock closes. uploadImage
|
||||
// keeps the agent-supplied id (it hits REST, not the collab doc).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Hold ONE per-page lock for the WHOLE operation (scan -> upload -> write).
|
||||
// Previously the scan and the write were two separate mutatePageContent
|
||||
@@ -3031,7 +3117,7 @@ export class DocmostClient {
|
||||
// reentrant, so the self-locking mutatePageContent would deadlock here)
|
||||
// closes that TOCTOU window. uploadImage hits /files/upload over plain HTTP
|
||||
// and does not touch the page lock, so it is safe to call while held.
|
||||
return withPageLock(pageId, async () => {
|
||||
return withPageLock(pageUuid, async () => {
|
||||
// STEP 1: read-only live check. Scan the live document for any image node
|
||||
// matching oldAttachmentId BEFORE uploading anything, so a wrong/stale id
|
||||
// throws without ever creating an orphan attachment.
|
||||
@@ -3050,7 +3136,7 @@ export class DocmostClient {
|
||||
}
|
||||
};
|
||||
|
||||
await this.mutateLiveContentUnlocked(pageId, collabToken, (liveDoc) => {
|
||||
await this.mutateLiveContentUnlocked(pageUuid, collabToken, (liveDoc) => {
|
||||
matchFound = false; // reset per-transform (collab may retry the read).
|
||||
const doc =
|
||||
liveDoc && liveDoc.type === "doc"
|
||||
@@ -3105,7 +3191,7 @@ export class DocmostClient {
|
||||
};
|
||||
|
||||
const mutation = await this.mutateLiveContentUnlocked(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
(liveDoc) => {
|
||||
// Reset per-transform so collab retries recompute cleanly (no double-count).
|
||||
@@ -3214,8 +3300,11 @@ export class DocmostClient {
|
||||
// JSON write path) before writing it back.
|
||||
this.validateDocUrls(version.content);
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// version.pageId is the page entity id (already a UUID); resolvePageId
|
||||
// short-circuits a UUID with no round-trip, so this is defensive only (#260).
|
||||
const pageUuid = await this.resolvePageId(version.pageId);
|
||||
const mutation = await mutatePageContent(
|
||||
version.pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
() => version.content,
|
||||
@@ -3414,8 +3503,10 @@ export class DocmostClient {
|
||||
|
||||
// Apply atomically against the live doc.
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
const mutation = await mutatePageContent(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
runTransform,
|
||||
|
||||
@@ -298,6 +298,26 @@ const TextStyle = Mark.create({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Inline spoiler mark. Mirrors the @docmost/editor-ext `spoiler` mark so a
|
||||
* document carrying a spoiler survives the MCP read -> transform -> write path
|
||||
* (and markdown export) instead of silently dropping the unrecognized mark.
|
||||
* packages/mcp does NOT depend on editor-ext, so the definition is kept local;
|
||||
* it parses span[data-spoiler] and renders the same span[data-spoiler][class]
|
||||
* the editor-ext mark emits.
|
||||
*/
|
||||
const Spoiler = Mark.create({
|
||||
name: "spoiler",
|
||||
// Don't bleed onto text typed at the boundary (mirrors editor-ext).
|
||||
inclusive: false,
|
||||
parseHTML() {
|
||||
return [{ tag: "span[data-spoiler]" }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["span", { "data-spoiler": "true", class: "spoiler", ...HTMLAttributes }, 0];
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Passthrough definitions for the remaining Docmost-specific nodes.
|
||||
*
|
||||
@@ -1164,7 +1184,26 @@ export const docmostExtensions = [
|
||||
heading: {},
|
||||
link: { openOnClick: false },
|
||||
}),
|
||||
Image.configure({ inline: false }),
|
||||
// Stock @tiptap/extension-image has no caption attribute, so a round-trip
|
||||
// through this schema would drop the data-caption the client TiptapImage
|
||||
// emits. Mirror editor-ext image.ts: add a caption attribute that parses
|
||||
// data-caption and re-renders it only when set (caption-less images stay
|
||||
// clean), keeping the MCP markdown round-trip lossless.
|
||||
Image.extend({
|
||||
addAttributes() {
|
||||
const parent = this.parent?.() ?? {};
|
||||
return {
|
||||
...parent,
|
||||
caption: {
|
||||
default: undefined,
|
||||
parseHTML: (el: HTMLElement) =>
|
||||
el.getAttribute("data-caption") || undefined,
|
||||
renderHTML: (attrs: Record<string, any>) =>
|
||||
attrs.caption ? { "data-caption": attrs.caption } : {},
|
||||
},
|
||||
};
|
||||
},
|
||||
}).configure({ inline: false }),
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
// Highlight stores its color unescaped and Docmost interpolates it into
|
||||
@@ -1194,6 +1233,7 @@ export const docmostExtensions = [
|
||||
// generateJSON drops <span style="color: ...">, defeating the color import.
|
||||
TextStyle,
|
||||
Comment,
|
||||
Spoiler,
|
||||
Callout,
|
||||
Table,
|
||||
TableRow,
|
||||
|
||||
@@ -167,6 +167,12 @@ export function convertProseMirrorToMarkdown(content: any): string {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "spoiler":
|
||||
// Markdown has no native spoiler syntax, so emit the same
|
||||
// lossless raw HTML the editor-ext turndown rule produces; the
|
||||
// schema's Spoiler mark parses span[data-spoiler] back on import.
|
||||
textContent = `<span data-spoiler="true">${textContent}</span>`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,16 +234,26 @@ export function convertProseMirrorToMarkdown(content: any): string {
|
||||
// a bare "\n" would be reimported as a soft break and lost.
|
||||
return " \n";
|
||||
|
||||
case "image":
|
||||
case "image": {
|
||||
const imgAlt = node.attrs?.alt || "";
|
||||
const imgCaption = node.attrs?.caption || "";
|
||||
if (imgCaption) {
|
||||
// ![]() can't carry a caption, so (symmetric to video) emit a raw
|
||||
// <img> wrapped in a block <div>. On import marked.parse keeps the raw
|
||||
// HTML and generateJSON runs the image extension's parseHTML, which
|
||||
// restores the caption from data-caption.
|
||||
const parts: string[] = [`src="${escapeAttr(node.attrs?.src ?? "")}"`];
|
||||
if (imgAlt) parts.push(`alt="${escapeAttr(imgAlt)}"`);
|
||||
parts.push(`data-caption="${escapeAttr(imgCaption)}"`);
|
||||
return `<div><img ${parts.join(" ")}></div>`;
|
||||
}
|
||||
// Neutralize characters that could break out of the markdown image
|
||||
// URL: spaces/newlines and parentheses would terminate the (...) target
|
||||
// and let a stored src inject following markdown/HTML. Percent-encode
|
||||
// them so the URL stays a single inert token.
|
||||
const imgSrc = encodeMdUrl(node.attrs?.src);
|
||||
// No "caption" attribute exists in the Docmost image schema, so we do
|
||||
// not emit one (the previous caption branch was dead).
|
||||
return ``;
|
||||
}
|
||||
|
||||
case "video": {
|
||||
// Emit the schema-matching <video> element so generateJSON rebuilds the
|
||||
@@ -678,6 +694,8 @@ export function convertProseMirrorToMarkdown(content: any): string {
|
||||
const attrs = node.attrs || {};
|
||||
const parts: string[] = [`src="${escapeAttr(attrs.src ?? "")}"`];
|
||||
if (attrs.alt) parts.push(`alt="${escapeAttr(attrs.alt)}"`);
|
||||
if (attrs.caption)
|
||||
parts.push(`data-caption="${escapeAttr(attrs.caption)}"`);
|
||||
if (attrs.title) parts.push(`title="${escapeAttr(attrs.title)}"`);
|
||||
if (attrs.width != null) parts.push(`width="${escapeAttr(attrs.width)}"`);
|
||||
if (attrs.height != null) parts.push(`height="${escapeAttr(attrs.height)}"`);
|
||||
|
||||
@@ -132,7 +132,7 @@ test("patch_node REFUSES an ambiguous (duplicate) id without writing to collab",
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
client.patchNode("page-1", DUP_ID, {
|
||||
client.patchNode("11111111-1111-4111-8111-111111111111", DUP_ID, {
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "replacement" }],
|
||||
}),
|
||||
@@ -152,7 +152,7 @@ test("delete_node REFUSES an ambiguous (duplicate) id without writing to collab"
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
await assert.rejects(
|
||||
() => client.deleteNode("page-2", DUP_ID),
|
||||
() => client.deleteNode("22222222-2222-4222-8222-222222222222", DUP_ID),
|
||||
/ambiguous/i,
|
||||
"delete_node must reject a duplicate-id target with an 'ambiguous' error",
|
||||
);
|
||||
|
||||
@@ -37,6 +37,11 @@ function makeClient(liveDoc) {
|
||||
async getCollabTokenWithReauth() {
|
||||
return "collab-token";
|
||||
}
|
||||
// Identity resolution: this test isolates the footnote wrapper, so the
|
||||
// slugId->uuid resolution (#260) is stubbed to a no-op and "p1" stays "p1".
|
||||
async resolvePageId(pageId) {
|
||||
return pageId;
|
||||
}
|
||||
async mutatePage(pageId, token, apiUrl, transform) {
|
||||
calls.pageId = pageId;
|
||||
calls.token = token;
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
// Mock collab regression for the #260 data-loss bug: the MCP must open every
|
||||
// collaboration document by the page's CANONICAL UUID (`page.<uuid>`) — the same
|
||||
// name the web editor uses — even when the agent supplies a public slugId.
|
||||
//
|
||||
// Root cause: the agent commonly passes a 10-char slugId (from URLs/listings) as
|
||||
// pageId. The web tab opens `page.<uuid>`, but the MCP used to pass the slugId
|
||||
// straight into the collab doc name (`page.<slugId>`), so one DB page ended up
|
||||
// with TWO independent Yjs documents whose debounced stores clobbered each other
|
||||
// — the agent's edit was silently lost on reload.
|
||||
//
|
||||
// We stand up a real Hocuspocus server (like ambiguous-node-id.test.mjs) and
|
||||
// capture the EXACT documentName each connection requests via onLoadDocument.
|
||||
// The /pages/info mock resolves the slugId -> uuid, and counts its own hits so we
|
||||
// can also prove the UUID short-circuit + cache (no redundant resolve round-trip).
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { Hocuspocus } from "@hocuspocus/server";
|
||||
import { DocmostClient } from "../../build/client.js";
|
||||
import { buildYDoc } from "../../build/lib/collaboration.js";
|
||||
// Import the SAME page-lock module instance that build/client.js imports. ESM
|
||||
// caches modules by resolved URL, so this `withPageLock` shares the very
|
||||
// per-page mutex map (`chains`) the client uses — letting the replaceImage test
|
||||
// probe which key the operation actually locks on (see that test for details).
|
||||
import { withPageLock } from "../../build/lib/page-lock.js";
|
||||
|
||||
const SLUG = "dwzDdgPep2"; // 10-char nanoid public id (no dashes)
|
||||
const UUID = "11111111-1111-4111-8111-111111111111"; // canonical page.id
|
||||
|
||||
// A simple one-paragraph document; "hello world" gives editPageText a match and
|
||||
// insertFootnote an anchor. No table node, so tableInsertRow aborts with
|
||||
// "no table found" — but the collab doc was still OPENED by then, which is what
|
||||
// we assert (the doc NAME is fixed at connect time, before any transform runs).
|
||||
function seedDoc() {
|
||||
return {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { id: "p1" },
|
||||
content: [{ type: "text", text: "hello world" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Same shape as seedDoc but with one image node carrying attachmentId "att-old"
|
||||
// (mirrors what client.addImage emits). replaceImage scans the live doc for this
|
||||
// node, so it must survive the Yjs round-trip with attachmentId intact.
|
||||
function seedDocWithImage() {
|
||||
return {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { id: "p1" },
|
||||
content: [{ type: "text", text: "hello world" }],
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
attrs: {
|
||||
src: "/api/files/att-old/old.png",
|
||||
attachmentId: "att-old",
|
||||
size: 10,
|
||||
align: "center",
|
||||
width: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let raw = "";
|
||||
req.on("data", (c) => (raw += c));
|
||||
req.on("end", () => resolve(raw));
|
||||
});
|
||||
}
|
||||
|
||||
// Stand up an HTTP server that authenticates, hands out a collab token, serves
|
||||
// /pages/info (slugId -> uuid resolution), and upgrades /collab to a Hocuspocus
|
||||
// instance whose onLoadDocument records the requested documentName.
|
||||
// opts.seed: a function returning the ProseMirror doc the collab server loads
|
||||
// (defaults to seedDoc). opts.onUpload: an optional async hook invoked when
|
||||
// /files/upload is hit, letting a test GATE the upload (hold replaceImage inside
|
||||
// its page lock). Existing callers pass no opts and are unaffected.
|
||||
async function spawnCollabStack(opts = {}) {
|
||||
const seed = opts.seed ?? seedDoc;
|
||||
const state = { docNames: [], pagesInfoCalls: [] };
|
||||
|
||||
const hocuspocus = new Hocuspocus({
|
||||
quiet: true,
|
||||
async onLoadDocument({ documentName }) {
|
||||
state.docNames.push(documentName);
|
||||
return buildYDoc(seed());
|
||||
},
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const raw = await readBody(req);
|
||||
if (req.url === "/api/auth/login") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||
});
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/auth/collab-token") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ data: { token: "collab-jwt" } }));
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/info") {
|
||||
let pageId;
|
||||
try {
|
||||
pageId = JSON.parse(raw)?.pageId;
|
||||
} catch {
|
||||
pageId = undefined;
|
||||
}
|
||||
state.pagesInfoCalls.push(pageId);
|
||||
// Always resolve to the SAME canonical record, mirroring the server's
|
||||
// findById (which accepts either the uuid or the slugId).
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
id: UUID,
|
||||
slugId: SLUG,
|
||||
title: "Doc",
|
||||
spaceId: "space-1",
|
||||
content: seedDoc(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.url && req.url.endsWith(".png")) {
|
||||
// Serve image bytes for fetchRemoteImage (replaceImage downloads the new
|
||||
// image before uploading it). Any non-empty image/* body is enough;
|
||||
// fetchRemoteImage does not validate PNG magic bytes.
|
||||
res.writeHead(200, { "Content-Type": "image/png" });
|
||||
res.end(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/files/upload") {
|
||||
// Optional gate: a test can hold replaceImage parked here (inside its page
|
||||
// lock, after the scan) to probe the lock key. Default: respond at once.
|
||||
if (opts.onUpload) await opts.onUpload();
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
data: { id: "att-new", fileName: "replacement.png", fileSize: 8 },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Title writes (/pages/update) and anything else: succeed quietly.
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ data: {} }));
|
||||
});
|
||||
|
||||
// buildCollabWsUrl maps http://host:port/api -> ws://host:port/collab.
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
if (!request.url || !request.url.startsWith("/collab")) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
hocuspocus.handleConnection(ws, request);
|
||||
});
|
||||
});
|
||||
|
||||
const baseURL = await new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server.address();
|
||||
resolve(`http://127.0.0.1:${port}/api`);
|
||||
});
|
||||
});
|
||||
|
||||
openStacks.push({ server, hocuspocus });
|
||||
return { state, baseURL };
|
||||
}
|
||||
|
||||
const openStacks = [];
|
||||
after(async () => {
|
||||
await Promise.all(
|
||||
openStacks.map(
|
||||
({ server, hocuspocus }) =>
|
||||
new Promise((resolve) => {
|
||||
server.close(() => {
|
||||
Promise.resolve(hocuspocus.destroy?.()).finally(resolve);
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("editPageText with a slugId opens the collab doc by the resolved UUID (#260)", async () => {
|
||||
const { state, baseURL } = await spawnCollabStack();
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
const res = await client.editPageText(SLUG, [
|
||||
{ find: "hello", replace: "hi" },
|
||||
]);
|
||||
assert.equal(res.success, true);
|
||||
|
||||
assert.ok(
|
||||
state.docNames.includes(`page.${UUID}`),
|
||||
`collab doc must be opened as page.${UUID}, got ${JSON.stringify(state.docNames)}`,
|
||||
);
|
||||
assert.ok(
|
||||
!state.docNames.includes(`page.${SLUG}`),
|
||||
"collab doc must NEVER be opened by the slugId (that is the data-loss bug)",
|
||||
);
|
||||
// The slugId had to be resolved via /pages/info at least once.
|
||||
assert.ok(state.pagesInfoCalls.length >= 1);
|
||||
});
|
||||
|
||||
test("tableInsertRow with a slugId opens the collab doc by the resolved UUID (#260)", async () => {
|
||||
const { state, baseURL } = await spawnCollabStack();
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
// No table in the seed doc, so this aborts with "no table found" — but the
|
||||
// collab doc has ALREADY been opened (by UUID) before the transform decides.
|
||||
await assert.rejects(
|
||||
() => client.tableInsertRow(SLUG, "#0", ["a", "b"]),
|
||||
/no table/i,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
state.docNames,
|
||||
[`page.${UUID}`],
|
||||
"tableInsertRow must open the collab doc by the resolved UUID",
|
||||
);
|
||||
});
|
||||
|
||||
test("the generic mutate (insert_footnote) with a slugId opens by the resolved UUID (#260)", async () => {
|
||||
const { state, baseURL } = await spawnCollabStack();
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
const res = await client.insertFootnote(SLUG, "world", "a note");
|
||||
assert.equal(res.success, true);
|
||||
|
||||
assert.deepEqual(
|
||||
state.docNames,
|
||||
[`page.${UUID}`],
|
||||
"insert_footnote (via the mutatePage seam) must open the collab doc by UUID",
|
||||
);
|
||||
});
|
||||
|
||||
test("a UUID input is passed through unchanged and triggers NO /pages/info fetch (short-circuit)", async () => {
|
||||
const { state, baseURL } = await spawnCollabStack();
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
const res = await client.editPageText(UUID, [
|
||||
{ find: "hello", replace: "hi" },
|
||||
]);
|
||||
assert.equal(res.success, true);
|
||||
|
||||
assert.deepEqual(state.docNames, [`page.${UUID}`]);
|
||||
assert.equal(
|
||||
state.pagesInfoCalls.length,
|
||||
0,
|
||||
"a UUID input must short-circuit resolvePageId with no /pages/info round-trip",
|
||||
);
|
||||
});
|
||||
|
||||
test("a repeated slugId edit resolves the UUID only once (cache)", async () => {
|
||||
const { state, baseURL } = await spawnCollabStack();
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
// Each mock connection re-seeds a fresh "hello world" doc (the mock does not
|
||||
// persist across connects), so both edits target "hello". The cache assertion
|
||||
// only concerns the slugId->uuid resolution, not the document content.
|
||||
await client.editPageText(SLUG, [{ find: "hello", replace: "hi" }]);
|
||||
await client.editPageText(SLUG, [{ find: "hello", replace: "hey" }]);
|
||||
|
||||
assert.deepEqual(state.docNames, [`page.${UUID}`, `page.${UUID}`]);
|
||||
assert.equal(
|
||||
state.pagesInfoCalls.length,
|
||||
1,
|
||||
"the slugId->uuid resolution must be cached across edits on the same page",
|
||||
);
|
||||
});
|
||||
|
||||
// PR#265 reviewer finding F1. replaceImage is the one path where the resolved
|
||||
// UUID gates BOTH (a) the collab-doc OPEN (mutateLiveContentUnlocked ->
|
||||
// page.<uuid>) AND (b) the per-page mutex key withPageLock(uuid). The lock
|
||||
// serializes the whole scan -> upload -> write against other writes to the same
|
||||
// page (which now also lock by the resolved UUID), closing a TOCTOU/orphan-
|
||||
// attachment window. A regression that re-keys this lock by the raw slugId would
|
||||
// desync it from mutatePageContent's UUID key and silently reopen that window.
|
||||
// This test pins both invariants and FAILS under either regression:
|
||||
// - open by slugId -> assertion (a) sees page.<slug> in docNames;
|
||||
// - lock by slugId -> assertion (b)'s UUID-keyed probe is no longer blocked.
|
||||
test("replaceImage opens by the resolved UUID AND keys its page lock by that UUID, not the slugId (#260 / PR#265 F1)", async () => {
|
||||
// A gate that holds the /files/upload response open, so replaceImage parks
|
||||
// INSIDE its page lock (after the read-only scan, mid-upload) until released.
|
||||
let releaseUpload;
|
||||
const uploadReleased = new Promise((r) => (releaseUpload = r));
|
||||
let uploadHit;
|
||||
const uploadStarted = new Promise((r) => (uploadHit = r));
|
||||
|
||||
const { state, baseURL } = await spawnCollabStack({
|
||||
seed: seedDocWithImage,
|
||||
onUpload: async () => {
|
||||
uploadHit(); // replaceImage is now holding its page lock...
|
||||
await uploadReleased; // ...and stays parked until the test releases it.
|
||||
},
|
||||
});
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
// Kick off the replace but DO NOT await: it resolves SLUG->UUID, takes
|
||||
// withPageLock(UUID), scan-opens page.<UUID>, finds the seeded "att-old"
|
||||
// image, then blocks in uploadImage on our gate while still holding the lock.
|
||||
// The image URL is served as image/png by the mock (the ".png" route above).
|
||||
const imageUrl = `${baseURL}/x.png`;
|
||||
const replacePromise = client.replaceImage(SLUG, "att-old", imageUrl);
|
||||
|
||||
await uploadStarted; // deterministic: replaceImage now holds its page lock.
|
||||
|
||||
// (a) OPEN BY UUID: the only collab doc opened so far (the scan pass) used the
|
||||
// canonical UUID, never the slugId. (The write pass opens a second time after
|
||||
// we release the gate; asserted at the end.)
|
||||
assert.deepEqual(
|
||||
state.docNames,
|
||||
[`page.${UUID}`],
|
||||
"replaceImage must scan-open the collab doc by the resolved UUID, never the slugId",
|
||||
);
|
||||
|
||||
// (b) LOCK KEY == UUID (the distinct invariant). We share the SAME page-lock
|
||||
// module instance as build/client.js, so enqueuing on key=UUID contends on the
|
||||
// very chain replaceImage holds. Because replaceImage is deterministically
|
||||
// parked mid-upload (still holding the lock), a UUID-keyed probe MUST stay
|
||||
// queued; it cannot run until the lock frees. The contention here is pure
|
||||
// in-memory promise-chain microtask scheduling (no timers, no socket I/O), so
|
||||
// a single macrotask flush is a sufficient and deterministic observation.
|
||||
// If replaceImage were reverted to lock by the slugId, the UUID chain would be
|
||||
// free and this probe would run during the flush -> probeRan === true -> FAIL.
|
||||
let probeRan = false;
|
||||
const probeDone = withPageLock(UUID, async () => {
|
||||
probeRan = true;
|
||||
});
|
||||
// setImmediate runs after the microtask queue fully drains, so a probe on a
|
||||
// FREE chain would already have run by the time this resolves.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(
|
||||
probeRan,
|
||||
false,
|
||||
"a probe on key=UUID must stay blocked while replaceImage holds the lock; " +
|
||||
"if it ran, replaceImage locked by a different key (e.g. the raw slugId)",
|
||||
);
|
||||
|
||||
// Non-vacuity guard: a probe on an UNRELATED key DOES run after the same
|
||||
// single flush. This proves the flush actually executes queued callbacks, so
|
||||
// probeRan === false above means "blocked", not "the flush never ran anyone".
|
||||
let freeRan = false;
|
||||
const freeDone = withPageLock(`page.free-${UUID}`, async () => {
|
||||
freeRan = true;
|
||||
});
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(
|
||||
freeRan,
|
||||
true,
|
||||
"sanity: a probe on a FREE key must run after one flush (the UUID probe was blocked by the held key, not by an inert flush)",
|
||||
);
|
||||
|
||||
// Release the gate; replaceImage finishes and the queued UUID probe can run.
|
||||
releaseUpload();
|
||||
const res = await replacePromise;
|
||||
await probeDone;
|
||||
await freeDone;
|
||||
|
||||
assert.equal(res.success, true);
|
||||
assert.equal(res.replaced, 1, "the one seeded image must be repointed");
|
||||
// Both opens (scan pass + write pass) used the UUID; the slugId never appears.
|
||||
assert.deepEqual(state.docNames, [`page.${UUID}`, `page.${UUID}`]);
|
||||
assert.ok(
|
||||
!state.docNames.includes(`page.${SLUG}`),
|
||||
"replaceImage must NEVER open the collab doc by the slugId (the #260 bug)",
|
||||
);
|
||||
});
|
||||
@@ -66,6 +66,14 @@ function makeServer() {
|
||||
sendJson(res, 200, { data: { token: "collab-jwt" } });
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/info") {
|
||||
// Resolve the pageId -> canonical UUID (#260) so the test exercises the
|
||||
// real body-write failure (no WS upgrade) rather than a resolve failure.
|
||||
sendJson(res, 200, {
|
||||
data: { id: "11111111-1111-4111-8111-111111111111", slugId: "page-1" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/update") {
|
||||
state.titlePosted = true;
|
||||
sendJson(res, 200, { data: {} });
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import { DocmostClient } from "../../build/index.js";
|
||||
|
||||
// Drift guard for the THIRD hand-written layer of the AI tool set (issue #193,
|
||||
// layer 3): the in-app server hand-mirrors the DocmostClient method signatures
|
||||
// it consumes as the `DocmostClientLike` interface in
|
||||
// apps/server/src/core/ai-chat/tools/docmost-client.loader.ts ("Signatures here
|
||||
// mirror that file exactly"). That mirror lives across the ESM(mcp)/CJS(server)
|
||||
// boundary and the package ships NO .d.ts, so the server typecheck cannot verify
|
||||
// the names against the real class — a rename/removal in client.ts would surface
|
||||
// only as a runtime "x is not a function" inside an agent tool call.
|
||||
//
|
||||
// SCOPE: this guard checks the method-NAME set only, not signatures. It pins the
|
||||
// contract from the mcp side (ESM, where the real class is directly importable):
|
||||
// every method the embedding host depends on MUST exist as a function on a real
|
||||
// DocmostClient instance. If you rename/remove a client method, this fails here
|
||||
// AND you must update DocmostClientLike to match. It does NOT verify parameter or
|
||||
// return-type parity — signature drift between the hand-mirror and client.ts can
|
||||
// still ship silently; full signature/type parity is the deferred staged-plan
|
||||
// item below.
|
||||
//
|
||||
// Keep the HOST_CONTRACT_METHODS NAME list aligned with the method NAMES declared
|
||||
// in the server's DocmostClientLike interface (the in-app per-user tool adapter
|
||||
// only — it is a SUBSET of the DocmostClient surface — covers only what the in-app adapter
|
||||
// consumes; the standalone MCP transport (packages/mcp/src/index.ts) calls additional
|
||||
// client methods (insertImage/replaceImage/deleteComment/updateComment/insertFootnote)
|
||||
// that this guard does NOT track — the MCP transport's own typecheck covers those). Full type-derivation
|
||||
// of DocmostClientLike from this class is deferred (see the staged plan in
|
||||
// docmost-client.loader.ts): the package emits no declarations and the real
|
||||
// (inferred, concrete) return types conflict with the host's loose
|
||||
// `Record<string,unknown>` + `as`-cast result handling.
|
||||
const HOST_CONTRACT_METHODS = [
|
||||
// read
|
||||
"search",
|
||||
"getPage",
|
||||
"getWorkspace",
|
||||
"getSpaces",
|
||||
"listPages",
|
||||
"listSidebarPages",
|
||||
"getOutline",
|
||||
"getPageJson",
|
||||
"getNode",
|
||||
"getTable",
|
||||
"listComments",
|
||||
"getComment",
|
||||
"checkNewComments",
|
||||
"listShares",
|
||||
"listPageHistory",
|
||||
"getPageHistory",
|
||||
"diffPageVersions",
|
||||
"exportPageMarkdown",
|
||||
// write (page)
|
||||
"createPage",
|
||||
"updatePage",
|
||||
"renamePage",
|
||||
"movePage",
|
||||
"deletePage",
|
||||
"editPageText",
|
||||
"patchNode",
|
||||
"insertNode",
|
||||
"deleteNode",
|
||||
"updatePageJson",
|
||||
"tableInsertRow",
|
||||
"tableDeleteRow",
|
||||
"tableUpdateCell",
|
||||
"copyPageContent",
|
||||
"importPageMarkdown",
|
||||
"sharePage",
|
||||
"unsharePage",
|
||||
"restorePageVersion",
|
||||
"transformPage",
|
||||
"stashPage",
|
||||
// write (comment)
|
||||
"createComment",
|
||||
"resolveComment",
|
||||
];
|
||||
|
||||
test("DocmostClient implements every method the in-app DocmostClientLike mirror declares", () => {
|
||||
// The constructor is side-effect-free (no network/login on construction): it
|
||||
// only stores config and creates an axios instance, so it is safe to build a
|
||||
// throwaway instance here with a dummy token provider.
|
||||
const client = new DocmostClient({
|
||||
apiUrl: "http://127.0.0.1:1/api",
|
||||
getToken: async () => "test-token",
|
||||
});
|
||||
|
||||
const missing = HOST_CONTRACT_METHODS.filter(
|
||||
(name) => typeof client[name] !== "function",
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`DocmostClient is missing host-contract method(s): ${missing.join(", ")}. ` +
|
||||
`Update packages/mcp/src/client.ts and/or the server's DocmostClientLike ` +
|
||||
`interface (apps/server/src/core/ai-chat/tools/docmost-client.loader.ts) ` +
|
||||
`so the hand-mirrored method NAMES stay aligned (this guards names only, ` +
|
||||
`not signatures).`,
|
||||
);
|
||||
});
|
||||
|
||||
test("HOST_CONTRACT_METHODS has no duplicates", () => {
|
||||
assert.equal(
|
||||
new Set(HOST_CONTRACT_METHODS).size,
|
||||
HOST_CONTRACT_METHODS.length,
|
||||
);
|
||||
});
|
||||
|
||||
// Parse the method names declared in the server's `DocmostClientLike` interface
|
||||
// body. We read the .ts source as plain text (no TS compiler dep, and the file
|
||||
// lives in the CJS server tree across the ESM boundary): scan from the
|
||||
// `export interface DocmostClientLike {` line to its closing brace at column 0,
|
||||
// matching member-signature lines like ` methodName(`. Nested param-object
|
||||
// braces (`opts: { ... }`) are indented, so only the interface's own closing
|
||||
// `}` (column 0) ends the scan.
|
||||
function parseDocmostClientLikeMethods() {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
// packages/mcp/test/unit -> repo root is four levels up.
|
||||
const loaderPath = resolve(
|
||||
here,
|
||||
"../../../../apps/server/src/core/ai-chat/tools/docmost-client.loader.ts",
|
||||
);
|
||||
let source;
|
||||
try {
|
||||
source = readFileSync(loaderPath, "utf8");
|
||||
} catch (err) {
|
||||
if (err && err.code === "ENOENT") {
|
||||
throw new Error(
|
||||
`Expected monorepo layout; server tree at ${loaderPath} not found. ` +
|
||||
`This drift-guard reads the server's DocmostClientLike interface via a ` +
|
||||
`fixed relative path and must run from inside the monorepo checkout.`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const lines = source.split(/\r?\n/);
|
||||
|
||||
const startIdx = lines.findIndex((l) =>
|
||||
/^export interface DocmostClientLike\s*\{/.test(l),
|
||||
);
|
||||
assert.notEqual(
|
||||
startIdx,
|
||||
-1,
|
||||
`Could not find "export interface DocmostClientLike {" in ${loaderPath}. ` +
|
||||
`If the interface was renamed/moved, update this drift-guard test.`,
|
||||
);
|
||||
|
||||
const methods = [];
|
||||
let closed = false;
|
||||
// Track whether we are inside a `/* ... */` block comment. Inner lines of a
|
||||
// block comment need NOT start with `*`, so a `name(` line inside one would be
|
||||
// falsely parsed as an interface method without this. (`//` line comments can
|
||||
// never match the method regex below since they start with `/`.)
|
||||
let inBlockComment = false;
|
||||
for (let i = startIdx + 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (inBlockComment) {
|
||||
// Stay in the block until we see its closing `*/`.
|
||||
if (line.includes("*/")) inBlockComment = false;
|
||||
continue;
|
||||
}
|
||||
// Enter a block comment only when it opens without closing on the same line;
|
||||
// a self-contained `/* ... */` on one line cannot precede a method name we
|
||||
// care about (such lines start with `/`, so the method regex won't match).
|
||||
if (line.includes("/*") && !line.includes("*/")) {
|
||||
inBlockComment = true;
|
||||
continue;
|
||||
}
|
||||
if (/^\}/.test(line)) {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
// Method-name match: a TS identifier (letters/digits/`_`/`$`, not starting
|
||||
// with a digit) optionally followed by a generic clause (`method<T>(`), then
|
||||
// the opening paren of the signature.
|
||||
const m = /^\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:<[^>]*>)?\(/.exec(line);
|
||||
if (m) methods.push(m[1]);
|
||||
}
|
||||
assert.ok(
|
||||
closed,
|
||||
`Did not find the closing brace of DocmostClientLike in ${loaderPath}.`,
|
||||
);
|
||||
assert.ok(
|
||||
methods.length > 0,
|
||||
`Parsed zero methods from DocmostClientLike in ${loaderPath} — the parser ` +
|
||||
`is likely out of date with the interface formatting.`,
|
||||
);
|
||||
return methods;
|
||||
}
|
||||
|
||||
// The point of the guard is to protect the DocmostClientLike mirror <-> client.ts
|
||||
// link, but HOST_CONTRACT_METHODS is itself a HAND-COPY of that interface kept in
|
||||
// sync manually. The list<->interface link must be tested too: a method consumed
|
||||
// by the adapter and added to DocmostClientLike but forgotten here (or removed
|
||||
// from the interface but left here) would otherwise escape both the server
|
||||
// typecheck (pkg emits no .d.ts) and the first test above (name not in the list).
|
||||
// Assert the two agree BOTH ways.
|
||||
test("HOST_CONTRACT_METHODS exactly mirrors the server's DocmostClientLike interface", () => {
|
||||
const interfaceMethods = parseDocmostClientLikeMethods();
|
||||
assert.deepEqual(
|
||||
[...HOST_CONTRACT_METHODS].sort(),
|
||||
[...interfaceMethods].sort(),
|
||||
`HOST_CONTRACT_METHODS has drifted from the DocmostClientLike interface in ` +
|
||||
`apps/server/src/core/ai-chat/tools/docmost-client.loader.ts. Add/remove ` +
|
||||
`method names in HOST_CONTRACT_METHODS so it lists EXACTLY the methods ` +
|
||||
`declared in that interface (both directions are checked).`,
|
||||
);
|
||||
});
|
||||
@@ -167,6 +167,38 @@ test("export emits comment anchors and they round-trip back to a comment mark",
|
||||
});
|
||||
});
|
||||
|
||||
test("export emits a spoiler span and it round-trips back to a spoiler mark", () => {
|
||||
// A small ProseMirror doc with a text run carrying a `spoiler` mark. The MCP
|
||||
// schema mirrors the editor-ext mark, so a spoiler must survive json -> md ->
|
||||
// json instead of being silently dropped as an unrecognized mark.
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "plot: " },
|
||||
{
|
||||
type: "text",
|
||||
text: "the butler did it",
|
||||
marks: [{ type: "spoiler" }],
|
||||
},
|
||||
{ type: "text", text: " end" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const body = convertProseMirrorToMarkdown(doc);
|
||||
assert.match(body, /<span data-spoiler="true">the butler did it<\/span>/);
|
||||
|
||||
return markdownToProseMirror(body).then((rebuilt) => {
|
||||
const spoilered = findTextWithMark(rebuilt, "spoiler");
|
||||
assert.ok(spoilered, "expected a text node with a spoiler mark");
|
||||
assert.equal(spoilered.text, "the butler did it");
|
||||
});
|
||||
});
|
||||
|
||||
test("drawio round-trips through export and import", () => {
|
||||
const doc = {
|
||||
type: "doc",
|
||||
|
||||
@@ -149,3 +149,37 @@ test("empty task item still emits its marker", () => {
|
||||
|
||||
assert.equal(convertProseMirrorToMarkdown(input), "- [ ]\n- [x]");
|
||||
});
|
||||
|
||||
// Image captions (issue #221). An image WITHOUT a caption stays the lossy-free
|
||||
// ``; WITH a caption it is emitted as a raw <img data-caption>
|
||||
// wrapped in a block <div> (symmetric to video) so the round-trip md -> html ->
|
||||
// json restores the caption via the image extension's parseHTML.
|
||||
test("image without a caption emits plain ", () => {
|
||||
const input = doc({
|
||||
type: "image",
|
||||
attrs: { src: "/files/a.png", alt: "cat" },
|
||||
});
|
||||
assert.equal(convertProseMirrorToMarkdown(input), "");
|
||||
});
|
||||
|
||||
test("image with a caption emits a raw <img data-caption> in a block div", () => {
|
||||
const input = doc({
|
||||
type: "image",
|
||||
attrs: { src: "/files/a.png", alt: "cat", caption: "A grey cat" },
|
||||
});
|
||||
assert.equal(
|
||||
convertProseMirrorToMarkdown(input),
|
||||
'<div><img src="/files/a.png" alt="cat" data-caption="A grey cat"></div>',
|
||||
);
|
||||
});
|
||||
|
||||
test("image caption escapes & and \" in the data-caption attribute", () => {
|
||||
const input = doc({
|
||||
type: "image",
|
||||
attrs: { src: "/files/a.png", caption: 'Tom & "Jerry"' },
|
||||
});
|
||||
assert.equal(
|
||||
convertProseMirrorToMarkdown(input),
|
||||
'<div><img src="/files/a.png" data-caption="Tom & "Jerry""></div>',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -142,3 +142,31 @@ test("round-trip: pdf node survives markdown export with src + name + attachment
|
||||
assert.equal(found[0].attrs?.name, "x.pdf");
|
||||
assert.equal(found[0].attrs?.attachmentId, "a4");
|
||||
});
|
||||
|
||||
// The converter emits captioned images as a raw <img data-caption="...">; for
|
||||
// the caption to survive the PM -> markdown -> PM round-trip the docmost-schema
|
||||
// Image node must parse data-caption back into the `caption` attr. Without that
|
||||
// (stock @tiptap/extension-image), the caption is silently lost — these guard
|
||||
// the "lossless" claim.
|
||||
test("round-trip: image caption survives markdown export (data-caption restored)", async () => {
|
||||
const found = await roundtrip(
|
||||
{ type: "image", attrs: { src: "/api/files/cat.png", alt: "cat", caption: "A grey cat" } },
|
||||
"image",
|
||||
);
|
||||
assert.equal(found.length, 1, "image node should survive");
|
||||
assert.equal(found[0].attrs?.src, "/api/files/cat.png");
|
||||
assert.equal(found[0].attrs?.caption, "A grey cat", "caption must round-trip");
|
||||
});
|
||||
|
||||
test("round-trip: image caption with special chars survives markdown export", async () => {
|
||||
const found = await roundtrip(
|
||||
{ type: "image", attrs: { src: "/api/files/cat.png", caption: 'Tom & "Jerry"' } },
|
||||
"image",
|
||||
);
|
||||
assert.equal(found.length, 1, "image node should survive");
|
||||
assert.equal(
|
||||
found[0].attrs?.caption,
|
||||
'Tom & "Jerry"',
|
||||
"special-char caption must round-trip unescaped",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -82,6 +82,24 @@ test("round-trip: image inside a column survives as an image node (not literal m
|
||||
assert.ok(!JSON.stringify(out).includes("![pic]"), "image must not become literal markdown text");
|
||||
});
|
||||
|
||||
test("round-trip: captioned image inside a column preserves its caption (imageToHtml branch)", async () => {
|
||||
// A captioned image in a column is emitted via the imageToHtml helper (raw
|
||||
// HTML container), a different path from the top-level image case. Special
|
||||
// chars in the caption exercise attribute escaping on the way out and in.
|
||||
const caption = 'Tom & "Jerry"';
|
||||
const input = doc({
|
||||
type: "columns",
|
||||
content: [
|
||||
{ type: "column", content: [{ type: "image", attrs: { src: "/api/files/a/p.png", alt: "pic", caption } }] },
|
||||
{ type: "column", content: [para(text("right"))] },
|
||||
],
|
||||
});
|
||||
const out = await roundtrip(input);
|
||||
const imgs = findNodes(out, "image");
|
||||
assert.equal(imgs.length, 1, "captioned image inside a column must survive");
|
||||
assert.equal(imgs[0].attrs?.caption, caption, "caption (incl. special chars) must be preserved");
|
||||
});
|
||||
|
||||
test("round-trip: blockquote inside a column survives as a blockquote node", async () => {
|
||||
const input = doc({
|
||||
type: "columns",
|
||||
|
||||
Reference in New Issue
Block a user