@lemoncat7/dsh-knowledge 2.7.0 → 2.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -1
- package/lib/api.d.ts.map +1 -1
- package/lib/api.js +13 -2
- package/lib/api.js.map +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2 -0
- package/lib/index.js.map +1 -1
- package/lib/local-provider.d.ts +2 -2
- package/lib/local-provider.d.ts.map +1 -1
- package/lib/local-provider.js +12 -5
- package/lib/local-provider.js.map +1 -1
- package/lib/note-recording.d.ts +21 -0
- package/lib/note-recording.d.ts.map +1 -0
- package/lib/note-recording.js +32 -0
- package/lib/note-recording.js.map +1 -0
- package/lib/note-reference-tools.js +3 -2
- package/lib/note-reference-tools.js.map +1 -1
- package/lib/note-tools.d.ts +3 -2
- package/lib/note-tools.d.ts.map +1 -1
- package/lib/note-tools.js +35 -12
- package/lib/note-tools.js.map +1 -1
- package/lib/notes/store.d.ts +5 -1
- package/lib/notes/store.d.ts.map +1 -1
- package/lib/notes/store.js +11 -2
- package/lib/notes/store.js.map +1 -1
- package/lib/provider.d.ts +2 -2
- package/lib/provider.d.ts.map +1 -1
- package/lib/remote-provider.d.ts +2 -2
- package/lib/remote-provider.d.ts.map +1 -1
- package/lib/remote-provider.js +6 -5
- package/lib/remote-provider.js.map +1 -1
- package/lib/tools.js +1 -1
- package/lib/tools.js.map +1 -1
- package/lib/web.js +1 -1
- package/lib/web.js.map +1 -1
- package/lib/writeback/queue.d.ts.map +1 -1
- package/lib/writeback/queue.js +10 -7
- package/lib/writeback/queue.js.map +1 -1
- package/package.json +1 -1
- package/web/app.js +174 -23
- package/web/document-sync-ui.js +23 -0
- package/web/document-sync.js +43 -0
- package/web/styles.css +14 -0
package/web/app.js
CHANGED
|
@@ -18,6 +18,7 @@ const { actionButton, badge, createToastPresenter, element, interfaceIcon, paneT
|
|
|
18
18
|
const { renderMenu: renderDocumentMenu, closeMenus: closeDocumentMenus } = documentActionsModule.createDocumentMenuPresenter(uiModule)
|
|
19
19
|
const readModelCatalog = modelCatalogModule.createModelCatalogLoader()
|
|
20
20
|
const { createWritebackWorkspace } = await import(moduleUrl('writeback-workspace'))
|
|
21
|
+
const { createDocumentSync } = await import(moduleUrl('document-sync'))
|
|
21
22
|
let writebackWorkspace
|
|
22
23
|
const TOKEN_KEY = 'dsh-knowledge.session-token'
|
|
23
24
|
const TYPES = ['preference', 'fact', 'decision', 'procedure', 'lesson']
|
|
@@ -352,7 +353,7 @@ function updateLoadingPhase(request, label, progress) {
|
|
|
352
353
|
}
|
|
353
354
|
|
|
354
355
|
async function saveBeforeNavigation() {
|
|
355
|
-
if (state.view === 'notes' && state.notes.dirty) return saveNoteDocument()
|
|
356
|
+
if (state.view === 'notes' && (state.notes.dirty || state.notes.titleDirty || state.notes.renaming)) return saveNoteDocument()
|
|
356
357
|
return saveBeforeLeavingDocument()
|
|
357
358
|
}
|
|
358
359
|
|
|
@@ -796,6 +797,7 @@ async function saveDocumentEditor(workspace = activeDocumentWorkspace()) {
|
|
|
796
797
|
if (!workspace) return false
|
|
797
798
|
const editor = workspace.view.editor
|
|
798
799
|
if (!editor || !editor.dirty) return true
|
|
800
|
+
if (editor.saving) return false
|
|
799
801
|
if (!editor.isNew && editor.documentState !== 'open') {
|
|
800
802
|
showToast('这篇文档已经结束并封存;请先重新打开。', 'error')
|
|
801
803
|
return false
|
|
@@ -805,26 +807,32 @@ async function saveDocumentEditor(workspace = activeDocumentWorkspace()) {
|
|
|
805
807
|
return false
|
|
806
808
|
}
|
|
807
809
|
editor.saveState = '正在保存…'
|
|
810
|
+
editor.saving = true
|
|
808
811
|
updateEditorSaveState(editor.saveState)
|
|
809
812
|
try {
|
|
810
813
|
const draft = editorDraft(editor)
|
|
811
814
|
const saved = editor.isNew
|
|
812
815
|
? await api('entries', { method: 'POST', body: { draft } })
|
|
813
|
-
: await api(`entries/${encodeURIComponent(editor.id)}`, { method: 'PUT', body: { draft } })
|
|
816
|
+
: await api(`entries/${encodeURIComponent(editor.id)}`, { method: 'PUT', body: { draft, expectedVersion: editor.version } })
|
|
814
817
|
editor.id = saved.id
|
|
815
818
|
editor.isNew = false
|
|
816
|
-
editor.dirty =
|
|
819
|
+
editor.dirty = JSON.stringify(editorDraft(editor)) !== JSON.stringify(draft)
|
|
820
|
+
editor.version = saved.version
|
|
821
|
+
editor.documentState = saved.documentState
|
|
817
822
|
editor.updatedAt = saved.updatedAt
|
|
818
|
-
editor.saveState = '已保存'
|
|
823
|
+
editor.saveState = editor.dirty ? '未保存' : '已保存'
|
|
819
824
|
workspace.view.documentId = saved.id
|
|
820
825
|
updateEditorSaveState(editor.saveState)
|
|
821
826
|
await reloadDocumentWorkspace(workspace)
|
|
822
|
-
return
|
|
827
|
+
return !editor.dirty
|
|
823
828
|
} catch (error) {
|
|
824
829
|
editor.saveState = '保存失败'
|
|
825
830
|
updateEditorSaveState(editor.saveState)
|
|
826
831
|
showToast(friendlyError(error), 'error')
|
|
832
|
+
if (error.status === 409) void openDocumentSyncConflict()
|
|
827
833
|
return false
|
|
834
|
+
} finally {
|
|
835
|
+
editor.saving = false
|
|
828
836
|
}
|
|
829
837
|
}
|
|
830
838
|
|
|
@@ -1009,6 +1017,7 @@ function renderShell() {
|
|
|
1009
1017
|
app.replaceChildren(shell)
|
|
1010
1018
|
applySidebarVisibility(shell, state.documentView.sidebarHidden)
|
|
1011
1019
|
restoreScrollPosition(currentScrollState())
|
|
1020
|
+
renderDocumentSyncNotice()
|
|
1012
1021
|
}
|
|
1013
1022
|
|
|
1014
1023
|
function renderContextPaneToggle() {
|
|
@@ -1750,7 +1759,7 @@ function renderNoteEditor(workspace, editor, base) {
|
|
|
1750
1759
|
const update = (key, value) => {
|
|
1751
1760
|
editor[key] = value
|
|
1752
1761
|
editor.dirty = true
|
|
1753
|
-
editor.saveState = '未保存'
|
|
1762
|
+
editor.saveState = editor.saving ? '正在保存…' : '未保存'
|
|
1754
1763
|
updateEditorSaveState(editor.saveState)
|
|
1755
1764
|
}
|
|
1756
1765
|
const title = element('input', {
|
|
@@ -2519,10 +2528,11 @@ function renderEditableNote(node) {
|
|
|
2519
2528
|
element('h1', {
|
|
2520
2529
|
class: 'notes-document-title', contenteditable: 'plaintext-only', spellcheck: 'false',
|
|
2521
2530
|
'aria-label': `修改 ${node.name} 的标题`, title: '点击修改标题',
|
|
2531
|
+
onInput: () => { state.notes.titleDirty = true },
|
|
2522
2532
|
onBlur: event => { void saveEditableNoteTitle(event.currentTarget, node) },
|
|
2523
2533
|
onKeyDown: event => {
|
|
2524
2534
|
if (event.key === 'Enter') { event.preventDefault(); event.currentTarget.blur() }
|
|
2525
|
-
if (event.key === 'Escape') { event.preventDefault(); event.currentTarget.textContent =
|
|
2535
|
+
if (event.key === 'Escape') { event.preventDefault(); event.currentTarget.textContent = editableNoteTitle(state.notes.selectedNode || node); state.notes.titleDirty = false; event.currentTarget.blur() }
|
|
2526
2536
|
},
|
|
2527
2537
|
}, title),
|
|
2528
2538
|
editor,
|
|
@@ -2545,21 +2555,25 @@ function editableNoteTitle(node) {
|
|
|
2545
2555
|
}
|
|
2546
2556
|
|
|
2547
2557
|
async function saveEditableNoteTitle(editor, node) {
|
|
2558
|
+
node = state.notes.selectedNode?.id === node.id ? state.notes.selectedNode : node
|
|
2548
2559
|
const title = readPlainTextEditor(editor).replace(/\s+/g, ' ').trim()
|
|
2549
2560
|
const name = node.kind === 'document' && title ? `${title.replace(/\.md$/i, '')}.md` : title
|
|
2550
2561
|
if (!name) {
|
|
2551
2562
|
editor.textContent = editableNoteTitle(node)
|
|
2563
|
+
state.notes.titleDirty = false
|
|
2552
2564
|
showToast('标题不能为空。', 'error')
|
|
2553
2565
|
return
|
|
2554
2566
|
}
|
|
2555
2567
|
if (name === node.name) {
|
|
2556
2568
|
editor.textContent = editableNoteTitle(node)
|
|
2569
|
+
state.notes.titleDirty = false
|
|
2557
2570
|
return
|
|
2558
2571
|
}
|
|
2559
2572
|
editor.setAttribute('contenteditable', 'false')
|
|
2573
|
+
state.notes.renaming = true
|
|
2560
2574
|
try {
|
|
2561
|
-
const updated = await api(`notes/${encodeURIComponent(node.id)}`, { method: 'PATCH', body: { name } })
|
|
2562
|
-
if (state.notes.selectedNode?.id === node.id) state.notes.selectedNode = updated
|
|
2575
|
+
const updated = await api(`notes/${encodeURIComponent(node.id)}`, { method: 'PATCH', body: { name, expectedName: node.name } })
|
|
2576
|
+
if (state.notes.selectedNode?.id === node.id) { state.notes.selectedNode = updated; state.notes.titleDirty = false }
|
|
2563
2577
|
editor.textContent = editableNoteTitle(updated)
|
|
2564
2578
|
const breadcrumb = document.querySelector('.notes-document-toolbar .notes-breadcrumb strong')
|
|
2565
2579
|
if (breadcrumb) breadcrumb.textContent = updated.name
|
|
@@ -2568,9 +2582,10 @@ async function saveEditableNoteTitle(editor, node) {
|
|
|
2568
2582
|
if (treeName) treeName.textContent = updated.name
|
|
2569
2583
|
showToast('标题已更新。')
|
|
2570
2584
|
} catch (error) {
|
|
2571
|
-
|
|
2585
|
+
// Keep the user's title available to copy/retry, rather than silently losing it.
|
|
2572
2586
|
showToast(friendlyError(error), 'error')
|
|
2573
2587
|
} finally {
|
|
2588
|
+
state.notes.renaming = false
|
|
2574
2589
|
if (editor.isConnected) editor.setAttribute('contenteditable', 'plaintext-only')
|
|
2575
2590
|
}
|
|
2576
2591
|
}
|
|
@@ -2694,11 +2709,11 @@ function releaseNoteEditors() {
|
|
|
2694
2709
|
function syncNoteEditorChrome() {
|
|
2695
2710
|
const status = document.querySelector('[data-note-save-state]')
|
|
2696
2711
|
if (status) {
|
|
2697
|
-
status.textContent = state.notes.dirty ? '未保存' : '已保存'
|
|
2712
|
+
status.textContent = state.notes.saving ? '正在保存…' : state.notes.dirty ? '未保存' : '已保存'
|
|
2698
2713
|
status.dataset.dirty = String(state.notes.dirty)
|
|
2699
2714
|
}
|
|
2700
2715
|
const save = document.querySelector('[data-note-save]')
|
|
2701
|
-
if (save) save.disabled = !state.notes.dirty
|
|
2716
|
+
if (save) save.disabled = state.notes.saving || !state.notes.dirty
|
|
2702
2717
|
}
|
|
2703
2718
|
|
|
2704
2719
|
function renderNoteDocumentBreadcrumb(node) {
|
|
@@ -2800,6 +2815,7 @@ function noteFilePreviewKind(node) {
|
|
|
2800
2815
|
}
|
|
2801
2816
|
|
|
2802
2817
|
async function selectNoteNode(node, options = {}) {
|
|
2818
|
+
if (state.notes.titleDirty || state.notes.renaming) return showToast('请先保存标题;若要放弃标题修改,请在标题中按 Escape。', 'error')
|
|
2803
2819
|
if (state.notes.dirty && !await saveNoteDocument()) return
|
|
2804
2820
|
const request = ++noteSelectionRequest
|
|
2805
2821
|
releaseNoteAsset()
|
|
@@ -2822,10 +2838,12 @@ async function selectNoteNode(node, options = {}) {
|
|
|
2822
2838
|
}
|
|
2823
2839
|
await loadNoteChildren(node.id)
|
|
2824
2840
|
} else if (node.editable) {
|
|
2825
|
-
const
|
|
2826
|
-
const content =
|
|
2841
|
+
const snapshot = await readNoteSnapshot(node.id)
|
|
2842
|
+
const content = snapshot.content
|
|
2827
2843
|
if (request !== noteSelectionRequest || state.notes.selectedId !== node.id) return
|
|
2828
2844
|
state.notes.content = content
|
|
2845
|
+
state.notes.selectedNode = snapshot.node
|
|
2846
|
+
state.notes.contentVersion = snapshot.node.version
|
|
2829
2847
|
state.notes.draft = content
|
|
2830
2848
|
state.notes.dirty = false
|
|
2831
2849
|
state.notes.currentFolderId = node.parentId
|
|
@@ -2881,6 +2899,7 @@ function findCachedNote(id) {
|
|
|
2881
2899
|
}
|
|
2882
2900
|
|
|
2883
2901
|
async function openNoteRoot() {
|
|
2902
|
+
if (state.notes.titleDirty || state.notes.renaming) return showToast('请先保存标题;若要放弃标题修改,请在标题中按 Escape。', 'error')
|
|
2884
2903
|
if (state.notes.dirty && !await saveNoteDocument()) return
|
|
2885
2904
|
noteSelectionRequest += 1
|
|
2886
2905
|
clearNoteSelection()
|
|
@@ -3275,15 +3294,22 @@ function showNoteImportResult(summary) {
|
|
|
3275
3294
|
}
|
|
3276
3295
|
|
|
3277
3296
|
async function saveNoteDocument() {
|
|
3297
|
+
if (state.notes.titleDirty || state.notes.renaming) { showToast('请先完成标题修改,或在标题中按 Escape 放弃。', 'error'); return false }
|
|
3278
3298
|
const node = state.notes.selectedNode
|
|
3279
3299
|
if (!node || !node.editable || !state.notes.dirty) return true
|
|
3300
|
+
if (state.notes.saving) return false
|
|
3301
|
+
state.notes.saving = true
|
|
3302
|
+
syncNoteEditorChrome()
|
|
3303
|
+
const submitted = state.notes.draft
|
|
3280
3304
|
try {
|
|
3281
|
-
const updated = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content`, {
|
|
3282
|
-
method: 'PUT', body: new Blob([
|
|
3305
|
+
const updated = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content?expectedVersion=${state.notes.contentVersion ?? node.version}`, {
|
|
3306
|
+
method: 'PUT', body: new Blob([submitted], { type: node.mediaType || 'text/plain' }), contentType: node.mediaType || 'text/plain',
|
|
3283
3307
|
})
|
|
3308
|
+
if (state.notes.selectedNode?.id !== node.id) return false
|
|
3284
3309
|
state.notes.selectedNode = updated
|
|
3285
|
-
state.notes.content =
|
|
3286
|
-
state.notes.
|
|
3310
|
+
state.notes.content = submitted
|
|
3311
|
+
state.notes.contentVersion = updated.version
|
|
3312
|
+
state.notes.dirty = state.notes.draft !== submitted
|
|
3287
3313
|
await loadNoteChildren(node.parentId, true)
|
|
3288
3314
|
const size = document.querySelector('[data-note-info="size"] > span')
|
|
3289
3315
|
const updatedAt = document.querySelector('[data-note-info="updated"] > span')
|
|
@@ -3291,10 +3317,14 @@ async function saveNoteDocument() {
|
|
|
3291
3317
|
if (updatedAt) updatedAt.textContent = formatDate(updated.updatedAt)
|
|
3292
3318
|
syncNoteEditorChrome()
|
|
3293
3319
|
showToast('文件已保存。')
|
|
3294
|
-
return
|
|
3320
|
+
return !state.notes.dirty
|
|
3295
3321
|
} catch (error) {
|
|
3296
3322
|
showToast(friendlyError(error), 'error')
|
|
3323
|
+
if (error.status === 409) void openDocumentSyncConflict()
|
|
3297
3324
|
return false
|
|
3325
|
+
} finally {
|
|
3326
|
+
state.notes.saving = false
|
|
3327
|
+
syncNoteEditorChrome()
|
|
3298
3328
|
}
|
|
3299
3329
|
}
|
|
3300
3330
|
|
|
@@ -3367,12 +3397,12 @@ function renderHistoricalNotePreview(content, markdown) {
|
|
|
3367
3397
|
return rendered
|
|
3368
3398
|
}
|
|
3369
3399
|
|
|
3370
|
-
function renderNoteHistoryDiff(historical, current) {
|
|
3400
|
+
function renderNoteHistoryDiff(historical, current, heading = '历史版本 → 当前版本') {
|
|
3371
3401
|
const diff = window.DshKnowledgeReview.createLineDiff(historical, current)
|
|
3372
3402
|
const lines = window.DshKnowledgeReview.compactDiffLines(diff.lines, 3)
|
|
3373
3403
|
return element('section', { class: 'note-history-diff', 'aria-label': '历史版本与当前版本的逐行差异' },
|
|
3374
3404
|
element('div', { class: 'note-history-diff-summary' },
|
|
3375
|
-
element('strong', {},
|
|
3405
|
+
element('strong', {}, heading),
|
|
3376
3406
|
element('div', { class: 'diff-summary', 'aria-label': `新增 ${diff.additions} 行,删除 ${diff.deletions} 行` },
|
|
3377
3407
|
element('span', { class: 'diff-stat additions' }, `+${diff.additions}`),
|
|
3378
3408
|
element('span', { class: 'diff-stat deletions' }, `-${diff.deletions}`),
|
|
@@ -4474,12 +4504,133 @@ function friendlyError(error) {
|
|
|
4474
4504
|
return error.message || '操作失败,请稍后重试。'
|
|
4475
4505
|
}
|
|
4476
4506
|
|
|
4507
|
+
let documentSyncState = { key: '', status: '' }
|
|
4508
|
+
let syncConflictLoading = false
|
|
4509
|
+
|
|
4510
|
+
function currentSyncTarget() {
|
|
4511
|
+
if (state.loading || (AUTH_MODE !== 'same-origin' && !state.token)) return null
|
|
4512
|
+
const focused = () => !!document.activeElement?.closest('.note-editor, .notes-content, [role="dialog"]')
|
|
4513
|
+
if (state.view === 'notes') {
|
|
4514
|
+
const node = state.notes.selectedNode
|
|
4515
|
+
if (!node?.editable || state.notes.loadingNodeId) return null
|
|
4516
|
+
return { key: `note:${node.id}`, id: node.id, kind: 'note', identity: node,
|
|
4517
|
+
version: state.notes.contentVersion ?? node.version, updatedAt: node.updatedAt,
|
|
4518
|
+
busy: () => state.notes.dirty || state.notes.saving || state.notes.titleDirty || state.notes.renaming || focused() }
|
|
4519
|
+
}
|
|
4520
|
+
const workspace = activeDocumentWorkspace()
|
|
4521
|
+
const editor = workspace?.view.editor
|
|
4522
|
+
if (!editor?.id || editor.isNew || workspace.view.editorLoading) return null
|
|
4523
|
+
return { key: `${workspace.kind}:${editor.id}`, id: editor.id, kind: 'knowledge', identity: editor, workspace,
|
|
4524
|
+
version: editor.version, updatedAt: editor.updatedAt,
|
|
4525
|
+
busy: () => editor.dirty || editor.saving || focused() }
|
|
4526
|
+
}
|
|
4527
|
+
|
|
4528
|
+
async function readNoteSnapshot(id, signal) {
|
|
4529
|
+
const node = await api(`notes/${encodeURIComponent(id)}`, { signal })
|
|
4530
|
+
if (!node.editable) throw new Error('该文件不再支持文本编辑')
|
|
4531
|
+
const blob = await binaryRequest(`notes/${encodeURIComponent(id)}/versions/${node.version}/content`, { responseType: 'blob', signal })
|
|
4532
|
+
return { node, content: await blob.text() }
|
|
4533
|
+
}
|
|
4534
|
+
|
|
4535
|
+
async function readSyncSnapshot(target, signal) {
|
|
4536
|
+
if (target.kind === 'note') return readNoteSnapshot(target.id, signal)
|
|
4537
|
+
const [entry, noteReferences] = await Promise.all([
|
|
4538
|
+
api(`entries/${encodeURIComponent(target.id)}`, { signal }),
|
|
4539
|
+
api(`entries/${encodeURIComponent(target.id)}/note-references`, { signal }),
|
|
4540
|
+
])
|
|
4541
|
+
return { entry, noteReferences }
|
|
4542
|
+
}
|
|
4543
|
+
|
|
4544
|
+
function applySyncSnapshot(target, snapshot, draft) {
|
|
4545
|
+
if (target.kind === 'note') {
|
|
4546
|
+
Object.assign(state.notes, { selectedNode: snapshot.node, content: snapshot.content, contentVersion: snapshot.node.version,
|
|
4547
|
+
draft: draft?.content ?? snapshot.content, dirty: draft !== undefined && draft.content !== snapshot.content })
|
|
4548
|
+
for (const [key, nodes] of state.notes.children) state.notes.children.set(key, nodes.map(node => node.id === target.id ? snapshot.node : node))
|
|
4549
|
+
} else {
|
|
4550
|
+
const entry = snapshot.entry
|
|
4551
|
+
Object.assign(target.identity, entry, { noteReferences: snapshot.noteReferences, tagsText: entry.tags.join(', '), dirty: !!draft,
|
|
4552
|
+
...(draft ? { title: draft.title, body: draft.content } : {}), saveState: draft ? '未保存' : '已同步' })
|
|
4553
|
+
const items = documentWorkspaceDocuments(target.workspace)
|
|
4554
|
+
setDocumentWorkspaceDocuments(target.workspace, items.map(item => item.id === target.id ? { ...item, title: target.identity.title, version: entry.version, updatedAt: entry.updatedAt } : item))
|
|
4555
|
+
}
|
|
4556
|
+
documentSyncState = { key: target.key, status: draft ? '' : 'synced' }
|
|
4557
|
+
renderShell()
|
|
4558
|
+
}
|
|
4559
|
+
|
|
4560
|
+
function renderDocumentSyncNotice() {
|
|
4561
|
+
const target = currentSyncTarget()
|
|
4562
|
+
const status = target?.key === documentSyncState.key ? documentSyncState.status : ''
|
|
4563
|
+
const host = document.querySelector('.note-editor-toolbar, .notes-document-toolbar')
|
|
4564
|
+
let notice = host?.querySelector('.document-sync-notice')
|
|
4565
|
+
if (!target || !status) { notice?.remove(); return }
|
|
4566
|
+
const labels = { changed: '文档有新版本,当前内容已保留', missing: '文档已删除或不可访问,草稿仍保留', offline: '同步暂不可用,将自动重试', synced: '已同步最新版本' }
|
|
4567
|
+
if (notice?.dataset.status === status) return
|
|
4568
|
+
if (!notice) { notice = element('div', { class: 'document-sync-notice' }); host?.append(notice) }
|
|
4569
|
+
notice.dataset.status = status
|
|
4570
|
+
notice.replaceChildren(element('span', { role: 'status' }, labels[status] || ''))
|
|
4571
|
+
if (status === 'changed') notice.append(actionButton('查看并处理', () => { void openDocumentSyncConflict() }, 'small'))
|
|
4572
|
+
}
|
|
4573
|
+
|
|
4574
|
+
async function openDocumentSyncConflict() {
|
|
4575
|
+
const target = currentSyncTarget()
|
|
4576
|
+
if (!target || syncConflictLoading) return
|
|
4577
|
+
if (target.kind === 'note' && (state.notes.titleDirty || state.notes.renaming)) return showToast('标题修改尚未保存,请先复制保留;在标题中按 Escape 可恢复原标题,再处理文档新版本。', 'error')
|
|
4578
|
+
syncConflictLoading = true
|
|
4579
|
+
try {
|
|
4580
|
+
const snapshot = await readSyncSnapshot(target, AbortSignal.timeout(10000))
|
|
4581
|
+
if (currentSyncTarget()?.identity !== target.identity) return
|
|
4582
|
+
const local = target.kind === 'note' ? { title: state.notes.selectedNode.name, content: state.notes.draft }
|
|
4583
|
+
: { title: target.identity.title, content: target.identity.body }
|
|
4584
|
+
const remote = target.kind === 'note' ? { title: snapshot.node.name, content: snapshot.content }
|
|
4585
|
+
: { title: snapshot.entry.title, content: snapshot.entry.body }
|
|
4586
|
+
const { openSyncConflict } = await import(moduleUrl('document-sync-ui'))
|
|
4587
|
+
const stillCurrent = () => {
|
|
4588
|
+
if (currentSyncTarget()?.identity !== target.identity) throw new Error('当前文档已切换,请重新打开差异')
|
|
4589
|
+
if ((target.kind === 'note' ? state.notes.draft : target.identity.body) !== local.content
|
|
4590
|
+
|| (target.kind === 'knowledge' && target.identity.title !== local.title)) throw new Error('本地草稿已有新修改,请关闭后重新查看差异')
|
|
4591
|
+
}
|
|
4592
|
+
openSyncConflict({ element, actionButton, openModal, openConfirm, renderDiff: renderNoteHistoryDiff, local, remote,
|
|
4593
|
+
allowTitle: target.kind === 'knowledge',
|
|
4594
|
+
canEdit: target.kind === 'note' || (snapshot.entry.documentState === 'open' && snapshot.entry.status === 'active' && !documentWorkspaceReadOnly(target.workspace)),
|
|
4595
|
+
apply: draft => { stillCurrent(); applySyncSnapshot(target, snapshot, draft) },
|
|
4596
|
+
useRemote: () => { stillCurrent(); applySyncSnapshot(target, snapshot) },
|
|
4597
|
+
})
|
|
4598
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
4599
|
+
finally { syncConflictLoading = false }
|
|
4600
|
+
}
|
|
4601
|
+
|
|
4602
|
+
const documentSync = createDocumentSync({
|
|
4603
|
+
current: currentSyncTarget,
|
|
4604
|
+
check: (target, signal) => api(target.kind === 'note' ? `notes/${encodeURIComponent(target.id)}` : `entries/${encodeURIComponent(target.id)}/revision`, { signal }),
|
|
4605
|
+
refresh: async (target, signal, valid) => {
|
|
4606
|
+
const snapshot = await readSyncSnapshot(target, signal)
|
|
4607
|
+
if (valid()) applySyncSnapshot(target, snapshot)
|
|
4608
|
+
else if (currentSyncTarget()?.identity === target.identity) {
|
|
4609
|
+
documentSyncState = { key: target.key, status: 'changed' }
|
|
4610
|
+
renderDocumentSyncNotice()
|
|
4611
|
+
}
|
|
4612
|
+
},
|
|
4613
|
+
notify: (target, status) => {
|
|
4614
|
+
if (status === 'current') {
|
|
4615
|
+
if (documentSyncState.key !== target.key || documentSyncState.status === 'synced') return
|
|
4616
|
+
status = ''
|
|
4617
|
+
}
|
|
4618
|
+
documentSyncState = { key: target.key, status }
|
|
4619
|
+
renderDocumentSyncNotice()
|
|
4620
|
+
},
|
|
4621
|
+
})
|
|
4622
|
+
document.addEventListener('visibilitychange', () => { if (document.hidden) documentSync.pause(); else documentSync.wake() })
|
|
4623
|
+
window.addEventListener('focus', () => documentSync.wake())
|
|
4624
|
+
window.addEventListener('online', () => documentSync.wake())
|
|
4625
|
+
window.addEventListener('pagehide', () => documentSync.pause())
|
|
4626
|
+
window.addEventListener('pageshow', () => documentSync.wake())
|
|
4627
|
+
|
|
4477
4628
|
window.addEventListener('beforeunload', event => {
|
|
4478
4629
|
const editor = activeDocumentWorkspace()?.view.editor
|
|
4479
|
-
if (!editor?.dirty && !state.notes.dirty) return
|
|
4630
|
+
if (!editor?.dirty && !state.notes.dirty && !state.notes.titleDirty) return
|
|
4480
4631
|
event.preventDefault()
|
|
4481
4632
|
event.returnValue = ''
|
|
4482
4633
|
})
|
|
4483
4634
|
|
|
4484
4635
|
installDragRecovery()
|
|
4485
|
-
void installHostThemeBridge().then(() => boot())
|
|
4636
|
+
void installHostThemeBridge().then(() => boot()).then(() => documentSync.wake())
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Conflict resolution is explicit: the remote version becomes the new base,
|
|
2
|
+
* and the user's chosen text remains a draft until saved with a version check. */
|
|
3
|
+
export function openSyncConflict({ element, actionButton, openModal, openConfirm, renderDiff, local, remote, apply, useRemote, canEdit = true, allowTitle = true }) {
|
|
4
|
+
const title = element('input', { class: 'input', value: local.title, 'aria-label': '合并后的标题', disabled: !canEdit || !allowTitle })
|
|
5
|
+
const content = element('textarea', { class: 'input sync-merge-content', 'aria-label': '合并后的正文', spellcheck: 'false', disabled: !canEdit }, local.content)
|
|
6
|
+
const remoteText = element('textarea', { class: 'input sync-merge-content', readonly: true, 'aria-label': '服务端最新正文', spellcheck: 'false' }, remote.content)
|
|
7
|
+
let modal
|
|
8
|
+
const body = element('div', { class: 'sync-conflict-body' },
|
|
9
|
+
element('p', {}, '你的未保存内容仍保留。请对照最新版本整理,应用后回到文档点击保存;保存时会再次检查版本。'),
|
|
10
|
+
element('details', {}, element('summary', {}, '查看最新版本与本地草稿的差异'), renderDiff(remote.content, local.content, '最新版本 → 本地草稿')),
|
|
11
|
+
element('div', { class: 'sync-merge-columns' },
|
|
12
|
+
element('section', {}, element('h3', {}, '服务端最新版本'), element('p', {}, remote.title), remoteText),
|
|
13
|
+
element('section', {}, element('h3', {}, '整理合并后的草稿'), title, content)),
|
|
14
|
+
!canEdit ? element('p', { role: 'status' }, '此知识文档已封存或归档,不能直接保存。你的草稿仍可在原编辑器中保留。') : null,
|
|
15
|
+
actionButton('放弃本地修改,使用最新版本', () => openConfirm({
|
|
16
|
+
title: '使用最新版本?', message: '将放弃这份未保存的本地草稿。', confirmLabel: '放弃草稿并加载', danger: true,
|
|
17
|
+
onConfirm: async () => { await useRemote(); modal.close(true) },
|
|
18
|
+
}), 'small'),
|
|
19
|
+
)
|
|
20
|
+
modal = openModal({ title: '文档有新版本', description: '不会自动覆盖任何一方的修改', body, className: 'sync-conflict-dialog', cancelLabel: '继续保留草稿',
|
|
21
|
+
...(canEdit ? { primaryLabel: '应用到草稿', onPrimary: async () => { await apply({ title: title.value, content: content.value }); return true } } : {}),
|
|
22
|
+
})
|
|
23
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** One bounded metadata poll for the active document; no full-library reloads. */
|
|
2
|
+
export function createDocumentSync({ current, check, refresh, notify, visible = () => !document.hidden, interval = 5000 }) {
|
|
3
|
+
let timer, controller, stopped = false, failures = 0
|
|
4
|
+
const same = target => {
|
|
5
|
+
const active = current()
|
|
6
|
+
return active && active.identity === target.identity && active.version === target.version && active.key === target.key
|
|
7
|
+
}
|
|
8
|
+
async function tick() {
|
|
9
|
+
clearTimeout(timer)
|
|
10
|
+
if (stopped || controller) return
|
|
11
|
+
const target = visible() ? current() : null
|
|
12
|
+
if (!target) return schedule()
|
|
13
|
+
controller = new AbortController()
|
|
14
|
+
const timeout = setTimeout(() => controller?.abort(), 10000)
|
|
15
|
+
try {
|
|
16
|
+
const remote = await check(target, controller.signal)
|
|
17
|
+
if (stopped || !visible() || !same(target)) return
|
|
18
|
+
failures = 0
|
|
19
|
+
if (remote.version !== target.version || remote.updatedAt !== target.updatedAt) {
|
|
20
|
+
if (current().busy()) notify(target, 'changed')
|
|
21
|
+
else await refresh(target, controller.signal, () => !stopped && visible() && same(target) && !current().busy())
|
|
22
|
+
} else notify(target, 'current')
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (!stopped && same(target)) {
|
|
25
|
+
failures++
|
|
26
|
+
notify(target, error.status === 404 ? 'missing' : 'offline')
|
|
27
|
+
}
|
|
28
|
+
} finally {
|
|
29
|
+
clearTimeout(timeout)
|
|
30
|
+
controller = null
|
|
31
|
+
schedule()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function schedule() {
|
|
35
|
+
clearTimeout(timer)
|
|
36
|
+
if (!stopped && visible()) timer = setTimeout(tick, Math.min(30000, interval * 2 ** Math.min(failures, 3)))
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
wake() { if (!stopped) void tick() },
|
|
40
|
+
pause() { clearTimeout(timer); controller?.abort() },
|
|
41
|
+
stop() { stopped = true; clearTimeout(timer); controller?.abort() },
|
|
42
|
+
}
|
|
43
|
+
}
|
package/web/styles.css
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
.document-sync-notice {
|
|
2
|
+
flex: 1 0 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
|
|
3
|
+
padding: 8px 0 0; color: var(--text); font-size: 12px;
|
|
4
|
+
border-top: 1px solid var(--separator); overflow-wrap: anywhere;
|
|
5
|
+
}
|
|
6
|
+
.note-editor-toolbar:has(.document-sync-notice), .notes-document-toolbar:has(.document-sync-notice) { flex-wrap: wrap; }
|
|
7
|
+
.sync-conflict-dialog { width: min(980px, calc(100vw - 32px)); max-width: 980px; }
|
|
8
|
+
.sync-conflict-body { display: grid; gap: 16px; }
|
|
9
|
+
.sync-merge-columns { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; }
|
|
10
|
+
.sync-merge-columns section { min-width: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
11
|
+
.sync-merge-columns h3, .sync-merge-columns p { margin: 0; overflow-wrap: anywhere; }
|
|
12
|
+
.sync-conflict-dialog .sync-merge-content { width: 100%; height: 260px; min-height: 200px; resize: vertical; line-height: 1.6; }
|
|
13
|
+
@media (max-width: 640px) { .sync-merge-columns { grid-template-columns: minmax(0, 1fr); } }
|
|
14
|
+
|
|
1
15
|
:root {
|
|
2
16
|
color-scheme: light dark;
|
|
3
17
|
font-family: var(--font-ui);
|