diff --git a/rust/crates/core-protocol/src/kernel.rs b/rust/crates/core-protocol/src/kernel.rs index f38a0b36..6537e976 100644 --- a/rust/crates/core-protocol/src/kernel.rs +++ b/rust/crates/core-protocol/src/kernel.rs @@ -181,6 +181,91 @@ pub struct KernelObjectIdentity { pub asset_id: Option, } +/// DocumentBuffer 打开状态 — 表达当前编辑器缓冲区对文件的打开态、dirty 状态和文件版本仲裁。 +/// +/// 五种状态:Clean(未修改)、Dirty(已修改未保存)、Stale(版本过时)、 +/// ExternalModified(外部已修改)、Deleted(外部已删除)。 +/// +/// Phase A2 收口后,tiptap autosave、AI 写入、外部 watcher 均围绕此状态仲裁。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DocBufferDirtyState { + Clean, + Dirty, + Stale, + ExternalModified, + Deleted, +} + +impl Default for DocBufferDirtyState { + fn default() -> Self { + Self::Clean + } +} + +/// DocumentBuffer 最小字段模型 — 表示一个已打开文档的缓冲区状态。 +/// +/// 不是持久化结构,而是运行时模型,用于仲裁写入冲突和外部修改检测。 +/// 每个打开的编辑器(tiptap / AI session / external editor)对应一个实例。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DocumentBuffer { + /// 文档在 workspace 中的统一路径身份 + pub workspace_path: ObjectWorkspacePath, + /// 最后已知文件版本(来自 aggregate.fileVersion 或 disk mtime content-hash) + pub file_version: Option, + /// 最后加载/保存时的内容 hash + pub base_content_hash: Option, + /// 当前编辑器内容 hash(dirty 时与 base 不同) + pub current_content_hash: Option, + /// 缓冲区 dirty 状态 + pub dirty_state: DocBufferDirtyState, + /// 最后加载时间戳 (ms since epoch) + pub last_loaded_at: Option, + /// 最后保存时间戳 (ms since epoch) + pub last_saved_at: Option, + /// 外部修改者 + pub external_actor: Option, +} + +impl DocumentBuffer { + pub fn is_dirty(&self) -> bool { + self.dirty_state == DocBufferDirtyState::Dirty + || self.dirty_state == DocBufferDirtyState::Stale + } + + pub fn mark_dirty(&mut self, content_hash: String) { + self.current_content_hash = Some(content_hash); + self.dirty_state = DocBufferDirtyState::Dirty; + } + + pub fn mark_saved(&mut self, file_version: String, content_hash: String) { + self.file_version = Some(file_version); + self.base_content_hash = Some(content_hash); + self.current_content_hash = None; + self.dirty_state = DocBufferDirtyState::Clean; + self.last_saved_at = Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64, + ); + } + + pub fn mark_external_modified(&mut self, actor: Option) { + self.external_actor = actor; + if self.is_dirty() { + self.dirty_state = DocBufferDirtyState::Stale; + } else { + self.dirty_state = DocBufferDirtyState::ExternalModified; + } + } + + pub fn mark_deleted(&mut self) { + self.dirty_state = DocBufferDirtyState::Deleted; + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct KernelBlockAssetRelation { @@ -190,6 +275,28 @@ pub struct KernelBlockAssetRelation { pub asset_kind: KernelProjectionAssetKind, } +/// 统一 workspace 路径身份 — 表达"哪个 workspace root 下的哪个对象/文件/资源"。 +/// +/// 这是 Phase A1 收口后的主身份类型,用于: +/// - File Tree / Page Tree / Resource Tree 的 resourceMeta +/// - 本地文件写入时的身份验证 +/// - 前端 sidebar/filetree 的统一 identity 消费 +/// +/// 相比旧的 `KernelObjectIdentity`,增加了 workspace 上下文: +/// `workspace_id`、`source_kind`、`root_uri`、`relative_path`。 +/// +/// 所有新代码应优先使用此类型;`KernelObjectIdentity` 保留为内核投影的兼容子集。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ObjectWorkspacePath { + pub workspace_id: String, + pub source_kind: WorkspaceSourceKind, + pub root_uri: String, + pub relative_path: String, + pub object_identity: KernelObjectIdentity, + pub resource_kind: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum KernelGraphDirection { @@ -817,4 +924,151 @@ mod tests { assert_eq!(request.base_content_hash, None); assert_eq!(request.editor_source, None); } + + #[test] + fn object_workspace_path_round_trip() { + let path = ObjectWorkspacePath { + workspace_id: "local-ws:user:my-space".into(), + source_kind: WorkspaceSourceKind::LocalFolder, + root_uri: "file:///mnt/Data1T/Mnote_data/users/user/workspaces/my-space".into(), + relative_path: "docs/README.md".into(), + object_identity: KernelObjectIdentity { + object_kind: KernelObjectKind::Page, + document_id: Some("local-md:docs~2FREADME.md".into()), + block_id: None, + asset_id: None, + }, + resource_kind: Some("document".into()), + }; + + let value = serde_json::to_value(&path).expect("ObjectWorkspacePath 应可序列化"); + assert_eq!(value["workspaceId"], json!("local-ws:user:my-space")); + assert_eq!(value["sourceKind"], json!("local_folder")); + assert_eq!( + value["rootUri"], + json!("file:///mnt/Data1T/Mnote_data/users/user/workspaces/my-space") + ); + assert_eq!(value["relativePath"], json!("docs/README.md")); + assert_eq!(value["objectIdentity"]["objectKind"], json!("page")); + assert_eq!( + value["objectIdentity"]["documentId"], + json!("local-md:docs~2FREADME.md") + ); + assert_eq!(value["resourceKind"], json!("document")); + + let decoded: ObjectWorkspacePath = + serde_json::from_value(value).expect("ObjectWorkspacePath 应可反序列化"); + assert_eq!(decoded, path); + } + + #[test] + fn object_workspace_path_accepts_resource_kind_null() { + #[derive(Deserialize)] + struct Minimal { + resource_kind: Option, + } + let value = serde_json::json!({ + "workspaceId": "ws_1", + "sourceKind": "convex_workspace", + "rootUri": "convex://ws", + "relativePath": "doc.md", + "objectIdentity": { + "objectKind": "page", + "documentId": "doc_1", + "blockId": null, + "assetId": null + } + }); + let path: ObjectWorkspacePath = + serde_json::from_value(value).expect("resource_kind null 应接受"); + assert_eq!(path.resource_kind, None); + } + + #[test] + fn document_buffer_round_trip() { + let identity = KernelObjectIdentity { + object_kind: KernelObjectKind::Page, + document_id: Some("local-md:doc.md".into()), + block_id: None, + asset_id: None, + }; + let path = ObjectWorkspacePath { + workspace_id: "ws_1".into(), + source_kind: WorkspaceSourceKind::LocalFolder, + root_uri: "file:///tmp/root".into(), + relative_path: "doc.md".into(), + object_identity: identity, + resource_kind: Some("document".into()), + }; + let buf = DocumentBuffer { + workspace_path: path, + file_version: Some("v1".into()), + base_content_hash: Some("sha256:abc".into()), + current_content_hash: None, + dirty_state: DocBufferDirtyState::Clean, + last_loaded_at: Some(1_700_000_000_000i64), + last_saved_at: Some(1_700_000_000_001i64), + external_actor: None, + }; + + let value = serde_json::to_value(&buf).expect("DocumentBuffer 应可序列化"); + assert_eq!(value["dirtyState"], json!("clean")); + assert_eq!(value["fileVersion"], json!("v1")); + assert_eq!(value["workspacePath"]["relativePath"], json!("doc.md")); + + let decoded: DocumentBuffer = + serde_json::from_value(value).expect("DocumentBuffer 应可反序列化"); + assert_eq!(decoded, buf); + } + + #[test] + fn document_buffer_state_transitions() { + let identity = KernelObjectIdentity { + object_kind: KernelObjectKind::Page, + document_id: Some("local-md:doc.md".into()), + block_id: None, + asset_id: None, + }; + let path = ObjectWorkspacePath { + workspace_id: "ws_1".into(), + source_kind: WorkspaceSourceKind::LocalFolder, + root_uri: "file:///tmp/root".into(), + relative_path: "doc.md".into(), + object_identity: identity, + resource_kind: Some("document".into()), + }; + let mut buf = DocumentBuffer { + workspace_path: path, + file_version: Some("v1".into()), + base_content_hash: Some("sha256:abc".into()), + current_content_hash: None, + dirty_state: DocBufferDirtyState::Clean, + last_loaded_at: None, + last_saved_at: None, + external_actor: None, + }; + + assert!(!buf.is_dirty()); + assert_eq!(buf.dirty_state, DocBufferDirtyState::Clean); + + buf.mark_dirty("sha256:dirty".into()); + assert!(buf.is_dirty()); + assert_eq!(buf.dirty_state, DocBufferDirtyState::Dirty); + + buf.mark_external_modified(Some("external-editor".into())); + assert!(buf.is_dirty()); + assert_eq!(buf.dirty_state, DocBufferDirtyState::Stale); + assert_eq!(buf.external_actor.as_deref(), Some("external-editor")); + + buf.mark_saved("v2".into(), "sha256:saved".into()); + assert!(!buf.is_dirty()); + assert_eq!(buf.dirty_state, DocBufferDirtyState::Clean); + assert_eq!(buf.file_version.as_deref(), Some("v2")); + + buf.mark_external_modified(None); + assert_eq!(buf.dirty_state, DocBufferDirtyState::ExternalModified); + + buf.mark_deleted(); + assert_eq!(buf.dirty_state, DocBufferDirtyState::Deleted); + } } diff --git a/rust/crates/core-protocol/src/lib.rs b/rust/crates/core-protocol/src/lib.rs index a21bb226..363f3b01 100644 --- a/rust/crates/core-protocol/src/lib.rs +++ b/rust/crates/core-protocol/src/lib.rs @@ -37,19 +37,20 @@ pub use editor::{ ReferenceToken, ReferenceTokenKind, ReferenceTokenStrategy, TextMark, }; pub use kernel::{ - DocumentContentResult, DocumentReadEvidenceItem, DocumentReadEvidenceKind, DocumentReadNode, - DocumentReadNodeMeta, DocumentReadNodeType, DocumentReadOutlineEntry, DocumentReadPageSubtree, - DocumentReadStats, DocumentReadSubtree, KernelAttachEdge, KernelAuditStamp, - KernelBlockAssetRelation, KernelContentPayload, KernelCreateNode, KernelDetachEdge, KernelEdge, - KernelEdgeListResult, KernelEdgeType, KernelGetNode, KernelGetSubtree, KernelGraphDirection, - KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren, KernelListEdges, - KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType, KernelObjectIdentity, - KernelObjectKind, KernelProjectionAssetKind, KernelProjectionCapability, - KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind, KernelProjectionRequest, + DocBufferDirtyState, DocumentBuffer, DocumentContentResult, DocumentReadEvidenceItem, + DocumentReadEvidenceKind, DocumentReadNode, DocumentReadNodeMeta, DocumentReadNodeType, + DocumentReadOutlineEntry, DocumentReadPageSubtree, DocumentReadStats, DocumentReadSubtree, + KernelAttachEdge, KernelAuditStamp, KernelBlockAssetRelation, KernelContentPayload, + KernelCreateNode, KernelDetachEdge, KernelEdge, KernelEdgeListResult, KernelEdgeType, + KernelGetNode, KernelGetSubtree, KernelGraphDirection, KernelGraphTraversalResult, + KernelGraphVisit, KernelListChildren, KernelListEdges, KernelMoveSubtree, KernelNode, + KernelNodeMetadata, KernelNodeType, KernelObjectIdentity, KernelObjectKind, + KernelProjectionAssetKind, KernelProjectionCapability, KernelProjectionFilter, + KernelProjectionItem, KernelProjectionKind, KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta, KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef, KernelSubtreeResult, - KernelTraverseGraph, KernelUpdateNode, PageBodyWriteRequest, WorkspaceSource, - WorkspaceSourceCapability, WorkspaceSourceKind, + KernelTraverseGraph, KernelUpdateNode, ObjectWorkspacePath, PageBodyWriteRequest, + WorkspaceSource, WorkspaceSourceCapability, WorkspaceSourceKind, }; pub use mindmap::{ MindmapAdapterProjection, MindmapAssociativeLine, MindmapCommand, MindmapKernelCapabilities, diff --git a/scripts/task456-resource-object-shell-sync-smoke.js b/scripts/task456-resource-object-shell-sync-smoke.js index 15dc028d..97195f8b 100644 --- a/scripts/task456-resource-object-shell-sync-smoke.js +++ b/scripts/task456-resource-object-shell-sync-smoke.js @@ -2,6 +2,7 @@ "use strict"; const fs = require("node:fs/promises"); +const fsSync = require("node:fs"); const path = require("node:path"); const { chromium } = require("playwright"); const { @@ -20,6 +21,10 @@ const { const TASK = "task456-resource-object-shell-sync-smoke"; const OUT_DIR = path.join(process.cwd(), "tmp", TASK); const RESULT_PATH = path.join(OUT_DIR, "result.json"); +const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; +const PROBE_DOCX_PATH = + process.env.MNOTE_ONLYOFFICE_PROBE_DOCX || + "/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx"; async function writeResult(result) { await fs.mkdir(OUT_DIR, { recursive: true }); @@ -38,6 +43,30 @@ async function createMindmap(request, workspaceId, documentId, mindmapId, title) }); } +async function uploadOfficeAsset(request, workspaceId, documentId) { + assert(fsSync.existsSync(PROBE_DOCX_PATH), `缺少探测文件:${PROBE_DOCX_PATH}`); + const buffer = await fs.readFile(PROBE_DOCX_PATH); + const response = await request.fetch(`${BASE_URL}/api/media/upload`, { + method: "POST", + multipart: { + file: { + name: "task456-office.docx", + mimeType: DOCX_MIME, + buffer, + }, + workspaceId, + documentId, + }, + timeout: UI_TIMEOUT_MS, + }); + const payload = await response.json().catch(async () => ({ raw: await response.text() })); + assert(response.ok(), `/api/media/upload 请求失败:${response.status()} ${JSON.stringify(payload)}`); + const asset = payload && payload.asset && typeof payload.asset === "object" ? payload.asset : null; + const assetId = asset && typeof asset.id === "string" ? asset.id : ""; + assert(assetId, `上传结果缺少 asset.id:${JSON.stringify(payload)}`); + return asset; +} + async function readResourceState(page, documentId, mindmapId, officeAssetId) { return await page.evaluate( ({ documentId: docId, mindmapId: mapId, officeId }) => { @@ -62,29 +91,23 @@ async function readResourceState(page, documentId, mindmapId, officeAssetId) { currentObjectIdentity: document.querySelector("[data-mnote-object-identity]")?.getAttribute("data-mnote-object-identity") || "", }; }, - { documentId, mindmapId, officeAssetId }, + { documentId, mindmapId, officeId: officeAssetId }, ); } -async function injectOfficeRow(page, documentId, assetId) { - await page.evaluate( - ({ docId, officeId }) => { - const root = document.getElementById("sidebar-file-tree-root"); - if (!(root instanceof HTMLElement)) throw new Error("缺少 file tree root"); - if (document.querySelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${CSS.escape(officeId)}"]`)) return; - const row = document.createElement("div"); - row.className = "tree-row"; - row.dataset.testid = "filetree-asset-row"; - row.dataset.assetId = officeId; - row.dataset.objectKind = "onlyoffice"; - row.dataset.objectIdentity = JSON.stringify({ objectKind: "onlyoffice", documentId: docId, assetId: officeId }); - row.innerHTML = ``; - row.querySelector("button").addEventListener("click", () => { - window.location.href = `/office/${encodeURIComponent(docId)}/${encodeURIComponent(officeId)}?fileName=task456-office.docx&fileType=docx&mode=edit`; - }); - root.appendChild(row); - }, - { docId: documentId, officeId: assetId }, +async function readObjectShellState(page) { + return await page.evaluate(() => ({ + currentObjectEditor: document.querySelector("[data-mnote-object-editor]")?.getAttribute("data-mnote-object-editor") || "", + currentObjectIdentity: document.querySelector("[data-mnote-object-identity]")?.getAttribute("data-mnote-object-identity") || "", + currentUrl: window.location.href, + })); +} + +async function readFileTreeProjection(request, workspaceId, rootNodeId) { + return await requestJson( + request, + `/api/tree/projections/file?workspaceId=${encodeURIComponent(workspaceId)}&rootNodeId=${encodeURIComponent(rootNodeId)}&depth=3`, + { method: "GET" }, ); } @@ -112,18 +135,33 @@ async function injectOfficeRow(page, documentId, assetId) { const title = `TEST-456-resource-${stamp}`; await renameDocument(context.request, doc.workspaceId, doc.documentId, title); const mindmapId = `mindmap_456_${stamp}`; - const officeAssetId = `asset_office_456_${stamp}`; await createMindmap(context.request, doc.workspaceId, doc.documentId, mindmapId, `TEST-456-mind-${stamp}`); - result.fixture = { ...doc, title, mindmapId, officeAssetId }; + const officeAsset = await uploadOfficeAsset(context.request, doc.workspaceId, doc.documentId); + const officeAssetId = officeAsset.id; + result.fixture = { ...doc, title, mindmapId, officeAssetId, officeAsset }; + result.projectionAfterUpload = await readFileTreeProjection(context.request, doc.workspaceId, doc.documentId).catch((error) => ({ + error: error instanceof Error ? error.message : String(error), + })); await openDocument(page, doc.workspaceId, doc.documentId); await openFilesystemView(page); await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"]`, { timeout: UI_TIMEOUT_MS }); - await injectOfficeRow(page, doc.documentId, officeAssetId); + await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${officeAssetId}"]`, { timeout: UI_TIMEOUT_MS }); await page.evaluate(() => { window.__task456FileRoot = document.getElementById("sidebar-file-tree-root"); }); result.before = await readResourceState(page, doc.documentId, mindmapId, officeAssetId); + result.beforeFileTreeRows = await page.evaluate(() => + Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode='filetree']")).map((row) => ({ + rowId: row.getAttribute("data-row-id") || "", + rowKind: row.getAttribute("data-row-kind") || "", + documentId: row.getAttribute("data-document-id") || "", + ownerDocumentId: row.getAttribute("data-owner-document-id") || "", + assetId: row.getAttribute("data-asset-id") || "", + objectKind: row.getAttribute("data-object-kind") || "", + title: row.querySelector(".tree-link-title")?.textContent?.trim() || "", + })), + ); assert(result.before.mindmapExists, "filetree 应显示 mindmap 资源行"); assert(result.before.officeExists, "filetree 应显示 office 资源行"); @@ -147,17 +185,17 @@ async function injectOfficeRow(page, documentId, assetId) { }); await openDocument(page, doc.workspaceId, doc.documentId); await openFilesystemView(page); - await injectOfficeRow(page, doc.documentId, officeAssetId); + await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${officeAssetId}"]`, { timeout: UI_TIMEOUT_MS }); result.afterMindmapUpdate = await readResourceState(page, doc.documentId, mindmapId, officeAssetId); assert(result.afterMindmapUpdate.mindmapExists, "mindmap 更新后 filetree 资源行不应丢失"); assert(result.afterMindmapUpdate.pageExists, "mindmap 更新后页面行不应丢失"); + const officePopupPromise = page.waitForEvent("popup", { timeout: UI_TIMEOUT_MS }); await page.click(`#sidebar-file-tree-root .tree-row[data-asset-id="${officeAssetId}"] .tree-link`, { timeout: UI_TIMEOUT_MS }); - await page.waitForURL((url) => url.pathname.includes(`/office/${encodeURIComponent(doc.documentId)}/${encodeURIComponent(officeAssetId)}`), { - timeout: UI_TIMEOUT_MS, - }); - await page.waitForSelector("[data-mnote-object-editor='onlyoffice']", { timeout: UI_TIMEOUT_MS }); - result.afterOfficeOpen = await readResourceState(page, doc.documentId, mindmapId, officeAssetId); + const officePage = await officePopupPromise; + await officePage.waitForLoadState("domcontentloaded", { timeout: UI_TIMEOUT_MS }); + await officePage.waitForSelector("[data-mnote-object-editor='onlyoffice']", { timeout: UI_TIMEOUT_MS }); + result.afterOfficeOpen = await readObjectShellState(officePage); assert(result.afterOfficeOpen.currentObjectIdentity.includes(`resource:onlyoffice:${doc.documentId}:${officeAssetId}`), "office 对象壳应暴露 resource identity"); await writeResult({ ...result, ok: true, finalUrl: page.url() });