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:
parent
b53ab5043f
commit
23a85267bf
60 changed files with 5298 additions and 0 deletions
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
4
apps/extension-clipper/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.zip
|
||||
.vite/
|
||||
75
apps/extension-clipper/README.md
Normal file
75
apps/extension-clipper/README.md
Normal 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.
|
||||
38
apps/extension-clipper/manifest.json
Normal file
38
apps/extension-clipper/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
24
apps/extension-clipper/package.json
Normal file
24
apps/extension-clipper/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
41
apps/extension-clipper/src/background/background.ts
Normal file
41
apps/extension-clipper/src/background/background.ts
Normal 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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
69
apps/extension-clipper/src/content/content.ts
Normal file
69
apps/extension-clipper/src/content/content.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
114
apps/extension-clipper/src/i18n/messages.ts
Normal file
114
apps/extension-clipper/src/i18n/messages.ts
Normal 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];
|
||||
}
|
||||
114
apps/extension-clipper/src/lib/api-client.ts
Normal file
114
apps/extension-clipper/src/lib/api-client.ts
Normal 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 }));
|
||||
}
|
||||
67
apps/extension-clipper/src/lib/html-extractor.ts
Normal file
67
apps/extension-clipper/src/lib/html-extractor.ts
Normal 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;
|
||||
}
|
||||
30
apps/extension-clipper/src/lib/storage.ts
Normal file
30
apps/extension-clipper/src/lib/storage.ts
Normal 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);
|
||||
}
|
||||
218
apps/extension-clipper/src/popup/popup.css
Normal file
218
apps/extension-clipper/src/popup/popup.css
Normal 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); }
|
||||
}
|
||||
13
apps/extension-clipper/src/popup/popup.html
Normal file
13
apps/extension-clipper/src/popup/popup.html
Normal 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>
|
||||
303
apps/extension-clipper/src/popup/popup.ts
Normal file
303
apps/extension-clipper/src/popup/popup.ts
Normal 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);
|
||||
});
|
||||
110
apps/extension-clipper/tests/api-client.test.ts
Normal file
110
apps/extension-clipper/tests/api-client.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
102
apps/extension-clipper/tests/html-extractor.test.ts
Normal file
102
apps/extension-clipper/tests/html-extractor.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
39
apps/extension-clipper/tests/i18n.test.ts
Normal file
39
apps/extension-clipper/tests/i18n.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
17
apps/extension-clipper/tsconfig.json
Normal file
17
apps/extension-clipper/tsconfig.json
Normal 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"]
|
||||
}
|
||||
19
apps/extension-clipper/vite.config.ts
Normal file
19
apps/extension-clipper/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
24
apps/server/src/core/acadenice/clipper/clipper.module.ts
Normal file
24
apps/server/src/core/acadenice/clipper/clipper.module.ts
Normal 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 {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
33
apps/server/src/core/acadenice/clipper/dto/import.dto.ts
Normal file
33
apps/server/src/core/acadenice/clipper/dto/import.dto.ts
Normal 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>;
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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
281
pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue