feat: consolidate local-first mnote web runtime
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
hydrateMindmapAttrsFromDom,
|
||||
legacyBlocksFromEditorDocument,
|
||||
pageBodyTiptapDocument,
|
||||
pageBodyTiptapDocumentSource,
|
||||
revisionFromConflictKey,
|
||||
textToTiptapDocument,
|
||||
toTiptapDocument,
|
||||
@@ -102,7 +103,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
|
||||
const normalizeSessionSourceKind = (bootstrap) => {
|
||||
const value = typeof bootstrap?.sourceKind === 'string' ? bootstrap.sourceKind.trim() : '';
|
||||
return value || 'convex_workspace';
|
||||
return value || 'local_folder';
|
||||
};
|
||||
|
||||
const buildDocumentSessionKey = (bootstrap) => {
|
||||
@@ -115,7 +116,90 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
|
||||
const sessionViews = (session) => Array.from(session.views.values());
|
||||
|
||||
const localFolderEventChannelKey = (session) => `${String(session?.rootUri || '').trim()}#${String(session?.documentId || '').trim()}`;
|
||||
const resourceWatchPath = (session) => String(session?.resourcePath || session?.relativePath || '').trim();
|
||||
|
||||
const localFolderEventChannelKey = (session) => {
|
||||
const scope = session?.sessionKind === 'resource'
|
||||
? `resource:${resourceWatchPath(session)}`
|
||||
: String(session?.documentId || '').trim();
|
||||
return `${String(session?.rootUri || '').trim()}#${scope}`;
|
||||
};
|
||||
|
||||
const localMarkdownRelativePathFromDocumentId = (documentId) => {
|
||||
const value = String(documentId || '').trim();
|
||||
if (!value.startsWith('local-md:')) return '';
|
||||
return value.slice('local-md:'.length).replace(/~2F/g, '/');
|
||||
};
|
||||
|
||||
const sessionRelativePath = (session) => (
|
||||
String(session?.relativePath || '').trim()
|
||||
|| localMarkdownRelativePathFromDocumentId(session?.documentId)
|
||||
);
|
||||
|
||||
const sessionBufferStateUrl = (session) => {
|
||||
if (!session || session.sourceKind !== 'local_folder' || !session.rootUri || !session.documentId) return null;
|
||||
const url = new URL('/api/documents/buffer-state', window.location.origin);
|
||||
url.searchParams.set('documentId', session.documentId);
|
||||
url.searchParams.set('sourceKind', session.sourceKind);
|
||||
url.searchParams.set('rootUri', session.rootUri);
|
||||
if (session.workspaceId) url.searchParams.set('workspaceId', session.workspaceId);
|
||||
const relativePath = sessionRelativePath(session);
|
||||
if (relativePath) url.searchParams.set('relativePath', relativePath);
|
||||
return url;
|
||||
};
|
||||
|
||||
const applyBufferStateToSession = (session, bufferState) => {
|
||||
if (!session || !bufferState || typeof bufferState !== 'object') return;
|
||||
const dirtyState = String(bufferState.dirtyState || '').trim();
|
||||
if (dirtyState) session.bufferDirtyState = dirtyState;
|
||||
if (typeof bufferState.fileVersion === 'string' && bufferState.fileVersion.trim()) {
|
||||
session.fileVersion = bufferState.fileVersion.trim();
|
||||
if (!session.conflictDetectionKey) session.conflictDetectionKey = session.fileVersion;
|
||||
}
|
||||
if (typeof bufferState.externalActor === 'string' && bufferState.externalActor.trim()) {
|
||||
session.lastExternalWriteSource = bufferState.externalActor.trim();
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSessionBufferState = async (session) => {
|
||||
const url = sessionBufferStateUrl(session);
|
||||
if (!url) return null;
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
cache: 'no-store',
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || payload.ok !== true) return null;
|
||||
const bufferState = payload.result || null;
|
||||
applyBufferStateToSession(session, bufferState);
|
||||
return bufferState;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const markSessionBufferDirty = (session) => {
|
||||
if (!session || session.sourceKind !== 'local_folder' || session.sessionKind === 'resource') return;
|
||||
const relativePath = sessionRelativePath(session);
|
||||
if (!relativePath) return;
|
||||
const serialized = String(session.currentSerialized || '');
|
||||
const contentHash = `browser-dirty:${serialized.length}:${serialized.charCodeAt(0) || 0}:${serialized.charCodeAt(serialized.length - 1) || 0}`;
|
||||
void fetch('/api/documents/buffer-state/dirty', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
documentId: session.documentId,
|
||||
workspaceId: session.workspaceId,
|
||||
sourceKind: session.sourceKind,
|
||||
rootUri: session.rootUri,
|
||||
relativePath,
|
||||
contentHash,
|
||||
}),
|
||||
}).then((response) => response.json().catch(() => null)).then((payload) => {
|
||||
if (payload && payload.ok === true) applyBufferStateToSession(session, payload.result);
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
const detachSessionFromLocalFolderChannel = (session) => {
|
||||
const channel = session.localFolderChannel;
|
||||
@@ -416,16 +500,21 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
const ensureSessionConflictEnvelope = (session, message) => {
|
||||
if (!session) return null;
|
||||
if (session.lastExternalConflictEnvelope) return session.lastExternalConflictEnvelope;
|
||||
const isResourceSession = session.sessionKind === 'resource';
|
||||
const defaultMessage = isResourceSession
|
||||
? '当前本地资源文件已在外部更新,请刷新或保存前先处理冲突'
|
||||
: externalConflictMessage;
|
||||
const envelope = {
|
||||
code: 'local_markdown_external_change',
|
||||
code: isResourceSession ? 'local_resource_external_change' : 'local_markdown_external_change',
|
||||
documentId: session.documentId,
|
||||
rootUri: session.rootUri || null,
|
||||
path: isResourceSession ? (session.resourcePath || '') : undefined,
|
||||
currentDiskVersion: session.conflictDetectionKey || session.fileVersion || null,
|
||||
editorBaseVersion: session.lastExternalConflictDetectionKey || session.conflictDetectionKey || session.fileVersion || null,
|
||||
externalActor: session.lastExternalWriteSource || null,
|
||||
dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'),
|
||||
bufferFileVersion: session.fileVersion || session.conflictDetectionKey || null,
|
||||
message: message || externalConflictMessage,
|
||||
message: isResourceSession && (!message || message === externalConflictMessage) ? defaultMessage : (message || defaultMessage),
|
||||
suggestedActions: ['accept_disk', 'keep_editor', 'open_diff', 'merge'],
|
||||
};
|
||||
session.lastExternalConflictEnvelope = envelope;
|
||||
@@ -802,13 +891,13 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
|
||||
const markSessionExternalConflict = (session, message) => {
|
||||
session.externalChangePending = false;
|
||||
ensureSessionConflictEnvelope(session, message);
|
||||
const envelope = ensureSessionConflictEnvelope(session, message);
|
||||
session.hasExternalConflict = true;
|
||||
if (session.saveTimer) {
|
||||
window.clearTimeout(session.saveTimer);
|
||||
session.saveTimer = 0;
|
||||
}
|
||||
const nextMessage = message || externalConflictMessage;
|
||||
const nextMessage = envelope?.message || message || externalConflictMessage;
|
||||
setSessionStatus(session, 'external-change-conflict', nextMessage);
|
||||
renderSessionConflictSurface(session, nextMessage);
|
||||
};
|
||||
@@ -817,12 +906,23 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
if (session.readOnly || session.hasExternalConflict) return;
|
||||
if (session.saveTimer) window.clearTimeout(session.saveTimer);
|
||||
setSessionStatus(session, 'dirty');
|
||||
markSessionBufferDirty(session);
|
||||
session.saveTimer = window.setTimeout(() => {
|
||||
session.saveTimer = 0;
|
||||
void persistSession(session);
|
||||
}, 650);
|
||||
};
|
||||
|
||||
const makeSaveOperationToken = (prefix, session) => {
|
||||
const doc = String(session?.documentId || session?.resourcePath || 'unknown')
|
||||
.replace(/[^a-zA-Z0-9._:-]+/g, '-')
|
||||
.slice(0, 80);
|
||||
const random = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
return `${prefix}:${doc}:${random}`;
|
||||
};
|
||||
|
||||
const persistSession = async (session) => {
|
||||
if (session.readOnly || session.saving || session.hasExternalConflict) return;
|
||||
const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0];
|
||||
@@ -841,11 +941,30 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
const editorDocument = editorDocumentFromTiptapDocument({ documentId: session.documentId }, session.currentTiptapDocument);
|
||||
const content = legacyBlocksFromEditorDocument(editorDocument);
|
||||
const saveEndpoint = session.saveEndpoint || '/api/documents/save';
|
||||
const expectedFileVersion = (
|
||||
session.sourceKind === 'local_folder'
|
||||
&& !session.lastExternalConflictEnvelope
|
||||
&& session.lastExternalConflictDetectionKey
|
||||
)
|
||||
? session.lastExternalConflictDetectionKey
|
||||
: session.conflictDetectionKey;
|
||||
if (session.sourceKind === 'local_folder' && expectedFileVersion && expectedFileVersion !== session.conflictDetectionKey) {
|
||||
session.conflictDetectionKey = expectedFileVersion;
|
||||
session.fileVersion = expectedFileVersion;
|
||||
}
|
||||
const writeIntentId = session.sourceKind === 'local_folder'
|
||||
? makeSaveOperationToken('intent:tiptap', session)
|
||||
: '';
|
||||
const saveOperationId = session.sourceKind === 'local_folder'
|
||||
? makeSaveOperationToken('save:tiptap', session)
|
||||
: '';
|
||||
const savePayload = session.sessionKind === 'resource'
|
||||
? {
|
||||
rootUri: session.rootUri,
|
||||
path: session.resourcePath,
|
||||
expectedFileVersion: session.conflictDetectionKey,
|
||||
expectedFileVersion: expectedFileVersion,
|
||||
writeIntentId,
|
||||
saveOperationId,
|
||||
contentFormat: 'editorBlocks',
|
||||
editorSource: 'tiptap-resource-tab',
|
||||
editorDocument,
|
||||
@@ -859,7 +978,9 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
sourceKind: session.sourceKind,
|
||||
rootUri: session.rootUri,
|
||||
revision: session.revision,
|
||||
expectedFileVersion: session.conflictDetectionKey,
|
||||
expectedFileVersion: expectedFileVersion,
|
||||
writeIntentId,
|
||||
saveOperationId,
|
||||
contentFormat: 'editorBlocks',
|
||||
editorSource: 'tiptap',
|
||||
editorDocument,
|
||||
@@ -889,6 +1010,16 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
throw new Error(message);
|
||||
}
|
||||
const saved = result.result || {};
|
||||
if (typeof saved.writeIntentId === 'string' && saved.writeIntentId.trim()) {
|
||||
session.lastWriteIntentId = saved.writeIntentId.trim();
|
||||
} else if (writeIntentId) {
|
||||
session.lastWriteIntentId = writeIntentId;
|
||||
}
|
||||
if (typeof saved.saveOperationId === 'string' && saved.saveOperationId.trim()) {
|
||||
session.lastSaveOperationId = saved.saveOperationId.trim();
|
||||
} else if (saveOperationId) {
|
||||
session.lastSaveOperationId = saveOperationId;
|
||||
}
|
||||
if (Number.isInteger(saved.revision)) session.revision = saved.revision;
|
||||
if (typeof saved.conflict_detection_key === 'string' && saved.conflict_detection_key.trim()) {
|
||||
session.conflictDetectionKey = saved.conflict_detection_key.trim();
|
||||
@@ -902,6 +1033,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
if (session.conflictDetectionKey) session.lastExternalConflictDetectionKey = session.conflictDetectionKey;
|
||||
session.hasExternalConflict = false;
|
||||
session.externalChangePending = false;
|
||||
session.bufferDirtyState = 'Clean';
|
||||
session.lastUserInputAt = 0;
|
||||
syncSessionMetaToViews(session);
|
||||
session.lastPersistedSerialized = serialized;
|
||||
@@ -951,7 +1083,47 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
if (document.hidden) return;
|
||||
if (!session || session.views.size === 0) return;
|
||||
if (session.sourceKind === 'local_folder' && !session.rootUri) return;
|
||||
if (session.sessionKind === 'resource') {
|
||||
await refreshResourceSessionFromExternalChange(session, source);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const bufferState = await fetchSessionBufferState(session);
|
||||
const dirtyState = String(bufferState?.dirtyState || session.bufferDirtyState || '').trim();
|
||||
if (dirtyState === 'Deleted') {
|
||||
session.lastExternalConflictEnvelope = {
|
||||
code: 'local_markdown_deleted',
|
||||
documentId: session.documentId,
|
||||
rootUri: session.rootUri || null,
|
||||
currentDiskVersion: bufferState?.fileVersion || session.conflictDetectionKey || session.fileVersion || null,
|
||||
editorBaseVersion: session.conflictDetectionKey || session.fileVersion || null,
|
||||
externalActor: bufferState?.externalActor || null,
|
||||
dirtyState,
|
||||
bufferFileVersion: bufferState?.fileVersion || session.fileVersion || null,
|
||||
message: '当前本地 Markdown 文件已被删除,请恢复文件或关闭当前标签',
|
||||
suggestedActions: ['keep_editor', 'open_diff'],
|
||||
};
|
||||
markSessionExternalConflict(session, session.lastExternalConflictEnvelope.message);
|
||||
return;
|
||||
}
|
||||
if (session.hasExternalConflict) {
|
||||
markSessionExternalConflict(session, conflictMessageFromEnvelope(session, {
|
||||
message: externalConflictMessage,
|
||||
dirtyState,
|
||||
fileVersion: bufferState?.fileVersion || null,
|
||||
externalActor: bufferState?.externalActor || null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
|
||||
session.externalChangePending = true;
|
||||
if (session.dirty && !session.saveTimer && !session.saving) {
|
||||
queueSessionSave(session);
|
||||
} else if (!session.saving) {
|
||||
setSessionStatus(session, 'dirty');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const response = await fetch(pageAggregateUrl({
|
||||
documentId: session.documentId,
|
||||
sourceKind: session.sourceKind,
|
||||
@@ -999,13 +1171,17 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
const nextSerialized = JSON.stringify(nextTiptapDocument);
|
||||
const contentChanged = nextSerialized !== session.currentSerialized;
|
||||
session.externalChangePending = false;
|
||||
session.bufferDirtyState = 'Clean';
|
||||
if (!nextConflictKey || !session.lastExternalConflictDetectionKey) {
|
||||
session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey;
|
||||
if (!contentChanged) return;
|
||||
}
|
||||
if (nextConflictKey === session.lastExternalConflictDetectionKey && !contentChanged) return;
|
||||
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
|
||||
markSessionExternalConflict(session, externalConflictMessage);
|
||||
if (nextConflictKey === session.lastExternalConflictDetectionKey) {
|
||||
if (nextConflictKey && session.conflictDetectionKey !== nextConflictKey) {
|
||||
session.conflictDetectionKey = nextConflictKey;
|
||||
session.fileVersion = nextConflictKey;
|
||||
syncSessionMetaToViews(session);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
|
||||
@@ -1031,6 +1207,78 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshResourceSessionFromExternalChange = async (session, source) => {
|
||||
if (document.hidden) return;
|
||||
if (!session || session.views.size === 0) return;
|
||||
if (session.sourceKind !== 'local_folder' || !session.rootUri || !session.resourcePath) return;
|
||||
let nextResource = null;
|
||||
try {
|
||||
nextResource = await fetchLatestResourceSnapshot(session);
|
||||
} catch (error) {
|
||||
session.lastExternalConflictEnvelope = {
|
||||
code: 'local_resource_deleted',
|
||||
documentId: session.documentId,
|
||||
rootUri: session.rootUri || null,
|
||||
path: session.resourcePath || '',
|
||||
currentDiskVersion: null,
|
||||
editorBaseVersion: session.conflictDetectionKey || session.fileVersion || null,
|
||||
externalActor: 'external-editor',
|
||||
dirtyState: 'Deleted',
|
||||
message: '当前本地资源文件已被删除或移动,请恢复文件或关闭当前标签',
|
||||
suggestedActions: ['keep_editor', 'open_diff'],
|
||||
};
|
||||
markSessionExternalConflict(session, session.lastExternalConflictEnvelope.message);
|
||||
return;
|
||||
}
|
||||
const nextConflictKey = String(nextResource?.fileVersion || nextResource?.conflictDetectionKey || '').trim();
|
||||
const nextTiptapDocument = toTiptapDocument(nextResource?.content, nextResource?.text || '');
|
||||
const nextSerialized = JSON.stringify(nextTiptapDocument);
|
||||
const contentChanged = nextSerialized !== session.currentSerialized;
|
||||
session.externalChangePending = false;
|
||||
if (!nextConflictKey || !session.lastExternalConflictDetectionKey) {
|
||||
session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey;
|
||||
if (!contentChanged) return;
|
||||
}
|
||||
if (nextConflictKey === session.lastExternalConflictDetectionKey) {
|
||||
if (nextConflictKey && session.conflictDetectionKey !== nextConflictKey) {
|
||||
session.conflictDetectionKey = nextConflictKey;
|
||||
session.fileVersion = nextConflictKey;
|
||||
syncSessionMetaToViews(session);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
|
||||
session.lastExternalConflictEnvelope = {
|
||||
code: 'local_resource_external_change',
|
||||
documentId: session.documentId,
|
||||
rootUri: session.rootUri || null,
|
||||
path: session.resourcePath || '',
|
||||
currentDiskVersion: nextConflictKey || null,
|
||||
editorBaseVersion: session.conflictDetectionKey || session.fileVersion || null,
|
||||
externalActor: 'external-editor',
|
||||
dirtyState: session.dirty ? 'Dirty' : 'Clean',
|
||||
message: '当前本地资源文件已在外部更新,请刷新或保存前先处理冲突',
|
||||
suggestedActions: ['accept_disk', 'keep_editor', 'open_diff'],
|
||||
};
|
||||
markSessionExternalConflict(session, session.lastExternalConflictEnvelope.message);
|
||||
return;
|
||||
}
|
||||
session.currentTiptapDocument = nextTiptapDocument;
|
||||
session.currentSerialized = nextSerialized;
|
||||
session.lastPersistedSerialized = nextSerialized;
|
||||
session.conflictDetectionKey = nextConflictKey;
|
||||
session.fileVersion = nextConflictKey;
|
||||
session.lastExternalConflictDetectionKey = nextConflictKey || '';
|
||||
session.dirty = false;
|
||||
session.hasExternalConflict = false;
|
||||
session.lastUserInputAt = 0;
|
||||
sessionViews(session).forEach((view) => {
|
||||
if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-resource-watch');
|
||||
});
|
||||
setSessionStatus(session, 'synced-external-change');
|
||||
syncResourceSessionTabGuards(session);
|
||||
};
|
||||
|
||||
const refreshSessionFromExternalFileChange = async (session) => {
|
||||
if (session.sourceKind !== 'local_folder') return;
|
||||
await refreshSessionFromExternalChange(session, 'mnote-web-local-folder-watch');
|
||||
@@ -1045,12 +1293,17 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
if (!channel) {
|
||||
const url = new URL('/api/local-folder/events', window.location.origin);
|
||||
url.searchParams.set('rootUri', session.rootUri);
|
||||
url.searchParams.set('documentId', session.documentId);
|
||||
if (session.sessionKind === 'resource') {
|
||||
url.searchParams.set('resourcePath', resourceWatchPath(session));
|
||||
} else {
|
||||
url.searchParams.set('documentId', session.documentId);
|
||||
}
|
||||
const eventSource = new EventSource(url.toString());
|
||||
channel = {
|
||||
key: channelKey,
|
||||
rootUri: session.rootUri,
|
||||
documentId: session.documentId,
|
||||
resourcePath: session.sessionKind === 'resource' ? resourceWatchPath(session) : '',
|
||||
eventSource,
|
||||
sessions: new Map(),
|
||||
};
|
||||
@@ -1060,6 +1313,19 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
Array.from(channel.sessions.values()).forEach((targetSession) => {
|
||||
if (!targetSession || targetSession.views.size === 0) return;
|
||||
const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : '';
|
||||
const relativePath = typeof payload.relativePath === 'string' ? payload.relativePath.trim() : '';
|
||||
if (targetSession.sessionKind === 'resource') {
|
||||
if (!relativePath || relativePath !== resourceWatchPath(targetSession)) return;
|
||||
if (targetSession.saving) {
|
||||
targetSession.externalChangePending = false;
|
||||
targetSession.lastSelfSaveSignalAt = Date.now();
|
||||
return;
|
||||
}
|
||||
targetSession.lastExternalChangeSignalAt = Date.now();
|
||||
targetSession.externalChangePending = true;
|
||||
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-resource-watch');
|
||||
return;
|
||||
}
|
||||
if (!documentId) return;
|
||||
const eventKind = String(payload.eventKind || '');
|
||||
const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId);
|
||||
@@ -1076,10 +1342,6 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
}
|
||||
targetSession.lastExternalChangeSignalAt = Date.now();
|
||||
targetSession.externalChangePending = true;
|
||||
if (targetsCurrentDocument && (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving)) {
|
||||
markSessionExternalConflict(targetSession, externalConflictMessage);
|
||||
return;
|
||||
}
|
||||
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch');
|
||||
});
|
||||
});
|
||||
@@ -1277,11 +1539,67 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
return scheduled;
|
||||
};
|
||||
|
||||
const applyLocalUploadEditorSave = (detail) => {
|
||||
const documentId = String(detail?.documentId || '').trim();
|
||||
const rootUri = String(detail?.rootUri || '').trim();
|
||||
const fileVersion = String(detail?.fileVersion || '').trim();
|
||||
const writeIntentId = String(detail?.writeIntentId || '').trim();
|
||||
const saveOperationId = String(detail?.saveOperationId || '').trim();
|
||||
const serialized = String(detail?.serialized || '');
|
||||
if (!documentId || !rootUri || !fileVersion) return 0;
|
||||
let applied = 0;
|
||||
Array.from(documentSessionRegistry.values()).forEach((session) => {
|
||||
if (session.sourceKind !== 'local_folder') return;
|
||||
if (session.documentId !== documentId) return;
|
||||
if (String(session.rootUri || '').trim() !== rootUri) return;
|
||||
if (session.saveTimer) {
|
||||
window.clearTimeout(session.saveTimer);
|
||||
session.saveTimer = 0;
|
||||
}
|
||||
session.conflictDetectionKey = fileVersion;
|
||||
session.fileVersion = fileVersion;
|
||||
session.lastExternalConflictDetectionKey = fileVersion;
|
||||
if (writeIntentId) session.lastWriteIntentId = writeIntentId;
|
||||
if (saveOperationId) session.lastSaveOperationId = saveOperationId;
|
||||
if (session.latestAggregate && typeof session.latestAggregate === 'object') {
|
||||
const nextAggregate = { ...session.latestAggregate };
|
||||
const nextBody = nextAggregate.body && typeof nextAggregate.body === 'object'
|
||||
? { ...nextAggregate.body }
|
||||
: {};
|
||||
nextBody.fileVersion = fileVersion;
|
||||
nextBody.conflictDetectionKey = fileVersion;
|
||||
nextBody.conflict_detection_key = fileVersion;
|
||||
nextAggregate.body = nextBody;
|
||||
session.latestAggregate = nextAggregate;
|
||||
syncPageAggregateScript(session, nextAggregate);
|
||||
}
|
||||
session.externalChangePending = false;
|
||||
session.hasExternalConflict = false;
|
||||
session.lastExternalConflictEnvelope = null;
|
||||
session.bufferDirtyState = 'Clean';
|
||||
if (!serialized || serialized === session.currentSerialized) {
|
||||
session.lastPersistedSerialized = session.currentSerialized;
|
||||
session.dirty = false;
|
||||
setSessionStatus(session, 'saved');
|
||||
} else {
|
||||
session.dirty = true;
|
||||
setSessionStatus(session, 'dirty');
|
||||
queueSessionSave(session);
|
||||
}
|
||||
syncResourceSessionTabGuards(session);
|
||||
applied += 1;
|
||||
});
|
||||
return applied;
|
||||
};
|
||||
|
||||
window.addEventListener('tree:delta', handleTreeExternalChange);
|
||||
window.addEventListener('tree:resync', handleTreeExternalChange);
|
||||
window.addEventListener('mnote:page-ai-tool-write-completed', (event) => {
|
||||
refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-hermes-tool');
|
||||
});
|
||||
window.addEventListener('mnote:local-upload-editor-save-completed', (event) => {
|
||||
applyLocalUploadEditorSave(event?.detail || {});
|
||||
});
|
||||
|
||||
const createDocumentSession = (runtimeDescriptor) => {
|
||||
const pageBody = runtimeDescriptor.aggregate.body || {};
|
||||
@@ -1291,6 +1609,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
const keyRevision = revisionFromConflictKey(conflictDetectionKey);
|
||||
const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap);
|
||||
const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);
|
||||
const pageBodySource = pageBodyTiptapDocumentSource(pageBody, '');
|
||||
const session = {
|
||||
key: buildDocumentSessionKey(runtimeDescriptor.bootstrap),
|
||||
documentId: runtimeDescriptor.bootstrap.documentId,
|
||||
@@ -1301,6 +1620,9 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
pageAggregateScriptId: runtimeDescriptor.bootstrap.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__',
|
||||
latestAggregate: runtimeDescriptor.aggregate,
|
||||
title: runtimeDescriptor.aggregate.head?.title || '无标题',
|
||||
pageBodySource,
|
||||
projectionSource: String(pageBody.projectionSource || pageBody.projection_source || '').trim(),
|
||||
blockProjectionVersion: String(pageBody.blockProjectionVersion || pageBody.block_projection_version || '').trim(),
|
||||
currentTiptapDocument: tiptapDocument,
|
||||
currentSerialized: JSON.stringify(tiptapDocument),
|
||||
lastPersistedSerialized: JSON.stringify(tiptapDocument),
|
||||
@@ -1308,6 +1630,8 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
conflictDetectionKey,
|
||||
fileVersion: typeof pageBody.fileVersion === 'string' ? pageBody.fileVersion : conflictDetectionKey,
|
||||
lastExternalConflictDetectionKey: conflictDetectionKey || '',
|
||||
relativePath: localMarkdownRelativePathFromDocumentId(runtimeDescriptor.bootstrap.documentId),
|
||||
bufferDirtyState: 'Clean',
|
||||
readOnly: Boolean(permissions.readOnly),
|
||||
dirty: false,
|
||||
saving: false,
|
||||
@@ -1355,6 +1679,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
markSessionExternalConflict,
|
||||
queueSessionSave,
|
||||
refreshSessionFromExternalChange,
|
||||
ensureLocalFolderEventChannel,
|
||||
releaseDocumentSession,
|
||||
scheduleDocumentSessionRelease,
|
||||
sessionHasRecentExternalSignal,
|
||||
|
||||
Reference in New Issue
Block a user