@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.
Files changed (43) hide show
  1. package/README.md +29 -1
  2. package/lib/api.d.ts.map +1 -1
  3. package/lib/api.js +13 -2
  4. package/lib/api.js.map +1 -1
  5. package/lib/index.d.ts.map +1 -1
  6. package/lib/index.js +2 -0
  7. package/lib/index.js.map +1 -1
  8. package/lib/local-provider.d.ts +2 -2
  9. package/lib/local-provider.d.ts.map +1 -1
  10. package/lib/local-provider.js +12 -5
  11. package/lib/local-provider.js.map +1 -1
  12. package/lib/note-recording.d.ts +21 -0
  13. package/lib/note-recording.d.ts.map +1 -0
  14. package/lib/note-recording.js +32 -0
  15. package/lib/note-recording.js.map +1 -0
  16. package/lib/note-reference-tools.js +3 -2
  17. package/lib/note-reference-tools.js.map +1 -1
  18. package/lib/note-tools.d.ts +3 -2
  19. package/lib/note-tools.d.ts.map +1 -1
  20. package/lib/note-tools.js +35 -12
  21. package/lib/note-tools.js.map +1 -1
  22. package/lib/notes/store.d.ts +5 -1
  23. package/lib/notes/store.d.ts.map +1 -1
  24. package/lib/notes/store.js +11 -2
  25. package/lib/notes/store.js.map +1 -1
  26. package/lib/provider.d.ts +2 -2
  27. package/lib/provider.d.ts.map +1 -1
  28. package/lib/remote-provider.d.ts +2 -2
  29. package/lib/remote-provider.d.ts.map +1 -1
  30. package/lib/remote-provider.js +6 -5
  31. package/lib/remote-provider.js.map +1 -1
  32. package/lib/tools.js +1 -1
  33. package/lib/tools.js.map +1 -1
  34. package/lib/web.js +1 -1
  35. package/lib/web.js.map +1 -1
  36. package/lib/writeback/queue.d.ts.map +1 -1
  37. package/lib/writeback/queue.js +10 -7
  38. package/lib/writeback/queue.js.map +1 -1
  39. package/package.json +1 -1
  40. package/web/app.js +174 -23
  41. package/web/document-sync-ui.js +23 -0
  42. package/web/document-sync.js +43 -0
  43. 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 = false
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 true
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 = title; event.currentTarget.blur() }
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
- editor.textContent = editableNoteTitle(node)
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 blob = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content`, { responseType: 'blob', accept: node.mediaType || 'text/plain' })
2826
- const content = await blob.text()
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([state.notes.draft], { type: node.mediaType || 'text/plain' }), contentType: node.mediaType || 'text/plain',
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 = state.notes.draft
3286
- state.notes.dirty = false
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 true
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);