2026-05-26 01:23:27 +08:00
|
|
|
|
import {
|
|
|
|
|
|
conflictDetectionKeyFromBody,
|
|
|
|
|
|
editorDocumentFromTiptapDocument,
|
|
|
|
|
|
flattenText,
|
2026-05-27 13:50:31 +08:00
|
|
|
|
hydrateMindmapAttrsFromDom,
|
2026-05-26 01:23:27 +08:00
|
|
|
|
legacyBlocksFromEditorDocument,
|
|
|
|
|
|
pageBodyTiptapDocument,
|
2026-05-28 22:01:44 +08:00
|
|
|
|
pageBodyTiptapDocumentSource,
|
2026-05-26 01:23:27 +08:00
|
|
|
|
revisionFromConflictKey,
|
2026-05-27 13:50:31 +08:00
|
|
|
|
textToTiptapDocument,
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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';
|
|
|
|
|
|
|
2026-07-21 05:13:05 +08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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() : '';
|
2026-05-28 22:01:44 +08:00
|
|
|
|
return value || 'local_folder';
|
2026-05-26 01:23:27 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
|
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-07 10:35:21 +08:00
|
|
|
|
const localFolderEventBusChannelKey = (session) => `${String(session?.rootUri || '').trim()}#event-bus`;
|
|
|
|
|
|
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-07-15 23:12:15 +08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-07 10:35:21 +08:00
|
|
|
|
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(),
|
2026-07-15 23:12:15 +08:00
|
|
|
|
changeType: String(item.changeType || item.change_type || '').trim(),
|
2026-07-03 23:20:16 +08:00
|
|
|
|
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(),
|
2026-06-07 10:35:21 +08:00
|
|
|
|
};
|
|
|
|
|
|
}).filter(Boolean);
|
|
|
|
|
|
}
|
|
|
|
|
|
return [payload];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
2026-05-26 01:23:27 +08:00
|
|
|
|
|
|
|
|
|
|
const detachSessionFromLocalFolderChannel = (session) => {
|
|
|
|
|
|
const channel = session.localFolderChannel;
|
|
|
|
|
|
if (!channel) return;
|
|
|
|
|
|
channel.sessions.delete(session.key);
|
|
|
|
|
|
if (channel.sessions.size === 0) {
|
2026-06-07 10:35:21 +08:00
|
|
|
|
if (typeof channel.unsubscribe === 'function') {
|
|
|
|
|
|
channel.unsubscribe();
|
|
|
|
|
|
} else if (channel.eventSource && typeof channel.eventSource.close === 'function') {
|
|
|
|
|
|
try {
|
|
|
|
|
|
channel.eventSource.close();
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
// noop
|
|
|
|
|
|
}
|
2026-05-26 01:23:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
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');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-07 10:35:21 +08:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 01:23:27 +08:00
|
|
|
|
const syncSessionMetaToViews = (session) => {
|
2026-06-07 10:35:21 +08:00
|
|
|
|
syncPageBodySourceDiagnosticsToViews(session);
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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 '';
|
2026-07-25 14:25:37 +08:00
|
|
|
|
const source = String(session.lastExternalWriteSource || '');
|
|
|
|
|
|
// 兼容历史 externalActor 名;产品面统一称 agent tool
|
|
|
|
|
|
if (source === 'mnote-agent-tool' || source === 'mnote-hermes-tool') {
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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;
|
2026-05-28 22:01:44 +08:00
|
|
|
|
const isResourceSession = session.sessionKind === 'resource';
|
|
|
|
|
|
const defaultMessage = isResourceSession
|
|
|
|
|
|
? '当前本地资源文件已在外部更新,请刷新或保存前先处理冲突'
|
|
|
|
|
|
: externalConflictMessage;
|
2026-05-26 01:23:27 +08:00
|
|
|
|
const envelope = {
|
2026-05-28 22:01:44 +08:00
|
|
|
|
code: isResourceSession ? 'local_resource_external_change' : 'local_markdown_external_change',
|
2026-05-26 01:23:27 +08:00
|
|
|
|
documentId: session.documentId,
|
|
|
|
|
|
rootUri: session.rootUri || null,
|
2026-05-28 22:01:44 +08:00
|
|
|
|
path: isResourceSession ? (session.resourcePath || '') : undefined,
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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,
|
2026-05-28 22:01:44 +08:00
|
|
|
|
message: isResourceSession && (!message || message === externalConflictMessage) ? defaultMessage : (message || defaultMessage),
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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);
|
2026-06-07 10:35:21 +08:00
|
|
|
|
applyPageBodySourceMetaToSession(session, nextBody);
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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 || '';
|
2026-07-21 05:13:05 +08:00
|
|
|
|
applySessionPermissionReadOnly(session, Boolean(nextPermissions.readOnly));
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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;
|
2026-05-28 22:01:44 +08:00
|
|
|
|
const envelope = ensureSessionConflictEnvelope(session, message);
|
2026-05-26 01:23:27 +08:00
|
|
|
|
session.hasExternalConflict = true;
|
|
|
|
|
|
if (session.saveTimer) {
|
|
|
|
|
|
window.clearTimeout(session.saveTimer);
|
|
|
|
|
|
session.saveTimer = 0;
|
|
|
|
|
|
}
|
2026-05-28 22:01:44 +08:00
|
|
|
|
const nextMessage = envelope?.message || message || externalConflictMessage;
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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');
|
2026-05-28 22:01:44 +08:00
|
|
|
|
markSessionBufferDirty(session);
|
2026-05-26 01:23:27 +08:00
|
|
|
|
session.saveTimer = window.setTimeout(() => {
|
|
|
|
|
|
session.saveTimer = 0;
|
|
|
|
|
|
void persistSession(session);
|
|
|
|
|
|
}, 650);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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';
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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)
|
|
|
|
|
|
: '';
|
2026-05-26 01:23:27 +08:00
|
|
|
|
const savePayload = session.sessionKind === 'resource'
|
|
|
|
|
|
? {
|
|
|
|
|
|
rootUri: session.rootUri,
|
|
|
|
|
|
path: session.resourcePath,
|
2026-05-28 22:01:44 +08:00
|
|
|
|
expectedFileVersion: expectedFileVersion,
|
|
|
|
|
|
writeIntentId,
|
|
|
|
|
|
saveOperationId,
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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,
|
2026-05-28 22:01:44 +08:00
|
|
|
|
expectedFileVersion: expectedFileVersion,
|
|
|
|
|
|
writeIntentId,
|
|
|
|
|
|
saveOperationId,
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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 || {};
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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;
|
2026-05-28 22:01:44 +08:00
|
|
|
|
session.bufferDirtyState = 'Clean';
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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;
|
2026-05-28 22:01:44 +08:00
|
|
|
|
if (session.sessionKind === 'resource') {
|
|
|
|
|
|
await refreshResourceSessionFromExternalChange(session, source);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-26 01:23:27 +08:00
|
|
|
|
try {
|
2026-05-28 22:01:44 +08:00
|
|
|
|
const bufferState = await fetchSessionBufferState(session);
|
|
|
|
|
|
const dirtyState = String(bufferState?.dirtyState || session.bufferDirtyState || '').trim();
|
|
|
|
|
|
if (dirtyState === 'Deleted') {
|
2026-07-15 23:12:15 +08:00
|
|
|
|
navigateLocalMarkdownDeletedFallback(session);
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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' },
|
|
|
|
|
|
});
|
2026-07-15 23:12:15 +08:00
|
|
|
|
const payload = await response.json().catch(() => null);
|
2026-05-26 01:23:27 +08:00
|
|
|
|
if (!response.ok) {
|
2026-07-15 23:12:15 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-26 17:24:05 +08:00
|
|
|
|
session.latestAggregate = nextAggregate;
|
|
|
|
|
|
syncPageAggregateScript(session, nextAggregate);
|
2026-05-26 01:23:27 +08:00
|
|
|
|
const nextBody = nextAggregate?.body || {};
|
|
|
|
|
|
const nextPermissions = nextAggregate?.head?.permissions || {};
|
|
|
|
|
|
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
|
|
|
|
|
|
const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);
|
2026-06-07 10:35:21 +08:00
|
|
|
|
const nextPageBodyMeta = pageBodySourceMeta(session.sourceKind, nextBody);
|
2026-05-26 01:23:27 +08:00
|
|
|
|
const nextSerialized = JSON.stringify(nextTiptapDocument);
|
|
|
|
|
|
const contentChanged = nextSerialized !== session.currentSerialized;
|
|
|
|
|
|
session.externalChangePending = false;
|
2026-05-28 22:01:44 +08:00
|
|
|
|
session.bufferDirtyState = 'Clean';
|
2026-05-26 01:23:27 +08:00
|
|
|
|
if (!nextConflictKey || !session.lastExternalConflictDetectionKey) {
|
|
|
|
|
|
session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey;
|
2026-06-07 10:35:21 +08:00
|
|
|
|
if (!contentChanged) {
|
|
|
|
|
|
applyPageBodySourceMetaToSession(session, nextBody);
|
|
|
|
|
|
syncPageBodySourceDiagnosticsToViews(session);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-26 01:23:27 +08:00
|
|
|
|
}
|
2026-05-28 22:01:44 +08:00
|
|
|
|
if (nextConflictKey === session.lastExternalConflictDetectionKey) {
|
2026-06-07 10:35:21 +08:00
|
|
|
|
applyPageBodySourceMetaToSession(session, nextBody);
|
|
|
|
|
|
syncPageBodySourceDiagnosticsToViews(session);
|
2026-05-28 22:01:44 +08:00
|
|
|
|
if (nextConflictKey && session.conflictDetectionKey !== nextConflictKey) {
|
|
|
|
|
|
session.conflictDetectionKey = nextConflictKey;
|
|
|
|
|
|
session.fileVersion = nextConflictKey;
|
|
|
|
|
|
syncSessionMetaToViews(session);
|
|
|
|
|
|
}
|
2026-05-26 01:23:27 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
|
|
|
|
|
|
session.latestAggregate = nextAggregate;
|
|
|
|
|
|
syncPageAggregateScript(session, nextAggregate);
|
2026-06-07 10:35:21 +08:00
|
|
|
|
session.pageBodySource = nextPageBodyMeta.pageBodySource;
|
|
|
|
|
|
session.projectionSource = nextPageBodyMeta.projectionSource;
|
|
|
|
|
|
session.blockProjectionVersion = nextPageBodyMeta.blockProjectionVersion;
|
|
|
|
|
|
session.pageBodyLocalCompatFallback = nextPageBodyMeta.localCompatFallback;
|
|
|
|
|
|
session.pageBodyHardGuard = nextPageBodyMeta.hardGuard;
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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 || '';
|
2026-07-21 05:13:05 +08:00
|
|
|
|
applySessionPermissionReadOnly(session, Boolean(nextPermissions.readOnly));
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 01:23:27 +08:00
|
|
|
|
const refreshSessionFromExternalFileChange = async (session) => {
|
|
|
|
|
|
if (session.sourceKind !== 'local_folder') return;
|
|
|
|
|
|
await refreshSessionFromExternalChange(session, 'mnote-web-local-folder-watch');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-07 10:35:21 +08:00
|
|
|
|
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;
|
2026-07-03 23:20:16 +08:00
|
|
|
|
if (item.selfWriteEcho === true || targetSession.saving && shouldSuppressLocalFolderSelfChange(targetSession.documentId, String(item.eventKind || ''))) {
|
2026-06-07 10:35:21 +08:00
|
|
|
|
targetSession.externalChangePending = false;
|
|
|
|
|
|
targetSession.lastSelfSaveSignalAt = Date.now();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-03 23:20:16 +08:00
|
|
|
|
if (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving) {
|
|
|
|
|
|
targetSession.lastExternalChangeSignalAt = Date.now();
|
|
|
|
|
|
targetSession.externalChangePending = true;
|
|
|
|
|
|
markSessionExternalConflict(targetSession, '当前本地资源文件已在外部更新,请刷新或保存前先处理冲突');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-07 10:35:21 +08:00
|
|
|
|
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;
|
2026-07-15 23:12:15 +08:00
|
|
|
|
const changeType = String(item.changeType || '').trim();
|
|
|
|
|
|
if (changeType === 'deleted') {
|
|
|
|
|
|
navigateLocalMarkdownDeletedFallback(targetSession);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-03 23:20:16 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-06-07 10:35:21 +08:00
|
|
|
|
if (shouldSuppressLocalFolderSelfChange(targetSession.documentId, eventKind)) {
|
|
|
|
|
|
targetSession.externalChangePending = false;
|
|
|
|
|
|
targetSession.lastSelfSaveSignalAt = Date.now();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-03 23:20:16 +08:00
|
|
|
|
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',
|
|
|
|
|
|
}));
|
2026-06-07 10:35:21 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
targetSession.lastExternalChangeSignalAt = Date.now();
|
|
|
|
|
|
targetSession.externalChangePending = true;
|
|
|
|
|
|
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 01:23:27 +08:00
|
|
|
|
const ensureLocalFolderEventChannel = (session) => {
|
2026-06-07 10:35:21 +08:00
|
|
|
|
if (session.sourceKind !== 'local_folder' || !session.rootUri) {
|
2026-05-26 01:23:27 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-07 10:35:21 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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);
|
2026-05-28 22:01:44 +08:00
|
|
|
|
if (session.sessionKind === 'resource') {
|
|
|
|
|
|
url.searchParams.set('resourcePath', resourceWatchPath(session));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
url.searchParams.set('documentId', session.documentId);
|
|
|
|
|
|
}
|
2026-05-26 01:23:27 +08:00
|
|
|
|
const eventSource = new EventSource(url.toString());
|
|
|
|
|
|
channel = {
|
|
|
|
|
|
key: channelKey,
|
|
|
|
|
|
rootUri: session.rootUri,
|
|
|
|
|
|
documentId: session.documentId,
|
2026-05-28 22:01:44 +08:00
|
|
|
|
resourcePath: session.sessionKind === 'resource' ? resourceWatchPath(session) : '',
|
2026-05-26 01:23:27 +08:00
|
|
|
|
eventSource,
|
|
|
|
|
|
sessions: new Map(),
|
|
|
|
|
|
};
|
|
|
|
|
|
eventSource.addEventListener('change', (event) => {
|
2026-06-07 10:35:21 +08:00
|
|
|
|
handleLocalFolderEventPayloadForSessions(channel, parseLocalFolderEventPayload(event));
|
2026-05-26 01:23:27 +08:00
|
|
|
|
});
|
|
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 01:23:27 +08:00
|
|
|
|
window.addEventListener('tree:delta', handleTreeExternalChange);
|
|
|
|
|
|
window.addEventListener('tree:resync', handleTreeExternalChange);
|
|
|
|
|
|
window.addEventListener('mnote:page-ai-tool-write-completed', (event) => {
|
2026-07-25 14:25:37 +08:00
|
|
|
|
refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-agent-tool');
|
2026-05-26 01:23:27 +08:00
|
|
|
|
});
|
2026-05-28 22:01:44 +08:00
|
|
|
|
window.addEventListener('mnote:local-upload-editor-save-completed', (event) => {
|
|
|
|
|
|
applyLocalUploadEditorSave(event?.detail || {});
|
|
|
|
|
|
});
|
2026-05-26 01:23:27 +08:00
|
|
|
|
|
|
|
|
|
|
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);
|
2026-06-07 10:35:21 +08:00
|
|
|
|
const pageBodyMeta = pageBodySourceMeta(sourceKind, pageBody);
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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 || '无标题',
|
2026-06-07 10:35:21 +08:00
|
|
|
|
pageBodySource: pageBodyMeta.pageBodySource,
|
|
|
|
|
|
projectionSource: pageBodyMeta.projectionSource,
|
|
|
|
|
|
blockProjectionVersion: pageBodyMeta.blockProjectionVersion,
|
|
|
|
|
|
pageBodyLocalCompatFallback: pageBodyMeta.localCompatFallback,
|
|
|
|
|
|
pageBodyHardGuard: pageBodyMeta.hardGuard,
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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 || '',
|
2026-05-28 22:01:44 +08:00
|
|
|
|
relativePath: localMarkdownRelativePathFromDocumentId(runtimeDescriptor.bootstrap.documentId),
|
|
|
|
|
|
bufferDirtyState: 'Clean',
|
2026-07-21 05:13:05 +08:00
|
|
|
|
permissionReadOnly: Boolean(permissions.readOnly),
|
|
|
|
|
|
userReadOnlyMode: true,
|
|
|
|
|
|
readOnly: true,
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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);
|
2026-07-21 05:13:05 +08:00
|
|
|
|
session.readOnly = effectiveSessionReadOnly(session);
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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,
|
2026-05-28 22:01:44 +08:00
|
|
|
|
ensureLocalFolderEventChannel,
|
2026-05-26 01:23:27 +08:00
|
|
|
|
releaseDocumentSession,
|
|
|
|
|
|
scheduleDocumentSessionRelease,
|
|
|
|
|
|
sessionHasRecentExternalSignal,
|
|
|
|
|
|
sessionHasRecentLocalInput,
|
|
|
|
|
|
sessionMatchesDocumentWorkspace,
|
|
|
|
|
|
sessionViews,
|
2026-07-21 05:13:05 +08:00
|
|
|
|
setDocumentSessionUserReadOnlyMode,
|
2026-05-26 01:23:27 +08:00
|
|
|
|
setSessionStatus,
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|