Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 72 additions & 2 deletions backend/app/documents/codec.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,26 @@
import hashlib
import json
import re
from copy import deepcopy
from dataclasses import dataclass
from typing import Any
from uuid import UUID, uuid5

CANONICAL_DOCUMENT_SCHEMA = "novelflow.tiptap.v1"
CANONICAL_DOCUMENT_SCHEMA = "novelflow.tiptap.v2"
_HASH_SCHEMA = "novelflow.scene-version.v1"
_NODE_ID_NAMESPACE = UUID("1ef77b39-c5b7-4ad4-bb25-9aec860d8f80")
_ADDRESSABLE_NODE_TYPES = frozenset(
{
"paragraph",
"heading",
"blockquote",
"codeBlock",
"bulletList",
"orderedList",
"horizontalRule",
"listItem",
}
)
_EMPTY_DOCUMENT: dict[str, Any] = {
"type": "doc",
"content": [{"type": "paragraph"}],
Expand Down Expand Up @@ -40,11 +55,12 @@ def build_scene_document(
) -> SceneDocument:
"""Build a canonical document with Tiptap JSON as the authority."""

document = (
source_document = (
markdown_to_tiptap(content_markdown or "")
if content_json is None
else _normalize_document(content_json)
)
document = ensure_scene_node_ids(source_document)
markdown = tiptap_to_markdown(document)
submitted_markdown = _normalize_markdown(content_markdown)
if content_json is not None and submitted_markdown is not None and submitted_markdown != markdown:
Expand Down Expand Up @@ -102,6 +118,60 @@ def markdown_to_tiptap(value: str) -> dict[str, Any]:
}


def ensure_scene_node_ids(value: dict[str, Any]) -> dict[str, Any]:
"""Return a copy with unique stable UUIDs on every addressable block node."""

document = deepcopy(_normalize_document(value))
seen: set[str] = set()

def visit(node: dict[str, Any], path: tuple[int, ...]) -> None:
if node.get("type") in _ADDRESSABLE_NODE_TYPES:
attrs = node.get("attrs") or {}
if not isinstance(attrs, dict):
raise SceneDocumentError(f"{node.get('type')} attrs must be an object")
candidate = attrs.get("nodeId")
node_id = str(candidate) if candidate is not None else ""
try:
valid = str(UUID(node_id)) == node_id.lower()
except (ValueError, AttributeError):
valid = False
if not valid or node_id in seen:
identity_source = json.dumps(
{
"path": path,
"node": _without_node_ids(node),
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
node_id = str(uuid5(_NODE_ID_NAMESPACE, identity_source))
attrs["nodeId"] = node_id
node["attrs"] = attrs
seen.add(node_id)
for index, child in enumerate(_content(node)):
visit(child, (*path, index))

visit(document, ())
return document


def _without_node_ids(value: Any) -> Any:
if isinstance(value, list):
return [_without_node_ids(item) for item in value]
if not isinstance(value, dict):
return value
result: dict[str, Any] = {}
for key, item in value.items():
if key == "attrs" and isinstance(item, dict):
attrs = {name: _without_node_ids(attr) for name, attr in item.items() if name != "nodeId"}
if attrs:
result[key] = attrs
else:
result[key] = _without_node_ids(item)
return result


def _normalize_document(value: dict[str, Any]) -> dict[str, Any]:
normalized = _normalize_json_value(value)
if not isinstance(normalized, dict) or normalized.get("type") != "doc":
Expand Down
41 changes: 36 additions & 5 deletions backend/tests/test_scene_documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from contextlib import closing
from pathlib import Path
from typing import Any
from uuid import UUID

import pytest
from fastapi.testclient import TestClient
Expand All @@ -13,6 +14,7 @@
CANONICAL_DOCUMENT_SCHEMA,
SceneDocumentError,
build_scene_document,
ensure_scene_node_ids,
)


Expand Down Expand Up @@ -56,15 +58,44 @@ def test_markdown_only_input_builds_canonical_rich_document(client: TestClient)

assert version["content_markdown"] == markdown
assert version["content_text"] == "标题\n重点\n下一行\n甲\n乙"
assert version["content_json"]["content"][0] == {
"type": "heading",
"attrs": {"level": 1},
"content": [{"type": "text", "text": "标题"}],
}
heading = version["content_json"]["content"][0]
assert heading["type"] == "heading"
assert heading["attrs"]["level"] == 1
assert str(UUID(heading["attrs"]["nodeId"])) == heading["attrs"]["nodeId"]
assert heading["content"] == [{"type": "text", "text": "标题"}]
assert version["document_schema_version"] == CANONICAL_DOCUMENT_SCHEMA
assert len(version["document_hash"]) == 64


def test_stable_node_ids_are_preserved_and_duplicates_are_repaired() -> None:
existing_id = "13a4b02a-4a2e-4a7b-a88e-6e47d6c8184f"
document = {
"type": "doc",
"content": [
{"type": "paragraph", "attrs": {"nodeId": existing_id}},
{"type": "paragraph", "attrs": {"nodeId": existing_id}},
{"type": "blockquote", "content": [{"type": "paragraph"}]},
],
}

first = ensure_scene_node_ids(document)
second = ensure_scene_node_ids(first)
repeated_import = ensure_scene_node_ids(document)
node_ids = [
first["content"][0]["attrs"]["nodeId"],
first["content"][1]["attrs"]["nodeId"],
first["content"][2]["attrs"]["nodeId"],
first["content"][2]["content"][0]["attrs"]["nodeId"],
]

assert node_ids[0] == existing_id
assert len(set(node_ids)) == 4
assert all(str(UUID(node_id)) == node_id for node_id in node_ids)
assert second == first
assert repeated_import == first
assert document["content"][1]["attrs"]["nodeId"] == existing_id


def test_document_codec_rejects_unsupported_nodes_and_mismatched_projection() -> None:
with pytest.raises(SceneDocumentError, match="unsupported block node"):
build_scene_document(
Expand Down
5 changes: 3 additions & 2 deletions backend/tests/test_working_draft.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,11 @@ def test_manual_version_stores_tiptap_json_and_rejects_html_markdown(
},
)

assert version["content_json"] == content_json
assert version["content_json"]["content"][0]["content"] == content_json["content"][0]["content"]
assert "nodeId" in version["content_json"]["content"][0]["attrs"]
assert version["content_markdown"] == "**规范**正文"
assert version["content_text"] == "规范正文"
assert version["document_schema_version"] == "novelflow.tiptap.v1"
assert version["document_schema_version"] == "novelflow.tiptap.v2"
assert len(version["document_hash"]) == 64
assert invalid.status_code == 422
assert immutable.status_code == 405
Expand Down
1 change: 1 addition & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@radix-ui/react-tabs": "^1.1.17",
"@radix-ui/react-tooltip": "^1.2.12",
"@tanstack/react-query": "^5.62.7",
"@tiptap/core": "^2.10.4",
"@tiptap/pm": "^2.10.4",
"@tiptap/react": "^2.10.4",
"@tiptap/starter-kit": "^2.10.4",
Expand Down
50 changes: 50 additions & 0 deletions frontend/src/extensions/StableNodeId.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { describe, expect, it } from "vitest";

import { StableNodeId } from "./StableNodeId";

const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

describe("StableNodeId", () => {
it("preserves existing identities and assigns unique IDs to new blocks", () => {
const existingId = "13a4b02a-4a2e-4a7b-a88e-6e47d6c8184f";
const editor = new Editor({
extensions: [StarterKit, StableNodeId],
content: "",
});
editor.commands.setContent({
type: "doc",
content: [
{
type: "paragraph",
attrs: { nodeId: existingId },
content: [{ type: "text", text: "existing" }],
},
{
type: "paragraph",
content: [{ type: "text", text: "missing" }],
},
],
});

const initial = editor.getJSON();
const initialIds = initial.content?.map((node) => node.attrs?.nodeId);
expect(initialIds?.[0]).toBe(existingId);
expect(initialIds?.[1]).toMatch(UUID_PATTERN);

editor.commands.insertContentAt(editor.state.doc.content.size, {
type: "paragraph",
content: [{ type: "text", text: "new" }],
});
const updatedIds = editor
.getJSON()
.content?.map((node) => String(node.attrs?.nodeId));

expect(updatedIds?.slice(0, 2)).toEqual(initialIds);
expect(updatedIds?.[2]).toMatch(UUID_PATTERN);
expect(new Set(updatedIds).size).toBe(3);
editor.destroy();
});
});
100 changes: 100 additions & 0 deletions frontend/src/extensions/StableNodeId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";

const ADDRESSABLE_NODE_TYPES = [
"paragraph",
"heading",
"blockquote",
"codeBlock",
"bulletList",
"orderedList",
"horizontalRule",
"listItem",
];

const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

function createNodeId(): string {
if (typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, "0"));
return [
hex.slice(0, 4).join(""),
hex.slice(4, 6).join(""),
hex.slice(6, 8).join(""),
hex.slice(8, 10).join(""),
hex.slice(10).join(""),
].join("-");
}

export const StableNodeId = Extension.create({
name: "stableNodeId",

onCreate() {
this.editor.view.dispatch(
this.editor.state.tr
.setMeta("preventUpdate", true)
.setMeta("addToHistory", false),
);
},

addGlobalAttributes() {
return [
{
types: ADDRESSABLE_NODE_TYPES,
attributes: {
nodeId: {
default: null,
parseHTML: (element) => element.getAttribute("data-node-id"),
renderHTML: (attributes) =>
attributes.nodeId
? { "data-node-id": String(attributes.nodeId) }
: {},
},
},
},
];
},

addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("stableNodeId"),
appendTransaction: (transactions, _oldState, newState) => {
const seen = new Set<string>();
let transaction = newState.tr;
let changed = false;
newState.doc.descendants((node, position) => {
if (!ADDRESSABLE_NODE_TYPES.includes(node.type.name)) return;
const candidate = String(node.attrs.nodeId ?? "");
const nodeId =
UUID_PATTERN.test(candidate) && !seen.has(candidate)
? candidate
: createNodeId();
seen.add(nodeId);
if (nodeId !== candidate) {
transaction = transaction.setNodeMarkup(position, undefined, {
...node.attrs,
nodeId,
});
changed = true;
}
});
if (!changed) return null;
transaction.setMeta("addToHistory", false);
if (
transactions.some((item) => item.getMeta("preventUpdate") === true)
) {
transaction.setMeta("preventUpdate", true);
}
return transaction;
},
}),
];
},
});
Loading