feat: advance local-first workspace checklist
- add admin access-policy UI and local access control surfaces - add local markdown conflict resolution UI and smoke coverage - add ACP local agent changed-files audit scaffold and read-only write guard - document current P0-P2 checklist progress and verification evidence
This commit is contained in:
@@ -7,6 +7,7 @@ use crate::routes::documents::{
|
||||
DocumentMetaQuery,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_read_access, is_local_access_policy_admin_context,
|
||||
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
|
||||
resolve_local_markdown_page_aggregate,
|
||||
};
|
||||
@@ -200,6 +201,7 @@ pub async fn document_page_shell(
|
||||
secondary_workspace_id={secondary_aggregate.as_ref().map(|aggregate| aggregate.identity.workspace_id.clone()).unwrap_or_default()}
|
||||
secondary_page_subtree_json={secondary_page_subtree_json.unwrap_or_default()}
|
||||
secondary_page_options_json={secondary_page_options_json.unwrap_or_default()}
|
||||
show_admin_access_policy={is_local_access_policy_admin_context(&context)}
|
||||
/>
|
||||
});
|
||||
let hermes_settings_config_script = render_hermes_settings_config_script();
|
||||
@@ -327,21 +329,27 @@ pub(crate) fn build_editor_bootstrap_json_with_ids(
|
||||
page_aggregate_script_id: &str,
|
||||
pane_role: &str,
|
||||
) -> String {
|
||||
let normalized_source_kind = source_kind
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("convex_workspace");
|
||||
let save_endpoint = if normalized_source_kind == "local_folder" {
|
||||
"/api/page-body/write"
|
||||
} else {
|
||||
"/api/documents/save"
|
||||
};
|
||||
serde_json::to_string(&json!({
|
||||
"schema": "mnote.editor_bootstrap.v1",
|
||||
"documentId": aggregate.identity.document_id,
|
||||
"workspaceId": aggregate.identity.workspace_id,
|
||||
"paneRole": pane_role,
|
||||
"sourceKind": source_kind
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("convex_workspace"),
|
||||
"sourceKind": normalized_source_kind,
|
||||
"rootUri": root_uri
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(""),
|
||||
"pageAggregateScriptId": page_aggregate_script_id,
|
||||
"saveEndpoint": "/api/documents/save",
|
||||
"saveEndpoint": save_endpoint,
|
||||
"titleEndpoint": "/api/documents/title",
|
||||
"editorHostKind": "leptos_tiptap_island",
|
||||
"assetMode": "rust-web-leptos-tiptap-spike-island-bundle",
|
||||
@@ -1023,20 +1031,42 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const text = typeof child.text === 'string' ? child.text : '';
|
||||
if (!text) return [];
|
||||
const styles = {};
|
||||
const marks = [];
|
||||
for (const mark of Array.isArray(child.marks) ? child.marks : []) {
|
||||
if (mark?.type === 'bold') styles.bold = true;
|
||||
if (mark?.type === 'italic') styles.italic = true;
|
||||
if (mark?.type === 'underline') styles.underline = true;
|
||||
if (mark?.type === 'strike') styles.strike = true;
|
||||
if (mark?.type === 'code') styles.code = true;
|
||||
if (mark?.type === 'bold') {
|
||||
styles.bold = true;
|
||||
marks.push('bold');
|
||||
}
|
||||
if (mark?.type === 'italic') {
|
||||
styles.italic = true;
|
||||
marks.push('italic');
|
||||
}
|
||||
if (mark?.type === 'underline') {
|
||||
styles.underline = true;
|
||||
marks.push('underline');
|
||||
}
|
||||
if (mark?.type === 'strike') {
|
||||
styles.strike = true;
|
||||
marks.push('strike');
|
||||
}
|
||||
if (mark?.type === 'code') {
|
||||
styles.code = true;
|
||||
marks.push('code');
|
||||
}
|
||||
if (mark?.type === 'link') {
|
||||
const href = typeof mark?.attrs?.href === 'string' ? mark.attrs.href.trim() : '';
|
||||
if (href) styles.link = href;
|
||||
}
|
||||
}
|
||||
return [{ type: 'text', text, ...(Object.keys(styles).length ? { styles } : {}) }];
|
||||
return [{
|
||||
payload: { type: 'text', text, ...(marks.length ? { marks } : {}) },
|
||||
attrs: Object.keys(styles).length ? { styles } : {},
|
||||
type: 'text',
|
||||
text,
|
||||
...(Object.keys(styles).length ? { styles } : {}),
|
||||
}];
|
||||
}
|
||||
if (child?.type === 'hardBreak') return [{ type: 'text', text: '\n' }];
|
||||
if (child?.type === 'hardBreak') return [{ payload: { type: 'hard_break' }, attrs: {}, type: 'text', text: '\n' }];
|
||||
return inlineTextNodes(child);
|
||||
});
|
||||
}
|
||||
@@ -1133,9 +1163,28 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
: Array.isArray(block.contentNodes)
|
||||
? block.contentNodes.map((node) => {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
const text = typeof node.text === 'string' ? node.text : '';
|
||||
const payload = node.payload && typeof node.payload === 'object' ? node.payload : {};
|
||||
const text = typeof payload.text === 'string'
|
||||
? payload.text
|
||||
: payload.type === 'hard_break'
|
||||
? '\n'
|
||||
: typeof node.text === 'string'
|
||||
? node.text
|
||||
: '';
|
||||
if (!text) return null;
|
||||
return { type: 'text', text, ...(node.styles && typeof node.styles === 'object' ? { styles: node.styles } : {}) };
|
||||
const attrs = node.attrs && typeof node.attrs === 'object' ? node.attrs : {};
|
||||
const styles = attrs.styles && typeof attrs.styles === 'object'
|
||||
? attrs.styles
|
||||
: node.styles && typeof node.styles === 'object'
|
||||
? node.styles
|
||||
: null;
|
||||
const marks = Array.isArray(payload.marks) ? payload.marks : [];
|
||||
return {
|
||||
type: 'text',
|
||||
text,
|
||||
...(styles ? { styles } : {}),
|
||||
...(marks.length ? { marks } : {}),
|
||||
};
|
||||
}).filter(Boolean)
|
||||
: '',
|
||||
}));
|
||||
@@ -1144,7 +1193,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
? body.conflictDetectionKey
|
||||
: typeof body?.conflict_detection_key === 'string'
|
||||
? body.conflict_detection_key
|
||||
: null;
|
||||
: typeof body?.fileVersion === 'string'
|
||||
? body.fileVersion
|
||||
: typeof body?.file_version === 'string'
|
||||
? body.file_version
|
||||
: null;
|
||||
|
||||
const revisionFromConflictKey = (value) => {
|
||||
const match = String(value || '').match(/:(\d+)$/);
|
||||
@@ -1206,7 +1259,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
sourceKind: descriptor.sourceKind || 'convex_workspace',
|
||||
rootUri: descriptor.rootUri || '',
|
||||
pageAggregateScriptId: paneRole === 'secondary' ? '__MNOTE_SECONDARY_PAGE_AGGREGATE__' : '__MNOTE_PAGE_AGGREGATE__',
|
||||
saveEndpoint: '/api/documents/save',
|
||||
saveEndpoint: (descriptor.sourceKind || 'convex_workspace') === 'local_folder'
|
||||
? '/api/page-body/write'
|
||||
: '/api/documents/save',
|
||||
titleEndpoint: '/api/documents/title',
|
||||
editorHostKind: 'leptos_tiptap_island',
|
||||
});
|
||||
@@ -1469,7 +1524,15 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
});
|
||||
};
|
||||
|
||||
const sessionPlainText = (session) => flattenText(session.currentTiptapDocument).replace(/\s+/g, ' ').trim();
|
||||
const normalizePlainText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
const sessionPlainText = (session) => {
|
||||
const liveText = sessionViews(session)
|
||||
.map((view) => normalizePlainText(currentEditorText(view)))
|
||||
.find((text) => text);
|
||||
if (liveText) return liveText;
|
||||
return normalizePlainText(flattenText(session.currentTiptapDocument));
|
||||
};
|
||||
|
||||
const sessionHasRecentExternalSignal = (session) => (
|
||||
session.sourceKind === 'local_folder'
|
||||
@@ -1484,6 +1547,196 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
&& (Date.now() - session.lastUserInputAt) < 1500
|
||||
);
|
||||
|
||||
const fetchLatestSessionAggregate = async (session) => {
|
||||
const response = await fetch(pageAggregateUrl({
|
||||
documentId: session.documentId,
|
||||
sourceKind: session.sourceKind,
|
||||
workspaceId: session.workspaceId,
|
||||
rootUri: session.rootUri,
|
||||
}).toString(), {
|
||||
cache: 'no-store',
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) throw new Error('conflict_latest_fetch_failed_' + response.status);
|
||||
const payload = await response.json();
|
||||
const nextAggregate = payload?.result;
|
||||
if (!nextAggregate || typeof nextAggregate !== 'object') {
|
||||
throw new Error('conflict_latest_missing_aggregate');
|
||||
}
|
||||
return nextAggregate;
|
||||
};
|
||||
|
||||
const aggregatePlainText = (aggregate) => {
|
||||
const body = aggregate?.body || {};
|
||||
return flattenText(toTiptapDocument(body.content)).replace(/\n{3,}/g, '\n\n').trim();
|
||||
};
|
||||
|
||||
const clearSessionConflictSurface = (session) => {
|
||||
sessionViews(session).forEach((view) => {
|
||||
const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root;
|
||||
if (!(host instanceof HTMLElement)) return;
|
||||
host.querySelectorAll('[data-testid="mnote-editor-conflict-panel"]').forEach((node) => node.remove());
|
||||
});
|
||||
};
|
||||
|
||||
const applyAggregateSnapshotToSession = (session, nextAggregate, source) => {
|
||||
const nextBody = nextAggregate?.body || {};
|
||||
const nextPermissions = nextAggregate?.head?.permissions || {};
|
||||
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
|
||||
const nextTiptapDocument = toTiptapDocument(nextBody.content);
|
||||
const nextSerialized = JSON.stringify(nextTiptapDocument);
|
||||
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
|
||||
session.latestAggregate = nextAggregate;
|
||||
syncPageAggregateScript(session, nextAggregate);
|
||||
session.title = nextAggregate?.head?.title || session.title;
|
||||
session.currentTiptapDocument = nextTiptapDocument;
|
||||
session.currentSerialized = nextSerialized;
|
||||
session.lastPersistedSerialized = nextSerialized;
|
||||
session.revision = nextRevision;
|
||||
session.conflictDetectionKey = nextConflictKey;
|
||||
session.lastExternalConflictDetectionKey = nextConflictKey || '';
|
||||
session.readOnly = Boolean(nextPermissions.readOnly);
|
||||
session.dirty = false;
|
||||
session.hasExternalConflict = false;
|
||||
session.externalChangePending = false;
|
||||
session.lastUserInputAt = 0;
|
||||
clearSessionConflictSurface(session);
|
||||
sessionViews(session).forEach((view) => {
|
||||
if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-conflict-resolved');
|
||||
});
|
||||
setSessionStatus(session, 'synced-external-change');
|
||||
};
|
||||
|
||||
const openConflictDiffPanel = async (session, panel) => {
|
||||
const diffPanel = panel.querySelector('[data-testid="mnote-conflict-diff-panel"]');
|
||||
if (!(diffPanel instanceof HTMLElement)) return;
|
||||
diffPanel.hidden = false;
|
||||
diffPanel.replaceChildren();
|
||||
const loading = document.createElement('div');
|
||||
loading.className = 'mnote-conflict-diff-status';
|
||||
loading.textContent = '正在读取磁盘版本...';
|
||||
diffPanel.appendChild(loading);
|
||||
try {
|
||||
const latest = await fetchLatestSessionAggregate(session);
|
||||
diffPanel.replaceChildren();
|
||||
const current = document.createElement('pre');
|
||||
current.setAttribute('data-testid', 'mnote-conflict-current-text');
|
||||
current.textContent = sessionPlainText(session) || '(当前编辑器为空)';
|
||||
const disk = document.createElement('pre');
|
||||
disk.setAttribute('data-testid', 'mnote-conflict-disk-text');
|
||||
disk.textContent = aggregatePlainText(latest) || '(磁盘版本为空)';
|
||||
const currentTitle = document.createElement('h3');
|
||||
currentTitle.textContent = '当前编辑器版本';
|
||||
const diskTitle = document.createElement('h3');
|
||||
diskTitle.textContent = '磁盘版本';
|
||||
const currentBox = document.createElement('section');
|
||||
currentBox.append(currentTitle, current);
|
||||
const diskBox = document.createElement('section');
|
||||
diskBox.append(diskTitle, disk);
|
||||
diffPanel.append(currentBox, diskBox);
|
||||
} catch (error) {
|
||||
loading.textContent = error instanceof Error ? error.message : String(error);
|
||||
diffPanel.replaceChildren(loading);
|
||||
}
|
||||
};
|
||||
|
||||
const acceptDiskVersion = async (session) => {
|
||||
setSessionStatus(session, 'conflict-resolving', '正在接受磁盘版本...');
|
||||
const latest = await fetchLatestSessionAggregate(session);
|
||||
applyAggregateSnapshotToSession(session, latest, 'mnote-web-conflict-accept-disk');
|
||||
};
|
||||
|
||||
const keepCurrentEditorVersion = async (session) => {
|
||||
setSessionStatus(session, 'conflict-resolving', '正在保留当前编辑器版本...');
|
||||
const latest = await fetchLatestSessionAggregate(session);
|
||||
const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0];
|
||||
if (hydrateView) {
|
||||
const liveText = normalizePlainText(currentEditorText(hydrateView));
|
||||
if (liveText) {
|
||||
session.currentTiptapDocument = hydrateMindmapAttrsFromDom(
|
||||
textToTiptapDocument(liveText),
|
||||
hydrateView.runtimeDescriptor.root,
|
||||
);
|
||||
}
|
||||
session.currentSerialized = JSON.stringify(session.currentTiptapDocument);
|
||||
}
|
||||
const nextKey = conflictDetectionKeyFromBody(latest.body || {});
|
||||
if (nextKey) {
|
||||
session.conflictDetectionKey = nextKey;
|
||||
session.lastExternalConflictDetectionKey = nextKey;
|
||||
}
|
||||
session.hasExternalConflict = false;
|
||||
session.externalChangePending = false;
|
||||
session.saving = false;
|
||||
session.dirty = true;
|
||||
clearSessionConflictSurface(session);
|
||||
await persistSession(session);
|
||||
};
|
||||
|
||||
const renderSessionConflictSurface = (session, message) => {
|
||||
clearSessionConflictSurface(session);
|
||||
sessionViews(session).forEach((view) => {
|
||||
const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root;
|
||||
if (!(host instanceof HTMLElement)) return;
|
||||
const panel = document.createElement('section');
|
||||
panel.className = 'mnote-editor-conflict-panel';
|
||||
panel.setAttribute('data-testid', 'mnote-editor-conflict-panel');
|
||||
panel.setAttribute('role', 'status');
|
||||
panel.setAttribute('aria-live', 'polite');
|
||||
|
||||
const heading = document.createElement('h2');
|
||||
heading.textContent = '文件冲突';
|
||||
const text = document.createElement('p');
|
||||
text.textContent = message || externalConflictMessage;
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'mnote-conflict-meta';
|
||||
meta.textContent = `文件:${session.rootUri || session.documentId} · 来源:本地文件变更`;
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'mnote-conflict-actions';
|
||||
const acceptDisk = document.createElement('button');
|
||||
acceptDisk.type = 'button';
|
||||
acceptDisk.textContent = '接受磁盘版本';
|
||||
acceptDisk.setAttribute('data-testid', 'mnote-conflict-accept-disk');
|
||||
const keepCurrent = document.createElement('button');
|
||||
keepCurrent.type = 'button';
|
||||
keepCurrent.textContent = '保留当前编辑器版本';
|
||||
keepCurrent.setAttribute('data-testid', 'mnote-conflict-keep-current');
|
||||
const openDiff = document.createElement('button');
|
||||
openDiff.type = 'button';
|
||||
openDiff.textContent = '打开 diff';
|
||||
openDiff.setAttribute('data-testid', 'mnote-conflict-open-diff');
|
||||
actions.append(acceptDisk, keepCurrent, openDiff);
|
||||
const diffPanel = document.createElement('div');
|
||||
diffPanel.className = 'mnote-conflict-diff-panel';
|
||||
diffPanel.setAttribute('data-testid', 'mnote-conflict-diff-panel');
|
||||
diffPanel.hidden = true;
|
||||
panel.append(heading, text, meta, actions, diffPanel);
|
||||
|
||||
acceptDisk.addEventListener('click', () => {
|
||||
acceptDiskVersion(session).catch((error) => {
|
||||
setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error));
|
||||
renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
});
|
||||
keepCurrent.addEventListener('click', () => {
|
||||
keepCurrentEditorVersion(session).catch((error) => {
|
||||
setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error));
|
||||
renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
});
|
||||
openDiff.addEventListener('click', () => {
|
||||
openConflictDiffPanel(session, panel);
|
||||
});
|
||||
|
||||
const header = host.querySelector('.document-shell-header');
|
||||
if (header && header.parentNode) {
|
||||
header.parentNode.insertBefore(panel, header.nextSibling);
|
||||
} else {
|
||||
host.prepend(panel);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const markSessionExternalConflict = (session, message) => {
|
||||
session.externalChangePending = false;
|
||||
session.hasExternalConflict = true;
|
||||
@@ -1492,6 +1745,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
session.saveTimer = 0;
|
||||
}
|
||||
setSessionStatus(session, 'external-change-conflict', message || externalConflictMessage);
|
||||
renderSessionConflictSurface(session, message || externalConflictMessage);
|
||||
};
|
||||
|
||||
const queueSessionSave = (session) => {
|
||||
@@ -1521,21 +1775,28 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
try {
|
||||
const editorDocument = editorDocumentFromTiptapDocument({ documentId: session.documentId }, session.currentTiptapDocument);
|
||||
const content = legacyBlocksFromEditorDocument(editorDocument);
|
||||
const response = await fetch(session.saveEndpoint || '/api/documents/save', {
|
||||
const saveEndpoint = session.saveEndpoint || '/api/documents/save';
|
||||
const savePayload = {
|
||||
documentId: session.documentId,
|
||||
workspaceId: session.workspaceId,
|
||||
sourceKind: session.sourceKind,
|
||||
rootUri: session.rootUri,
|
||||
revision: session.revision,
|
||||
expectedFileVersion: session.conflictDetectionKey,
|
||||
contentFormat: 'editorBlocks',
|
||||
editorSource: 'tiptap',
|
||||
editorDocument,
|
||||
content,
|
||||
tiptapDocument: session.currentTiptapDocument,
|
||||
blockCount: editorDocument.blocks.length,
|
||||
};
|
||||
if (session.sourceKind !== 'local_folder') {
|
||||
savePayload.conflictDetectionKey = session.conflictDetectionKey;
|
||||
}
|
||||
const response = await fetch(saveEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
documentId: session.documentId,
|
||||
workspaceId: session.workspaceId,
|
||||
sourceKind: session.sourceKind,
|
||||
rootUri: session.rootUri,
|
||||
revision: session.revision,
|
||||
conflictDetectionKey: session.conflictDetectionKey,
|
||||
editorDocument,
|
||||
content,
|
||||
tiptapDocument: session.currentTiptapDocument,
|
||||
blockCount: editorDocument.blocks.length,
|
||||
}),
|
||||
body: JSON.stringify(savePayload),
|
||||
});
|
||||
const result = await response.json().catch(() => null);
|
||||
if (!response.ok || !result || result.ok !== true) {
|
||||
@@ -1550,6 +1811,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
if (typeof saved.conflictDetectionKey === 'string' && saved.conflictDetectionKey.trim()) {
|
||||
session.conflictDetectionKey = saved.conflictDetectionKey.trim();
|
||||
}
|
||||
if (typeof saved.fileVersion === 'string' && saved.fileVersion.trim()) {
|
||||
session.conflictDetectionKey = saved.fileVersion.trim();
|
||||
}
|
||||
if (session.conflictDetectionKey) session.lastExternalConflictDetectionKey = session.conflictDetectionKey;
|
||||
session.hasExternalConflict = false;
|
||||
session.externalChangePending = false;
|
||||
@@ -1912,6 +2176,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
lastPersistedSerialized: JSON.stringify(tiptapDocument),
|
||||
revision: pageBodyRevision && pageBodyRevision > 0 ? pageBodyRevision : keyRevision,
|
||||
conflictDetectionKey,
|
||||
fileVersion: typeof pageBody.fileVersion === 'string' ? pageBody.fileVersion : conflictDetectionKey,
|
||||
lastExternalConflictDetectionKey: conflictDetectionKey || '',
|
||||
readOnly: Boolean(permissions.readOnly),
|
||||
dirty: false,
|
||||
@@ -2633,6 +2898,8 @@ pub(crate) async fn build_page_aggregate_snapshot(
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
})?;
|
||||
ensure_local_workspace_read_access(context, root_uri)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
return resolve_local_markdown_page_aggregate(root_uri, document_id);
|
||||
}
|
||||
|
||||
@@ -2937,7 +3204,7 @@ mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{header, HeaderMap, Method, Request, StatusCode, Uri};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use serde_json::Value;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -2970,6 +3237,19 @@ mod tests {
|
||||
},
|
||||
"documents:getContent": {
|
||||
"content": [{"id": "block_1", "type": "paragraph", "content": []}],
|
||||
"editorDocument": {
|
||||
"documentId": "doc_1",
|
||||
"rootBlockIds": ["editor_1"],
|
||||
"blocks": [{
|
||||
"blockId": "editor_1",
|
||||
"blockType": "paragraph",
|
||||
"contentNodes": [{
|
||||
"payload": {"type": "text", "text": "来自 editorDocument 的正文"},
|
||||
"attrs": {}
|
||||
}],
|
||||
"childBlockIds": []
|
||||
}]
|
||||
},
|
||||
"revision": 7,
|
||||
"conflict_detection_key": "doc_1:7",
|
||||
"pageSubtree": {"rootNodeId": "doc_1", "outline": []}
|
||||
@@ -2982,6 +3262,30 @@ mod tests {
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
.layer(axum::middleware::from_fn(inject_test_actor))
|
||||
}
|
||||
|
||||
async fn inject_test_actor(
|
||||
mut request: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
request
|
||||
.headers_mut()
|
||||
.entry("x-mnote-actor-id")
|
||||
.or_insert(HeaderValue::from_static("user_test"));
|
||||
request
|
||||
.headers_mut()
|
||||
.entry("x-mnote-actor-type")
|
||||
.or_insert(HeaderValue::from_static("user"));
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
fn init_local_workspace(root: &std::path::Path, actor_id: &str) {
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
actor_id,
|
||||
&format!("file://{}", root.display()),
|
||||
)
|
||||
.expect("init local workspace");
|
||||
}
|
||||
|
||||
fn app_with_unreachable_convex_without_fixture() -> axum::Router {
|
||||
@@ -3137,6 +3441,18 @@ mod tests {
|
||||
assert_eq!(payload["result"]["projectionVersion"], 1);
|
||||
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
|
||||
assert_eq!(payload["result"]["body"]["revision"], 7);
|
||||
assert_eq!(
|
||||
payload["result"]["body"]["projectionSource"],
|
||||
"editorDocument"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["body"]["blockDocument"]["rootBlockIds"][0],
|
||||
"editor_1"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["body"]["blockDocument"]["blocks"][0]["text"],
|
||||
"来自 editorDocument 的正文"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3246,12 +3562,15 @@ mod tests {
|
||||
.expect("write local md");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/api/page-aggregate/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
@@ -3297,12 +3616,15 @@ mod tests {
|
||||
std::fs::write(root.join("asset.png"), b"png").expect("write asset");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/documents/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
@@ -3325,6 +3647,7 @@ mod tests {
|
||||
let html = String::from_utf8(body.to_vec()).expect("html");
|
||||
assert!(html.contains("Local Shell"));
|
||||
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
|
||||
assert!(html.contains("\"saveEndpoint\":\"/api/page-body/write\""));
|
||||
assert!(html.contains("Child Page"));
|
||||
assert!(html.contains("asset.png"));
|
||||
assert!(html.contains("data-row-kind=\"markdown\""));
|
||||
@@ -3349,6 +3672,10 @@ mod tests {
|
||||
assert!(html.contains("eventKind.includes('Remove') || eventKind.includes('Name')"));
|
||||
assert!(html.contains("command: 'replaceContent'"));
|
||||
assert!(html.contains("external-change-conflict"));
|
||||
assert!(html.contains("mnote-editor-conflict-panel"));
|
||||
assert!(html.contains("mnote-conflict-accept-disk"));
|
||||
assert!(html.contains("mnote-conflict-keep-current"));
|
||||
assert!(html.contains("mnote-conflict-open-diff"));
|
||||
assert!(!html.contains(
|
||||
"setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);"
|
||||
));
|
||||
@@ -3420,12 +3747,15 @@ mod tests {
|
||||
.expect("write local md");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/documents/local-md:docs~2Fblocks.md?sourceKind=local_folder&rootUri={root_uri}"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
@@ -3495,6 +3825,11 @@ mod tests {
|
||||
assert!(html.contains("marks.push({ type: 'link', attrs: { href } })"));
|
||||
assert!(html.contains("styles.link = href"));
|
||||
assert!(html.contains("contentNodes.map((node) => {"));
|
||||
assert!(html.contains("payload: { type: 'text', text"));
|
||||
assert!(html.contains("typeof payload.text === 'string'"));
|
||||
assert!(html.contains("payload.type === 'hard_break'"));
|
||||
assert!(html.contains("typeof body?.fileVersion === 'string'"));
|
||||
assert!(html.contains("expectedFileVersion: session.conflictDetectionKey"));
|
||||
assert!(html.contains("node?.attrs?.mnoteBlockType === 'mindmap'"));
|
||||
assert!(html.contains("blockType: 'mindmap'"));
|
||||
assert!(html.contains("props: mindmapPropsFromAttrs(node?.attrs, blockId)"));
|
||||
|
||||
Reference in New Issue
Block a user