Files
mnote/rust/crates/mnote-web/browser/document-session-runtime.js
T

1363 lines
60 KiB
JavaScript
Raw Normal View History

2026-05-26 01:23:27 +08:00
import {
conflictDetectionKeyFromBody,
editorDocumentFromTiptapDocument,
flattenText,
legacyBlocksFromEditorDocument,
pageBodyTiptapDocument,
revisionFromConflictKey,
toTiptapDocument,
} from './document-tiptap-conversion-runtime.js';
export const createDocumentSessionRuntime = (dependencies = {}) => {
const {
bridgeProtocol,
commandEvent,
normalizeEnvelopePayload,
pageAggregateUrl,
setStatus,
syncPageAggregateScript,
syncResourceSessionTabGuards,
} = dependencies;
const documentSessionRegistry = new Map();
const localFolderEventRegistry = new Map();
const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突';
const treeExternalConflictMessage = '当前页面已在其它窗口更新,请刷新或保存前先处理冲突';
const SESSION_RELEASE_DELAY_MS = 1200;
const LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY = 'mnote.localFolder.selfChangeSuppressions.v1';
const ensureLocalFolderSelfChangeSuppressions = () => {
const now = Date.now();
const map = window.__mnoteLocalFolderSelfChangeSuppressions instanceof Map
? window.__mnoteLocalFolderSelfChangeSuppressions
: new Map();
window.__mnoteLocalFolderSelfChangeSuppressions = map;
try {
const raw = window.sessionStorage ? window.sessionStorage.getItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY) : '';
const parsed = raw ? JSON.parse(raw) : null;
if (parsed && typeof parsed === 'object') {
Object.entries(parsed).forEach(([documentId, expiresAt]) => {
const doc = String(documentId || '').trim();
const expiry = Number(expiresAt || 0);
if (doc && Number.isFinite(expiry) && expiry > now) {
map.set(doc, expiry);
} else if (doc) {
map.delete(doc);
delete parsed[documentId];
}
});
if (window.sessionStorage) window.sessionStorage.setItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY, JSON.stringify(parsed));
}
} catch (_) {}
return map;
};
const parseLocalFolderEventPayload = (event) => {
try {
return JSON.parse(String(event?.data || '{}'));
} catch (_) {
return null;
}
};
const shouldSuppressLocalFolderSelfChange = (documentId, eventKind) => {
const doc = String(documentId || '').trim();
if (!doc) return false;
const kind = String(eventKind || '');
const suppressibleSelfWrite = kind.includes('Create')
|| kind.includes('Metadata')
|| kind.includes('Modify(Data')
|| kind.includes('Modify(Any')
|| kind.includes('Modify(Name');
if (!suppressibleSelfWrite) return false;
const suppressions = ensureLocalFolderSelfChangeSuppressions();
if (!suppressions || typeof suppressions.get !== 'function') return false;
const expiresAt = Number(suppressions.get(doc) || 0);
if (!Number.isFinite(expiresAt) || expiresAt <= 0) return false;
if (Date.now() > expiresAt) {
if (typeof suppressions.delete === 'function') suppressions.delete(doc);
return false;
}
return true;
};
const markLocalFolderSelfChangeSuppression = (session, ttlMs = 5000) => {
if (!session || session.sourceKind !== 'local_folder') return;
const doc = String(session.documentId || '').trim();
if (!doc) return;
const suppressions = ensureLocalFolderSelfChangeSuppressions();
if (!suppressions || typeof suppressions.set !== 'function') return;
const expiresAt = Date.now() + Math.max(1000, Number(ttlMs) || 5000);
suppressions.set(doc, expiresAt);
try {
if (!window.sessionStorage) return;
const raw = window.sessionStorage.getItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY);
const parsed = raw ? JSON.parse(raw) : {};
const next = parsed && typeof parsed === 'object' ? parsed : {};
next[doc] = expiresAt;
window.sessionStorage.setItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY, JSON.stringify(next));
} catch (_) {}
};
const normalizeSessionSourceKind = (bootstrap) => {
const value = typeof bootstrap?.sourceKind === 'string' ? bootstrap.sourceKind.trim() : '';
return value || 'convex_workspace';
};
const buildDocumentSessionKey = (bootstrap) => {
const sourceKind = normalizeSessionSourceKind(bootstrap);
const scope = sourceKind === 'local_folder'
? String(bootstrap?.rootUri || '').trim()
: String(bootstrap?.workspaceId || '').trim();
return `${sourceKind}:${scope}:${String(bootstrap?.documentId || '').trim()}`;
};
const sessionViews = (session) => Array.from(session.views.values());
const localFolderEventChannelKey = (session) => `${String(session?.rootUri || '').trim()}#${String(session?.documentId || '').trim()}`;
const detachSessionFromLocalFolderChannel = (session) => {
const channel = session.localFolderChannel;
if (!channel) return;
channel.sessions.delete(session.key);
if (channel.sessions.size === 0) {
try {
channel.eventSource.close();
} catch (_) {
// noop
}
localFolderEventRegistry.delete(channel.key);
}
session.localFolderChannel = null;
};
const releaseDocumentSession = (session) => {
if (session.saveTimer) {
window.clearTimeout(session.saveTimer);
session.saveTimer = 0;
}
if (session.externalRefreshTimer) {
window.clearTimeout(session.externalRefreshTimer);
session.externalRefreshTimer = 0;
}
if (session.releaseTimer) {
window.clearTimeout(session.releaseTimer);
session.releaseTimer = 0;
}
detachSessionFromLocalFolderChannel(session);
if (documentSessionRegistry.get(session.key) === session) {
documentSessionRegistry.delete(session.key);
}
};
const scheduleDocumentSessionRelease = (session) => {
if (session.views.size > 0) return;
if (session.releaseTimer) window.clearTimeout(session.releaseTimer);
session.releaseTimer = window.setTimeout(() => {
session.releaseTimer = 0;
if (session.views.size === 0) {
releaseDocumentSession(session);
}
}, SESSION_RELEASE_DELAY_MS);
};
const cancelDocumentSessionRelease = (session) => {
if (!session.releaseTimer) return;
window.clearTimeout(session.releaseTimer);
session.releaseTimer = 0;
};
window.__mnoteDebugDocumentSessions = {
snapshot: () => ({
sessionCount: documentSessionRegistry.size,
sessions: Array.from(documentSessionRegistry.entries()).map(([key, session]) => ({
key,
documentId: session.documentId,
sourceKind: session.sourceKind,
rootUri: session.rootUri,
workspaceId: session.workspaceId,
viewCount: session.views.size,
status: session.status,
conflictDetectionKey: session.conflictDetectionKey || '',
lastExternalConflictDetectionKey: session.lastExternalConflictDetectionKey || '',
lastExternalConflictEnvelope: session.lastExternalConflictEnvelope || null,
dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'),
})),
localFolderChannelCount: localFolderEventRegistry.size,
localFolderRoots: Array.from(localFolderEventRegistry.keys()),
}),
};
const currentEditorText = (view) => {
const editor = view.runtimeDescriptor.root.querySelector('.editor-surface .ProseMirror');
return editor?.textContent || '';
};
const findScrollableEditorContainer = (view) => {
let current = view.runtimeDescriptor.root.querySelector('.editor-surface .ProseMirror');
while (current instanceof HTMLElement) {
if (current.scrollHeight > current.clientHeight + 8) {
return current;
}
current = current.parentElement;
}
return null;
};
const preserveViewScrollPosition = (view, operation) => {
const scrollable = findScrollableEditorContainer(view);
const scrollTop = scrollable instanceof HTMLElement ? scrollable.scrollTop : null;
const viewportTop = window.scrollY;
const viewportLeft = window.scrollX;
operation();
const restore = () => {
const current = findScrollableEditorContainer(view);
if (current instanceof HTMLElement && scrollTop != null) {
current.scrollTop = scrollTop;
}
if (Number.isFinite(viewportTop) || Number.isFinite(viewportLeft)) {
window.scrollTo({
top: Number.isFinite(viewportTop) ? viewportTop : window.scrollY,
left: Number.isFinite(viewportLeft) ? viewportLeft : window.scrollX,
behavior: 'auto',
});
}
};
window.requestAnimationFrame(() => {
restore();
window.setTimeout(restore, 0);
window.setTimeout(restore, 80);
window.setTimeout(restore, 180);
window.setTimeout(restore, 320);
});
};
const dispatchRuntimeCommand = (view, payload, source) => {
view.runtimeDescriptor.root.dispatchEvent(new CustomEvent(commandEvent, {
bubbles: true,
detail: {
protocol: bridgeProtocol,
runtime: 'mnote-leptos-tiptap-spike',
version: '1.1.0',
source: source || 'mnote-web-document-session',
event: commandEvent,
payload,
},
}));
};
const clearEmbeddedLocalDraft = (runtimeDescriptor) => {
if (runtimeDescriptor.bootstrap.sourceKind !== 'local_folder') return;
try {
const storage = window.localStorage;
const base = 'mnote.leptos-tiptap-spike.document';
const keys = [
`${base}:${runtimeDescriptor.bootstrap.workspaceId}:${runtimeDescriptor.bootstrap.documentId}`,
`${base}:${runtimeDescriptor.bootstrap.documentId}`,
];
for (const key of keys) storage.removeItem(key);
} catch (error) {
console.warn('mnote local folder 草稿清理失败', error);
}
};
const setSessionStatus = (session, status, message) => {
session.status = status;
session.error = message || null;
sessionViews(session).forEach((view) => {
setStatus(view.runtimeDescriptor, status, message);
});
};
const dispatchSessionContentToView = (session, view, source) => {
view.suppressedSerialized = session.currentSerialized;
view.lastKnownSerialized = session.currentSerialized;
preserveViewScrollPosition(view, () => {
dispatchRuntimeCommand(view, {
command: 'replaceContent',
documentId: session.documentId,
workspaceId: session.workspaceId,
title: session.title,
content: session.currentTiptapDocument,
revision: session.revision,
conflictDetectionKey: session.conflictDetectionKey,
readOnly: session.readOnly,
editable: !session.readOnly,
}, source || 'mnote-web-document-session-sync');
});
};
const dispatchSessionMetaToView = (session, view, source) => {
dispatchRuntimeCommand(view, {
command: 'replaceContent',
documentId: session.documentId,
workspaceId: session.workspaceId,
title: session.title,
revision: session.revision,
conflictDetectionKey: session.conflictDetectionKey,
readOnly: session.readOnly,
editable: !session.readOnly,
}, source || 'mnote-web-document-session-meta');
};
const syncSessionMetaToViews = (session) => {
sessionViews(session).forEach((view) => {
if (view.mountId != null) dispatchSessionMetaToView(session, view);
});
};
const broadcastSessionContent = (session, sourceView) => {
sessionViews(session).forEach((view) => {
if (sourceView && view.id === sourceView.id) return;
if (view.mountId == null) return;
dispatchSessionContentToView(session, view);
});
};
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'
&& Number.isFinite(session.lastExternalChangeSignalAt)
&& session.lastExternalChangeSignalAt > 0
&& (Date.now() - session.lastExternalChangeSignalAt) < 1500
);
const sessionHasRecentLocalInput = (session) => (
Number.isFinite(session.lastUserInputAt)
&& session.lastUserInputAt > 0
&& (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 fetchLatestResourceSnapshot = async (session) => {
const url = new URL('/api/local-folder/resource/read', window.location.origin);
url.searchParams.set('rootUri', session.rootUri || '');
url.searchParams.set('path', session.resourcePath || '');
const response = await fetch(url.toString(), {
cache: 'no-store',
headers: { accept: 'application/json' },
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) throw new Error('resource_conflict_latest_fetch_failed_' + response.status);
const nextResource = payload.result;
if (!nextResource || typeof nextResource !== 'object') throw new Error('resource_conflict_latest_missing_snapshot');
return nextResource;
};
const aggregatePlainText = (aggregate) => {
const body = aggregate?.body || {};
return flattenText(pageBodyTiptapDocument(body)).replace(/\n{3,}/g, '\n\n').trim();
};
const resourceSnapshotPlainText = (snapshot) => (
String(snapshot?.text || flattenText(toTiptapDocument(snapshot?.content, '')) || '')
.replace(/\n{3,}/g, '\n\n')
.trim()
);
const shouldRetryTransientEmptyLocalAggregate = (session, nextAggregate) => {
if (!session || session.sourceKind !== 'local_folder') return false;
if (!sessionHasRecentExternalSignal(session)) return false;
if (!sessionPlainText(session)) return false;
return !aggregatePlainText(nextAggregate);
};
const delay = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms));
const conflictEnvelopeFromResponse = (payload) => (
payload?.error?.details?.conflict
|| payload?.details?.conflict
|| null
);
const conflictSourceLabel = (session) => {
if (!session) return '';
if (session.lastExternalWriteSource === 'mnote-hermes-tool') {
const runId = String(session.lastExternalWriteRunId || '').trim();
return runId ? `agent run ${runId}` : 'agent run';
}
return '本地文件变更';
};
const conflictMessageFromEnvelope = (session, envelope) => {
const baseMessage = envelope?.message || externalConflictMessage;
const sourceLabel = conflictSourceLabel(session);
return sourceLabel ? `${baseMessage}(来源:${sourceLabel}` : baseMessage;
};
const ensureSessionConflictEnvelope = (session, message) => {
if (!session) return null;
if (session.lastExternalConflictEnvelope) return session.lastExternalConflictEnvelope;
const envelope = {
code: 'local_markdown_external_change',
documentId: session.documentId,
rootUri: session.rootUri || null,
currentDiskVersion: session.conflictDetectionKey || session.fileVersion || null,
editorBaseVersion: session.lastExternalConflictDetectionKey || session.conflictDetectionKey || session.fileVersion || null,
externalActor: session.lastExternalWriteSource || null,
dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'),
bufferFileVersion: session.fileVersion || session.conflictDetectionKey || null,
message: message || externalConflictMessage,
suggestedActions: ['accept_disk', 'keep_editor', 'open_diff', 'merge'],
};
session.lastExternalConflictEnvelope = envelope;
return envelope;
};
const clearSessionConflictSurface = (session) => {
var _rt_ = window.__mnoteDocumentConflictPanelRuntime;
if (_rt_ && typeof _rt_.clearSessionConflictSurface === 'function') {
_rt_.clearSessionConflictSurface(session, { sessionViews: sessionViews });
return;
}
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 = pageBodyTiptapDocument(nextBody, '', session);
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 applyResourceSnapshotToSession = (session, nextResource, source) => {
const nextConflictKey = String(nextResource?.fileVersion || nextResource?.conflictDetectionKey || '').trim();
const nextTiptapDocument = toTiptapDocument(nextResource?.content, nextResource?.text || '');
const nextSerialized = JSON.stringify(nextTiptapDocument);
session.currentTiptapDocument = nextTiptapDocument;
session.currentSerialized = nextSerialized;
session.lastPersistedSerialized = nextSerialized;
if (nextConflictKey) {
session.conflictDetectionKey = nextConflictKey;
session.lastExternalConflictDetectionKey = nextConflictKey;
session.fileVersion = nextConflictKey;
}
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-resource-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 = session.sessionKind === 'resource'
? await fetchLatestResourceSnapshot(session)
: await fetchLatestSessionAggregate(session);
const latestText = session.sessionKind === 'resource'
? resourceSnapshotPlainText(latest)
: aggregatePlainText(latest);
diffPanel.replaceChildren();
var _dpRT_ = window.__mnoteDocumentConflictPanelRuntime;
if (_dpRT_ && typeof _dpRT_.populateSessionConflictDiffPanel === 'function') {
_dpRT_.populateSessionConflictDiffPanel(diffPanel, {
currentText: sessionPlainText(session) || '(当前编辑器为空)',
diskText: latestText || '(磁盘版本为空)',
currentMergeText: sessionPlainText(session) || '',
diskMergeText: latestText || '',
mergeDefaultText: sessionPlainText(session) || latestText || '',
}, {
onUseCurrent: function() {
return sessionPlainText(session) || '';
},
onUseDisk: function() {
return latestText || '';
},
onSaveMerge: function(mergeText) {
writeMergedConflictResult(session, panel, mergeText).catch((error) => {
setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error));
renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error));
});
},
});
} else {
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 = latestText || '(磁盘版本为空)';
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);
const mergeTitle = document.createElement('h3');
mergeTitle.textContent = '合并结果';
const mergeText = document.createElement('textarea');
mergeText.setAttribute('data-testid', 'mnote-conflict-merge-text');
mergeText.value = sessionPlainText(session) || latestText || '';
const mergeActions = document.createElement('div');
mergeActions.className = 'mnote-conflict-actions';
const useCurrent = document.createElement('button');
useCurrent.type = 'button';
useCurrent.textContent = '使用当前版本';
useCurrent.setAttribute('data-testid', 'mnote-conflict-merge-use-current');
const useDisk = document.createElement('button');
useDisk.type = 'button';
useDisk.textContent = '使用磁盘版本';
useDisk.setAttribute('data-testid', 'mnote-conflict-merge-use-disk');
const saveMerge = document.createElement('button');
saveMerge.type = 'button';
saveMerge.textContent = '写回合并结果';
saveMerge.setAttribute('data-testid', 'mnote-conflict-merge-save');
mergeActions.append(useCurrent, useDisk, saveMerge);
const mergeBox = document.createElement('section');
mergeBox.className = 'mnote-conflict-merge-box';
mergeBox.append(mergeTitle, mergeText, mergeActions);
diffPanel.append(currentBox, diskBox, mergeBox);
useCurrent.addEventListener('click', () => {
mergeText.value = sessionPlainText(session) || '';
});
useDisk.addEventListener('click', () => {
mergeText.value = latestText || '';
});
saveMerge.addEventListener('click', () => {
writeMergedConflictResult(session, panel, mergeText.value).catch((error) => {
setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error));
renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error));
});
});
}
} catch (error) {
loading.textContent = error instanceof Error ? error.message : String(error);
diffPanel.replaceChildren(loading);
}
};
const acceptDiskVersion = async (session) => {
var _RT_ = window.__mnoteDocumentConflictPanelRuntime;
if (_RT_ && typeof _RT_.acceptDiskVersion === 'function') {
return _RT_.acceptDiskVersion(session, {
setSessionStatus: setSessionStatus,
fetchLatestResourceSnapshot: fetchLatestResourceSnapshot,
applyResourceSnapshotToSession: applyResourceSnapshotToSession,
fetchLatestSessionAggregate: fetchLatestSessionAggregate,
applyAggregateSnapshotToSession: applyAggregateSnapshotToSession,
});
}
// inline fallback
setSessionStatus(session, 'conflict-resolving', '正在接受磁盘版本...');
if (session.sessionKind === 'resource') {
const latestResource = await fetchLatestResourceSnapshot(session);
applyResourceSnapshotToSession(session, latestResource, 'mnote-web-resource-conflict-accept-disk');
return;
}
const latest = await fetchLatestSessionAggregate(session);
applyAggregateSnapshotToSession(session, latest, 'mnote-web-conflict-accept-disk');
};
const keepCurrentEditorVersion = async (session) => {
var _RT_ = window.__mnoteDocumentConflictPanelRuntime;
if (_RT_ && typeof _RT_.keepCurrentEditorVersion === 'function') {
return _RT_.keepCurrentEditorVersion(session, {
setSessionStatus: setSessionStatus,
fetchLatestResourceSnapshot: fetchLatestResourceSnapshot,
fetchLatestSessionAggregate: fetchLatestSessionAggregate,
sessionViews: sessionViews,
currentEditorText: currentEditorText,
normalizePlainText: normalizePlainText,
hydrateMindmapAttrsFromDom: hydrateMindmapAttrsFromDom,
textToTiptapDocument: textToTiptapDocument,
conflictDetectionKeyFromBody: conflictDetectionKeyFromBody,
clearSessionConflictSurface: clearSessionConflictSurface,
persistSession: persistSession,
});
}
// inline fallback
setSessionStatus(session, 'conflict-resolving', '正在保留当前编辑器版本...');
const latest = session.sessionKind === 'resource'
? await fetchLatestResourceSnapshot(session)
: 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 = session.sessionKind === 'resource'
? String(latest?.fileVersion || latest?.conflictDetectionKey || '').trim()
: 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 writeMergedConflictResult = async (session, panel, mergedText) => {
var _RT_ = window.__mnoteDocumentConflictPanelRuntime;
if (_RT_ && typeof _RT_.writeMergedConflictResult === 'function') {
return _RT_.writeMergedConflictResult(session, panel, mergedText, {
setSessionStatus: setSessionStatus,
fetchLatestResourceSnapshot: fetchLatestResourceSnapshot,
fetchLatestSessionAggregate: fetchLatestSessionAggregate,
conflictDetectionKeyFromBody: conflictDetectionKeyFromBody,
textToTiptapDocument: textToTiptapDocument,
persistSession: persistSession,
});
}
// inline fallback
setSessionStatus(session, 'conflict-resolving', '正在写回合并结果...');
const latest = session.sessionKind === 'resource'
? await fetchLatestResourceSnapshot(session)
: await fetchLatestSessionAggregate(session);
const nextKey = session.sessionKind === 'resource'
? String(latest?.fileVersion || latest?.conflictDetectionKey || '').trim()
: conflictDetectionKeyFromBody(latest.body || {});
if (nextKey) {
session.conflictDetectionKey = nextKey;
session.lastExternalConflictDetectionKey = nextKey;
}
session.currentTiptapDocument = textToTiptapDocument(String(mergedText || ''));
session.currentSerialized = JSON.stringify(session.currentTiptapDocument);
session.hasExternalConflict = false;
session.externalChangePending = false;
session.saving = false;
session.dirty = true;
if (panel && typeof panel.remove === 'function') panel.remove();
await persistSession(session);
};
const runSessionConflictAction = (action, onError) => {
const runtime = window.__mnoteDocumentConflictPanelRuntime;
if (runtime && typeof runtime.runSessionConflictAction === 'function') {
runtime.runSessionConflictAction(action, { onError });
return;
}
try {
const result = action();
if (result && typeof result.then === 'function') result.catch(onError);
} catch (error) {
onError(error);
}
};
const renderSessionConflictSurface = (session, message) => {
clearSessionConflictSurface(session);
sessionViews(session).forEach((view) => {
const runtime = window.__mnoteDocumentConflictPanelRuntime;
const handleConflictActionError = (error) => {
setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error));
renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error));
};
let panel = null;
if (runtime && typeof runtime.createSessionConflictPanel === 'function') {
panel = runtime.createSessionConflictPanel(session, message, {
externalConflictMessage,
conflictSourceLabel,
onAcceptDisk: () => {
runSessionConflictAction(() => acceptDiskVersion(session), handleConflictActionError);
},
onKeepCurrent: () => {
runSessionConflictAction(() => keepCurrentEditorVersion(session), handleConflictActionError);
},
onOpenDiff: (_session, createdPanel) => {
openConflictDiffPanel(session, createdPanel);
},
});
}
if (!(panel instanceof HTMLElement)) {
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';
const fileLabel = session.sessionKind === 'resource'
? `${session.rootUri || ''}/${session.resourcePath || session.documentId}`
: (session.rootUri || session.documentId);
meta.textContent = `文件:${fileLabel} · 来源:${conflictSourceLabel(session) || '本地文件变更'}`;
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', () => {
runSessionConflictAction(() => acceptDiskVersion(session), handleConflictActionError);
});
keepCurrent.addEventListener('click', () => {
runSessionConflictAction(() => keepCurrentEditorVersion(session), handleConflictActionError);
});
openDiff.addEventListener('click', () => {
openConflictDiffPanel(session, panel);
});
}
if (runtime && typeof runtime.mountSessionConflictPanel === 'function') {
if (runtime.mountSessionConflictPanel(view.runtimeDescriptor.root, panel)) {
return;
}
}
const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root;
if (!(host instanceof HTMLElement)) return;
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;
ensureSessionConflictEnvelope(session, message);
session.hasExternalConflict = true;
if (session.saveTimer) {
window.clearTimeout(session.saveTimer);
session.saveTimer = 0;
}
const nextMessage = message || externalConflictMessage;
setSessionStatus(session, 'external-change-conflict', nextMessage);
renderSessionConflictSurface(session, nextMessage);
};
const queueSessionSave = (session) => {
if (session.readOnly || session.hasExternalConflict) return;
if (session.saveTimer) window.clearTimeout(session.saveTimer);
setSessionStatus(session, 'dirty');
session.saveTimer = window.setTimeout(() => {
session.saveTimer = 0;
void persistSession(session);
}, 650);
};
const persistSession = async (session) => {
if (session.readOnly || session.saving || session.hasExternalConflict) return;
const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0];
if (hydrateView) {
hydrateMindmapAttrsFromDom(session.currentTiptapDocument, hydrateView.runtimeDescriptor.root);
session.currentSerialized = JSON.stringify(session.currentTiptapDocument);
}
const serialized = session.currentSerialized;
if (!session.dirty && serialized === session.lastPersistedSerialized) {
setSessionStatus(session, 'saved');
return;
}
session.saving = true;
setSessionStatus(session, 'saving');
try {
const editorDocument = editorDocumentFromTiptapDocument({ documentId: session.documentId }, session.currentTiptapDocument);
const content = legacyBlocksFromEditorDocument(editorDocument);
const saveEndpoint = session.saveEndpoint || '/api/documents/save';
const savePayload = session.sessionKind === 'resource'
? {
rootUri: session.rootUri,
path: session.resourcePath,
expectedFileVersion: session.conflictDetectionKey,
contentFormat: 'editorBlocks',
editorSource: 'tiptap-resource-tab',
editorDocument,
content,
tiptapDocument: session.currentTiptapDocument,
blockCount: editorDocument.blocks.length,
}
: {
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' && session.sessionKind !== 'resource') {
savePayload.conflictDetectionKey = session.conflictDetectionKey;
}
markLocalFolderSelfChangeSuppression(session);
const response = await fetch(saveEndpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(savePayload),
});
const result = await response.json().catch(() => null);
if (!response.ok || !result || result.ok !== true) {
const message = result?.error?.message || result?.message || `save_failed_${response.status}`;
const conflictEnvelope = conflictEnvelopeFromResponse(result);
if (response.status === 409 && conflictEnvelope && session.sourceKind === 'local_folder') {
session.saving = false;
session.lastExternalConflictEnvelope = conflictEnvelope;
markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope));
return;
}
throw new Error(message);
}
const saved = result.result || {};
if (Number.isInteger(saved.revision)) session.revision = saved.revision;
if (typeof saved.conflict_detection_key === 'string' && saved.conflict_detection_key.trim()) {
session.conflictDetectionKey = saved.conflict_detection_key.trim();
}
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;
session.lastUserInputAt = 0;
syncSessionMetaToViews(session);
session.lastPersistedSerialized = serialized;
session.saving = false;
if (session.sessionKind !== 'resource' && typeof window.__mnoteRecordPageHistorySnapshot === 'function') {
const primaryView = sessionViews(session).find((view) => view.runtimeDescriptor.paneRole === 'primary') || sessionViews(session)[0];
if (primaryView) {
const plainText = sessionPlainText(session);
window.__mnoteRecordPageHistorySnapshot('save', {
wordCount: content.filter((block) => typeof block?.content === 'string' && block.content.trim()).length,
characterCount: plainText.replace(/\s/g, '').length,
blockCount: editorDocument.blocks.length,
todoTotal: editorDocument.blocks.filter((block) => block.blockType === 'todo').length,
todoDone: editorDocument.blocks.filter((block) => block.blockType === 'todo' && block.props?.checked).length,
});
}
}
if (session.currentSerialized === serialized) {
session.dirty = false;
setSessionStatus(session, 'saved');
} else {
session.dirty = true;
setSessionStatus(session, 'dirty');
queueSessionSave(session);
}
syncResourceSessionTabGuards(session);
} catch (error) {
session.saving = false;
setSessionStatus(session, 'error', error instanceof Error ? error.message : String(error));
syncResourceSessionTabGuards(session);
}
};
const scheduleSessionExternalRefresh = (session, source) => {
if (!session || session.views.size === 0) return;
if (session.externalRefreshTimer) return;
session.externalRefreshSource = source || session.externalRefreshSource || 'mnote-web-external-change';
session.externalRefreshTimer = window.setTimeout(() => {
const refreshSource = session.externalRefreshSource || 'mnote-web-external-change';
session.externalRefreshSource = '';
session.externalRefreshTimer = 0;
void refreshSessionFromExternalChange(session, refreshSource);
}, 120);
};
const refreshSessionFromExternalChange = async (session, source) => {
if (document.hidden) return;
if (!session || session.views.size === 0) return;
if (session.sourceKind === 'local_folder' && !session.rootUri) return;
try {
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) {
if (session.sourceKind === 'local_folder') {
markSessionExternalConflict(session, externalConflictMessage);
}
return;
}
const payload = await response.json();
const conflictEnvelope = conflictEnvelopeFromResponse(payload);
if (response.status === 409 && conflictEnvelope) {
session.lastExternalConflictEnvelope = conflictEnvelope;
markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope));
return;
}
let nextAggregate = payload?.result;
if (shouldRetryTransientEmptyLocalAggregate(session, nextAggregate)) {
await delay(500);
try {
const retryAggregate = await fetchLatestSessionAggregate(session);
if (aggregatePlainText(retryAggregate)) {
nextAggregate = retryAggregate;
} else {
markSessionExternalConflict(session, '检测到外部编辑器正在写入空内容,已暂停自动刷新以保护当前编辑区。');
return;
}
} catch (_retryError) {
markSessionExternalConflict(session, externalConflictMessage);
return;
}
}
const nextBody = nextAggregate?.body || {};
const nextPermissions = nextAggregate?.head?.permissions || {};
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);
const nextSerialized = JSON.stringify(nextTiptapDocument);
const contentChanged = nextSerialized !== session.currentSerialized;
session.externalChangePending = false;
if (!nextConflictKey || !session.lastExternalConflictDetectionKey) {
session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey;
if (!contentChanged) return;
}
if (nextConflictKey === session.lastExternalConflictDetectionKey && !contentChanged) return;
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
markSessionExternalConflict(session, externalConflictMessage);
return;
}
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 = session.currentSerialized;
session.revision = nextRevision;
session.conflictDetectionKey = nextConflictKey;
session.lastExternalConflictDetectionKey = nextConflictKey || '';
session.readOnly = Boolean(nextPermissions.readOnly);
session.dirty = false;
session.hasExternalConflict = false;
session.lastUserInputAt = 0;
sessionViews(session).forEach((view) => {
if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-external-change');
});
setSessionStatus(session, 'synced-external-change');
} catch (error) {
console.warn('mnote 页面外部更新检测失败', error);
}
};
const refreshSessionFromExternalFileChange = async (session) => {
if (session.sourceKind !== 'local_folder') return;
await refreshSessionFromExternalChange(session, 'mnote-web-local-folder-watch');
};
const ensureLocalFolderEventChannel = (session) => {
if (session.sourceKind !== 'local_folder' || !session.rootUri || typeof window.EventSource !== 'function') {
return;
}
const channelKey = localFolderEventChannelKey(session);
let channel = localFolderEventRegistry.get(channelKey);
if (!channel) {
const url = new URL('/api/local-folder/events', window.location.origin);
url.searchParams.set('rootUri', session.rootUri);
url.searchParams.set('documentId', session.documentId);
const eventSource = new EventSource(url.toString());
channel = {
key: channelKey,
rootUri: session.rootUri,
documentId: session.documentId,
eventSource,
sessions: new Map(),
};
eventSource.addEventListener('change', (event) => {
const payload = parseLocalFolderEventPayload(event);
if (!payload) return;
Array.from(channel.sessions.values()).forEach((targetSession) => {
if (!targetSession || targetSession.views.size === 0) return;
const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : '';
if (!documentId) return;
const eventKind = String(payload.eventKind || '');
const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId);
if (documentId && !targetsCurrentDocument) return;
if (targetsCurrentDocument && shouldSuppressLocalFolderSelfChange(documentId, eventKind)) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
if (targetsCurrentDocument && targetSession.saving) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true;
if (targetsCurrentDocument && (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving)) {
markSessionExternalConflict(targetSession, externalConflictMessage);
return;
}
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch');
});
});
eventSource.onerror = () => {
console.warn('mnote local folder 外部更新事件流中断,将等待浏览器自动重连');
};
localFolderEventRegistry.set(channelKey, channel);
}
channel.sessions.set(session.key, session);
session.localFolderChannel = channel;
};
const readTreePayloadData = (payload) => (
payload && typeof payload === 'object'
? (payload.data || payload.delta || payload)
: null
);
const readTreePayloadOverview = (payload) => (
payload && typeof payload === 'object' && payload.overview && typeof payload.overview === 'object'
? payload.overview
: null
);
const readTreePayloadCursor = (payload) => {
const raw = String(payload?.cursor || payload?.revision || '').trim();
if (!raw) return { id: '', createdAt: '', raw: '' };
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return {
id: String(parsed.id || parsed.commandId || parsed.command_id || '').trim(),
createdAt: String(parsed.createdAt || parsed.created_at || '').trim(),
raw,
};
}
} catch (_) {}
return { id: raw, createdAt: '', raw };
};
const treeRecordMatchesPayloadCursor = (record, payload) => {
if (!record || typeof record !== 'object') return false;
const cursor = readTreePayloadCursor(payload);
if (!cursor.id && !cursor.createdAt && !cursor.raw) return false;
const ids = [
record.id,
record._id,
record.command_log_id,
record.commandLogId,
record.domain_event_id,
record.domainEventId,
record.command_id,
record.commandId,
].map((value) => String(value || '').trim()).filter(Boolean);
if (cursor.id && ids.includes(cursor.id)) return true;
const createdAt = String(record.created_at || record.createdAt || '').trim();
return Boolean(cursor.createdAt && createdAt && cursor.createdAt === createdAt);
};
const treeRecordTargetsDocument = (record, documentId) => {
if (!record || typeof record !== 'object' || !documentId) return false;
const targetPageId = String(record.target_page_id || record.targetPageId || '').trim();
const aggregateId = String(record.aggregate_id || record.aggregateId || '').trim();
if (targetPageId === documentId || aggregateId === documentId) return true;
const payload = record.payload && typeof record.payload === 'object' ? record.payload : null;
if (!payload) return false;
const streamDelta = payload.streamDelta || payload.stream_delta || null;
const deltaDocumentId = streamDelta && typeof streamDelta === 'object'
? String(streamDelta.documentId || streamDelta.pageId || streamDelta.document_id || streamDelta.page_id || '').trim()
: '';
return deltaDocumentId === documentId;
};
const collectMindmapIdsFromTreeRecord = (record, documentId, out) => {
if (!record || typeof record !== 'object' || !documentId) return;
if (!treeRecordTargetsDocument(record, documentId)) return;
const targetBlockId = String(record.target_block_id || record.targetBlockId || '').trim();
if (targetBlockId) out.add(targetBlockId);
const aggregateType = String(record.aggregate_type || record.aggregateType || '').trim();
const aggregateId = String(record.aggregate_id || record.aggregateId || '').trim();
if (aggregateType === 'block' && aggregateId) out.add(aggregateId);
const payload = record.payload && typeof record.payload === 'object' ? record.payload : null;
const streamDelta = payload && typeof payload === 'object' ? (payload.streamDelta || payload.stream_delta || null) : null;
const blockId = streamDelta && typeof streamDelta === 'object'
? String(streamDelta.blockId || streamDelta.block_id || '').trim()
: '';
if (blockId) out.add(blockId);
};
const collectMindmapIdsFromTreePayload = (payload, session) => {
const ids = new Set();
if (!payload || typeof payload !== 'object' || !session?.documentId) return [];
const kind = String(payload.kind || '').trim();
const data = readTreePayloadData(payload);
if (kind === 'delta' && data && typeof data === 'object') {
const documentId = String(data.documentId || data.pageId || data.document_id || data.page_id || '').trim();
if (!documentId || documentId === session.documentId) {
const blockId = String(data.blockId || data.block_id || '').trim();
if (blockId) ids.add(blockId);
const streamDelta = data.streamDelta || data.stream_delta || null;
const streamBlockId = streamDelta && typeof streamDelta === 'object'
? String(streamDelta.blockId || streamDelta.block_id || '').trim()
: '';
if (streamBlockId) ids.add(streamBlockId);
}
}
if (kind === 'resync') {
const overview = readTreePayloadOverview(payload);
if (overview) {
const commandLogs = Array.isArray(overview.command_logs) ? overview.command_logs : Array.isArray(overview.commandLogs) ? overview.commandLogs : [];
const domainEvents = Array.isArray(overview.domain_events) ? overview.domain_events : Array.isArray(overview.domainEvents) ? overview.domainEvents : [];
commandLogs
.filter((record) => treeRecordMatchesPayloadCursor(record, payload))
.forEach((record) => collectMindmapIdsFromTreeRecord(record, session.documentId, ids));
domainEvents
.filter((record) => treeRecordMatchesPayloadCursor(record, payload))
.forEach((record) => collectMindmapIdsFromTreeRecord(record, session.documentId, ids));
}
}
return Array.from(ids);
};
const refreshMindmapRuntimesFromTreePayload = (payload, session) => {
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
collectMindmapIdsFromTreePayload(payload, session).forEach((mindmapId) => {
const bridge = registry[mindmapId];
if (bridge && typeof bridge.refreshProjection === 'function') {
void bridge.refreshProjection('mnote-web-tree-live');
}
});
};
const treePayloadTargetsDocument = (payload, session) => {
if (!payload || typeof payload !== 'object' || !session?.documentId) return false;
if (payload.workspaceId && session.workspaceId && String(payload.workspaceId) !== String(session.workspaceId)) return false;
const data = readTreePayloadData(payload);
if (data && typeof data === 'object') {
const documentId = String(data.documentId || data.pageId || data.document_id || data.page_id || '').trim();
if (documentId === session.documentId) return true;
const documents = Array.isArray(data.upsertDocuments) ? data.upsertDocuments : Array.isArray(data.upsert_documents) ? data.upsert_documents : [];
if (documents.some((item) => String(item?.id || item?.documentId || '').trim() === session.documentId)) return true;
}
const overview = readTreePayloadOverview(payload);
if (!overview) return false;
const commandLogs = Array.isArray(overview.command_logs) ? overview.command_logs : Array.isArray(overview.commandLogs) ? overview.commandLogs : [];
const domainEvents = Array.isArray(overview.domain_events) ? overview.domain_events : Array.isArray(overview.domainEvents) ? overview.domainEvents : [];
return commandLogs.some((record) => treeRecordTargetsDocument(record, session.documentId))
|| domainEvents.some((record) => treeRecordTargetsDocument(record, session.documentId));
};
const handleTreeExternalChange = (event) => {
const payload = event?.detail?.payload || event?.detail || null;
if (!payload) return;
Array.from(documentSessionRegistry.values()).forEach((session) => {
if (session.sourceKind === 'local_folder') return;
if (!treePayloadTargetsDocument(payload, session)) return;
refreshMindmapRuntimesFromTreePayload(payload, session);
session.lastExternalChangeSignalAt = Date.now();
session.externalChangePending = true;
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
markSessionExternalConflict(session, treeExternalConflictMessage);
return;
}
scheduleSessionExternalRefresh(session, 'mnote-web-tree-live');
});
};
const sessionMatchesDocumentWorkspace = (session, documentId, workspaceId) => {
if (!session) return false;
const doc = String(documentId || '').trim();
const workspace = String(workspaceId || '').trim();
if (doc && session.documentId !== doc) return false;
if (workspace && session.workspaceId && session.workspaceId !== workspace) return false;
return true;
};
const refreshDocumentSessionsFromExternalWrite = (detail, source) => {
const documentId = String(detail?.documentId || '').trim();
const workspaceId = String(detail?.workspaceId || '').trim();
let scheduled = 0;
Array.from(documentSessionRegistry.values()).forEach((session) => {
if (!sessionMatchesDocumentWorkspace(session, documentId, workspaceId)) return;
session.lastExternalChangeSignalAt = Date.now();
session.externalChangePending = true;
session.lastExternalWriteSource = source || session.lastExternalWriteSource || '';
session.lastExternalWriteRunId = String(detail?.runId || detail?.traceId || detail?.toolCallId || '').trim();
session.lastExternalWriteDocumentId = documentId;
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
markSessionExternalConflict(session, conflictMessageFromEnvelope(session, session.lastExternalConflictEnvelope) || treeExternalConflictMessage);
return;
}
scheduled += 1;
scheduleSessionExternalRefresh(session, source || 'mnote-web-external-write');
});
return scheduled;
};
window.addEventListener('tree:delta', handleTreeExternalChange);
window.addEventListener('tree:resync', handleTreeExternalChange);
window.addEventListener('mnote:page-ai-tool-write-completed', (event) => {
refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-hermes-tool');
});
const createDocumentSession = (runtimeDescriptor) => {
const pageBody = runtimeDescriptor.aggregate.body || {};
const permissions = runtimeDescriptor.aggregate.head?.permissions || {};
const conflictDetectionKey = conflictDetectionKeyFromBody(pageBody);
const pageBodyRevision = Number.isInteger(pageBody.revision) ? pageBody.revision : null;
const keyRevision = revisionFromConflictKey(conflictDetectionKey);
const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap);
const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);
const session = {
key: buildDocumentSessionKey(runtimeDescriptor.bootstrap),
documentId: runtimeDescriptor.bootstrap.documentId,
workspaceId: runtimeDescriptor.bootstrap.workspaceId,
sourceKind,
rootUri: runtimeDescriptor.bootstrap.rootUri,
saveEndpoint: runtimeDescriptor.bootstrap.saveEndpoint || '/api/documents/save',
pageAggregateScriptId: runtimeDescriptor.bootstrap.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__',
latestAggregate: runtimeDescriptor.aggregate,
title: runtimeDescriptor.aggregate.head?.title || '无标题',
currentTiptapDocument: tiptapDocument,
currentSerialized: JSON.stringify(tiptapDocument),
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,
saving: false,
hasExternalConflict: false,
externalChangePending: false,
externalRefreshSource: '',
lastExternalChangeSignalAt: 0,
lastSelfSaveSignalAt: 0,
lastUserInputAt: 0,
status: 'booting',
error: null,
saveTimer: 0,
externalRefreshTimer: 0,
releaseTimer: 0,
views: new Map(),
localFolderChannel: null,
};
ensureLocalFolderEventChannel(session);
return session;
};
const getOrCreateDocumentSession = (runtimeDescriptor) => {
const key = buildDocumentSessionKey(runtimeDescriptor.bootstrap);
const existing = documentSessionRegistry.get(key);
if (existing) {
cancelDocumentSessionRelease(existing);
return existing;
}
const session = createDocumentSession(runtimeDescriptor);
documentSessionRegistry.set(key, session);
return session;
};
return {
cancelDocumentSessionRelease,
broadcastSessionContent,
clearEmbeddedLocalDraft,
clearSessionConflictSurface,
currentEditorText,
dispatchSessionContentToView,
documentSessionRegistry,
externalConflictMessage,
getOrCreateDocumentSession,
markSessionExternalConflict,
queueSessionSave,
refreshSessionFromExternalChange,
releaseDocumentSession,
scheduleDocumentSessionRelease,
sessionHasRecentExternalSignal,
sessionHasRecentLocalInput,
sessionMatchesDocumentWorkspace,
sessionViews,
setSessionStatus,
};
};