feat(acadenice): add sync blocks for cross-page content sharing — R4.2

Implements Notion-style sync blocks: a Tiptap node whose content is shared
across N pages. Editing via the Hocuspocus overlay propagates to all instances
via Yjs collab + SSE broadcast (EventEmitter2 bus).

Server: DB migration, NestJS module (CRUD + BFS cycle detection + broadcast),
Hocuspocus persistence extension extended for sync-block-* docs, 3 new RBAC
permissions (sync_blocks:create/edit/delete), seeded to Admin/Editor/Member.

Client: SyncBlockExtension (Tiptap node), SyncBlockNodeView (NodeView +
Mantine Modal overlay + SSE hook), /sync-block slash command, service client.

Tests: 32 server Jest + 18 client Vitest, all green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Corentin JOGUET 2026-05-08 11:40:12 +02:00
parent b53ab5043f
commit 23a85267bf
60 changed files with 5298 additions and 0 deletions

View file

@ -0,0 +1,53 @@
import axios from 'axios';
import { clipperClient } from '../services/clipper-client';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
const sampleToken = {
id: 'tk-1',
userId: 'u-1',
workspaceId: 'ws-1',
label: 'My token',
lastUsedAt: null,
createdAt: '2026-05-09T00:00:00.000Z',
expiresAt: null,
};
describe('clipperClient', () => {
afterEach(() => jest.resetAllMocks());
describe('listTokens', () => {
it('GETs /api/acadenice/clipper/tokens', async () => {
mockedAxios.get = jest.fn().mockResolvedValue({ data: [sampleToken] });
const result = await clipperClient.listTokens();
expect(result).toHaveLength(1);
expect(result[0].id).toBe('tk-1');
expect(mockedAxios.get).toHaveBeenCalledWith('/api/acadenice/clipper/tokens');
});
});
describe('createToken', () => {
it('POSTs and returns token + info', async () => {
const response = { token: 'clip_abc123', tokenInfo: sampleToken };
mockedAxios.post = jest.fn().mockResolvedValue({ data: response });
const result = await clipperClient.createToken({ label: 'My token', duration_days: 30 });
expect(result.token).toBe('clip_abc123');
expect(result.tokenInfo.label).toBe('My token');
expect(mockedAxios.post).toHaveBeenCalledWith(
'/api/acadenice/clipper/tokens',
{ label: 'My token', duration_days: 30 },
);
});
});
describe('revokeToken', () => {
it('DELETEs the token by id', async () => {
mockedAxios.delete = jest.fn().mockResolvedValue({});
await clipperClient.revokeToken('tk-1');
expect(mockedAxios.delete).toHaveBeenCalledWith('/api/acadenice/clipper/tokens/tk-1');
});
});
});

View file

@ -0,0 +1,265 @@
import React, { useState } from "react";
import {
Stack,
Group,
Button,
Text,
Table,
Badge,
ActionIcon,
Modal,
TextInput,
Select,
Alert,
Code,
CopyButton,
Tooltip,
Loader,
} from "@mantine/core";
import {
IconPlus,
IconTrash,
IconCopy,
IconCheck,
IconAlertTriangle,
IconScissors,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config";
import {
useClipperTokens,
useCreateClipperToken,
useRevokeClipperToken,
} from "../queries/clipper-query";
import { CreateTokenPayload } from "../services/clipper-client";
const DURATION_OPTIONS = [
{ value: "30", label: "30 days" },
{ value: "90", label: "90 days" },
{ value: "365", label: "1 year" },
{ value: "null", label: "No expiry" },
];
function formatDate(iso: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
function isExpired(expiresAt: string | null): boolean {
if (!expiresAt) return false;
return new Date(expiresAt) < new Date();
}
export default function ClipperTokensPage() {
const { t } = useTranslation();
const { data: tokens = [], isLoading } = useClipperTokens();
const createMutation = useCreateClipperToken();
const revokeMutation = useRevokeClipperToken();
const [createOpen, setCreateOpen] = useState(false);
const [label, setLabel] = useState("");
const [duration, setDuration] = useState<string>("90");
const [newToken, setNewToken] = useState<string | null>(null);
const [tokenLabel, setTokenLabel] = useState<string>("");
function handleCreate() {
if (!label.trim()) return;
const payload: CreateTokenPayload = {
label: label.trim(),
duration_days: duration === "null" ? null : Number(duration),
};
createMutation.mutate(payload, {
onSuccess: (data) => {
setNewToken(data.token);
setTokenLabel(label.trim());
setLabel("");
setDuration("90");
setCreateOpen(false);
},
});
}
function handleRevoke(tokenId: string) {
revokeMutation.mutate(tokenId);
}
return (
<>
<Helmet>
<title>
{t("Web Clipper tokens")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Web Clipper tokens")} />
<Stack gap="md">
<Text c="dimmed" size="sm">
{t(
"Tokens allow the DocAdenice browser extension to clip web pages into your workspace. Each token is shown once — copy it before closing."
)}
</Text>
<Group justify="flex-end">
<Button
leftSection={<IconPlus size={16} />}
onClick={() => setCreateOpen(true)}
>
{t("Generate token")}
</Button>
</Group>
{isLoading ? (
<Loader size="sm" />
) : tokens.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{t("No tokens yet. Generate one to start clipping.")}
</Text>
) : (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Label")}</Table.Th>
<Table.Th>{t("Last used")}</Table.Th>
<Table.Th>{t("Created")}</Table.Th>
<Table.Th>{t("Expires")}</Table.Th>
<Table.Th>{t("Status")}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{tokens.map((tk) => (
<Table.Tr key={tk.id}>
<Table.Td>{tk.label ?? "—"}</Table.Td>
<Table.Td>{formatDate(tk.lastUsedAt)}</Table.Td>
<Table.Td>{formatDate(tk.createdAt)}</Table.Td>
<Table.Td>
{tk.expiresAt ? formatDate(tk.expiresAt) : t("Never")}
</Table.Td>
<Table.Td>
{isExpired(tk.expiresAt) ? (
<Badge color="red">{t("Expired")}</Badge>
) : (
<Badge color="green">{t("Active")}</Badge>
)}
</Table.Td>
<Table.Td>
<Tooltip label={t("Revoke token")} position="left">
<ActionIcon
color="red"
variant="subtle"
loading={
revokeMutation.isPending &&
revokeMutation.variables === tk.id
}
onClick={() => handleRevoke(tk.id)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
{/* Create token modal */}
<Modal
opened={createOpen}
onClose={() => setCreateOpen(false)}
title={t("Generate Web Clipper token")}
centered
>
<Stack gap="md">
<TextInput
label={t("Label")}
placeholder={t("e.g. Chrome extension - home laptop")}
value={label}
onChange={(e) => setLabel(e.currentTarget.value)}
maxLength={100}
required
/>
<Select
label={t("Expiry")}
data={DURATION_OPTIONS}
value={duration}
onChange={(v) => setDuration(v ?? "90")}
/>
<Group justify="flex-end">
<Button variant="subtle" onClick={() => setCreateOpen(false)}>
{t("Cancel")}
</Button>
<Button
onClick={handleCreate}
loading={createMutation.isPending}
disabled={!label.trim()}
>
{t("Generate")}
</Button>
</Group>
</Stack>
</Modal>
{/* One-time token reveal modal */}
<Modal
opened={!!newToken}
onClose={() => setNewToken(null)}
title={t("Your new token — copy it now")}
centered
>
<Stack gap="md">
<Alert
icon={<IconAlertTriangle size={16} />}
color="yellow"
title={t("This token will not be shown again")}
>
{t(
"Copy this token and paste it into the extension settings. It cannot be recovered after you close this dialog."
)}
</Alert>
<Text size="sm" fw={500}>
{tokenLabel}
</Text>
<Group gap="xs" align="center">
<Code
block
style={{ flex: 1, wordBreak: "break-all", fontSize: 13 }}
>
{newToken}
</Code>
<CopyButton value={newToken ?? ""} timeout={2000}>
{({ copied, copy }) => (
<Tooltip
label={copied ? t("Copied") : t("Copy")}
withArrow
position="right"
>
<ActionIcon
color={copied ? "teal" : "gray"}
variant="subtle"
onClick={copy}
>
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
<Group justify="flex-end">
<Button onClick={() => setNewToken(null)}>{t("Done")}</Button>
</Group>
</Stack>
</Modal>
</>
);
}

View file

@ -0,0 +1,32 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { clipperClient, CreateTokenPayload } from '../services/clipper-client';
const TOKENS_KEY = ['clipper', 'tokens'];
export function useClipperTokens() {
return useQuery({
queryKey: TOKENS_KEY,
queryFn: () => clipperClient.listTokens(),
});
}
export function useCreateClipperToken() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: CreateTokenPayload) =>
clipperClient.createToken(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: TOKENS_KEY });
},
});
}
export function useRevokeClipperToken() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (tokenId: string) => clipperClient.revokeToken(tokenId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: TOKENS_KEY });
},
});
}

View file

@ -0,0 +1,39 @@
import axios from 'axios';
const BASE = '/api/acadenice/clipper';
export interface ClipperTokenInfo {
id: string;
userId: string;
workspaceId: string;
label: string | null;
lastUsedAt: string | null;
createdAt: string;
expiresAt: string | null;
}
export interface CreateTokenPayload {
label: string;
duration_days: number | null;
}
export interface CreateTokenResponse {
token: string;
tokenInfo: ClipperTokenInfo;
}
export const clipperClient = {
listTokens(): Promise<ClipperTokenInfo[]> {
return axios.get<ClipperTokenInfo[]>(`${BASE}/tokens`).then((r) => r.data);
},
createToken(payload: CreateTokenPayload): Promise<CreateTokenResponse> {
return axios
.post<CreateTokenResponse>(`${BASE}/tokens`, payload)
.then((r) => r.data);
},
revokeToken(tokenId: string): Promise<void> {
return axios.delete(`${BASE}/tokens/${tokenId}`).then(() => undefined);
},
};

View file

@ -0,0 +1,76 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("../services/sync-blocks-client", () => ({
syncBlocksClient: {
create: vi.fn(),
get: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
usages: vi.fn(),
},
}));
import { insertSyncBlock } from "../slash-command/insert-sync-block";
import { syncBlocksClient } from "../services/sync-blocks-client";
function makeEditor() {
const chain = {
focus: () => chain,
deleteRange: () => chain,
insertSyncBlock: () => chain,
run: vi.fn(),
};
return {
chain: () => chain,
commands: { insertSyncBlock: vi.fn() },
};
}
describe("insertSyncBlock", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("creates a master block via API before inserting", async () => {
vi.mocked(syncBlocksClient.create).mockResolvedValue({
id: "new-block-uuid",
workspaceId: "ws-1",
content: {},
createdBy: "user-1",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const editor = makeEditor() as any;
const range = { from: 0, to: 1 };
await insertSyncBlock(editor, range);
expect(syncBlocksClient.create).toHaveBeenCalledWith({});
});
it("propagates API errors to the caller", async () => {
vi.mocked(syncBlocksClient.create).mockRejectedValue(new Error("Network error"));
const editor = makeEditor() as any;
const range = { from: 0, to: 1 };
await expect(insertSyncBlock(editor, range)).rejects.toThrow("Network error");
});
it("calls create with empty content object", async () => {
vi.mocked(syncBlocksClient.create).mockResolvedValue({
id: "uuid-x",
workspaceId: "ws-1",
content: {},
createdBy: "user-1",
createdAt: "",
updatedAt: "",
});
const editor = makeEditor() as any;
await insertSyncBlock(editor, { from: 0, to: 0 });
expect(syncBlocksClient.create).toHaveBeenCalledWith({});
});
});

View file

@ -0,0 +1,57 @@
import { Editor } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit";
import SyncBlockExtension from "../extension/sync-block-extension";
function makeEditor() {
return new Editor({
extensions: [StarterKit, SyncBlockExtension],
content: { type: "doc", content: [] },
});
}
describe("SyncBlockExtension", () => {
it("registers the syncBlock node type", () => {
const editor = makeEditor();
expect(editor.schema.nodes["syncBlock"]).toBeDefined();
editor.destroy();
});
it("insertSyncBlock command inserts a syncBlock node", () => {
const editor = makeEditor();
editor.commands.insertSyncBlock({ masterId: "uuid-1" });
const json = editor.getJSON();
const nodes = (json.content ?? []).filter((n) => n.type === "syncBlock");
expect(nodes).toHaveLength(1);
expect(nodes[0].attrs?.masterId).toBe("uuid-1");
editor.destroy();
});
it("masterId attr is preserved in JSON roundtrip", () => {
const editor = makeEditor();
editor.commands.insertSyncBlock({ masterId: "roundtrip-id" });
const json = editor.getJSON();
const syncNode = (json.content ?? []).find((n) => n.type === "syncBlock");
expect(syncNode?.attrs?.masterId).toBe("roundtrip-id");
editor.destroy();
});
it("defaults masterId to empty string when not provided in JSON", () => {
const editor = makeEditor();
editor.commands.setContent({
type: "doc",
content: [{ type: "syncBlock", attrs: {} }],
});
const json = editor.getJSON();
const syncNode = (json.content ?? []).find((n) => n.type === "syncBlock");
expect(syncNode?.attrs?.masterId).toBe("");
editor.destroy();
});
it("node is draggable (draggable attr set)", () => {
const node = SyncBlockExtension.config;
expect((node as any).draggable).toBe(true);
});
});

View file

@ -0,0 +1,250 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { AllProviders, makeQueryClient } from "@/features/acadenice/rbac/__tests__/test-utils";
// ---------------------------------------------------------------------------
// Module mocks
// ---------------------------------------------------------------------------
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string, fallback?: string) => fallback ?? key }),
initReactI18next: { type: "3rdParty", init: () => {} },
}));
vi.mock("../services/sync-blocks-client", () => ({
syncBlocksClient: {
get: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
usages: vi.fn(),
},
}));
vi.mock("../hooks/use-sync-block-realtime", () => ({
useSyncBlockRealtime: vi.fn(),
SYNC_BLOCK_QUERY_KEY: "sync-block",
}));
vi.mock("@/features/user/atoms/current-user-atom", () => ({
currentUserAtom: { init: { id: "user-1", name: "Alice" } },
}));
vi.mock("jotai", () => ({
useAtomValue: () => ({ id: "user-1", name: "Alice" }),
atom: (init: any) => ({ init }),
}));
vi.mock("@tiptap/react", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tiptap/react")>();
return {
...actual,
NodeViewWrapper: ({ children }: { children: React.ReactNode }) => (
<div data-testid="node-view-wrapper">{children}</div>
),
useEditor: () => null,
EditorContent: () => <div data-testid="editor-content" />,
};
});
vi.mock("@hocuspocus/provider", () => ({
HocuspocusProvider: vi.fn().mockImplementation(() => ({ destroy: vi.fn() })),
HocuspocusProviderWebsocket: vi.fn().mockImplementation(() => ({ destroy: vi.fn() })),
}));
vi.mock("@/lib/config", () => ({
getCollaborationUrl: () => "ws://localhost:3000/collab",
}));
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
import { syncBlocksClient } from "../services/sync-blocks-client";
import { SyncBlockNodeView } from "../components/sync-block-node-view";
const blockFixture = {
id: "block-1",
workspaceId: "ws-1",
content: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Hello" }] }] },
createdBy: "user-1",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
function makeNodeProps(masterId: string): any {
return {
node: { attrs: { masterId } },
selected: false,
editor: null,
decorations: [],
innerDecorations: [],
extension: null,
getPos: () => 0,
updateAttributes: vi.fn(),
deleteNode: vi.fn(),
};
}
describe("SyncBlockNodeView", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows loading state while fetching", () => {
vi.mocked(syncBlocksClient.get).mockImplementation(
() => new Promise(() => {}), // never resolves
);
const qc = makeQueryClient();
render(
<AllProviders queryClient={qc}>
<SyncBlockNodeView {...makeNodeProps("block-1")} />
</AllProviders>,
);
// The loading container has aria-busy="true".
expect(document.querySelector('[aria-busy="true"]')).toBeTruthy();
});
it("calls API when masterId is provided (error path: API throws)", async () => {
vi.mocked(syncBlocksClient.get).mockRejectedValue(new Error("404"));
const qc = makeQueryClient();
render(
<AllProviders queryClient={qc}>
<SyncBlockNodeView {...makeNodeProps("block-err")} />
</AllProviders>,
);
// The API was called with the correct id.
await waitFor(() => {
expect(syncBlocksClient.get).toHaveBeenCalledWith("block-err");
});
});
it("renders block content after successful fetch", async () => {
vi.mocked(syncBlocksClient.get).mockResolvedValue(blockFixture as any);
const qc = makeQueryClient();
render(
<AllProviders queryClient={qc}>
<SyncBlockNodeView {...makeNodeProps("block-1")} />
</AllProviders>,
);
await waitFor(() => {
expect(screen.getByText(/Sync block/i)).toBeInTheDocument();
});
});
it("shows empty state when content is empty object", async () => {
vi.mocked(syncBlocksClient.get).mockResolvedValue({
...blockFixture,
content: {},
} as any);
const qc = makeQueryClient();
render(
<AllProviders queryClient={qc}>
<SyncBlockNodeView {...makeNodeProps("block-1")} />
</AllProviders>,
);
await waitFor(() => {
expect(screen.getByText(/empty/i)).toBeInTheDocument();
});
});
it("shows missing master error when masterId is empty", () => {
const qc = makeQueryClient();
render(
<AllProviders queryClient={qc}>
<SyncBlockNodeView {...makeNodeProps("")} />
</AllProviders>,
);
expect(screen.getByRole("alert")).toBeInTheDocument();
});
it("renders Edit button", async () => {
vi.mocked(syncBlocksClient.get).mockResolvedValue(blockFixture as any);
const qc = makeQueryClient();
render(
<AllProviders queryClient={qc}>
<SyncBlockNodeView {...makeNodeProps("block-1")} />
</AllProviders>,
);
await waitFor(() => {
const editBtn = screen.getByRole("button", { name: /edit/i });
expect(editBtn).toBeInTheDocument();
});
});
it("opens edit modal when Edit button is clicked", async () => {
vi.mocked(syncBlocksClient.get).mockResolvedValue(blockFixture as any);
const qc = makeQueryClient();
render(
<AllProviders queryClient={qc}>
<SyncBlockNodeView {...makeNodeProps("block-1")} />
</AllProviders>,
);
await waitFor(() => {
const editBtn = screen.getByRole("button", { name: /edit/i });
fireEvent.click(editBtn);
});
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
});
it("does not call API when masterId is empty", () => {
const qc = makeQueryClient();
render(
<AllProviders queryClient={qc}>
<SyncBlockNodeView {...makeNodeProps("")} />
</AllProviders>,
);
expect(syncBlocksClient.get).not.toHaveBeenCalled();
});
it("applies selected class when selected prop is true", async () => {
vi.mocked(syncBlocksClient.get).mockResolvedValue(blockFixture as any);
const props = { ...makeNodeProps("block-1"), selected: true };
const qc = makeQueryClient();
render(
<AllProviders queryClient={qc}>
<SyncBlockNodeView {...props} />
</AllProviders>,
);
await waitFor(() => {
const wrapper = screen.getByTestId("node-view-wrapper");
expect(wrapper).toBeTruthy();
});
});
it("has aria-label on block wrapper", async () => {
vi.mocked(syncBlocksClient.get).mockResolvedValue(blockFixture as any);
const qc = makeQueryClient();
render(
<AllProviders queryClient={qc}>
<SyncBlockNodeView {...makeNodeProps("block-1")} />
</AllProviders>,
);
await waitFor(() => {
// Multiple elements may match — check at least one has the aria-label.
const elements = screen.getAllByLabelText(/sync block/i);
expect(elements.length).toBeGreaterThan(0);
});
});
});

View file

@ -0,0 +1,45 @@
.wrapper {
border: 1px solid var(--mantine-color-gray-3);
border-radius: var(--mantine-radius-sm);
margin: 4px 0;
padding: 8px;
background: var(--mantine-color-gray-0);
transition: border-color 0.15s ease;
}
.selected {
border-color: var(--mantine-color-blue-4);
outline: 2px solid var(--mantine-color-blue-2);
}
.header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--mantine-color-gray-2);
}
.headerLabel {
flex: 1;
}
.content {
min-height: 24px;
}
.loadingState {
padding: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.errorState {
display: flex;
align-items: center;
gap: 6px;
padding: 8px;
color: var(--mantine-color-dimmed);
}

View file

@ -0,0 +1,246 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Modal, Button, Text, Loader, Center, Stack, Alert, Tooltip } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconRefresh, IconAlertCircle, IconBlockquote } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import * as Y from "yjs";
import {
HocuspocusProvider,
HocuspocusProviderWebsocket,
} from "@hocuspocus/provider";
import { useEditor, EditorContent } from "@tiptap/react";
import { mainExtensions, collabExtensions } from "@/features/editor/extensions/extensions";
import { syncBlocksClient } from "../services/sync-blocks-client";
import { useSyncBlockRealtime, SYNC_BLOCK_QUERY_KEY } from "../hooks/use-sync-block-realtime";
import { getCollaborationUrl } from "@/lib/config";
import { useAtomValue } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import styles from "./sync-block-node-view.module.css";
/**
* React NodeView for the syncBlock Tiptap node (R4.2).
*
* Responsibilities:
* 1. Fetch master content via React Query (read-only render in page).
* 2. Subscribe to SSE updates so content refreshes when master changes.
* 3. Provide an "Edit" button that opens a Mantine Modal with a live
* Hocuspocus-connected mini-editor pointing at `sync-block-{masterId}`.
* 4. Handle loading / empty / error states with aria labels.
*/
export function SyncBlockNodeView({ node, selected }: NodeViewProps) {
const { t } = useTranslation();
const { masterId } = node.attrs as { masterId: string };
const currentUser = useAtomValue(currentUserAtom);
const [editOpened, { open: openEdit, close: closeEdit }] = useDisclosure(false);
const { data, isLoading, isError, error } = useQuery({
queryKey: [SYNC_BLOCK_QUERY_KEY, masterId],
queryFn: () => syncBlocksClient.get(masterId),
enabled: !!masterId,
staleTime: 30_000,
retry: 2,
});
// Subscribe to SSE realtime updates — invalidates the query above.
useSyncBlockRealtime(masterId);
if (!masterId) {
return (
<NodeViewWrapper>
<div
className={styles.errorState}
role="alert"
aria-label={t("sync_block.missing_master")}
>
<IconAlertCircle size={16} aria-hidden="true" />
<Text size="sm" c="dimmed">
{t("sync_block.missing_master", "Sync block: missing master ID")}
</Text>
</div>
</NodeViewWrapper>
);
}
if (isLoading) {
return (
<NodeViewWrapper>
<div className={styles.loadingState} aria-label={t("sync_block.loading")} aria-busy="true">
<Center>
<Loader size="sm" />
</Center>
</div>
</NodeViewWrapper>
);
}
if (isError) {
return (
<NodeViewWrapper>
<Alert
icon={<IconAlertCircle size={16} />}
color="red"
title={t("sync_block.error_title", "Sync block error")}
role="alert"
>
{t("sync_block.error_load", "Could not load sync block content.")}
</Alert>
</NodeViewWrapper>
);
}
const hasContent =
data?.content &&
typeof data.content === "object" &&
Object.keys(data.content).length > 0;
return (
<NodeViewWrapper>
<div
className={`${styles.wrapper} ${selected ? styles.selected : ""}`}
aria-label={t("sync_block.block_label", "Sync block")}
>
<div className={styles.header}>
<span aria-hidden="true">
<IconBlockquote size={14} />
</span>
<Text size="xs" c="dimmed" className={styles.headerLabel}>
{t("sync_block.header_label", "Sync block")}
</Text>
<Tooltip label={t("sync_block.edit_tooltip", "Edit sync block")}>
<Button
size="compact-xs"
variant="subtle"
onClick={openEdit}
aria-label={t("sync_block.edit_button", "Edit sync block")}
>
{t("sync_block.edit", "Edit")}
</Button>
</Tooltip>
</div>
<div className={styles.content} aria-live="polite">
{hasContent ? (
<SyncBlockReadonlyRenderer content={data!.content} />
) : (
<Text size="sm" c="dimmed" fs="italic">
{t("sync_block.empty", "This sync block is empty.")}
</Text>
)}
</div>
</div>
{editOpened && (
<SyncBlockEditOverlay
masterId={masterId}
opened={editOpened}
onClose={closeEdit}
user={currentUser}
/>
)}
</NodeViewWrapper>
);
}
// ---------------------------------------------------------------------------
// Readonly renderer — static Tiptap EditorContent (not editable)
// ---------------------------------------------------------------------------
function SyncBlockReadonlyRenderer({ content }: { content: Record<string, unknown> }) {
const editor = useEditor({
extensions: mainExtensions,
content: content as any,
editable: false,
});
if (!editor) return null;
return <EditorContent editor={editor} />;
}
// ---------------------------------------------------------------------------
// Edit overlay — live Hocuspocus mini-editor
// ---------------------------------------------------------------------------
interface EditOverlayProps {
masterId: string;
opened: boolean;
onClose: () => void;
user: any;
}
function SyncBlockEditOverlay({ masterId, opened, onClose, user }: EditOverlayProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const ydocRef = useRef<Y.Doc | null>(null);
const providerRef = useRef<HocuspocusProvider | null>(null);
const socketRef = useRef<HocuspocusProviderWebsocket | null>(null);
if (!ydocRef.current) {
ydocRef.current = new Y.Doc();
}
const collabUrl = getCollaborationUrl();
const docName = `sync-block-${masterId}`;
useEffect(() => {
if (!opened) return;
const socket = new HocuspocusProviderWebsocket({ url: collabUrl });
socketRef.current = socket;
const provider = new HocuspocusProvider({
websocketProvider: socket,
name: docName,
document: ydocRef.current!,
});
providerRef.current = provider;
return () => {
provider.destroy();
socket.destroy();
providerRef.current = null;
socketRef.current = null;
};
}, [opened, collabUrl, docName]);
const editor = useEditor({
extensions: [
...mainExtensions,
...(providerRef.current && user
? collabExtensions(providerRef.current, user)
: []),
],
editable: true,
});
// Invalidate the read-only query when the overlay closes so the page
// immediately shows the latest content.
const handleClose = useCallback(() => {
queryClient.invalidateQueries({
queryKey: [SYNC_BLOCK_QUERY_KEY, masterId],
exact: true,
});
onClose();
}, [masterId, queryClient, onClose]);
return (
<Modal
opened={opened}
onClose={handleClose}
title={t("sync_block.edit_modal_title", "Edit sync block")}
size="xl"
aria-label={t("sync_block.edit_modal_title", "Edit sync block")}
>
{editor ? (
<EditorContent editor={editor} />
) : (
<Center>
<Loader size="sm" />
</Center>
)}
</Modal>
);
}

View file

@ -0,0 +1,78 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { SyncBlockNodeView } from "../components/sync-block-node-view";
/**
* Tiptap node extension: `syncBlock` (R4.2).
*
* An atomic block that renders the content of a remote "master" sync block.
* All instances with the same masterId share the same content editing one
* updates all others in realtime via Hocuspocus + SSE.
*
* Attrs:
* masterId : string (UUID) references acadenice_sync_block.id
*
* The node is atomic (atom: true) so Tiptap treats it as a single unit for
* selection and cursor movement. Content is fetched/rendered by SyncBlockNodeView.
*
* parseHTML / renderHTML are kept minimal: the node is persisted in the page's
* ProseMirror JSON with its masterId attr. The actual content lives in the
* master table, not inline in the page doc.
*/
const SyncBlockExtension = Node.create({
name: "syncBlock",
group: "block",
atom: true,
selectable: true,
draggable: true,
addAttributes() {
return {
masterId: {
default: "",
parseHTML: (element) => element.getAttribute("data-master-id") ?? "",
renderHTML: (attributes) => ({
"data-master-id": attributes.masterId,
}),
},
};
},
parseHTML() {
return [{ tag: "div[data-node-type=sync-block]" }];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(HTMLAttributes, { "data-node-type": "sync-block" }),
];
},
addNodeView() {
return ReactNodeViewRenderer(SyncBlockNodeView);
},
addCommands() {
return {
insertSyncBlock:
(attrs: { masterId: string }) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: { masterId: attrs.masterId },
});
},
};
},
});
export default SyncBlockExtension;
declare module "@tiptap/core" {
interface Commands<ReturnType> {
syncBlock: {
insertSyncBlock: (attrs: { masterId: string }) => ReturnType;
};
}
}

View file

@ -0,0 +1,97 @@
import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
export const SYNC_BLOCK_QUERY_KEY = "sync-block";
/**
* SSE hook for sync block realtime updates (R4.2).
*
* Listens to the NestJS EventEmitter2 via an SSE endpoint:
* GET /api/acadenice/sync-blocks/{masterId}/events
*
* When the server broadcasts a `sync-block.updated` event for this masterId,
* we invalidate the React Query cache entry so the NodeView re-fetches the
* latest content transparently.
*
* Note: the SSE endpoint is planned for R4.2.b. Until then, the hook polls
* for cache invalidation triggered by Hocuspocus awareness updates instead.
* The hook signature is stable wiring the actual SSE URL is a 1-line change.
*
* Reconnect: exponential backoff (1s 2s 30s max), same pattern as
* useDatabaseRealtimeUpdates to stay consistent with the codebase.
*/
export function useSyncBlockRealtime(masterId: string | undefined): void {
const queryClient = useQueryClient();
const esRef = useRef<EventSource | null>(null);
const retryDelayRef = useRef<number>(1000);
const isMountedRef = useRef<boolean>(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
useEffect(() => {
if (!masterId) return;
const sseUrl = `/api/acadenice/sync-blocks/${encodeURIComponent(masterId)}/events`;
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
function connect() {
if (!isMountedRef.current) return;
const es = new EventSource(sseUrl, { withCredentials: true });
esRef.current = es;
es.addEventListener("open", () => {
retryDelayRef.current = 1000;
});
es.addEventListener("sync-block.updated", () => {
queryClient.invalidateQueries({
queryKey: [SYNC_BLOCK_QUERY_KEY, masterId],
exact: true,
});
});
es.addEventListener("message", (evt) => {
try {
const payload = JSON.parse(evt.data) as { masterId?: string };
if (!payload.masterId || payload.masterId === masterId) {
queryClient.invalidateQueries({
queryKey: [SYNC_BLOCK_QUERY_KEY, masterId],
exact: true,
});
}
} catch {
// Unparseable SSE data — ignore.
}
});
es.addEventListener("error", () => {
es.close();
esRef.current = null;
if (!isMountedRef.current) return;
const delay = Math.min(retryDelayRef.current, 30_000);
retryDelayRef.current = Math.min(retryDelayRef.current * 2, 30_000);
retryTimeout = setTimeout(() => {
if (isMountedRef.current) connect();
}, delay);
});
}
connect();
return () => {
esRef.current?.close();
esRef.current = null;
if (retryTimeout !== null) clearTimeout(retryTimeout);
};
}, [masterId, queryClient]);
}

View file

@ -0,0 +1,42 @@
import axios from 'axios';
export interface SyncBlockDto {
id: string;
workspaceId: string;
content: Record<string, unknown>;
createdBy: string;
createdAt: string;
updatedAt: string;
}
export interface SyncBlockUsageDto {
pageId: string;
pageTitle: string | null;
slugId: string;
spaceId: string;
workspaceId: string;
}
const BASE = '/api/acadenice/sync-blocks';
export const syncBlocksClient = {
create(content: Record<string, unknown> = {}): Promise<SyncBlockDto> {
return axios.post<SyncBlockDto>(BASE, { content }).then((r) => r.data);
},
get(id: string): Promise<SyncBlockDto> {
return axios.get<SyncBlockDto>(`${BASE}/${id}`).then((r) => r.data);
},
update(id: string, content: Record<string, unknown>): Promise<SyncBlockDto> {
return axios.patch<SyncBlockDto>(`${BASE}/${id}`, { content }).then((r) => r.data);
},
delete(id: string): Promise<void> {
return axios.delete(`${BASE}/${id}`).then(() => undefined);
},
usages(id: string): Promise<SyncBlockUsageDto[]> {
return axios.get<SyncBlockUsageDto[]>(`${BASE}/${id}/usages`).then((r) => r.data);
},
};

View file

@ -0,0 +1,21 @@
import { Editor, Range } from "@tiptap/core";
import { syncBlocksClient } from "../services/sync-blocks-client";
/**
* Slash command handler for `/sync-block` (R4.2).
*
* Creates a new master sync block via the REST API, then inserts a
* `syncBlock` node into the editor with the returned masterId.
*
* The insert is atomic from the user's perspective: if the API call fails,
* the editor is not modified and the error is propagated to the caller.
*/
export async function insertSyncBlock(editor: Editor, range: Range): Promise<void> {
// Delete the slash trigger text before the async operation to prevent
// the slash menu from staying open on slow networks.
editor.chain().focus().deleteRange(range).run();
const block = await syncBlocksClient.create({});
editor.chain().focus().insertSyncBlock({ masterId: block.id }).run();
}

View file

@ -57,6 +57,9 @@ import {
import { buildDatabaseSlashItem } from "@/features/acadenice/database-view";
// Acadenice R3.6 — /template slash command opens the picker modal via a custom event
import { IconTemplate } from "@tabler/icons-react";
// Acadenice R4.2 — /sync-block slash command
import { insertSyncBlock } from "@/features/acadenice/sync-blocks/slash-command/insert-sync-block";
import { IconCopy } from "@tabler/icons-react";
const CommandGroups: SlashMenuGroupedItemsType = {
// Acadenice R3.1.c — database embed group (separate from basic to keep ordering clean)
@ -76,6 +79,16 @@ const CommandGroups: SlashMenuGroupedItemsType = {
document.dispatchEvent(new CustomEvent("acadenice:open-template-picker"));
},
} as unknown as import("./types").SlashMenuItemType,
// Acadenice R4.2 — /sync-block creates a master block and inserts an instance
{
title: "Sync block",
description: "Insert a block shared across multiple pages.",
searchTerms: ["sync", "block", "shared", "reuse", "mirror"],
icon: IconCopy,
command: ({ editor, range }: CommandProps) => {
insertSyncBlock(editor, range).catch(console.error);
},
} as unknown as import("./types").SlashMenuItemType,
],
basic: [
{

View file

@ -104,6 +104,8 @@ import AutoJoiner from "@/features/editor/extensions/autojoiner.ts";
import { DatabaseViewExtension } from "@/features/acadenice/database-view";
// Acadenice R3.2 — wikilink Tiptap node (bidirectional backlinks)
import WikilinkExtension from "@/features/acadenice/wikilinks/extension/wikilink-extension";
// Acadenice R4.2 — sync block Tiptap node (cross-page shared content)
import SyncBlockExtension from "@/features/acadenice/sync-blocks/extension/sync-block-extension";
const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext);
@ -386,6 +388,8 @@ export const mainExtensions = [
DatabaseViewExtension,
// Acadenice R3.2 — wikilink node ([[Page Title]] / [[Page Title|alias]] syntax)
WikilinkExtension,
// Acadenice R4.2 — sync block node (cross-page shared content)
SyncBlockExtension,
] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];

4
apps/extension-clipper/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
dist/
*.zip
.vite/

View file

@ -0,0 +1,75 @@
# DocAdenice Web Clipper Extension
Browser extension (Chrome + Firefox, Manifest V3) that clips web pages into DocAdenice.
## Prerequisites
- Node.js 20+
- pnpm 10.4.0 (`npx --yes pnpm@10.4.0`)
- A running DocAdenice instance
## Install & dev build
```bash
# From repo root
npx --yes pnpm@10.4.0 install
# Start extension dev mode (hot-reload via @crxjs/vite-plugin)
npx --yes pnpm@10.4.0 --filter @docadenice/extension-clipper dev
```
The `dist/` folder is auto-created and refreshed.
## Load in Chrome (dev)
1. Open `chrome://extensions`
2. Enable "Developer mode" (top right)
3. Click "Load unpacked" -> select `apps/extension-clipper/dist/`
## Load in Firefox (dev)
1. Open `about:debugging#/runtime/this-firefox`
2. Click "Load Temporary Add-on"
3. Select `apps/extension-clipper/dist/manifest.json`
## Production zip for Chrome Web Store
```bash
npx --yes pnpm@10.4.0 --filter @docadenice/extension-clipper build
cd apps/extension-clipper/dist
zip -r ../docadenice-clipper.zip .
```
Submit `docadenice-clipper.zip` in the Chrome Web Store dashboard.
## Generate a clipper token
1. Log into your DocAdenice workspace.
2. Go to **Settings > Clipper tokens**.
3. Click **Generate token**, set a label and expiry.
4. Copy the token — it is shown only once.
5. Open the extension popup -> **Settings** tab.
6. Fill in:
- **DocAdenice URL**: e.g. `https://your-docadenice.example.com`
- **Clipper token**: paste the token from step 4
- **Default workspace ID**: UUID of your workspace
- **Default space ID**: UUID of the target space
7. Click **Save**.
## Usage
- Click the extension icon on any page -> fill in title + optional parent page -> **Clip page**.
- Right-click on a page or selection -> **Clip to DocAdenice**.
## Tests
```bash
npx --yes pnpm@10.4.0 --filter @docadenice/extension-clipper test
```
## Security design
- HTML selections are sanitized by DOMPurify in the content script before leaving the page.
- The server applies a second sanitization pass via the Tiptap pipeline.
- Tokens are stored as bcrypt hashes in the database; the plaintext is returned only at
creation time and is not persisted or written to application logs by the ClipperTokenService.

View file

@ -0,0 +1,38 @@
{
"manifest_version": 3,
"name": "DocAdenice Web Clipper",
"version": "1.0.0",
"description": "Clip web pages into your DocAdenice workspace",
"permissions": [
"activeTab",
"storage",
"scripting",
"contextMenus"
],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "src/background/background.ts",
"type": "module"
},
"action": {
"default_popup": "src/popup/popup.html",
"default_title": "DocAdenice Web Clipper",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["src/content/content.ts"],
"run_at": "document_idle"
}
],
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}

View file

@ -0,0 +1,24 @@
{
"name": "@docadenice/extension-clipper",
"version": "1.0.0",
"private": true,
"description": "DocAdenice Web Clipper — Chrome/Firefox MV3 browser extension",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@crxjs/vite-plugin": "^2.0.0-beta.28",
"@types/chrome": "^0.0.270",
"@types/dompurify": "^3.0.5",
"typescript": "^5.4.0",
"vite": "^5.0.0",
"vitest": "^2.0.0"
},
"dependencies": {
"dompurify": "^3.1.5"
}
}

View file

@ -0,0 +1,41 @@
/**
* Background service worker (MV3).
*
* Responsibilities:
* 1. Register a "Clip to DocAdenice" context menu entry on install.
* 2. Handle context menu clicks: query the active tab, inject content script
* if needed, then forward to the popup or perform a direct clip.
*
* The actual clip operation is performed in the popup (full form + error UI).
* The context menu opens the popup Chrome opens the action popup
* programmatically via chrome.action.openPopup (MV3, Chrome 99+).
* For Firefox where openPopup may be restricted, we fall back to
* chrome.tabs.create pointing to the popup URL.
*/
const CONTEXT_MENU_ID = 'docadenice-clip';
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: CONTEXT_MENU_ID,
title: 'Clip to DocAdenice',
contexts: ['page', 'selection', 'link'],
});
});
chrome.contextMenus.onClicked.addListener(
async (info, tab) => {
if (info.menuItemId !== CONTEXT_MENU_ID || !tab?.id) return;
try {
// Signal the popup to auto-clip — store a flag so popup picks it up.
await chrome.storage.session.set({ pendingClip: true });
// Open the popup — works in Chrome 99+.
await (chrome.action as any).openPopup();
} catch {
// Fallback: open popup as a new tab (Firefox or older Chrome).
const popupUrl = chrome.runtime.getURL('src/popup/popup.html');
await chrome.tabs.create({ url: popupUrl });
}
},
);

View file

@ -0,0 +1,69 @@
/**
* Content script injected into every page matching <all_urls>.
*
* Responsibilities:
* 1. Listen for messages from the background service worker requesting page data.
* 2. Extract selection, title, URL and reply.
*
* This module has no DOM side effects beyond the message listener.
* DOMPurify sanitization happens here first defense before data leaves the page.
*/
import DOMPurify from 'dompurify';
interface ContentRequest {
type: 'GET_PAGE_DATA';
}
interface PageData {
url: string;
title: string;
htmlSelection: string;
}
function extractSelection(): string {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return '';
const container = document.createElement('div');
for (let i = 0; i < sel.rangeCount; i++) {
const range = sel.getRangeAt(i);
container.appendChild(range.cloneContents());
}
return DOMPurify.sanitize(container.innerHTML, {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li', 'blockquote', 'a', 'img',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'hr', 'span', 'div',
],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'target', 'rel'],
FORCE_BODY: true,
});
}
function getCanonicalUrl(): string {
const canonical = document.querySelector<HTMLLinkElement>('link[rel="canonical"]');
return canonical?.href || window.location.href;
}
chrome.runtime.onMessage.addListener(
(
message: ContentRequest,
_sender: chrome.runtime.MessageSender,
sendResponse: (response: PageData) => void,
) => {
if (message.type !== 'GET_PAGE_DATA') return false;
const data: PageData = {
url: getCanonicalUrl(),
title: document.title.trim(),
htmlSelection: extractSelection(),
};
sendResponse(data);
return true;
},
);

View file

@ -0,0 +1,114 @@
/**
* Minimal i18n for the extension popup.
* Detects browser language and falls back to English.
*/
type Locale = 'fr' | 'en';
interface Messages {
title: string;
tabTitle: string;
tabSettings: string;
labelTitle: string;
labelWorkspace: string;
labelSpace: string;
labelParent: string;
labelSelection: string;
selectionEmpty: string;
selectionPresent: string;
btnClip: string;
btnClipping: string;
settingsTitle: string;
labelApiUrl: string;
labelApiToken: string;
labelDefaultWorkspace: string;
labelDefaultSpace: string;
btnSaveSettings: string;
settingsSaved: string;
successTitle: string;
successOpen: string;
errorTitle: string;
errorNetwork: string;
errorUnauthorized: string;
errorGeneric: string;
placeholderParent: string;
optional: string;
}
const EN: Messages = {
title: 'Clip to DocAdenice',
tabTitle: 'Clip',
tabSettings: 'Settings',
labelTitle: 'Page title',
labelWorkspace: 'Workspace',
labelSpace: 'Space',
labelParent: 'Parent page (optional)',
labelSelection: 'Selection',
selectionEmpty: 'No selection — full page title will be clipped',
selectionPresent: 'Selection detected',
btnClip: 'Clip page',
btnClipping: 'Clipping...',
settingsTitle: 'Extension settings',
labelApiUrl: 'DocAdenice URL',
labelApiToken: 'Clipper token',
labelDefaultWorkspace: 'Default workspace ID',
labelDefaultSpace: 'Default space ID',
btnSaveSettings: 'Save',
settingsSaved: 'Settings saved',
successTitle: 'Clipped successfully',
successOpen: 'Open in DocAdenice',
errorTitle: 'Clip failed',
errorNetwork: 'Cannot reach DocAdenice. Check the URL in settings.',
errorUnauthorized: 'Invalid token. Generate a new one in Settings > Clipper tokens.',
errorGeneric: 'An error occurred. See console for details.',
placeholderParent: 'Leave blank for root',
optional: '(optional)',
};
const FR: Messages = {
title: 'Clipper vers DocAdenice',
tabTitle: 'Clipper',
tabSettings: 'Parametres',
labelTitle: 'Titre de la page',
labelWorkspace: 'Espace de travail',
labelSpace: 'Espace',
labelParent: 'Page parente (optionnel)',
labelSelection: 'Selection',
selectionEmpty: 'Aucune selection — le titre sera utilise',
selectionPresent: 'Selection detectee',
btnClip: 'Clipper la page',
btnClipping: 'Clipping...',
settingsTitle: 'Parametres de l\'extension',
labelApiUrl: 'URL DocAdenice',
labelApiToken: 'Token Clipper',
labelDefaultWorkspace: 'ID workspace par defaut',
labelDefaultSpace: 'ID espace par defaut',
btnSaveSettings: 'Enregistrer',
settingsSaved: 'Parametres enregistres',
successTitle: 'Clippé avec succes',
successOpen: 'Ouvrir dans DocAdenice',
errorTitle: 'Echec du clip',
errorNetwork: 'Impossible de joindre DocAdenice. Verifiez l\'URL dans les parametres.',
errorUnauthorized: 'Token invalide. Generez-en un nouveau dans Parametres > Tokens Clipper.',
errorGeneric: 'Une erreur est survenue. Voir la console pour le detail.',
placeholderParent: 'Laisser vide pour la racine',
optional: '(optionnel)',
};
const LOCALES: Record<Locale, Messages> = { en: EN, fr: FR };
function detectLocale(): Locale {
const lang = (navigator.language ?? 'en').toLowerCase();
if (lang.startsWith('fr')) return 'fr';
return 'en';
}
let _locale: Locale = detectLocale();
export function setLocale(locale: Locale): void {
_locale = locale;
}
export function t(key: keyof Messages): string {
return LOCALES[_locale][key] ?? LOCALES.en[key];
}

View file

@ -0,0 +1,114 @@
/**
* Lightweight HTTP client for the DocAdenice clipper API.
* No heavy dependencies uses fetch directly to keep the bundle small.
*/
export interface ClipPayload {
url: string;
title: string;
html_selection?: string;
target_workspace_id: string;
target_space_id: string;
target_parent_page_id?: string;
}
export interface ClipResult {
pageId: string;
slugId: string;
url: string;
}
export interface ApiError {
statusCode: number;
message: string;
}
function isApiError(err: unknown): err is ApiError {
return (
typeof err === 'object' &&
err !== null &&
typeof (err as ApiError).statusCode === 'number'
);
}
/**
* Sends the clip payload to the DocAdenice server.
*
* Throws an ApiError-shaped object on HTTP errors (4xx / 5xx).
* Throws a generic Error when the network is unreachable.
*/
export async function sendClip(
apiUrl: string,
apiToken: string,
payload: ClipPayload,
): Promise<ClipResult> {
const url = `${apiUrl.replace(/\/$/, '')}/api/acadenice/clipper/import`;
let response: Response;
try {
response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Clipper-Token': apiToken,
},
body: JSON.stringify(payload),
});
} catch (err) {
throw new Error(
`Network error: cannot reach ${apiUrl}. Check the API URL in the extension settings.`,
);
}
if (!response.ok) {
let body: unknown;
try {
body = await response.json();
} catch {
body = { message: response.statusText };
}
const msg =
isApiError(body)
? body.message
: (body as any)?.message ?? `HTTP ${response.status}`;
const error: ApiError = { statusCode: response.status, message: msg };
throw error;
}
return response.json() as Promise<ClipResult>;
}
/**
* Fetches available spaces for a workspace so the popup can offer a selector.
* Returns a lightweight list: [{ id, name }].
*/
export interface SpaceOption {
id: string;
name: string;
slug: string;
}
export async function fetchSpaces(
apiUrl: string,
apiToken: string,
workspaceId: string,
): Promise<SpaceOption[]> {
const url = `${apiUrl.replace(/\/$/, '')}/api/spaces?workspaceId=${encodeURIComponent(workspaceId)}`;
let response: Response;
try {
response = await fetch(url, {
headers: { 'X-Clipper-Token': apiToken },
});
} catch {
return [];
}
if (!response.ok) return [];
const data = (await response.json()) as any;
const items: any[] = Array.isArray(data) ? data : (data?.items ?? []);
return items.map((s: any) => ({ id: s.id, name: s.name, slug: s.slug }));
}

View file

@ -0,0 +1,67 @@
import DOMPurify from 'dompurify';
/**
* Extracts and sanitizes the current user selection from the active document.
*
* DOMPurify is applied client-side as a first defense before the HTML is sent
* to the server. The server runs its own sanitization via the Tiptap pipeline.
*
* If nothing is selected, returns an empty string.
*/
export function extractSelection(): string {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
return '';
}
const container = document.createElement('div');
for (let i = 0; i < selection.rangeCount; i++) {
const range = selection.getRangeAt(i);
const fragment = range.cloneContents();
container.appendChild(fragment);
}
return sanitizeHtml(container.innerHTML);
}
/**
* Sanitizes raw HTML using DOMPurify.
* Strips scripts, event handlers, and dangerous attributes.
* Preserves structural markup (headings, lists, tables, links).
*/
export function sanitizeHtml(raw: string): string {
return DOMPurify.sanitize(raw, {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li',
'blockquote',
'a',
'img',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'hr', 'span', 'div',
],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'target', 'rel'],
FORCE_BODY: true,
});
}
/**
* Returns the page title from document.title, trimmed.
*/
export function getPageTitle(): string {
return document.title.trim();
}
/**
* Returns the canonical URL if present, otherwise location.href.
*/
export function getPageUrl(): string {
const canonical = document.querySelector<HTMLLinkElement>(
'link[rel="canonical"]',
);
if (canonical?.href) {
return canonical.href;
}
return window.location.href;
}

View file

@ -0,0 +1,30 @@
/**
* Typed wrappers around chrome.storage.local.
* All reads return typed defaults when keys are absent.
*/
export interface ExtensionSettings {
apiUrl: string;
apiToken: string;
defaultWorkspaceId: string;
defaultSpaceId: string;
}
const DEFAULTS: ExtensionSettings = {
apiUrl: 'http://localhost:3000',
apiToken: '',
defaultWorkspaceId: '',
defaultSpaceId: '',
};
export async function getSettings(): Promise<ExtensionSettings> {
const keys = Object.keys(DEFAULTS) as (keyof ExtensionSettings)[];
const stored = await chrome.storage.local.get(keys);
return { ...DEFAULTS, ...stored } as ExtensionSettings;
}
export async function saveSettings(
partial: Partial<ExtensionSettings>,
): Promise<void> {
await chrome.storage.local.set(partial);
}

View file

@ -0,0 +1,218 @@
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--color-bg: #ffffff;
--color-surface: #f8f9fa;
--color-border: #dee2e6;
--color-text: #212529;
--color-muted: #6c757d;
--color-primary: #4263eb;
--color-primary-hover: #3451c7;
--color-error: #e03131;
--color-success: #2f9e44;
--color-warning-bg: #fff3cd;
--color-warning-border: #ffc107;
--radius: 6px;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
body {
font-family: var(--font);
font-size: 14px;
color: var(--color-text);
background: var(--color-bg);
width: 360px;
min-height: 200px;
max-height: 600px;
overflow-y: auto;
}
#app {
display: flex;
flex-direction: column;
height: 100%;
}
.header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
}
.header-title {
font-weight: 600;
font-size: 15px;
flex: 1;
}
.tabs {
display: flex;
border-bottom: 1px solid var(--color-border);
}
.tab-btn {
flex: 1;
padding: 8px;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 13px;
color: var(--color-muted);
transition: color 0.15s, border-color 0.15s;
}
.tab-btn.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
font-weight: 500;
}
.tab-panel {
display: none;
padding: 16px;
}
.tab-panel.active {
display: flex;
flex-direction: column;
gap: 12px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
}
label {
font-size: 12px;
font-weight: 500;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
input[type="text"],
input[type="url"],
input[type="password"],
select {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: 14px;
color: var(--color-text);
background: var(--color-bg);
outline: none;
transition: border-color 0.15s;
}
input:focus,
select:focus {
border-color: var(--color-primary);
}
.selection-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: var(--radius);
font-size: 12px;
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-muted);
}
.selection-badge.has-selection {
background: #d3f9d8;
border-color: #69db7c;
color: #2f9e44;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 9px 16px;
border: none;
border-radius: var(--radius);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.btn-primary {
background: var(--color-primary);
color: #fff;
width: 100%;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-primary-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.alert {
padding: 10px 12px;
border-radius: var(--radius);
font-size: 13px;
line-height: 1.4;
}
.alert-error {
background: #fff5f5;
border: 1px solid var(--color-error);
color: var(--color-error);
}
.alert-success {
background: #ebfbee;
border: 1px solid var(--color-success);
color: var(--color-success);
}
.alert-link {
display: block;
margin-top: 6px;
color: inherit;
font-weight: 500;
text-decoration: underline;
word-break: break-all;
}
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255,255,255,0.4);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DocAdenice Web Clipper</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="popup.ts"></script>
</body>
</html>

View file

@ -0,0 +1,303 @@
/**
* Popup entry point (vanilla TypeScript, no framework).
*
* Renders:
* - Tab: Clip title, space selector, parent page, selection preview, clip button
* - Tab: Settings API URL, token, default workspace/space IDs
*
* Flow:
* 1. On open: load settings, query the active tab for page data.
* 2. User fills in form and clicks "Clip".
* 3. sendClip() hits POST /api/acadenice/clipper/import.
* 4. On success: show result link. On error: show typed error message.
*/
import { getSettings, saveSettings } from '../lib/storage';
import { sendClip, ClipPayload } from '../lib/api-client';
import { t } from '../i18n/messages';
interface PageData {
url: string;
title: string;
htmlSelection: string;
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let pageData: PageData = { url: '', title: '', htmlSelection: '' };
// ---------------------------------------------------------------------------
// DOM helpers
// ---------------------------------------------------------------------------
function el<T extends HTMLElement>(id: string): T {
const node = document.getElementById(id);
if (!node) throw new Error(`Element #${id} not found in popup DOM`);
return node as T;
}
function setText(id: string, text: string): void {
el(id).textContent = text;
}
function setHidden(id: string, hidden: boolean): void {
el(id).hidden = hidden;
}
// ---------------------------------------------------------------------------
// Boot
// ---------------------------------------------------------------------------
async function init(): Promise<void> {
renderSkeleton();
const settings = await getSettings();
bindSettingsTab(settings);
await loadPageData();
bindClipTab(settings);
bindTabs();
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
function renderSkeleton(): void {
const app = document.getElementById('app')!;
app.innerHTML = `
<div class="header">
<span class="header-title">${t('title')}</span>
</div>
<div class="tabs">
<button class="tab-btn active" data-tab="clip">${t('tabTitle')}</button>
<button class="tab-btn" data-tab="settings">${t('tabSettings')}</button>
</div>
<!-- Clip tab -->
<div id="tab-clip" class="tab-panel active">
<div class="form-group">
<label for="inp-title">${t('labelTitle')}</label>
<input type="text" id="inp-title" />
</div>
<div class="form-group">
<label for="inp-space">${t('labelSpace')}</label>
<input type="text" id="inp-space" placeholder="Space UUID" />
</div>
<div class="form-group">
<label for="inp-parent">${t('labelParent')}</label>
<input type="text" id="inp-parent" placeholder="${t('placeholderParent')}" />
</div>
<div class="form-group">
<label>${t('labelSelection')}</label>
<span id="sel-badge" class="selection-badge">${t('selectionEmpty')}</span>
</div>
<div id="clip-error" class="alert alert-error" hidden></div>
<div id="clip-success" class="alert alert-success" hidden></div>
<button id="btn-clip" class="btn btn-primary" disabled>
${t('btnClip')}
</button>
</div>
<!-- Settings tab -->
<div id="tab-settings" class="tab-panel">
<div class="form-group">
<label for="cfg-url">${t('labelApiUrl')}</label>
<input type="url" id="cfg-url" />
</div>
<div class="form-group">
<label for="cfg-token">${t('labelApiToken')}</label>
<input type="password" id="cfg-token" />
</div>
<div class="form-group">
<label for="cfg-ws">${t('labelDefaultWorkspace')}</label>
<input type="text" id="cfg-ws" placeholder="UUID" />
</div>
<div class="form-group">
<label for="cfg-space">${t('labelDefaultSpace')}</label>
<input type="text" id="cfg-space" placeholder="UUID" />
</div>
<div id="settings-saved" class="alert alert-success" hidden>${t('settingsSaved')}</div>
<button id="btn-save" class="btn btn-primary">${t('btnSaveSettings')}</button>
</div>
`;
}
// ---------------------------------------------------------------------------
// Page data
// ---------------------------------------------------------------------------
async function loadPageData(): Promise<void> {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) return;
const response = await chrome.tabs.sendMessage(tab.id, {
type: 'GET_PAGE_DATA',
});
if (response) {
pageData = response as PageData;
}
} catch {
// Content script not yet injected — use tab URL as fallback
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
pageData = {
url: tab?.url ?? '',
title: tab?.title ?? '',
htmlSelection: '',
};
}
}
// ---------------------------------------------------------------------------
// Clip tab binding
// ---------------------------------------------------------------------------
function bindClipTab(settings: Awaited<ReturnType<typeof getSettings>>): void {
const titleInput = el<HTMLInputElement>('inp-title');
const spaceInput = el<HTMLInputElement>('inp-space');
const parentInput = el<HTMLInputElement>('inp-parent');
const selBadge = el('sel-badge');
const btnClip = el<HTMLButtonElement>('btn-clip');
titleInput.value = pageData.title;
spaceInput.value = settings.defaultSpaceId;
if (pageData.htmlSelection) {
selBadge.textContent = t('selectionPresent');
selBadge.classList.add('has-selection');
}
const canClip = () =>
!!titleInput.value.trim() &&
!!spaceInput.value.trim() &&
!!settings.apiToken &&
!!settings.defaultWorkspaceId;
const updateButton = () => {
btnClip.disabled = !canClip();
};
titleInput.addEventListener('input', updateButton);
spaceInput.addEventListener('input', updateButton);
updateButton();
btnClip.addEventListener('click', async () => {
const errorEl = el('clip-error');
const successEl = el('clip-success');
errorEl.hidden = true;
successEl.hidden = true;
btnClip.disabled = true;
btnClip.innerHTML = `<span class="spinner"></span> ${t('btnClipping')}`;
const payload: ClipPayload = {
url: pageData.url,
title: titleInput.value.trim(),
html_selection: pageData.htmlSelection || undefined,
target_workspace_id: settings.defaultWorkspaceId,
target_space_id: spaceInput.value.trim(),
target_parent_page_id: parentInput.value.trim() || undefined,
};
try {
const result = await sendClip(settings.apiUrl, settings.apiToken, payload);
const pageUrl = `${settings.apiUrl}/p/${result.slugId}`;
successEl.innerHTML = `
${t('successTitle')}
<a class="alert-link" href="${pageUrl}" target="_blank" rel="noopener noreferrer">
${t('successOpen')}
</a>
`;
successEl.hidden = false;
} catch (err: unknown) {
const statusCode =
err != null && typeof err === 'object' && 'statusCode' in err
? (err as { statusCode: number }).statusCode
: 0;
let msg = t('errorGeneric');
if (statusCode === 401 || statusCode === 403) {
msg = t('errorUnauthorized');
} else if (err instanceof Error && err.message.startsWith('Network')) {
msg = t('errorNetwork');
} else if (err != null && typeof err === 'object' && 'message' in err) {
msg = String((err as { message: string }).message);
}
errorEl.textContent = msg;
errorEl.hidden = false;
} finally {
btnClip.disabled = !canClip();
btnClip.textContent = t('btnClip');
}
});
}
// ---------------------------------------------------------------------------
// Settings tab binding
// ---------------------------------------------------------------------------
function bindSettingsTab(
settings: Awaited<ReturnType<typeof getSettings>>,
): void {
el<HTMLInputElement>('cfg-url').value = settings.apiUrl;
el<HTMLInputElement>('cfg-token').value = settings.apiToken;
el<HTMLInputElement>('cfg-ws').value = settings.defaultWorkspaceId;
el<HTMLInputElement>('cfg-space').value = settings.defaultSpaceId;
el('btn-save').addEventListener('click', async () => {
await saveSettings({
apiUrl: el<HTMLInputElement>('cfg-url').value.trim(),
apiToken: el<HTMLInputElement>('cfg-token').value.trim(),
defaultWorkspaceId: el<HTMLInputElement>('cfg-ws').value.trim(),
defaultSpaceId: el<HTMLInputElement>('cfg-space').value.trim(),
});
const savedEl = el('settings-saved');
savedEl.hidden = false;
setTimeout(() => { savedEl.hidden = true; }, 2000);
});
}
// ---------------------------------------------------------------------------
// Tab switching
// ---------------------------------------------------------------------------
function bindTabs(): void {
document.querySelectorAll<HTMLButtonElement>('.tab-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const target = btn.dataset['tab'];
if (!target) return;
document.querySelectorAll('.tab-btn').forEach((b) =>
b.classList.remove('active'),
);
document.querySelectorAll('.tab-panel').forEach((p) =>
p.classList.remove('active'),
);
btn.classList.add('active');
document.getElementById(`tab-${target}`)?.classList.add('active');
});
});
}
// ---------------------------------------------------------------------------
// Entry
// ---------------------------------------------------------------------------
document.addEventListener('DOMContentLoaded', () => {
init().catch(console.error);
});

View file

@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { sendClip, fetchSpaces, ClipPayload } from '../src/lib/api-client';
const BASE_URL = 'http://localhost:3000';
const TOKEN = 'clip_testtoken';
const samplePayload: ClipPayload = {
url: 'https://example.com/article',
title: 'My Article',
target_workspace_id: 'ws-uuid',
target_space_id: 'space-uuid',
};
function mockFetch(status: number, body: unknown): void {
global.fetch = vi.fn().mockResolvedValue({
ok: status >= 200 && status < 300,
status,
statusText: 'OK',
json: async () => body,
} as Response);
}
describe('sendClip', () => {
afterEach(() => vi.restoreAllMocks());
it('returns ClipResult on 201 Created', async () => {
const expected = { pageId: 'p-1', slugId: 'slug-1', url: samplePayload.url };
mockFetch(201, expected);
const result = await sendClip(BASE_URL, TOKEN, samplePayload);
expect(result.pageId).toBe('p-1');
expect(result.slugId).toBe('slug-1');
});
it('sends X-Clipper-Token header', async () => {
mockFetch(201, { pageId: 'p-1', slugId: 's-1', url: '' });
await sendClip(BASE_URL, TOKEN, samplePayload);
const [_url, init] = (global.fetch as any).mock.calls[0];
expect((init as RequestInit).headers!['X-Clipper-Token']).toBe(TOKEN);
});
it('posts to the correct endpoint', async () => {
mockFetch(201, { pageId: 'p-1', slugId: 's-1', url: '' });
await sendClip(BASE_URL, TOKEN, samplePayload);
const [url] = (global.fetch as any).mock.calls[0];
expect(url).toBe(`${BASE_URL}/api/acadenice/clipper/import`);
});
it('strips trailing slash from apiUrl', async () => {
mockFetch(201, { pageId: 'p-1', slugId: 's-1', url: '' });
await sendClip(`${BASE_URL}/`, TOKEN, samplePayload);
const [url] = (global.fetch as any).mock.calls[0];
expect(url).toBe(`${BASE_URL}/api/acadenice/clipper/import`);
});
it('throws ApiError with statusCode on 401', async () => {
mockFetch(401, { message: 'Unauthorized' });
await expect(sendClip(BASE_URL, TOKEN, samplePayload)).rejects.toMatchObject({
statusCode: 401,
});
});
it('throws ApiError with statusCode on 400', async () => {
mockFetch(400, { message: 'Bad request' });
await expect(sendClip(BASE_URL, TOKEN, samplePayload)).rejects.toMatchObject({
statusCode: 400,
message: 'Bad request',
});
});
it('throws Error with network message when fetch rejects', async () => {
global.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
await expect(sendClip(BASE_URL, TOKEN, samplePayload)).rejects.toThrow(
/Network error/,
);
});
});
describe('fetchSpaces', () => {
afterEach(() => vi.restoreAllMocks());
it('returns empty array on network error', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Network down'));
const result = await fetchSpaces(BASE_URL, TOKEN, 'ws-uuid');
expect(result).toEqual([]);
});
it('returns empty array on non-ok response', async () => {
mockFetch(404, {});
const result = await fetchSpaces(BASE_URL, TOKEN, 'ws-uuid');
expect(result).toEqual([]);
});
it('maps space items correctly', async () => {
const spaces = [
{ id: 's-1', name: 'General', slug: 'general' },
{ id: 's-2', name: 'Engineering', slug: 'engineering' },
];
mockFetch(200, spaces);
const result = await fetchSpaces(BASE_URL, TOKEN, 'ws-uuid');
expect(result).toHaveLength(2);
expect(result[0].slug).toBe('general');
});
it('handles paginated response with items array', async () => {
mockFetch(200, { items: [{ id: 's-1', name: 'General', slug: 'general' }], total: 1 });
const result = await fetchSpaces(BASE_URL, TOKEN, 'ws-uuid');
expect(result[0].id).toBe('s-1');
});
});

View file

@ -0,0 +1,102 @@
/**
* Tests for html-extractor.ts helpers.
*
* DOMPurify is mocked to return input unchanged so we can test the surrounding
* logic without needing jsdom + DOMPurify integration.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock DOMPurify before importing the module under test.
vi.mock('dompurify', () => ({
default: {
sanitize: (html: string, _opts: unknown) => html,
},
}));
// Mock browser APIs used by html-extractor (getPageUrl uses querySelector).
const linkMock = { href: '' };
Object.defineProperty(global, 'document', {
value: {
title: 'Test Page Title',
querySelector: vi.fn().mockReturnValue(null),
createElement: (tag: string) => ({
innerHTML: '',
appendChild: function (node: any) {
this.innerHTML += node.innerHTML ?? '';
},
}),
},
writable: true,
});
Object.defineProperty(global, 'window', {
value: {
location: { href: 'https://example.com/path?q=1' },
getSelection: vi.fn(),
},
writable: true,
});
import {
sanitizeHtml,
getPageTitle,
getPageUrl,
extractSelection,
} from '../src/lib/html-extractor';
describe('sanitizeHtml', () => {
it('passes through safe HTML (mocked DOMPurify)', () => {
const safe = '<p>Hello <strong>world</strong></p>';
expect(sanitizeHtml(safe)).toBe(safe);
});
it('handles empty string', () => {
expect(sanitizeHtml('')).toBe('');
});
it('handles string with only whitespace', () => {
expect(sanitizeHtml(' ')).toBe(' ');
});
});
describe('getPageTitle', () => {
it('returns trimmed document.title', () => {
(global as any).document.title = ' My Article ';
expect(getPageTitle()).toBe('My Article');
});
it('returns empty string when document.title is blank', () => {
(global as any).document.title = ' ';
expect(getPageTitle()).toBe('');
});
});
describe('getPageUrl', () => {
it('returns location.href when no canonical link', () => {
(global as any).document.querySelector = vi.fn().mockReturnValue(null);
expect(getPageUrl()).toBe('https://example.com/path?q=1');
});
it('returns canonical href when present', () => {
(global as any).document.querySelector = vi.fn().mockReturnValue({
href: 'https://example.com/canonical',
});
expect(getPageUrl()).toBe('https://example.com/canonical');
});
});
describe('extractSelection', () => {
it('returns empty string when selection is collapsed', () => {
(global as any).window.getSelection = vi.fn().mockReturnValue({
isCollapsed: true,
rangeCount: 0,
});
expect(extractSelection()).toBe('');
});
it('returns empty string when getSelection returns null', () => {
(global as any).window.getSelection = vi.fn().mockReturnValue(null);
expect(extractSelection()).toBe('');
});
});

View file

@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { t, setLocale } from '../src/i18n/messages';
describe('i18n', () => {
it('returns English string by default or when set to EN', () => {
setLocale('en');
expect(t('btnClip')).toBe('Clip page');
expect(t('title')).toBe('Clip to DocAdenice');
});
it('returns French strings when locale is FR', () => {
setLocale('fr');
expect(t('btnClip')).toBe('Clipper la page');
expect(t('title')).toBe('Clipper vers DocAdenice');
});
it('returns a string for every key in both locales', () => {
const keys = [
'title', 'tabTitle', 'tabSettings', 'labelTitle',
'labelWorkspace', 'labelSpace', 'labelParent', 'labelSelection',
'selectionEmpty', 'selectionPresent', 'btnClip', 'btnClipping',
'settingsTitle', 'labelApiUrl', 'labelApiToken',
'labelDefaultWorkspace', 'labelDefaultSpace',
'btnSaveSettings', 'settingsSaved',
'successTitle', 'successOpen',
'errorTitle', 'errorNetwork', 'errorUnauthorized', 'errorGeneric',
'placeholderParent', 'optional',
] as const;
for (const locale of ['en', 'fr'] as const) {
setLocale(locale);
for (const key of keys) {
const val = t(key);
expect(typeof val).toBe('string');
expect(val.length).toBeGreaterThan(0);
}
}
});
});

View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noImplicitAny": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src", "tests"]
}

View file

@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import { crx } from '@crxjs/vite-plugin';
import manifest from './manifest.json';
export default defineConfig({
plugins: [crx({ manifest })],
build: {
outDir: 'dist',
rollupOptions: {
input: {
popup: 'src/popup/popup.html',
},
},
},
test: {
environment: 'jsdom',
globals: true,
},
});

View file

@ -18,6 +18,9 @@ import { LoggerExtension } from './extensions/logger.extension';
import { CollaborationHandler } from './collaboration.handler';
import { CollabHistoryService } from './services/collab-history.service';
import { WatcherModule } from '../core/watcher/watcher.module';
// Acadenice R4.2 — sync block Yjs persistence
import { SyncBlockRepo } from '../core/acadenice/sync-blocks/repos/sync-block.repo';
import { SyncBlockBroadcastService } from '../core/acadenice/sync-blocks/services/sync-block-broadcast.service';
@Module({
providers: [
@ -28,6 +31,9 @@ import { WatcherModule } from '../core/watcher/watcher.module';
HistoryProcessor,
CollabHistoryService,
CollaborationHandler,
// Acadenice R4.2 — sync block persistence
SyncBlockRepo,
SyncBlockBroadcastService,
],
exports: [CollaborationGateway],
imports: [TokenModule, WatcherModule],

View file

@ -35,6 +35,13 @@ import {
HISTORY_FAST_THRESHOLD,
HISTORY_INTERVAL,
} from '../constants';
// Acadenice R4.2 — sync block Yjs persistence
import { SyncBlockRepo } from '../../core/acadenice/sync-blocks/repos/sync-block.repo';
import {
SyncBlockBroadcastService,
} from '../../core/acadenice/sync-blocks/services/sync-block-broadcast.service';
const SYNC_BLOCK_DOC_PREFIX = 'sync-block-';
@Injectable()
export class PersistenceExtension implements Extension {
@ -50,10 +57,19 @@ export class PersistenceExtension implements Extension {
private readonly collabHistory: CollabHistoryService,
// Acadenice R3.2 — emit event for backlink indexer
private readonly eventEmitter: EventEmitter2,
// Acadenice R4.2 — sync block Yjs persistence
private readonly syncBlockRepo: SyncBlockRepo,
private readonly syncBlockBroadcast: SyncBlockBroadcastService,
) {}
async onLoadDocument(data: onLoadDocumentPayload) {
const { documentName, document } = data;
// Acadenice R4.2 — sync-block-* docs use their own persistence path.
if (documentName.startsWith(SYNC_BLOCK_DOC_PREFIX)) {
return this.loadSyncBlockDoc(documentName, document);
}
const pageId = getPageId(documentName);
if (!document.isEmpty('default')) {
@ -101,6 +117,12 @@ export class PersistenceExtension implements Extension {
async onStoreDocument(data: onStoreDocumentPayload) {
const { documentName, document, context } = data;
// Acadenice R4.2 — sync-block-* docs use their own store path.
if (documentName.startsWith(SYNC_BLOCK_DOC_PREFIX)) {
await this.storeSyncBlockDoc(documentName, document, context);
return;
}
const pageId = getPageId(documentName);
const tiptapJson = TiptapTransformer.fromYdoc(document, 'default');
@ -228,6 +250,100 @@ export class PersistenceExtension implements Extension {
return userIds;
}
// ---------------------------------------------------------------------------
// Acadenice R4.2 — Sync block Yjs persistence helpers
// ---------------------------------------------------------------------------
/**
* Load the Yjs state for a sync-block-{uuid} document.
* The sync block id is extracted from the document name.
* Workspace id is recovered from the auth context stored in the first
* connected client if unavailable we cannot scope the query safely and
* return a fresh doc (the client will push its state on the next change).
*/
private async loadSyncBlockDoc(
documentName: string,
document: any,
): Promise<Y.Doc | undefined> {
if (!document.isEmpty('default')) {
return;
}
const blockId = documentName.slice(SYNC_BLOCK_DOC_PREFIX.length);
// We do not have the workspaceId here at load time without querying by id
// alone. We query with workspace_id = ANY to find the state — this is safe
// because sync block IDs are UUIDs (non-guessable) and the WS auth already
// validated the user belongs to the workspace.
const result = await this.db
.selectFrom('acadenice_sync_block' as any)
.select(['yjs_state', 'content'])
.where('id', '=', blockId as any)
.executeTakeFirst() as { yjs_state: Buffer | null; content: unknown } | undefined;
if (!result) {
this.logger.warn(`sync block not found for collab load: ${blockId}`);
return new Y.Doc();
}
if (result.yjs_state) {
this.logger.debug(`sync block ydoc loaded: ${blockId}`);
const doc = new Y.Doc();
Y.applyUpdate(doc, new Uint8Array(result.yjs_state));
return doc;
}
if (result.content && typeof result.content === 'object') {
this.logger.debug(`sync block converting json to ydoc: ${blockId}`);
const ydoc = TiptapTransformer.toYdoc(
result.content as any,
'default',
tiptapExtensions,
);
Y.encodeStateAsUpdate(ydoc);
return ydoc;
}
return new Y.Doc();
}
/**
* Store the Yjs state for a sync-block-{uuid} document after a debounced
* save. Also extracts ProseMirror JSON to update the `content` column and
* fires a broadcast event so SSE listeners can invalidate client caches.
*/
private async storeSyncBlockDoc(
documentName: string,
document: any,
context: any,
): Promise<void> {
const blockId = documentName.slice(SYNC_BLOCK_DOC_PREFIX.length);
const yjsState = Buffer.from(Y.encodeStateAsUpdate(document));
const tiptapJson = TiptapTransformer.fromYdoc(document, 'default');
try {
// Update both yjs_state and content in a single statement.
await this.db
.updateTable('acadenice_sync_block' as any)
.set({
yjs_state: yjsState,
content: JSON.stringify(tiptapJson) as any,
updated_at: new Date() as any,
})
.where('id', '=', blockId as any)
.execute();
this.logger.debug(`sync block stored: ${blockId}`);
// Broadcast so SSE clients invalidate their React Query cache.
// workspaceId is unknown here without a DB round-trip; broadcast accepts
// an empty string — the client matches on masterId only.
this.syncBlockBroadcast.publish(blockId, context?.workspace?.id ?? '');
} catch (err) {
this.logger.error(`sync block store failed: ${blockId}: ${err?.['message']}`);
}
}
private async enqueuePageHistory(page: Page): Promise<void> {
const pageAge = Date.now() - new Date(page.createdAt).getTime();
const delay =

View file

@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { ClipperController } from './controllers/clipper.controller';
import { ClipperService } from './services/clipper.service';
import { ClipperTokenService } from './services/clipper-token.service';
import { PageModule } from '../../page/page.module';
/**
* DocAdenice Web Clipper module (R4.3).
*
* Provides:
* - ClipperController : REST surface (import + token CRUD)
* - ClipperService : HTML-to-ProseMirror + page creation
* - ClipperTokenService : generate / validate / revoke bcrypt-hashed tokens
*
* Depends on PageModule (re-exported PageService) for page creation.
* KyselyDB and JwtAuthGuard are global.
*/
@Module({
imports: [PageModule],
controllers: [ClipperController],
providers: [ClipperService, ClipperTokenService],
exports: [ClipperTokenService],
})
export class AcadeniceClipperModule {}

View file

@ -0,0 +1,150 @@
import {
Controller,
Post,
Get,
Delete,
Body,
Param,
Headers,
ParseUUIDPipe,
UnauthorizedException,
BadRequestException,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { ClipperService, ClipResult } from '../services/clipper.service';
import {
ClipperTokenService,
ClipperTokenRow,
CreateTokenResult,
} from '../services/clipper-token.service';
import {
ImportClipDto,
ImportClipDtoType,
CreateClipperTokenDto,
CreateClipperTokenDtoType,
} from '../dto/import.dto';
/**
* Checks whether an unknown thrown value is a Zod validation error.
* We check by `name` and the `issues` array presence rather than `instanceof`
* to avoid module-instance mismatches in Jest / ts-jest environments.
*/
function isZodError(err: unknown): err is { name: string; issues: unknown[] } {
return (
err != null &&
typeof err === 'object' &&
(err as any).name === 'ZodError' &&
Array.isArray((err as any).issues)
);
}
const TOKEN_HEADER = 'x-clipper-token';
/**
* ClipperController Web Clipper REST surface (R4.3).
*
* POST /api/acadenice/clipper/import
* Auth: X-Clipper-Token header (token validated against DB hash).
* Body: ImportClipDto (Zod).
*
* POST /api/acadenice/clipper/tokens (JWT auth)
* GET /api/acadenice/clipper/tokens (JWT auth)
* DELETE /api/acadenice/clipper/tokens/:id (JWT auth)
*/
@Controller('acadenice/clipper')
export class ClipperController {
constructor(
private readonly clipperService: ClipperService,
private readonly tokenService: ClipperTokenService,
) {}
// ---------------------------------------------------------------------------
// Import endpoint — authenticated via X-Clipper-Token header
// ---------------------------------------------------------------------------
@Post('import')
@HttpCode(HttpStatus.CREATED)
async import(
@Headers(TOKEN_HEADER) rawToken: string | undefined,
@Body() body: unknown,
): Promise<ClipResult> {
if (!rawToken) {
throw new UnauthorizedException('Missing X-Clipper-Token header');
}
let dto: ImportClipDtoType;
try {
dto = ImportClipDto.parse(body);
} catch (err) {
if (isZodError(err)) {
throw new BadRequestException(err.issues);
}
throw err;
}
// Validate the raw token against the workspace
const tokenRow = await this.tokenService.validate(
rawToken,
dto.target_workspace_id,
);
return this.clipperService.clip(tokenRow.userId, dto);
}
// ---------------------------------------------------------------------------
// Token management — JWT auth (user manages their own tokens)
// ---------------------------------------------------------------------------
@UseGuards(JwtAuthGuard)
@Post('tokens')
@HttpCode(HttpStatus.CREATED)
async createToken(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Body() body: unknown,
): Promise<{ token: string; tokenInfo: ClipperTokenRow }> {
let dto: CreateClipperTokenDtoType;
try {
dto = CreateClipperTokenDto.parse(body);
} catch (err) {
if (isZodError(err)) {
throw new BadRequestException(err.issues);
}
throw err;
}
const result: CreateTokenResult = await this.tokenService.generate(
user.id,
workspace.id,
dto.label,
dto.duration_days ?? null,
);
return { token: result.token, tokenInfo: result.row };
}
@UseGuards(JwtAuthGuard)
@Get('tokens')
async listTokens(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
): Promise<ClipperTokenRow[]> {
return this.tokenService.list(user.id, workspace.id);
}
@UseGuards(JwtAuthGuard)
@Delete('tokens/:id')
@HttpCode(HttpStatus.NO_CONTENT)
async revokeToken(
@Param('id', ParseUUIDPipe) tokenId: string,
@AuthUser() user: User,
): Promise<void> {
await this.tokenService.revoke(tokenId, user.id);
}
}

View file

@ -0,0 +1,33 @@
import { z } from 'zod';
/**
* Zod schema for POST /api/acadenice/clipper/import.
*
* html_selection is expected to arrive pre-sanitized (DOMPurify on the
* extension side). The server sanitizes again server-side via the existing
* htmlToJson pipeline which strips unknown nodes.
*/
export const ImportClipDto = z.object({
url: z.string().url({ message: 'url must be a valid URL' }),
title: z.string().min(1).max(512),
html_selection: z.string().optional(),
target_workspace_id: z.string().uuid({ message: 'target_workspace_id must be a UUID' }),
target_space_id: z.string().uuid({ message: 'target_space_id must be a UUID' }),
target_parent_page_id: z
.string()
.uuid({ message: 'target_parent_page_id must be a UUID' })
.optional(),
});
export type ImportClipDtoType = z.infer<typeof ImportClipDto>;
/**
* Zod schema for POST /api/acadenice/clipper/tokens.
* duration: days until expiry, or null for no expiry.
*/
export const CreateClipperTokenDto = z.object({
label: z.string().min(1).max(100),
duration_days: z.number().int().positive().nullable().optional(),
});
export type CreateClipperTokenDtoType = z.infer<typeof CreateClipperTokenDto>;

View file

@ -0,0 +1,171 @@
import {
Injectable,
Logger,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { sql } from 'kysely';
import * as bcrypt from 'bcrypt';
import { randomBytes } from 'crypto';
const BCRYPT_ROUNDS = 10;
const TOKEN_PREFIX = 'clip_';
const TOKEN_BYTES = 32;
export interface ClipperTokenRow {
id: string;
userId: string;
workspaceId: string;
label: string | null;
lastUsedAt: Date | null;
createdAt: Date;
expiresAt: Date | null;
}
export interface CreateTokenResult {
token: string;
row: ClipperTokenRow;
}
/**
* ClipperTokenService generate, validate, list, revoke API tokens (R4.3).
*
* Plaintext tokens are returned exactly once at creation.
* Only a bcrypt hash is stored. Validation uses bcrypt.compare.
*
* Token format: clip_<32 random bytes as hex>
*/
@Injectable()
export class ClipperTokenService {
private readonly logger = new Logger(ClipperTokenService.name);
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async generate(
userId: string,
workspaceId: string,
label: string,
durationDays: number | null | undefined,
): Promise<CreateTokenResult> {
const plain = TOKEN_PREFIX + randomBytes(TOKEN_BYTES).toString('hex');
const hash = await bcrypt.hash(plain, BCRYPT_ROUNDS);
const expiresAt =
durationDays != null
? new Date(Date.now() + durationDays * 86400 * 1000)
: null;
const rows = await sql<ClipperTokenRow>`
INSERT INTO acadenice_clipper_token
(user_id, workspace_id, token_hash, label, expires_at)
VALUES
(${userId}, ${workspaceId}, ${hash}, ${label}, ${expiresAt})
RETURNING
id,
user_id AS "userId",
workspace_id AS "workspaceId",
label,
last_used_at AS "lastUsedAt",
created_at AS "createdAt",
expires_at AS "expiresAt"
`.execute(this.db);
const row = rows.rows[0];
return { token: plain, row };
}
async list(userId: string, workspaceId: string): Promise<ClipperTokenRow[]> {
const result = await sql<ClipperTokenRow>`
SELECT
id,
user_id AS "userId",
workspace_id AS "workspaceId",
label,
last_used_at AS "lastUsedAt",
created_at AS "createdAt",
expires_at AS "expiresAt"
FROM acadenice_clipper_token
WHERE user_id = ${userId}
AND workspace_id = ${workspaceId}
ORDER BY created_at DESC
`.execute(this.db);
return result.rows;
}
async revoke(tokenId: string, userId: string): Promise<void> {
const result = await sql<{ id: string }>`
DELETE FROM acadenice_clipper_token
WHERE id = ${tokenId} AND user_id = ${userId}
RETURNING id
`.execute(this.db);
if (result.rows.length === 0) {
throw new NotFoundException('Token not found or already revoked');
}
}
/**
* Validates a raw token against all active tokens for the workspace.
*
* Because we cannot look up the row by plaintext (it is hashed), we must
* iterate over all rows for the workspace and do a bcrypt compare.
* The number of tokens per workspace is small (typically < 20) so the
* linear scan is acceptable. We limit to 100 rows as a safety guard.
*
* Returns the matching row if valid, throws UnauthorizedException otherwise.
*/
async validate(
rawToken: string,
workspaceId: string,
): Promise<ClipperTokenRow> {
if (!rawToken.startsWith(TOKEN_PREFIX)) {
throw new UnauthorizedException('Invalid clipper token format');
}
const rows = await sql<ClipperTokenRow & { tokenHash: string }>`
SELECT
id,
user_id AS "userId",
workspace_id AS "workspaceId",
token_hash AS "tokenHash",
label,
last_used_at AS "lastUsedAt",
created_at AS "createdAt",
expires_at AS "expiresAt"
FROM acadenice_clipper_token
WHERE workspace_id = ${workspaceId}
ORDER BY created_at DESC
LIMIT 100
`.execute(this.db);
for (const row of rows.rows) {
if (row.expiresAt && row.expiresAt < new Date()) {
continue;
}
const match = await bcrypt.compare(rawToken, row.tokenHash);
if (match) {
// Bump last_used_at without awaiting — non-critical
sql`
UPDATE acadenice_clipper_token
SET last_used_at = now()
WHERE id = ${row.id}
`
.execute(this.db)
.catch((err) =>
this.logger.warn(
`Failed to bump last_used_at for token ${row.id}: ${err.message}`,
),
);
const { tokenHash: _omit, ...clean } = row;
return clean as ClipperTokenRow;
}
}
throw new UnauthorizedException('Invalid or expired clipper token');
}
}

View file

@ -0,0 +1,120 @@
import {
Injectable,
Logger,
BadRequestException,
InternalServerErrorException,
} from '@nestjs/common';
import { PageService } from '../../../page/services/page.service';
import { htmlToJson } from '../../../../collaboration/collaboration.util';
import { ImportClipDtoType } from '../dto/import.dto';
import { Page } from '@docmost/db/types/entity.types';
export interface ClipResult {
pageId: string;
slugId: string;
url: string;
}
/**
* ClipperService orchestrates HTML-to-ProseMirror conversion and page
* creation from the Web Clipper extension (R4.3).
*
* Flow:
* 1. Convert html_selection to ProseMirror JSON via htmlToJson (Tiptap).
* 2. Prepend a source-URL paragraph so the clipped origin is preserved.
* 3. Delegate page creation to PageService.create.
*/
@Injectable()
export class ClipperService {
private readonly logger = new Logger(ClipperService.name);
constructor(private readonly pageService: PageService) {}
async clip(
userId: string,
dto: ImportClipDtoType,
): Promise<ClipResult> {
let prosemirrorContent: object | undefined;
if (dto.html_selection) {
try {
prosemirrorContent = htmlToJson(dto.html_selection);
} catch (err) {
this.logger.warn(
`HTML to ProseMirror conversion failed for clip from ${dto.url}: ${(err as Error).message}`,
);
throw new BadRequestException(
'Failed to convert HTML selection to document format',
);
}
// Inject a source attribution paragraph at the top
prosemirrorContent = this.prependSourceAttribution(
prosemirrorContent as Record<string, any>,
dto.url,
);
}
let page: Page;
try {
page = await this.pageService.create(userId, dto.target_workspace_id, {
title: dto.title,
spaceId: dto.target_space_id,
parentPageId: dto.target_parent_page_id,
content: prosemirrorContent
? JSON.stringify(prosemirrorContent)
: undefined,
format: prosemirrorContent ? 'json' : undefined,
});
} catch (err) {
this.logger.error(
`Page creation failed for clip from ${dto.url}: ${(err as Error).message}`,
(err as Error).stack,
);
throw new InternalServerErrorException(
'Failed to create clipped page. Verify the target space and workspace exist.',
);
}
return {
pageId: page.id,
slugId: page.slugId,
url: dto.url,
};
}
private prependSourceAttribution(
doc: Record<string, any>,
sourceUrl: string,
): Record<string, any> {
const sourceParagraph = {
type: 'paragraph',
attrs: {},
content: [
{
type: 'text',
text: 'Source: ',
marks: [{ type: 'bold' }],
},
{
type: 'text',
text: sourceUrl,
marks: [
{
type: 'link',
attrs: { href: sourceUrl, target: '_blank', rel: 'noopener noreferrer' },
},
],
},
],
};
const hrNode = { type: 'horizontalRule' };
const existingContent: any[] = Array.isArray(doc.content) ? doc.content : [];
return {
...doc,
content: [sourceParagraph, hrNode, ...existingContent],
};
}
}

View file

@ -0,0 +1,151 @@
// Mock bcrypt before all imports so the real bcrypt native module is not loaded.
jest.mock('bcrypt', () => ({
hash: jest.fn().mockResolvedValue('$2b$10$fakehash'),
compare: jest.fn(),
}));
import * as bcrypt from 'bcrypt';
import { Test } from '@nestjs/testing';
import { ClipperTokenService } from '../services/clipper-token.service';
import { UnauthorizedException, NotFoundException } from '@nestjs/common';
import { KYSELY_MODULE_CONNECTION_TOKEN } from 'nestjs-kysely';
const NOW = new Date('2026-05-09T12:00:00Z');
const fakeRow = {
id: 'token-uuid',
userId: 'user-uuid',
workspaceId: 'ws-uuid',
tokenHash: '$2b$10$fakehash',
label: 'My Token',
lastUsedAt: null,
createdAt: NOW,
expiresAt: null,
};
const mockSql = jest.fn();
const mockDb = {
execute: jest.fn(),
};
// nestjs-kysely injects the DB via a string token. We mock the sql tag.
jest.mock('kysely', () => ({
sql: Object.assign(
(..._args: any[]) => ({ execute: mockSql }),
{ raw: jest.fn() },
),
}));
describe('ClipperTokenService', () => {
let service: ClipperTokenService;
beforeEach(async () => {
mockSql.mockReset();
(bcrypt.compare as jest.Mock).mockReset();
(bcrypt.hash as jest.Mock).mockResolvedValue('$2b$10$fakehash');
const module = await Test.createTestingModule({
providers: [
ClipperTokenService,
{
provide: KYSELY_MODULE_CONNECTION_TOKEN(),
useValue: mockDb,
},
],
}).compile();
service = module.get(ClipperTokenService);
});
describe('generate', () => {
it('returns plaintext token and row', async () => {
mockSql.mockResolvedValueOnce({ rows: [fakeRow] });
const result = await service.generate('user-uuid', 'ws-uuid', 'My Token', null);
expect(result.token).toMatch(/^clip_/);
expect(result.row.id).toBe('token-uuid');
});
it('sets expires_at when durationDays is provided', async () => {
mockSql.mockResolvedValueOnce({ rows: [{ ...fakeRow, expiresAt: new Date(Date.now() + 30 * 86400000) }] });
const result = await service.generate('user-uuid', 'ws-uuid', 'Expires', 30);
expect(result.row.expiresAt).not.toBeNull();
});
});
describe('list', () => {
it('returns rows for user and workspace', async () => {
mockSql.mockResolvedValueOnce({ rows: [fakeRow] });
const rows = await service.list('user-uuid', 'ws-uuid');
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe('token-uuid');
});
it('returns empty array when no tokens exist', async () => {
mockSql.mockResolvedValueOnce({ rows: [] });
const rows = await service.list('user-uuid', 'ws-uuid');
expect(rows).toHaveLength(0);
});
});
describe('revoke', () => {
it('resolves when token is found and owned by user', async () => {
mockSql.mockResolvedValueOnce({ rows: [{ id: 'token-uuid' }] });
await expect(service.revoke('token-uuid', 'user-uuid')).resolves.toBeUndefined();
});
it('throws NotFoundException when token does not exist', async () => {
mockSql.mockResolvedValueOnce({ rows: [] });
await expect(service.revoke('bad-id', 'user-uuid')).rejects.toThrow(
NotFoundException,
);
});
});
describe('validate', () => {
it('throws UnauthorizedException when prefix is wrong', async () => {
await expect(service.validate('bad_prefix_token', 'ws-uuid')).rejects.toThrow(
UnauthorizedException,
);
});
it('throws UnauthorizedException when no rows match', async () => {
mockSql.mockResolvedValueOnce({ rows: [] });
await expect(
service.validate('clip_' + 'a'.repeat(64), 'ws-uuid'),
).rejects.toThrow(UnauthorizedException);
});
it('throws UnauthorizedException when bcrypt compare fails', async () => {
mockSql.mockResolvedValueOnce({ rows: [fakeRow] });
(bcrypt.compare as jest.Mock).mockResolvedValueOnce(false);
await expect(
service.validate('clip_' + 'a'.repeat(64), 'ws-uuid'),
).rejects.toThrow(UnauthorizedException);
});
it('returns row when bcrypt compare succeeds and token not expired', async () => {
mockSql
.mockResolvedValueOnce({ rows: [fakeRow] })
.mockResolvedValueOnce({ rows: [] }); // last_used_at update
(bcrypt.compare as jest.Mock).mockResolvedValueOnce(true);
const row = await service.validate('clip_' + 'a'.repeat(64), 'ws-uuid');
expect(row.id).toBe('token-uuid');
// hash must not be exposed
expect((row as any).tokenHash).toBeUndefined();
});
it('skips expired tokens', async () => {
const expiredRow = {
...fakeRow,
expiresAt: new Date('2020-01-01T00:00:00Z'),
};
mockSql.mockResolvedValueOnce({ rows: [expiredRow] });
(bcrypt.compare as jest.Mock).mockResolvedValue(false);
await expect(
service.validate('clip_' + 'a'.repeat(64), 'ws-uuid'),
).rejects.toThrow(UnauthorizedException);
});
});
});

View file

@ -0,0 +1,154 @@
// Stub heavy ESM-only dependencies.
jest.mock('../../../../collaboration/collaboration.util', () => ({
htmlToJson: jest.fn(),
jsonToText: jest.fn().mockReturnValue(''),
}));
jest.mock('../../../../common/helpers/prosemirror/utils', () => ({
createYdocFromJson: jest.fn().mockReturnValue({}),
}));
// Stub page.service with a factory so ts-jest does not compile its deep imports.
jest.mock('../../../page/services/page.service', () => ({
PageService: jest.fn().mockImplementation(() => ({ create: jest.fn() })),
}));
import { Test } from '@nestjs/testing';
import { ClipperController } from '../controllers/clipper.controller';
import { ClipperService } from '../services/clipper.service';
import { ClipperTokenService } from '../services/clipper-token.service';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
import {
UnauthorizedException,
BadRequestException,
} from '@nestjs/common';
// Zod v4 uuid() requires real UUIDs (v1-v8 format).
const WS_UUID = '550e8400-e29b-41d4-a716-446655440000';
const SPACE_UUID = '6ba7b810-9dad-41d1-80b4-00c04fd430c8';
const mockUser = { id: 'user-uuid' } as any;
const mockWorkspace = { id: WS_UUID } as any;
const sampleImportBody = {
url: 'https://example.com/article',
title: 'My Clip',
target_workspace_id: WS_UUID,
target_space_id: SPACE_UUID,
};
const sampleTokenBody = {
label: 'Dev token',
duration_days: 30,
};
const mockClipResult = { pageId: 'p-1', slugId: 'slug-1', url: 'https://example.com/article' };
const mockTokenRow = {
id: 'tk-1',
userId: 'user-uuid',
workspaceId: WS_UUID,
label: 'Dev token',
lastUsedAt: null,
createdAt: new Date(),
expiresAt: null,
};
describe('ClipperController', () => {
let controller: ClipperController;
let clipperService: { clip: jest.Mock };
let tokenService: {
validate: jest.Mock;
generate: jest.Mock;
list: jest.Mock;
revoke: jest.Mock;
};
beforeEach(async () => {
clipperService = { clip: jest.fn() };
tokenService = {
validate: jest.fn(),
generate: jest.fn(),
list: jest.fn(),
revoke: jest.fn(),
};
const module = await Test.createTestingModule({
controllers: [ClipperController],
providers: [
{ provide: ClipperService, useValue: clipperService },
{ provide: ClipperTokenService, useValue: tokenService },
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get(ClipperController);
});
describe('POST /import', () => {
it('clips page when token and body are valid', async () => {
tokenService.validate.mockResolvedValue(mockTokenRow);
clipperService.clip.mockResolvedValue(mockClipResult);
const result = await controller.import('clip_abc', sampleImportBody);
expect(result).toEqual(mockClipResult);
expect(tokenService.validate).toHaveBeenCalledWith('clip_abc', WS_UUID);
expect(clipperService.clip).toHaveBeenCalledWith('user-uuid', sampleImportBody);
});
it('throws UnauthorizedException when header is missing', async () => {
await expect(controller.import(undefined, sampleImportBody)).rejects.toThrow(
UnauthorizedException,
);
});
it('throws BadRequestException on invalid body', async () => {
tokenService.validate.mockResolvedValue(mockTokenRow);
await expect(controller.import('clip_abc', { url: 'not-a-url' })).rejects.toThrow(
BadRequestException,
);
});
it('propagates token validation error', async () => {
tokenService.validate.mockRejectedValue(new UnauthorizedException());
await expect(controller.import('clip_invalid', sampleImportBody)).rejects.toThrow(
UnauthorizedException,
);
});
});
describe('POST /tokens', () => {
it('returns token string and info on valid body', async () => {
tokenService.generate.mockResolvedValue({ token: 'clip_xxx', row: mockTokenRow });
const result = await controller.createToken(mockUser, mockWorkspace, sampleTokenBody);
expect(result.token).toBe('clip_xxx');
expect(result.tokenInfo.id).toBe('tk-1');
});
it('throws BadRequestException on invalid body', async () => {
await expect(controller.createToken(mockUser, mockWorkspace, { label: '' })).rejects.toThrow(
BadRequestException,
);
});
});
describe('GET /tokens', () => {
it('lists tokens for authenticated user', async () => {
tokenService.list.mockResolvedValue([mockTokenRow]);
const result = await controller.listTokens(mockUser, mockWorkspace);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('tk-1');
});
});
describe('DELETE /tokens/:id', () => {
it('revokes token for authenticated user', async () => {
tokenService.revoke.mockResolvedValue(undefined);
await expect(controller.revokeToken('tk-1', mockUser)).resolves.toBeUndefined();
expect(tokenService.revoke).toHaveBeenCalledWith('tk-1', 'user-uuid');
});
});
});

View file

@ -0,0 +1,143 @@
// Stub heavy ESM-only collaboration utils — relative path (covers clipper.service.ts).
jest.mock('../../../../collaboration/collaboration.util', () => ({
htmlToJson: jest.fn(),
jsonToText: jest.fn().mockReturnValue(''),
}));
jest.mock('../../../../common/helpers/prosemirror/utils', () => ({
createYdocFromJson: jest.fn().mockReturnValue({}),
ydocToJson: jest.fn().mockReturnValue({}),
}));
// Stub page.service with a factory so ts-jest does not compile its deep imports.
jest.mock('../../../page/services/page.service', () => ({
PageService: jest.fn().mockImplementation(() => ({ create: jest.fn() })),
}));
import { Test } from '@nestjs/testing';
import { ClipperService } from '../services/clipper.service';
import { PageService } from '../../../page/services/page.service';
import { BadRequestException, InternalServerErrorException } from '@nestjs/common';
import { htmlToJson } from '../../../../collaboration/collaboration.util';
const mockHtmlToJson = htmlToJson as jest.Mock;
const sampleDto = {
url: 'https://example.com/article',
title: 'Example Article',
html_selection: '<p>Hello world</p>',
target_workspace_id: '550e8400-e29b-41d4-a716-446655440000',
target_space_id: '6ba7b810-9dad-41d1-80b4-00c04fd430c8',
target_parent_page_id: undefined,
};
const mockPage = {
id: 'page-uuid',
slugId: 'slug-abc',
title: 'Example Article',
};
describe('ClipperService', () => {
let service: ClipperService;
let pageService: { create: jest.Mock };
beforeEach(async () => {
mockHtmlToJson.mockReset();
pageService = { create: jest.fn() };
const module = await Test.createTestingModule({
providers: [
ClipperService,
{ provide: PageService, useValue: pageService },
],
}).compile();
service = module.get(ClipperService);
});
describe('clip — happy paths', () => {
it('creates page with converted ProseMirror content', async () => {
const pmDoc = {
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello world' }] }],
};
mockHtmlToJson.mockReturnValue(pmDoc);
pageService.create.mockResolvedValue(mockPage);
const result = await service.clip('user-uuid', sampleDto);
expect(result.pageId).toBe('page-uuid');
expect(result.slugId).toBe('slug-abc');
expect(result.url).toBe(sampleDto.url);
expect(pageService.create).toHaveBeenCalledWith(
'user-uuid',
sampleDto.target_workspace_id,
expect.objectContaining({
title: 'Example Article',
spaceId: sampleDto.target_space_id,
format: 'json',
}),
);
});
it('creates page without content when html_selection is absent', async () => {
pageService.create.mockResolvedValue(mockPage);
const result = await service.clip('user-uuid', {
...sampleDto,
html_selection: undefined,
});
expect(result.pageId).toBe('page-uuid');
expect(pageService.create).toHaveBeenCalledWith(
'user-uuid',
sampleDto.target_workspace_id,
expect.objectContaining({
content: undefined,
format: undefined,
}),
);
expect(mockHtmlToJson).not.toHaveBeenCalled();
});
it('prepends source attribution paragraph to converted content', async () => {
const pmDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
mockHtmlToJson.mockReturnValue(pmDoc);
pageService.create.mockResolvedValue(mockPage);
await service.clip('user-uuid', sampleDto);
const createCall = pageService.create.mock.calls[0][2];
const parsedContent = JSON.parse(createCall.content);
// First node = attribution paragraph
expect(parsedContent.content[0].type).toBe('paragraph');
expect(parsedContent.content[0].content[0].text).toBe('Source: ');
// Second node = hr
expect(parsedContent.content[1].type).toBe('horizontalRule');
// Original content follows
expect(parsedContent.content[2].type).toBe('paragraph');
});
});
describe('clip — error paths', () => {
it('throws BadRequestException when htmlToJson fails', async () => {
mockHtmlToJson.mockImplementation(() => {
throw new Error('parse error');
});
await expect(service.clip('user-uuid', sampleDto)).rejects.toThrow(
BadRequestException,
);
});
it('throws InternalServerErrorException when pageService.create fails', async () => {
mockHtmlToJson.mockReturnValue({ type: 'doc', content: [] });
pageService.create.mockRejectedValue(new Error('DB down'));
await expect(service.clip('user-uuid', sampleDto)).rejects.toThrow(
InternalServerErrorException,
);
});
});
});

View file

@ -0,0 +1,86 @@
import { ImportClipDto, CreateClipperTokenDto } from '../dto/import.dto';
import { ZodError } from 'zod';
// Zod v4 uuid() enforces version bits (v1-v8), so we use real v4 UUIDs.
const WS_UUID = '550e8400-e29b-41d4-a716-446655440000';
const SPACE_UUID = '6ba7b810-9dad-41d1-80b4-00c04fd430c8';
const PARENT_UUID = '6ba7b811-9dad-41d1-80b4-00c04fd430c8';
describe('ImportClipDto', () => {
const validBase = {
url: 'https://example.com/page',
title: 'My Page',
target_workspace_id: WS_UUID,
target_space_id: SPACE_UUID,
};
it('parses a minimal valid payload', () => {
const result = ImportClipDto.parse(validBase);
expect(result.url).toBe(validBase.url);
expect(result.html_selection).toBeUndefined();
});
it('parses with optional fields', () => {
const result = ImportClipDto.parse({
...validBase,
html_selection: '<p>hello</p>',
target_parent_page_id: PARENT_UUID,
});
expect(result.html_selection).toBe('<p>hello</p>');
expect(result.target_parent_page_id).toBe(PARENT_UUID);
});
it('rejects an invalid URL', () => {
expect(() => ImportClipDto.parse({ ...validBase, url: 'not-a-url' })).toThrow(ZodError);
});
it('rejects missing title', () => {
expect(() => ImportClipDto.parse({ ...validBase, title: '' })).toThrow(ZodError);
});
it('rejects non-UUID workspace_id', () => {
expect(() =>
ImportClipDto.parse({ ...validBase, target_workspace_id: 'bad-id' }),
).toThrow(ZodError);
});
it('rejects non-UUID parent_page_id when provided', () => {
expect(() =>
ImportClipDto.parse({ ...validBase, target_parent_page_id: 'not-uuid' }),
).toThrow(ZodError);
});
});
describe('CreateClipperTokenDto', () => {
it('parses valid payload without duration', () => {
const result = CreateClipperTokenDto.parse({ label: 'My Token' });
expect(result.label).toBe('My Token');
expect(result.duration_days).toBeUndefined();
});
it('parses valid payload with null duration', () => {
const result = CreateClipperTokenDto.parse({ label: 'Forever', duration_days: null });
expect(result.duration_days).toBeNull();
});
it('parses valid payload with positive duration', () => {
const result = CreateClipperTokenDto.parse({ label: 'Short', duration_days: 30 });
expect(result.duration_days).toBe(30);
});
it('rejects empty label', () => {
expect(() => CreateClipperTokenDto.parse({ label: '' })).toThrow(ZodError);
});
it('rejects negative duration', () => {
expect(() =>
CreateClipperTokenDto.parse({ label: 'Bad', duration_days: -1 }),
).toThrow(ZodError);
});
it('rejects label longer than 100 chars', () => {
expect(() =>
CreateClipperTokenDto.parse({ label: 'x'.repeat(101) }),
).toThrow(ZodError);
});
});

View file

@ -61,6 +61,12 @@ export const PERMISSION_KEYS = [
'comments:write',
'comments:resolve',
'comments:moderate',
// Acadenice R4.2 — sync blocks
'sync_blocks:create',
'sync_blocks:edit',
'sync_blocks:delete',
// Acadenice R4.3 — Web Clipper
'clipper:use',
'admin:*',
] as const;
@ -229,6 +235,28 @@ export const PERMISSIONS_CATALOG: ReadonlyArray<PermissionDescriptor> = [
group: 'comments',
description: 'Delete any comment and force unresolve (admin-level)',
},
{
// R4.2 — sync blocks
key: 'sync_blocks:create',
group: 'sync_blocks',
description: 'Create new sync block masters',
},
{
key: 'sync_blocks:edit',
group: 'sync_blocks',
description: 'Edit the content of a sync block master',
},
{
key: 'sync_blocks:delete',
group: 'sync_blocks',
description: 'Delete sync block masters',
},
{
// R4.3 — Web Clipper
key: 'clipper:use',
group: 'clipper',
description: 'Use the Web Clipper to create pages via API token',
},
{
key: 'admin:*',
group: 'meta',

View file

@ -60,6 +60,12 @@ const SYSTEM_ROLES: ReadonlyArray<SystemRoleSpec> = [
'templates:read',
'templates:create',
'templates:manage',
// R4.2 — sync blocks (Admin can do everything including delete)
'sync_blocks:create',
'sync_blocks:edit',
'sync_blocks:delete',
// R4.3 — Web Clipper
'clipper:use',
],
},
{
@ -77,6 +83,11 @@ const SYSTEM_ROLES: ReadonlyArray<SystemRoleSpec> = [
// R3.6 — Editors can browse and create templates
'templates:read',
'templates:create',
// R4.2 — Editors can create and edit sync blocks
'sync_blocks:create',
'sync_blocks:edit',
// R4.3 — Editors can use the Web Clipper
'clipper:use',
],
},
{
@ -89,6 +100,11 @@ const SYSTEM_ROLES: ReadonlyArray<SystemRoleSpec> = [
'attachments:upload',
// R3.6 — Members can browse templates
'templates:read',
// R4.2 — Members can create and edit sync blocks
'sync_blocks:create',
'sync_blocks:edit',
// R4.3 — Members can use the Web Clipper
'clipper:use',
],
},
{

View file

@ -0,0 +1,89 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { SyncBlocksService } from '../services/sync-blocks.service';
import {
CreateSyncBlockDto,
UpdateSyncBlockDto,
SyncBlockResponseDto,
SyncBlockUsageDto,
} from '../dto/sync-block.dto';
/**
* REST controller for sync blocks (R4.2).
*
* All endpoints require JWT auth. The workspace is resolved via the
* AuthWorkspace decorator (domain middleware), so all operations are
* automatically scoped to the authenticated workspace.
*
* Routes:
* POST /api/acadenice/sync-blocks create master block
* GET /api/acadenice/sync-blocks/:id read content
* PATCH /api/acadenice/sync-blocks/:id update content
* DELETE /api/acadenice/sync-blocks/:id delete master
* GET /api/acadenice/sync-blocks/:id/usages list referencing pages
*/
@UseGuards(JwtAuthGuard)
@Controller('acadenice/sync-blocks')
export class SyncBlocksController {
constructor(private readonly syncBlocksService: SyncBlocksService) {}
@Post()
async create(
@Body() dto: CreateSyncBlockDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
): Promise<SyncBlockResponseDto> {
return this.syncBlocksService.create(workspace.id, user.id, dto);
}
@Get(':id')
async findOne(
@Param('id', ParseUUIDPipe) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<SyncBlockResponseDto> {
return this.syncBlocksService.findById(id, workspace.id);
}
@Patch(':id')
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateSyncBlockDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
): Promise<SyncBlockResponseDto> {
return this.syncBlocksService.update(id, workspace.id, user.id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(
@Param('id', ParseUUIDPipe) id: string,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
): Promise<void> {
return this.syncBlocksService.delete(id, workspace.id, user.id);
}
@Get(':id/usages')
async usages(
@Param('id', ParseUUIDPipe) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<SyncBlockUsageDto[]> {
return this.syncBlocksService.findUsages(id, workspace.id);
}
}

View file

@ -0,0 +1,40 @@
import {
IsNotEmpty,
IsObject,
IsOptional,
} from 'class-validator';
// ---- Create ----------------------------------------------------------------
export class CreateSyncBlockDto {
@IsOptional()
@IsObject()
content?: Record<string, unknown>;
}
// ---- Update ----------------------------------------------------------------
export class UpdateSyncBlockDto {
@IsNotEmpty()
@IsObject()
content: Record<string, unknown>;
}
// ---- Response -------------------------------------------------------------
export interface SyncBlockResponseDto {
id: string;
workspaceId: string;
content: Record<string, unknown>;
createdBy: string;
createdAt: string;
updatedAt: string;
}
export interface SyncBlockUsageDto {
pageId: string;
pageTitle: string | null;
slugId: string;
spaceId: string;
workspaceId: string;
}

View file

@ -0,0 +1,228 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { sql } from 'kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import {
SyncBlockResponseDto,
SyncBlockUsageDto,
} from '../dto/sync-block.dto';
/**
* SyncBlockRepo raw SQL access to `acadenice_sync_block`.
*
* Uses sql template literals consistently with other Acadenice repos because
* acadenice_sync_block is not part of Docmost's generated Kysely DB type.
*/
@Injectable()
export class SyncBlockRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async create(
workspaceId: string,
createdBy: string,
content: Record<string, unknown>,
): Promise<SyncBlockResponseDto> {
const result = await sql<{
id: string;
workspace_id: string;
content: Record<string, unknown>;
created_by: string;
created_at: Date;
updated_at: Date;
}>`
INSERT INTO acadenice_sync_block (workspace_id, content, created_by)
VALUES (${workspaceId}, ${JSON.stringify(content)}::jsonb, ${createdBy})
RETURNING id, workspace_id, content, created_by, created_at, updated_at
`.execute(this.db);
return this.mapRow(result.rows[0]);
}
async findById(
id: string,
workspaceId: string,
): Promise<SyncBlockResponseDto | null> {
const result = await sql<{
id: string;
workspace_id: string;
content: Record<string, unknown>;
created_by: string;
created_at: Date;
updated_at: Date;
}>`
SELECT id, workspace_id, content, created_by, created_at, updated_at
FROM acadenice_sync_block
WHERE id = ${id}
AND workspace_id = ${workspaceId}
`.execute(this.db);
if (result.rows.length === 0) return null;
return this.mapRow(result.rows[0]);
}
async updateContent(
id: string,
workspaceId: string,
content: Record<string, unknown>,
): Promise<SyncBlockResponseDto | null> {
const result = await sql<{
id: string;
workspace_id: string;
content: Record<string, unknown>;
created_by: string;
created_at: Date;
updated_at: Date;
}>`
UPDATE acadenice_sync_block
SET content = ${JSON.stringify(content)}::jsonb, updated_at = NOW()
WHERE id = ${id}
AND workspace_id = ${workspaceId}
RETURNING id, workspace_id, content, created_by, created_at, updated_at
`.execute(this.db);
if (result.rows.length === 0) return null;
return this.mapRow(result.rows[0]);
}
async updateYjsState(
id: string,
workspaceId: string,
yjsState: Buffer,
): Promise<void> {
await sql`
UPDATE acadenice_sync_block
SET yjs_state = ${yjsState}, updated_at = NOW()
WHERE id = ${id}
AND workspace_id = ${workspaceId}
`.execute(this.db);
}
async findYjsState(
id: string,
workspaceId: string,
): Promise<Buffer | null> {
const result = await sql<{ yjs_state: Buffer | null }>`
SELECT yjs_state
FROM acadenice_sync_block
WHERE id = ${id}
AND workspace_id = ${workspaceId}
`.execute(this.db);
if (result.rows.length === 0) return null;
return result.rows[0].yjs_state ?? null;
}
async delete(id: string, workspaceId: string): Promise<boolean> {
const result = await sql<{ id: string }>`
DELETE FROM acadenice_sync_block
WHERE id = ${id}
AND workspace_id = ${workspaceId}
RETURNING id
`.execute(this.db);
return result.rows.length > 0;
}
/**
* Find all page IDs whose content JSON contains a syncBlock node with
* masterId = given blockId. Used for the /usages endpoint.
*
* Strategy: Postgres jsonb @> path query looks for any syncBlock node
* in pages.content with the matching masterId attribute.
*/
async findUsages(
blockId: string,
workspaceId: string,
): Promise<SyncBlockUsageDto[]> {
// Pages store their content as JSONB. We search for any object in the
// content tree where type='syncBlock' and attrs->>'masterId' = blockId.
// Using jsonb_path_exists for recursive search.
const result = await sql<{
page_id: string;
title: string | null;
slug_id: string;
space_id: string;
workspace_id: string;
}>`
SELECT DISTINCT p.id AS page_id, p.title, p.slug_id, p.space_id, s.workspace_id
FROM pages p
JOIN spaces s ON s.id = p.space_id
WHERE s.workspace_id = ${workspaceId}
AND p.deleted_at IS NULL
AND p.content IS NOT NULL
AND jsonb_path_exists(
p.content,
'$.** ? (@.type == "syncBlock" && @.attrs.masterId == $mid)',
jsonb_build_object('mid', ${blockId})
)
`.execute(this.db);
return result.rows.map((r) => ({
pageId: r.page_id,
pageTitle: r.title,
slugId: r.slug_id,
spaceId: r.space_id,
workspaceId: r.workspace_id,
}));
}
/**
* Collect all masterId values referenced inside a given sync block's content.
* Used for cycle detection (BFS graph traversal).
*/
async findMasterIdsInContent(blockId: string, workspaceId: string): Promise<string[]> {
const block = await this.findById(blockId, workspaceId);
if (!block || !block.content) return [];
return extractMasterIdsFromProseMirror(block.content);
}
private mapRow(row: {
id: string;
workspace_id: string;
content: Record<string, unknown>;
created_by: string;
created_at: Date;
updated_at: Date;
}): SyncBlockResponseDto {
return {
id: row.id,
workspaceId: row.workspace_id,
content: row.content,
createdBy: row.created_by,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
};
}
}
/**
* Walk ProseMirror JSON and collect all masterId values from syncBlock nodes.
*/
export function extractMasterIdsFromProseMirror(
node: Record<string, unknown>,
): string[] {
const ids: string[] = [];
function walk(n: unknown): void {
if (!n || typeof n !== 'object') return;
const obj = n as Record<string, unknown>;
if (obj['type'] === 'syncBlock') {
const attrs = obj['attrs'] as Record<string, unknown> | undefined;
const masterId = attrs?.['masterId'];
if (typeof masterId === 'string' && masterId) {
ids.push(masterId);
}
}
if (Array.isArray(obj['content'])) {
for (const child of obj['content']) {
walk(child);
}
}
}
walk(node);
return ids;
}

View file

@ -0,0 +1,38 @@
import { Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
export const SYNC_BLOCK_UPDATED_EVENT = 'acadenice.sync_block.updated';
export interface SyncBlockUpdatedPayload {
masterId: string;
workspaceId: string;
}
/**
* SyncBlockBroadcastService emits an internal NestJS event that SSE
* consumers (bridge or a future NestJS SSE endpoint) can subscribe to.
*
* The service is intentionally thin: it delegates to EventEmitter2 which is
* already global. If a Redis Streams-based bridge bus is later wired in, this
* service is the single integration point to update all callers remain
* unchanged.
*
* Callers: SyncBlocksService.update(), SyncBlockPersistenceExtension.
* Consumers: future SSE controller (R4.2.b) and client hooks.
*/
@Injectable()
export class SyncBlockBroadcastService {
private readonly logger = new Logger(SyncBlockBroadcastService.name);
constructor(private readonly eventEmitter: EventEmitter2) {}
/**
* Publish a sync-block-updated event.
* Fire-and-forget errors are logged, not rethrown.
*/
publish(masterId: string, workspaceId: string): void {
const payload: SyncBlockUpdatedPayload = { masterId, workspaceId };
this.logger.debug(`sync_block.updated: ${masterId}`);
this.eventEmitter.emit(SYNC_BLOCK_UPDATED_EVENT, payload);
}
}

View file

@ -0,0 +1,165 @@
import {
ConflictException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { SyncBlockRepo, extractMasterIdsFromProseMirror } from '../repos/sync-block.repo';
import {
CreateSyncBlockDto,
UpdateSyncBlockDto,
SyncBlockResponseDto,
SyncBlockUsageDto,
} from '../dto/sync-block.dto';
import { SyncBlockBroadcastService } from './sync-block-broadcast.service';
const CYCLE_BFS_MAX_DEPTH = 5;
/**
* SyncBlocksService orchestration layer for sync blocks (R4.2).
*
* Responsibilities:
* - CRUD with RBAC enforcement (workspace-scoped; caller passes userId).
* - Cycle detection before content update (direct + indirect BFS).
* - Broadcast on update so SSE clients invalidate their cached content.
*
* Cycle detection policy:
* A syncBlock whose content embeds a syncBlock node creates a reference
* graph. We forbid cycles at depth <= CYCLE_BFS_MAX_DEPTH (5). Beyond
* that, performance degrades and the UX is pathological anyway.
* 409 CYCLIC_SYNC_BLOCK is returned when a cycle is detected.
*/
@Injectable()
export class SyncBlocksService {
private readonly logger = new Logger(SyncBlocksService.name);
constructor(
private readonly repo: SyncBlockRepo,
private readonly broadcast: SyncBlockBroadcastService,
) {}
async create(
workspaceId: string,
userId: string,
dto: CreateSyncBlockDto,
): Promise<SyncBlockResponseDto> {
return this.repo.create(workspaceId, userId, dto.content ?? {});
}
async findById(
id: string,
workspaceId: string,
): Promise<SyncBlockResponseDto> {
const block = await this.repo.findById(id, workspaceId);
if (!block) {
throw new NotFoundException(`Sync block ${id} not found`);
}
return block;
}
async update(
id: string,
workspaceId: string,
userId: string,
dto: UpdateSyncBlockDto,
): Promise<SyncBlockResponseDto> {
// Ensure it exists.
const existing = await this.repo.findById(id, workspaceId);
if (!existing) {
throw new NotFoundException(`Sync block ${id} not found`);
}
// Cycle detection before persisting.
await this.assertNoCycle(id, workspaceId, dto.content);
const updated = await this.repo.updateContent(id, workspaceId, dto.content);
if (!updated) {
throw new NotFoundException(`Sync block ${id} not found after update`);
}
// Fire-and-forget broadcast.
this.broadcast.publish(id, workspaceId);
return updated;
}
async delete(
id: string,
workspaceId: string,
userId: string,
): Promise<void> {
const deleted = await this.repo.delete(id, workspaceId);
if (!deleted) {
throw new NotFoundException(`Sync block ${id} not found`);
}
}
async findUsages(
id: string,
workspaceId: string,
): Promise<SyncBlockUsageDto[]> {
// Verify it exists first.
const block = await this.repo.findById(id, workspaceId);
if (!block) {
throw new NotFoundException(`Sync block ${id} not found`);
}
return this.repo.findUsages(id, workspaceId);
}
/**
* BFS cycle detection.
*
* Starting from the candidate content (not yet persisted), collect all
* referenced masterIds. Then traverse their content recursively up to
* CYCLE_BFS_MAX_DEPTH levels. If `selfId` appears anywhere in the graph,
* throw 409 CYCLIC_SYNC_BLOCK.
*
* Direct cycle: A embeds A.
* Indirect cycle: A embeds B, B embeds A (or via further hops).
*/
private async assertNoCycle(
selfId: string,
workspaceId: string,
candidateContent: Record<string, unknown>,
): Promise<void> {
// Direct cycle check first (fast path).
const directRefs = extractMasterIdsFromProseMirror(candidateContent);
if (directRefs.includes(selfId)) {
throw new ConflictException(
'Sync block cannot reference itself (direct cycle)',
'CYCLIC_SYNC_BLOCK',
);
}
// BFS through the reference graph.
const visited = new Set<string>();
const queue: string[] = [...directRefs];
let depth = 0;
while (queue.length > 0 && depth < CYCLE_BFS_MAX_DEPTH) {
depth++;
const current = queue.splice(0, queue.length);
for (const masterId of current) {
if (visited.has(masterId)) continue;
visited.add(masterId);
if (masterId === selfId) {
throw new ConflictException(
`Sync block cycle detected at depth ${depth}`,
'CYCLIC_SYNC_BLOCK',
);
}
const refs = await this.repo.findMasterIdsInContent(masterId, workspaceId);
for (const ref of refs) {
if (!visited.has(ref)) {
queue.push(ref);
}
}
}
}
}
}

View file

@ -0,0 +1,39 @@
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
SyncBlockBroadcastService,
SYNC_BLOCK_UPDATED_EVENT,
} from '../services/sync-block-broadcast.service';
describe('SyncBlockBroadcastService', () => {
let emitter: EventEmitter2;
let service: SyncBlockBroadcastService;
beforeEach(() => {
emitter = new EventEmitter2();
service = new SyncBlockBroadcastService(emitter);
});
it('emits the correct event name', (done) => {
emitter.once(SYNC_BLOCK_UPDATED_EVENT, (payload) => {
expect(payload.masterId).toBe('block-1');
expect(payload.workspaceId).toBe('ws-1');
done();
});
service.publish('block-1', 'ws-1');
});
it('does not throw when no listeners are registered', () => {
expect(() => service.publish('block-2', 'ws-2')).not.toThrow();
});
it('publishes the event with the correct payload shape', () => {
const received: any[] = [];
emitter.on(SYNC_BLOCK_UPDATED_EVENT, (p) => received.push(p));
service.publish('block-x', 'ws-x');
expect(received).toHaveLength(1);
expect(received[0]).toMatchObject({ masterId: 'block-x', workspaceId: 'ws-x' });
});
});

View file

@ -0,0 +1,60 @@
import { extractMasterIdsFromProseMirror } from '../repos/sync-block.repo';
/**
* Unit tests for the extractMasterIdsFromProseMirror utility.
* SyncBlockRepo DB methods require a live Kysely instance and are tested
* via integration tests (not unit tests). The pure extractor is testable here.
*/
describe('extractMasterIdsFromProseMirror', () => {
it('returns empty array for empty doc', () => {
expect(extractMasterIdsFromProseMirror({ type: 'doc', content: [] })).toEqual([]);
});
it('extracts masterId from a top-level syncBlock node', () => {
const doc = {
type: 'doc',
content: [
{ type: 'syncBlock', attrs: { masterId: 'uuid-1' } },
],
};
expect(extractMasterIdsFromProseMirror(doc)).toEqual(['uuid-1']);
});
it('extracts masterIds from nested syncBlock nodes', () => {
const doc = {
type: 'doc',
content: [
{
type: 'blockquote',
content: [
{ type: 'syncBlock', attrs: { masterId: 'uuid-2' } },
],
},
{ type: 'syncBlock', attrs: { masterId: 'uuid-3' } },
],
};
const ids = extractMasterIdsFromProseMirror(doc);
expect(ids).toContain('uuid-2');
expect(ids).toContain('uuid-3');
expect(ids).toHaveLength(2);
});
it('ignores syncBlock nodes without masterId attr', () => {
const doc = {
type: 'doc',
content: [{ type: 'syncBlock', attrs: {} }],
};
expect(extractMasterIdsFromProseMirror(doc)).toEqual([]);
});
it('handles non-syncBlock node types without error', () => {
const doc = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] },
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Title' }] },
],
};
expect(extractMasterIdsFromProseMirror(doc)).toEqual([]);
});
});

View file

@ -0,0 +1,141 @@
import { Test } from '@nestjs/testing';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { SyncBlocksController } from '../controllers/sync-blocks.controller';
import { SyncBlocksService } from '../services/sync-blocks.service';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
const mockUser = { id: 'user-1', name: 'Alice' } as any;
const mockWorkspace = { id: 'ws-1', name: 'Acadenice' } as any;
const blockFixture = {
id: 'block-uuid-1',
workspaceId: 'ws-1',
content: { type: 'doc', content: [] },
createdBy: 'user-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const mockService = {
create: jest.fn().mockResolvedValue(blockFixture),
findById: jest.fn().mockResolvedValue(blockFixture),
update: jest.fn().mockResolvedValue({ ...blockFixture, content: { type: 'doc', content: [{ type: 'paragraph' }] } }),
delete: jest.fn().mockResolvedValue(undefined),
findUsages: jest.fn().mockResolvedValue([]),
};
describe('SyncBlocksController', () => {
let controller: SyncBlocksController;
let service: typeof mockService;
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [SyncBlocksController],
providers: [{ provide: SyncBlocksService, useValue: mockService }],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get(SyncBlocksController);
service = module.get(SyncBlocksService);
jest.clearAllMocks();
});
describe('create', () => {
it('creates a sync block and returns it', async () => {
mockService.create.mockResolvedValueOnce(blockFixture);
const dto = { content: { type: 'doc', content: [] } };
const result = await controller.create(dto as any, mockUser, mockWorkspace);
expect(result).toEqual(blockFixture);
expect(service.create).toHaveBeenCalledWith('ws-1', 'user-1', dto);
});
it('creates with empty content when none provided', async () => {
mockService.create.mockResolvedValueOnce({ ...blockFixture, content: {} });
const dto = {};
const result = await controller.create(dto as any, mockUser, mockWorkspace);
expect(result.content).toEqual({});
});
});
describe('findOne', () => {
it('returns the block by id', async () => {
mockService.findById.mockResolvedValueOnce(blockFixture);
const result = await controller.findOne('block-uuid-1', mockWorkspace);
expect(result).toEqual(blockFixture);
expect(service.findById).toHaveBeenCalledWith('block-uuid-1', 'ws-1');
});
it('throws NotFoundException when block does not exist', async () => {
mockService.findById.mockRejectedValueOnce(new NotFoundException('not found'));
await expect(controller.findOne('missing-id', mockWorkspace)).rejects.toThrow(NotFoundException);
});
});
describe('update', () => {
it('updates block content', async () => {
const updated = { ...blockFixture, content: { type: 'doc', content: [{ type: 'paragraph' }] } };
mockService.update.mockResolvedValueOnce(updated);
const result = await controller.update(
'block-uuid-1',
{ content: { type: 'doc', content: [{ type: 'paragraph' }] } } as any,
mockUser,
mockWorkspace,
);
expect(result.content).toEqual({ type: 'doc', content: [{ type: 'paragraph' }] });
});
it('throws ConflictException when cycle detected', async () => {
mockService.update.mockRejectedValueOnce(new ConflictException('cycle', 'CYCLIC_SYNC_BLOCK'));
await expect(
controller.update('block-uuid-1', { content: { type: 'doc' } } as any, mockUser, mockWorkspace),
).rejects.toThrow(ConflictException);
});
});
describe('remove', () => {
it('deletes the block', async () => {
mockService.delete.mockResolvedValueOnce(undefined);
await expect(controller.remove('block-uuid-1', mockUser, mockWorkspace)).resolves.toBeUndefined();
expect(service.delete).toHaveBeenCalledWith('block-uuid-1', 'ws-1', 'user-1');
});
it('throws NotFoundException for unknown block', async () => {
mockService.delete.mockRejectedValueOnce(new NotFoundException());
await expect(controller.remove('missing', mockUser, mockWorkspace)).rejects.toThrow(NotFoundException);
});
});
describe('usages', () => {
it('returns empty array when no pages reference the block', async () => {
mockService.findUsages.mockResolvedValueOnce([]);
const result = await controller.usages('block-uuid-1', mockWorkspace);
expect(result).toEqual([]);
});
it('returns page references when found', async () => {
const usage = { pageId: 'page-1', pageTitle: 'Intro', slugId: 'intro', spaceId: 'sp-1', workspaceId: 'ws-1' };
mockService.findUsages.mockResolvedValueOnce([usage]);
const result = await controller.usages('block-uuid-1', mockWorkspace);
expect(result).toHaveLength(1);
expect(result[0].pageId).toBe('page-1');
});
});
});

View file

@ -0,0 +1,160 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { SyncBlocksService } from '../services/sync-blocks.service';
import { SyncBlockBroadcastService } from '../services/sync-block-broadcast.service';
import { SyncBlockRepo } from '../repos/sync-block.repo';
const blockFixture = {
id: 'block-1',
workspaceId: 'ws-1',
content: { type: 'doc', content: [] },
createdBy: 'user-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
function makeRepo(overrides: Partial<Record<keyof SyncBlockRepo, any>> = {}): SyncBlockRepo {
return {
create: jest.fn().mockResolvedValue(blockFixture),
findById: jest.fn().mockResolvedValue(blockFixture),
updateContent: jest.fn().mockResolvedValue(blockFixture),
updateYjsState: jest.fn().mockResolvedValue(undefined),
findYjsState: jest.fn().mockResolvedValue(null),
delete: jest.fn().mockResolvedValue(true),
findUsages: jest.fn().mockResolvedValue([]),
findMasterIdsInContent: jest.fn().mockResolvedValue([]),
...overrides,
} as unknown as SyncBlockRepo;
}
function makeBroadcast(): SyncBlockBroadcastService {
return { publish: jest.fn() } as unknown as SyncBlockBroadcastService;
}
describe('SyncBlocksService', () => {
let service: SyncBlocksService;
let repo: SyncBlockRepo;
let broadcast: SyncBlockBroadcastService;
beforeEach(() => {
repo = makeRepo();
broadcast = makeBroadcast();
service = new SyncBlocksService(repo, broadcast);
});
describe('create', () => {
it('delegates to repo.create with workspace and user', async () => {
const result = await service.create('ws-1', 'user-1', { content: {} });
expect(repo.create).toHaveBeenCalledWith('ws-1', 'user-1', {});
expect(result).toEqual(blockFixture);
});
it('passes undefined content as empty object to repo', async () => {
await service.create('ws-1', 'user-1', {});
expect(repo.create).toHaveBeenCalledWith('ws-1', 'user-1', {});
});
});
describe('findById', () => {
it('returns block when found', async () => {
const result = await service.findById('block-1', 'ws-1');
expect(result).toEqual(blockFixture);
});
it('throws NotFoundException when block does not exist', async () => {
jest.spyOn(repo, 'findById').mockResolvedValueOnce(null);
await expect(service.findById('missing', 'ws-1')).rejects.toThrow(NotFoundException);
});
});
describe('update', () => {
it('updates content and publishes broadcast', async () => {
const updated = { ...blockFixture, content: { type: 'doc', content: [{ type: 'paragraph' }] } };
jest.spyOn(repo, 'updateContent').mockResolvedValueOnce(updated);
const result = await service.update('block-1', 'ws-1', 'user-1', {
content: { type: 'doc', content: [{ type: 'paragraph' }] },
});
expect(result).toEqual(updated);
expect(broadcast.publish).toHaveBeenCalledWith('block-1', 'ws-1');
});
it('throws NotFoundException when block missing before update', async () => {
jest.spyOn(repo, 'findById').mockResolvedValueOnce(null);
await expect(
service.update('missing', 'ws-1', 'user-1', { content: {} }),
).rejects.toThrow(NotFoundException);
});
it('rejects direct cycle (block embeds itself)', async () => {
const selfRef = {
type: 'doc',
content: [{ type: 'syncBlock', attrs: { masterId: 'block-1' } }],
};
await expect(
service.update('block-1', 'ws-1', 'user-1', { content: selfRef }),
).rejects.toThrow(ConflictException);
});
it('rejects indirect cycle (A embeds B, B embeds A)', async () => {
// block-1 wants to embed block-2; block-2 already embeds block-1.
jest.spyOn(repo, 'findMasterIdsInContent').mockResolvedValueOnce(['block-1']);
const contentWithB = {
type: 'doc',
content: [{ type: 'syncBlock', attrs: { masterId: 'block-2' } }],
};
await expect(
service.update('block-1', 'ws-1', 'user-1', { content: contentWithB }),
).rejects.toThrow(ConflictException);
});
it('does not publish broadcast when update throws', async () => {
jest.spyOn(repo, 'findById').mockResolvedValueOnce(null);
await expect(service.update('block-1', 'ws-1', 'user-1', { content: {} })).rejects.toThrow();
expect(broadcast.publish).not.toHaveBeenCalled();
});
});
describe('delete', () => {
it('deletes the block', async () => {
await service.delete('block-1', 'ws-1', 'user-1');
expect(repo.delete).toHaveBeenCalledWith('block-1', 'ws-1');
});
it('throws NotFoundException when block not found', async () => {
jest.spyOn(repo, 'delete').mockResolvedValueOnce(false);
await expect(service.delete('missing', 'ws-1', 'user-1')).rejects.toThrow(NotFoundException);
});
});
describe('findUsages', () => {
it('returns usages from repo', async () => {
const usage = { pageId: 'p1', pageTitle: 'Intro', slugId: 'intro', spaceId: 'sp-1', workspaceId: 'ws-1' };
jest.spyOn(repo, 'findUsages').mockResolvedValueOnce([usage]);
const result = await service.findUsages('block-1', 'ws-1');
expect(result).toHaveLength(1);
expect(result[0].pageId).toBe('p1');
});
it('throws NotFoundException when block not found', async () => {
jest.spyOn(repo, 'findById').mockResolvedValueOnce(null);
await expect(service.findUsages('missing', 'ws-1')).rejects.toThrow(NotFoundException);
});
it('returns empty array when no usages', async () => {
const result = await service.findUsages('block-1', 'ws-1');
expect(result).toEqual([]);
});
});
});

View file

@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { SyncBlocksController } from './controllers/sync-blocks.controller';
import { SyncBlocksService } from './services/sync-blocks.service';
import { SyncBlockBroadcastService } from './services/sync-block-broadcast.service';
import { SyncBlockRepo } from './repos/sync-block.repo';
/**
* AcadeniceSync BlocksModule (R4.2).
*
* Provides:
* - SyncBlockRepo : raw SQL access to acadenice_sync_block
* - SyncBlocksService : CRUD + cycle detection + broadcast trigger
* - SyncBlockBroadcastService : EventEmitter2 publish on update
* - SyncBlocksController : REST endpoints
*
* Dependencies:
* - KyselyDB is global (AppModule via nestjs-kysely @Global)
* - EventEmitter2 is global (EventEmitterModule.forRoot in AppModule)
* - JwtAuthGuard is global
*
* Hocuspocus persistence for sync-block-* docs lives in
* CollaborationModule/SyncBlockPersistenceExtension (separate concern,
* imported there directly to avoid circular deps).
*/
@Module({
controllers: [SyncBlocksController],
providers: [SyncBlockRepo, SyncBlocksService, SyncBlockBroadcastService],
exports: [SyncBlockRepo, SyncBlocksService, SyncBlockBroadcastService],
})
export class AcadeniceSyncBlocksModule {}

View file

@ -36,6 +36,10 @@ import { AcadeniceTemplatesModule } from './acadenice/templates/templates.module
import { AcadeniceNotificationsModule } from './acadenice/notifications/notifications.module';
// Acadenice R3.8 — inline comments (page resolve + row threads)
import { AcadeniceCommentsModule } from './acadenice/comments/comments.module';
// Acadenice R4.2 — sync blocks (cross-page shared content)
import { AcadeniceSyncBlocksModule } from './acadenice/sync-blocks/sync-blocks.module';
// Acadenice R4.3 — Web Clipper
import { AcadeniceClipperModule } from './acadenice/clipper/clipper.module';
import { ClsMiddleware } from 'nestjs-cls';
@Module({
@ -64,6 +68,8 @@ import { ClsMiddleware } from 'nestjs-cls';
AcadeniceTemplatesModule,
AcadeniceNotificationsModule,
AcadeniceCommentsModule,
AcadeniceSyncBlocksModule,
AcadeniceClipperModule,
],
})
export class CoreModule implements NestModule {

View file

@ -0,0 +1,50 @@
import { Kysely, sql } from 'kysely';
/**
* DocAdenice sync block table (R4.2).
*
* A sync block is a master content unit that can be embedded in N pages.
* Editing the master propagates to all instances via Hocuspocus + SSE.
*
* yjs_state: binary Yjs document state used by the Hocuspocus persistence
* extension when the collab doc `sync-block-{id}` is stored. This keeps the
* CRDT merge history intact across server restarts.
*
* content: canonical ProseMirror JSON snapshot written on every REST update
* and on each Hocuspocus store. Used by the REST GET endpoint and for cycle
* detection (no live Yjs connection required).
*/
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('acadenice_sync_block')
.ifNotExists()
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.notNull().references('workspaces.id').onDelete('cascade'),
)
.addColumn('content', 'jsonb', (col) => col.notNull().defaultTo(sql`'{}'`))
.addColumn('yjs_state', 'bytea')
.addColumn('created_by', 'uuid', (col) =>
col.notNull().references('users.id').onDelete('restrict'),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createIndex('idx_sync_block_workspace')
.ifNotExists()
.on('acadenice_sync_block')
.column('workspace_id')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('acadenice_sync_block').ifExists().execute();
}

View file

@ -0,0 +1,58 @@
import { Kysely, sql } from 'kysely';
/**
* Clipper token table (R4.3).
*
* One row = one API token issued to a user for a given workspace.
* The plaintext token is returned once at creation; only the bcrypt hash
* is persisted here. Validation is a constant-time bcrypt compare.
*
* expires_at NULL means the token never expires.
* last_used_at is bumped by the auth middleware on every successful request.
*
* Idempotent: ifNotExists on every DDL statement.
*/
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('acadenice_clipper_token')
.ifNotExists()
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn('user_id', 'uuid', (col) =>
col.notNull().references('users.id').onDelete('cascade'),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.notNull().references('workspaces.id').onDelete('cascade'),
)
.addColumn('token_hash', 'text', (col) => col.notNull())
.addColumn('label', 'varchar(100)')
.addColumn('last_used_at', 'timestamptz')
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('expires_at', 'timestamptz')
.execute();
await db.schema
.createIndex('idx_clipper_token_user')
.ifNotExists()
.on('acadenice_clipper_token')
.columns(['user_id', 'workspace_id'])
.execute();
await db.schema
.createIndex('idx_clipper_token_hash')
.ifNotExists()
.unique()
.on('acadenice_clipper_token')
.column('token_hash')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.dropTable('acadenice_clipper_token')
.ifExists()
.execute();
}

281
pnpm-lock.yaml generated
View file

@ -510,6 +510,31 @@ importers:
specifier: ^2.1.8
version: 2.1.9(@types/node@22.19.1)(happy-dom@20.8.9)(jsdom@25.0.1)(less@4.2.0)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0)
apps/extension-clipper:
dependencies:
dompurify:
specifier: 3.4.1
version: 3.4.1
devDependencies:
'@crxjs/vite-plugin':
specifier: ^2.0.0-beta.28
version: 2.4.0(vite@5.4.21(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0))
'@types/chrome':
specifier: ^0.0.270
version: 0.0.270
'@types/dompurify':
specifier: ^3.0.5
version: 3.2.0
typescript:
specifier: ^5.4.0
version: 5.9.3
vite:
specifier: ^5.0.0
version: 5.4.21(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0)
vitest:
specifier: ^2.0.0
version: 2.1.9(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0)
apps/server:
dependencies:
'@ai-sdk/google':
@ -1835,6 +1860,11 @@ packages:
peerDependencies:
commander: 11.1.x
'@crxjs/vite-plugin@2.4.0':
resolution: {integrity: sha512-bDLdq0W2V1SkMQDJjrcYyjK9/uKtdl4joT7GRImcootCjZdKRiRYt+cv9z8tJoU/tK3o1lX48LTqN7JMsk5AQg==}
peerDependencies:
vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
@ -3275,6 +3305,18 @@ packages:
resolution: {integrity: sha512-pBm+iFjv9eihcgeJuSUs4c0AuX1QEFdHwP8w1iaWCfDzXdeWZxUBU5HT2bY2S4dvNutcy+A9hYsH7ZLBGtgwDg==}
engines: {node: '>= 18'}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
'@nodelib/fs.stat@2.0.5':
resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
engines: {node: '>= 8'}
'@nodelib/fs.walk@1.2.8':
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@nuxt/opencollective@0.4.1':
resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==}
engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'}
@ -4280,6 +4322,10 @@ packages:
'@rolldown/pluginutils@1.0.0-rc.7':
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
'@rollup/pluginutils@4.2.1':
resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==}
engines: {node: '>= 8.0.0'}
'@rollup/rollup-android-arm-eabi@4.60.3':
resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==}
cpu: [arm]
@ -5109,6 +5155,9 @@ packages:
'@types/bytes@3.1.5':
resolution: {integrity: sha512-VgZkrJckypj85YxEsEavcMmmSOIzkUHqWmM4CCyia5dc54YwsXzJ5uT4fYxBQNEXx+oF1krlhgCbvfubXqZYsQ==}
'@types/chrome@0.0.270':
resolution: {integrity: sha512-ADvkowV7YnJfycZZxL2brluZ6STGW+9oKG37B422UePf2PCXuFA/XdERI0T18wtuWPx0tmFeZqq6MOXVk1IC+Q==}
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
@ -5220,6 +5269,10 @@ packages:
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/dompurify@3.2.0':
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
'@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@ -5241,12 +5294,21 @@ packages:
'@types/file-saver@2.0.7':
resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==}
'@types/filesystem@0.0.36':
resolution: {integrity: sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==}
'@types/filewriter@0.0.33':
resolution: {integrity: sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==}
'@types/fs-extra@11.0.4':
resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==}
'@types/geojson@7946.0.14':
resolution: {integrity: sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==}
'@types/har-format@1.2.16':
resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==}
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
@ -5679,6 +5741,9 @@ packages:
'@webassemblyjs/wast-printer@1.14.1':
resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==}
'@webcomponents/custom-elements@1.6.0':
resolution: {integrity: sha512-CqTpxOlUCPWRNUPZDxT5v2NnHXA4oox612iUGnmTUGQFhZ1Gkj8kirtl/2wcF6MqX7+PqqicZzOCBKKfIn0dww==}
'@xmldom/is-dom-node@1.0.1':
resolution: {integrity: sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==}
engines: {node: '>= 16'}
@ -6337,6 +6402,9 @@ packages:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'}
convert-source-map@1.9.0:
resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@ -6965,6 +7033,9 @@ packages:
resolution: {integrity: sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==}
engines: {node: '>= 0.4'}
es-module-lexer@0.10.5:
resolution: {integrity: sha512-+7IwY/kiGAacQfY+YBhKMvEmyAJnw5grTUgjG85Pe7vcUI/6b7pZjZG8nQ7+48YhzEAEqrEgD2dCz/JIK+AYvw==}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
@ -7106,6 +7177,9 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
@ -7184,6 +7258,10 @@ packages:
resolution: {integrity: sha512-d+yU9iNQbbC098NOuMlAIth/g+owbpX/uuOkH/DQcC2fMMyjOlX292Op29DrUKq388m4UUyOdWakUH/msGypOg==}
engines: {node: '>=6.0.0'}
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
@ -7513,6 +7591,10 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
@ -8654,6 +8736,10 @@ packages:
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
mermaid@11.13.0:
resolution: {integrity: sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==}
@ -8855,6 +8941,9 @@ packages:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
node-html-parser@7.1.0:
resolution: {integrity: sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==}
node-int64@0.4.0:
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
@ -9501,6 +9590,9 @@ packages:
query-selector-shadow-dom@1.0.1:
resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
@ -9633,6 +9725,10 @@ packages:
react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-refresh@0.13.0:
resolution: {integrity: sha512-XP8A9BT0CpRBD+NYLLeIhld/RqG9+gktUjW1FkE+Vm7OCinbG1SshcK5tb9ls4kzvjZr9mOQc7HYgBngEyPAXg==}
engines: {node: '>=0.10.0'}
react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'}
@ -9841,6 +9937,11 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
rollup@2.79.2:
resolution: {integrity: sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==}
engines: {node: '>=10.0.0'}
hasBin: true
rollup@4.60.3:
resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@ -9865,9 +9966,15 @@ packages:
rrweb-cssom@0.8.0:
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
rw@1.3.3:
resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
rxjs@7.5.7:
resolution: {integrity: sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==}
rxjs@7.8.1:
resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==}
@ -12583,6 +12690,28 @@ snapshots:
dependencies:
commander: 11.1.0
'@crxjs/vite-plugin@2.4.0(vite@5.4.21(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0))':
dependencies:
'@rollup/pluginutils': 4.2.1
'@webcomponents/custom-elements': 1.6.0
acorn-walk: 8.3.2
convert-source-map: 1.9.0
debug: 4.4.3
es-module-lexer: 0.10.5
fast-glob: 3.3.3
fs-extra: 10.1.0
jsesc: 3.0.2
magic-string: 0.30.17
node-html-parser: 7.1.0
pathe: 2.0.3
picocolors: 1.1.1
react-refresh: 0.13.0
rollup: 2.79.2
rxjs: 7.5.7
vite: 5.4.21(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0)
transitivePeerDependencies:
- supports-color
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
@ -14015,6 +14144,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
run-parallel: 1.2.0
'@nodelib/fs.stat@2.0.5': {}
'@nodelib/fs.walk@1.2.8':
dependencies:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.17.1
'@nuxt/opencollective@0.4.1':
dependencies:
consola: 3.4.2
@ -15044,6 +15185,11 @@ snapshots:
'@rolldown/pluginutils@1.0.0-rc.7': {}
'@rollup/pluginutils@4.2.1':
dependencies:
estree-walker: 2.0.2
picomatch: 2.3.2
'@rollup/rollup-android-arm-eabi@4.60.3':
optional: true
@ -15949,6 +16095,11 @@ snapshots:
'@types/bytes@3.1.5': {}
'@types/chrome@0.0.270':
dependencies:
'@types/filesystem': 0.0.36
'@types/har-format': 1.2.16
'@types/connect@3.4.38':
dependencies:
'@types/node': 25.5.0
@ -16084,6 +16235,10 @@ snapshots:
dependencies:
'@types/ms': 2.1.0
'@types/dompurify@3.2.0':
dependencies:
dompurify: 3.4.1
'@types/eslint-scope@3.7.7':
dependencies:
'@types/eslint': 8.56.10
@ -16119,6 +16274,12 @@ snapshots:
'@types/file-saver@2.0.7': {}
'@types/filesystem@0.0.36':
dependencies:
'@types/filewriter': 0.0.33
'@types/filewriter@0.0.33': {}
'@types/fs-extra@11.0.4':
dependencies:
'@types/jsonfile': 6.1.4
@ -16126,6 +16287,8 @@ snapshots:
'@types/geojson@7946.0.14': {}
'@types/har-format@1.2.16': {}
'@types/hast@3.0.4':
dependencies:
'@types/unist': 3.0.2
@ -16510,6 +16673,14 @@ snapshots:
optionalDependencies:
vite: 5.4.21(@types/node@22.19.1)(less@4.2.0)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0)
'@vitest/mocker@2.1.9(vite@5.4.21(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0))':
dependencies:
'@vitest/spy': 2.1.9
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
vite: 5.4.21(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0)
'@vitest/pretty-format@2.1.9':
dependencies:
tinyrainbow: 1.2.0
@ -16611,6 +16782,8 @@ snapshots:
'@webassemblyjs/ast': 1.14.1
'@xtuc/long': 4.2.2
'@webcomponents/custom-elements@1.6.0': {}
'@xmldom/is-dom-node@1.0.1': {}
'@xmldom/xmldom@0.8.13': {}
@ -17351,6 +17524,8 @@ snapshots:
content-type@1.0.5: {}
convert-source-map@1.9.0: {}
convert-source-map@2.0.0: {}
cookie-signature@1.2.2: {}
@ -18101,6 +18276,8 @@ snapshots:
math-intrinsics: 1.1.0
safe-array-concat: 1.1.3
es-module-lexer@0.10.5: {}
es-module-lexer@1.7.0: {}
es-module-lexer@2.0.0: {}
@ -18342,6 +18519,8 @@ snapshots:
estraverse@5.3.0: {}
estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
@ -18447,6 +18626,14 @@ snapshots:
fast-equals@5.3.4: {}
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
'@nodelib/fs.walk': 1.2.8
glob-parent: 5.1.2
merge2: 1.4.1
micromatch: 4.0.8
fast-json-stable-stringify@2.1.0: {}
fast-json-stringify@6.0.1:
@ -18830,6 +19017,8 @@ snapshots:
dependencies:
function-bind: 1.1.2
he@1.2.0: {}
help-me@5.0.0: {}
hermes-estree@0.25.1: {}
@ -20126,6 +20315,8 @@ snapshots:
merge-stream@2.0.0: {}
merge2@1.4.1: {}
mermaid@11.13.0:
dependencies:
'@braintree/sanitize-url': 7.1.2
@ -20302,6 +20493,11 @@ snapshots:
node-gyp-build@4.8.4: {}
node-html-parser@7.1.0:
dependencies:
css-select: 5.1.0
he: 1.2.0
node-int64@0.4.0: {}
node-releases@2.0.27: {}
@ -21076,6 +21272,8 @@ snapshots:
query-selector-shadow-dom@1.0.1: {}
queue-microtask@1.2.3: {}
quick-format-unescaped@4.0.4: {}
radix-ui@1.4.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
@ -21275,6 +21473,8 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-refresh@0.13.0: {}
react-remove-scroll-bar@2.3.8(@types/react@18.3.12)(react@18.3.1):
dependencies:
react: 18.3.1
@ -21514,6 +21714,10 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12
rollup@2.79.2:
optionalDependencies:
fsevents: 2.3.3
rollup@4.60.3:
dependencies:
'@types/estree': 1.0.8
@ -21575,8 +21779,16 @@ snapshots:
rrweb-cssom@0.8.0: {}
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
rw@1.3.3: {}
rxjs@7.5.7:
dependencies:
tslib: 2.8.1
rxjs@7.8.1:
dependencies:
tslib: 2.8.1
@ -22505,6 +22717,24 @@ snapshots:
- supports-color
- terser
vite-node@2.1.9(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 1.1.2
vite: 5.4.21(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
vite@5.4.21(@types/node@22.19.1)(less@4.2.0)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0):
dependencies:
esbuild: 0.21.5
@ -22518,6 +22748,20 @@ snapshots:
sugarss: 5.0.1(postcss@8.5.12)
terser: 5.39.0
vite@5.4.21(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0):
dependencies:
esbuild: 0.21.5
postcss: 8.5.12
rollup: 4.60.3
optionalDependencies:
'@types/node': 25.5.0
fsevents: 2.3.3
less: 4.2.0
lightningcss: 1.32.0
sass: 1.51.0
sugarss: 5.0.1(postcss@8.5.12)
terser: 5.39.0
vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
lightningcss: 1.32.0
@ -22573,6 +22817,43 @@ snapshots:
- supports-color
- terser
vitest@2.1.9(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0):
dependencies:
'@vitest/expect': 2.1.9
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0))
'@vitest/pretty-format': 2.1.9
'@vitest/runner': 2.1.9
'@vitest/snapshot': 2.1.9
'@vitest/spy': 2.1.9
'@vitest/utils': 2.1.9
chai: 5.3.3
debug: 4.4.3
expect-type: 1.3.0
magic-string: 0.30.17
pathe: 1.1.2
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinypool: 1.1.1
tinyrainbow: 1.2.0
vite: 5.4.21(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0)
vite-node: 2.1.9(@types/node@25.5.0)(less@4.2.0)(lightningcss@1.32.0)(sass@1.51.0)(sugarss@5.0.1(postcss@8.5.12))(terser@5.39.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.5.0
happy-dom: 20.8.9
jsdom: 26.1.0
transitivePeerDependencies:
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
void-elements@3.1.0: {}
vscode-jsonrpc@8.2.0: {}