import { conflictDetectionKeyFromBody, editorDocumentFromTiptapDocument, flattenText, hydrateMindmapAttrsFromDom, legacyBlocksFromEditorDocument, pageBodyTiptapDocument, pageBodyTiptapDocumentSource, revisionFromConflictKey, textToTiptapDocument, 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 effectiveSessionReadOnly = (session) => Boolean(session?.permissionReadOnly || session?.userReadOnlyMode); const applySessionPermissionReadOnly = (session, permissionReadOnly) => { if (!session) return false; session.permissionReadOnly = Boolean(permissionReadOnly); session.readOnly = effectiveSessionReadOnly(session); return session.readOnly; }; const setDocumentSessionUserReadOnlyMode = (session, userReadOnlyMode, source) => { if (!session) return false; session.userReadOnlyMode = Boolean(userReadOnlyMode); session.readOnly = effectiveSessionReadOnly(session); sessionViews(session).forEach((view) => { if (view.mountId == null) return; dispatchRuntimeCommand(view, { command: 'setEditable', documentId: session.documentId, workspaceId: session.workspaceId, title: session.title, readOnly: session.readOnly, editable: !session.readOnly, }, source || 'mnote-web-document-user-readonly-mode'); }); setSessionStatus(session, session.readOnly ? 'read-only' : 'editable'); return session.readOnly; }; 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 || 'local_folder'; }; 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 resourceWatchPath = (session) => String(session?.resourcePath || session?.relativePath || '').trim(); const localFolderEventChannelKey = (session) => { const scope = session?.sessionKind === 'resource' ? `resource:${resourceWatchPath(session)}` : String(session?.documentId || '').trim(); return `${String(session?.rootUri || '').trim()}#${scope}`; }; const localFolderEventBusChannelKey = (session) => `${String(session?.rootUri || '').trim()}#event-bus`; const localMarkdownRelativePathFromDocumentId = (documentId) => { const value = String(documentId || '').trim(); if (!value.startsWith('local-md:')) return ''; return value.slice('local-md:'.length).replace(/~2F/g, '/'); }; const sessionRelativePath = (session) => ( String(session?.relativePath || '').trim() || localMarkdownRelativePathFromDocumentId(session?.documentId) ); const localFolderNavigationFallbackUrl = (session, reason) => { const url = new URL('/', window.location.origin); url.searchParams.set('sourceKind', 'local_folder'); if (session?.rootUri) url.searchParams.set('rootUri', session.rootUri); url.searchParams.set('treeView', 'filetree'); const relativePath = sessionRelativePath(session); const currentScope = String(new URL(window.location.href).searchParams.get('fileTreeScope') || '').trim(); const parentScope = relativePath.includes('/') ? relativePath.split('/').slice(0, -1).join('/') : ''; const scope = currentScope || parentScope; if (scope) url.searchParams.set('fileTreeScope', scope); if (session?.documentId) url.searchParams.set('missingPage', session.documentId); if (reason) url.searchParams.set('routeGuard', reason); return url; }; const navigateLocalMarkdownDeletedFallback = (session, reason = 'local_markdown_deleted') => { if (!session || session.sessionKind === 'resource') return false; if (session.saveTimer) { window.clearTimeout(session.saveTimer); session.saveTimer = 0; } if (session.externalRefreshTimer) { window.clearTimeout(session.externalRefreshTimer); session.externalRefreshTimer = 0; } session.externalChangePending = false; session.hasExternalConflict = false; session.bufferDirtyState = 'Deleted'; clearSessionConflictSurface(session); window.location.assign(localFolderNavigationFallbackUrl(session, reason).toString()); return true; }; const localFolderEventItems = (payload) => { if (!payload || typeof payload !== 'object') return []; const changedPaths = Array.isArray(payload.changedPaths) ? payload.changedPaths : (Array.isArray(payload.changed_paths) ? payload.changed_paths : []); if (changedPaths.length > 0) { return changedPaths.map((item) => { if (typeof item === 'string') return { relativePath: item }; if (!item || typeof item !== 'object') return null; return { relativePath: String(item.relativePath || item.relative_path || item.path || '').trim(), documentId: String(item.documentId || item.document_id || '').trim(), eventKind: String(item.eventKind || item.event_kind || item.changeType || item.change_type || '').trim(), changeType: String(item.changeType || item.change_type || '').trim(), selfWriteEcho: Boolean(item.selfWriteEcho || item.self_write_echo), fileVersion: String(item.fileVersion || item.file_version || item.observedFileVersion || item.observed_file_version || '').trim(), observedFileVersion: String(item.observedFileVersion || item.observed_file_version || '').trim(), bufferFileVersion: String(item.bufferFileVersion || item.buffer_file_version || '').trim(), }; }).filter(Boolean); } return [payload]; }; const sessionBufferStateUrl = (session) => { if (!session || session.sourceKind !== 'local_folder' || !session.rootUri || !session.documentId) return null; const url = new URL('/api/documents/buffer-state', window.location.origin); url.searchParams.set('documentId', session.documentId); url.searchParams.set('sourceKind', session.sourceKind); url.searchParams.set('rootUri', session.rootUri); if (session.workspaceId) url.searchParams.set('workspaceId', session.workspaceId); const relativePath = sessionRelativePath(session); if (relativePath) url.searchParams.set('relativePath', relativePath); return url; }; const applyBufferStateToSession = (session, bufferState) => { if (!session || !bufferState || typeof bufferState !== 'object') return; const dirtyState = String(bufferState.dirtyState || '').trim(); if (dirtyState) session.bufferDirtyState = dirtyState; if (typeof bufferState.fileVersion === 'string' && bufferState.fileVersion.trim()) { session.fileVersion = bufferState.fileVersion.trim(); if (!session.conflictDetectionKey) session.conflictDetectionKey = session.fileVersion; } if (typeof bufferState.externalActor === 'string' && bufferState.externalActor.trim()) { session.lastExternalWriteSource = bufferState.externalActor.trim(); } }; const fetchSessionBufferState = async (session) => { const url = sessionBufferStateUrl(session); if (!url) return null; try { 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) return null; const bufferState = payload.result || null; applyBufferStateToSession(session, bufferState); return bufferState; } catch (_error) { return null; } }; const markSessionBufferDirty = (session) => { if (!session || session.sourceKind !== 'local_folder' || session.sessionKind === 'resource') return; const relativePath = sessionRelativePath(session); if (!relativePath) return; const serialized = String(session.currentSerialized || ''); const contentHash = `browser-dirty:${serialized.length}:${serialized.charCodeAt(0) || 0}:${serialized.charCodeAt(serialized.length - 1) || 0}`; void fetch('/api/documents/buffer-state/dirty', { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ documentId: session.documentId, workspaceId: session.workspaceId, sourceKind: session.sourceKind, rootUri: session.rootUri, relativePath, contentHash, }), }).then((response) => response.json().catch(() => null)).then((payload) => { if (payload && payload.ok === true) applyBufferStateToSession(session, payload.result); }).catch(() => undefined); }; const detachSessionFromLocalFolderChannel = (session) => { const channel = session.localFolderChannel; if (!channel) return; channel.sessions.delete(session.key); if (channel.sessions.size === 0) { if (typeof channel.unsubscribe === 'function') { channel.unsubscribe(); } else if (channel.eventSource && typeof channel.eventSource.close === 'function') { 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 pageBodySourceMeta = (sourceKind, pageBody) => { const pageBodySource = pageBodyTiptapDocumentSource(pageBody || {}, ''); const projectionSource = String(pageBody?.projectionSource || pageBody?.projection_source || '').trim(); const blockProjectionVersion = String(pageBody?.blockProjectionVersion || pageBody?.block_projection_version || '').trim(); const localCompatFallback = sourceKind === 'local_folder' && pageBodySource === 'compat.legacy_content'; return { pageBodySource, projectionSource, blockProjectionVersion, localCompatFallback, hardGuard: localCompatFallback ? 'local_compat_fallback' : (sourceKind === 'local_folder' ? 'local_ok' : 'compat_allowed'), }; }; const applyPageBodySourceMetaToSession = (session, pageBody) => { const meta = pageBodySourceMeta(session.sourceKind, pageBody || {}); session.pageBodySource = meta.pageBodySource; session.projectionSource = meta.projectionSource; session.blockProjectionVersion = meta.blockProjectionVersion; session.pageBodyLocalCompatFallback = meta.localCompatFallback; session.pageBodyHardGuard = meta.hardGuard; }; const syncPageBodySourceDiagnosticsToViews = (session) => { sessionViews(session).forEach((view) => { const root = view?.runtimeDescriptor?.root; if (!(root instanceof HTMLElement)) return; root.setAttribute('data-mnote-page-body-source', session.pageBodySource || ''); root.setAttribute('data-mnote-page-body-local-compat-fallback', session.pageBodyLocalCompatFallback ? 'true' : 'false'); root.setAttribute('data-mnote-page-body-hard-guard', session.pageBodyHardGuard || ''); if (session.projectionSource) { root.setAttribute('data-mnote-projection-source', session.projectionSource); } else { root.removeAttribute('data-mnote-projection-source'); } if (session.blockProjectionVersion) { root.setAttribute('data-mnote-block-projection-version', session.blockProjectionVersion); } else { root.removeAttribute('data-mnote-block-projection-version'); } }); }; const syncSessionMetaToViews = (session) => { syncPageBodySourceDiagnosticsToViews(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 ''; const source = String(session.lastExternalWriteSource || ''); // 兼容历史 externalActor 名;产品面统一称 agent tool if (source === 'mnote-agent-tool' || source === '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 isResourceSession = session.sessionKind === 'resource'; const defaultMessage = isResourceSession ? '当前本地资源文件已在外部更新,请刷新或保存前先处理冲突' : externalConflictMessage; const envelope = { code: isResourceSession ? 'local_resource_external_change' : 'local_markdown_external_change', documentId: session.documentId, rootUri: session.rootUri || null, path: isResourceSession ? (session.resourcePath || '') : undefined, 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: isResourceSession && (!message || message === externalConflictMessage) ? defaultMessage : (message || defaultMessage), 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); applyPageBodySourceMetaToSession(session, nextBody); 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 || ''; applySessionPermissionReadOnly(session, 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; const envelope = ensureSessionConflictEnvelope(session, message); session.hasExternalConflict = true; if (session.saveTimer) { window.clearTimeout(session.saveTimer); session.saveTimer = 0; } const nextMessage = envelope?.message || 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'); markSessionBufferDirty(session); session.saveTimer = window.setTimeout(() => { session.saveTimer = 0; void persistSession(session); }, 650); }; const makeSaveOperationToken = (prefix, session) => { const doc = String(session?.documentId || session?.resourcePath || 'unknown') .replace(/[^a-zA-Z0-9._:-]+/g, '-') .slice(0, 80); const random = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; return `${prefix}:${doc}:${random}`; }; 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 expectedFileVersion = ( session.sourceKind === 'local_folder' && !session.lastExternalConflictEnvelope && session.lastExternalConflictDetectionKey ) ? session.lastExternalConflictDetectionKey : session.conflictDetectionKey; if (session.sourceKind === 'local_folder' && expectedFileVersion && expectedFileVersion !== session.conflictDetectionKey) { session.conflictDetectionKey = expectedFileVersion; session.fileVersion = expectedFileVersion; } const writeIntentId = session.sourceKind === 'local_folder' ? makeSaveOperationToken('intent:tiptap', session) : ''; const saveOperationId = session.sourceKind === 'local_folder' ? makeSaveOperationToken('save:tiptap', session) : ''; const savePayload = session.sessionKind === 'resource' ? { rootUri: session.rootUri, path: session.resourcePath, expectedFileVersion: expectedFileVersion, writeIntentId, saveOperationId, 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: expectedFileVersion, writeIntentId, saveOperationId, 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 (typeof saved.writeIntentId === 'string' && saved.writeIntentId.trim()) { session.lastWriteIntentId = saved.writeIntentId.trim(); } else if (writeIntentId) { session.lastWriteIntentId = writeIntentId; } if (typeof saved.saveOperationId === 'string' && saved.saveOperationId.trim()) { session.lastSaveOperationId = saved.saveOperationId.trim(); } else if (saveOperationId) { session.lastSaveOperationId = saveOperationId; } 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.bufferDirtyState = 'Clean'; 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; if (session.sessionKind === 'resource') { await refreshResourceSessionFromExternalChange(session, source); return; } try { const bufferState = await fetchSessionBufferState(session); const dirtyState = String(bufferState?.dirtyState || session.bufferDirtyState || '').trim(); if (dirtyState === 'Deleted') { navigateLocalMarkdownDeletedFallback(session); return; } if (session.hasExternalConflict) { markSessionExternalConflict(session, conflictMessageFromEnvelope(session, { message: externalConflictMessage, dirtyState, fileVersion: bufferState?.fileVersion || null, externalActor: bufferState?.externalActor || null, })); return; } if (sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) { session.externalChangePending = true; if (session.dirty && !session.saveTimer && !session.saving) { queueSessionSave(session); } else if (!session.saving) { setSessionStatus(session, 'dirty'); } return; } 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' }, }); const payload = await response.json().catch(() => null); if (!response.ok) { const errorCode = String(payload?.error?.code || payload?.code || '').trim(); if (session.sourceKind === 'local_folder' && (response.status === 404 || errorCode === 'local_markdown_not_found')) { navigateLocalMarkdownDeletedFallback(session, 'local_markdown_not_found'); return; } const conflictEnvelope = conflictEnvelopeFromResponse(payload); if (response.status === 409 && conflictEnvelope) { session.lastExternalConflictEnvelope = conflictEnvelope; markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope)); return; } if (session.sourceKind === 'local_folder') { markSessionExternalConflict(session, externalConflictMessage); } return; } 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; } } session.latestAggregate = nextAggregate; syncPageAggregateScript(session, nextAggregate); const nextBody = nextAggregate?.body || {}; const nextPermissions = nextAggregate?.head?.permissions || {}; const nextConflictKey = conflictDetectionKeyFromBody(nextBody); const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session); const nextPageBodyMeta = pageBodySourceMeta(session.sourceKind, nextBody); const nextSerialized = JSON.stringify(nextTiptapDocument); const contentChanged = nextSerialized !== session.currentSerialized; session.externalChangePending = false; session.bufferDirtyState = 'Clean'; if (!nextConflictKey || !session.lastExternalConflictDetectionKey) { session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey; if (!contentChanged) { applyPageBodySourceMetaToSession(session, nextBody); syncPageBodySourceDiagnosticsToViews(session); return; } } if (nextConflictKey === session.lastExternalConflictDetectionKey) { applyPageBodySourceMetaToSession(session, nextBody); syncPageBodySourceDiagnosticsToViews(session); if (nextConflictKey && session.conflictDetectionKey !== nextConflictKey) { session.conflictDetectionKey = nextConflictKey; session.fileVersion = nextConflictKey; syncSessionMetaToViews(session); } return; } const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey); session.latestAggregate = nextAggregate; syncPageAggregateScript(session, nextAggregate); session.pageBodySource = nextPageBodyMeta.pageBodySource; session.projectionSource = nextPageBodyMeta.projectionSource; session.blockProjectionVersion = nextPageBodyMeta.blockProjectionVersion; session.pageBodyLocalCompatFallback = nextPageBodyMeta.localCompatFallback; session.pageBodyHardGuard = nextPageBodyMeta.hardGuard; 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 || ''; applySessionPermissionReadOnly(session, 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 refreshResourceSessionFromExternalChange = async (session, source) => { if (document.hidden) return; if (!session || session.views.size === 0) return; if (session.sourceKind !== 'local_folder' || !session.rootUri || !session.resourcePath) return; let nextResource = null; try { nextResource = await fetchLatestResourceSnapshot(session); } catch (error) { session.lastExternalConflictEnvelope = { code: 'local_resource_deleted', documentId: session.documentId, rootUri: session.rootUri || null, path: session.resourcePath || '', currentDiskVersion: null, editorBaseVersion: session.conflictDetectionKey || session.fileVersion || null, externalActor: 'external-editor', dirtyState: 'Deleted', message: '当前本地资源文件已被删除或移动,请恢复文件或关闭当前标签', suggestedActions: ['keep_editor', 'open_diff'], }; markSessionExternalConflict(session, session.lastExternalConflictEnvelope.message); return; } const nextConflictKey = String(nextResource?.fileVersion || nextResource?.conflictDetectionKey || '').trim(); const nextTiptapDocument = toTiptapDocument(nextResource?.content, nextResource?.text || ''); 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) { if (nextConflictKey && session.conflictDetectionKey !== nextConflictKey) { session.conflictDetectionKey = nextConflictKey; session.fileVersion = nextConflictKey; syncSessionMetaToViews(session); } return; } if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) { session.lastExternalConflictEnvelope = { code: 'local_resource_external_change', documentId: session.documentId, rootUri: session.rootUri || null, path: session.resourcePath || '', currentDiskVersion: nextConflictKey || null, editorBaseVersion: session.conflictDetectionKey || session.fileVersion || null, externalActor: 'external-editor', dirtyState: session.dirty ? 'Dirty' : 'Clean', message: '当前本地资源文件已在外部更新,请刷新或保存前先处理冲突', suggestedActions: ['accept_disk', 'keep_editor', 'open_diff'], }; markSessionExternalConflict(session, session.lastExternalConflictEnvelope.message); return; } session.currentTiptapDocument = nextTiptapDocument; session.currentSerialized = nextSerialized; session.lastPersistedSerialized = nextSerialized; session.conflictDetectionKey = nextConflictKey; session.fileVersion = nextConflictKey; session.lastExternalConflictDetectionKey = nextConflictKey || ''; session.dirty = false; session.hasExternalConflict = false; session.lastUserInputAt = 0; sessionViews(session).forEach((view) => { if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-resource-watch'); }); setSessionStatus(session, 'synced-external-change'); syncResourceSessionTabGuards(session); }; const refreshSessionFromExternalFileChange = async (session) => { if (session.sourceKind !== 'local_folder') return; await refreshSessionFromExternalChange(session, 'mnote-web-local-folder-watch'); }; const handleLocalFolderEventPayloadForSessions = (channel, payload) => { if (!payload) return; localFolderEventItems(payload).forEach((item) => { Array.from(channel.sessions.values()).forEach((targetSession) => { if (!targetSession || targetSession.views.size === 0) return; const documentId = typeof item.documentId === 'string' ? item.documentId.trim() : ''; const relativePath = typeof item.relativePath === 'string' ? item.relativePath.trim() : ''; if (targetSession.sessionKind === 'resource') { if (!relativePath || relativePath !== resourceWatchPath(targetSession)) return; if (item.selfWriteEcho === true || targetSession.saving && shouldSuppressLocalFolderSelfChange(targetSession.documentId, String(item.eventKind || ''))) { targetSession.externalChangePending = false; targetSession.lastSelfSaveSignalAt = Date.now(); return; } if (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving) { targetSession.lastExternalChangeSignalAt = Date.now(); targetSession.externalChangePending = true; markSessionExternalConflict(targetSession, '当前本地资源文件已在外部更新,请刷新或保存前先处理冲突'); return; } targetSession.lastExternalChangeSignalAt = Date.now(); targetSession.externalChangePending = true; scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-resource-watch'); return; } const eventKind = String(item.eventKind || ''); const relativeTarget = sessionRelativePath(targetSession); const targetsCurrentDocument = Boolean( (documentId && documentId === targetSession.documentId) || (!documentId && relativePath && relativeTarget && relativePath === relativeTarget) ); if (!targetsCurrentDocument) return; const changeType = String(item.changeType || '').trim(); if (changeType === 'deleted') { navigateLocalMarkdownDeletedFallback(targetSession); return; } if (item.selfWriteEcho === true) { targetSession.externalChangePending = false; targetSession.lastSelfSaveSignalAt = Date.now(); if (item.fileVersion) { targetSession.fileVersion = item.fileVersion; targetSession.conflictDetectionKey = item.fileVersion; targetSession.lastExternalConflictDetectionKey = item.fileVersion; } return; } if (shouldSuppressLocalFolderSelfChange(targetSession.documentId, eventKind)) { targetSession.externalChangePending = false; targetSession.lastSelfSaveSignalAt = Date.now(); return; } if (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving) { targetSession.lastExternalChangeSignalAt = Date.now(); targetSession.externalChangePending = true; markSessionExternalConflict(targetSession, conflictMessageFromEnvelope(targetSession, { message: externalConflictMessage, dirtyState: targetSession.dirty ? 'Dirty' : (targetSession.saving ? 'Saving' : 'Clean'), fileVersion: item.fileVersion || null, externalActor: 'external-editor', })); return; } targetSession.lastExternalChangeSignalAt = Date.now(); targetSession.externalChangePending = true; scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch'); }); }); }; const ensureLocalFolderEventChannel = (session) => { if (session.sourceKind !== 'local_folder' || !session.rootUri) { return; } const eventBus = window.__mnoteLocalFolderEventBus; if (eventBus && typeof eventBus.startLocalFolderWatcher === 'function') { const channelKey = localFolderEventBusChannelKey(session); let channel = localFolderEventRegistry.get(channelKey); if (!channel) { const rootUri = String(session.rootUri || '').trim(); const workspaceId = String(session.workspaceId || '').trim(); const handler = (event) => { const detail = event?.detail || {}; const eventRootUri = String(detail.rootUri || detail.payload?.rootUri || '').trim(); if (eventRootUri && eventRootUri !== rootUri) return; handleLocalFolderEventPayloadForSessions(channel, detail.payload || detail); }; const handle = eventBus.startLocalFolderWatcher({ rootUri, workspaceId, bootstrap: { schema: 'mnote.document_session.local_folder_event_bus.v1', transport: 'local-folder-events', workspaceId, }, }); channel = { key: channelKey, rootUri, documentId: '', resourcePath: '', eventSource: handle, sessions: new Map(), unsubscribe: () => window.removeEventListener('mnote:local-folder:document-changed', handler), }; window.addEventListener('mnote:local-folder:document-changed', handler); localFolderEventRegistry.set(channelKey, channel); } channel.sessions.set(session.key, session); session.localFolderChannel = channel; return; } if (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); if (session.sessionKind === 'resource') { url.searchParams.set('resourcePath', resourceWatchPath(session)); } else { url.searchParams.set('documentId', session.documentId); } const eventSource = new EventSource(url.toString()); channel = { key: channelKey, rootUri: session.rootUri, documentId: session.documentId, resourcePath: session.sessionKind === 'resource' ? resourceWatchPath(session) : '', eventSource, sessions: new Map(), }; eventSource.addEventListener('change', (event) => { handleLocalFolderEventPayloadForSessions(channel, parseLocalFolderEventPayload(event)); }); 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; }; const applyLocalUploadEditorSave = (detail) => { const documentId = String(detail?.documentId || '').trim(); const rootUri = String(detail?.rootUri || '').trim(); const fileVersion = String(detail?.fileVersion || '').trim(); const writeIntentId = String(detail?.writeIntentId || '').trim(); const saveOperationId = String(detail?.saveOperationId || '').trim(); const serialized = String(detail?.serialized || ''); if (!documentId || !rootUri || !fileVersion) return 0; let applied = 0; Array.from(documentSessionRegistry.values()).forEach((session) => { if (session.sourceKind !== 'local_folder') return; if (session.documentId !== documentId) return; if (String(session.rootUri || '').trim() !== rootUri) return; if (session.saveTimer) { window.clearTimeout(session.saveTimer); session.saveTimer = 0; } session.conflictDetectionKey = fileVersion; session.fileVersion = fileVersion; session.lastExternalConflictDetectionKey = fileVersion; if (writeIntentId) session.lastWriteIntentId = writeIntentId; if (saveOperationId) session.lastSaveOperationId = saveOperationId; if (session.latestAggregate && typeof session.latestAggregate === 'object') { const nextAggregate = { ...session.latestAggregate }; const nextBody = nextAggregate.body && typeof nextAggregate.body === 'object' ? { ...nextAggregate.body } : {}; nextBody.fileVersion = fileVersion; nextBody.conflictDetectionKey = fileVersion; nextBody.conflict_detection_key = fileVersion; nextAggregate.body = nextBody; session.latestAggregate = nextAggregate; syncPageAggregateScript(session, nextAggregate); } session.externalChangePending = false; session.hasExternalConflict = false; session.lastExternalConflictEnvelope = null; session.bufferDirtyState = 'Clean'; if (!serialized || serialized === session.currentSerialized) { session.lastPersistedSerialized = session.currentSerialized; session.dirty = false; setSessionStatus(session, 'saved'); } else { session.dirty = true; setSessionStatus(session, 'dirty'); queueSessionSave(session); } syncResourceSessionTabGuards(session); applied += 1; }); return applied; }; window.addEventListener('tree:delta', handleTreeExternalChange); window.addEventListener('tree:resync', handleTreeExternalChange); window.addEventListener('mnote:page-ai-tool-write-completed', (event) => { refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-agent-tool'); }); window.addEventListener('mnote:local-upload-editor-save-completed', (event) => { applyLocalUploadEditorSave(event?.detail || {}); }); 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 pageBodyMeta = pageBodySourceMeta(sourceKind, pageBody); 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 || '无标题', pageBodySource: pageBodyMeta.pageBodySource, projectionSource: pageBodyMeta.projectionSource, blockProjectionVersion: pageBodyMeta.blockProjectionVersion, pageBodyLocalCompatFallback: pageBodyMeta.localCompatFallback, pageBodyHardGuard: pageBodyMeta.hardGuard, 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 || '', relativePath: localMarkdownRelativePathFromDocumentId(runtimeDescriptor.bootstrap.documentId), bufferDirtyState: 'Clean', permissionReadOnly: Boolean(permissions.readOnly), userReadOnlyMode: true, readOnly: true, 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); session.readOnly = effectiveSessionReadOnly(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, ensureLocalFolderEventChannel, releaseDocumentSession, scheduleDocumentSessionRelease, sessionHasRecentExternalSignal, sessionHasRecentLocalInput, sessionMatchesDocumentWorkspace, sessionViews, setDocumentSessionUserReadOnlyMode, setSessionStatus, }; };