@aicupa/plugin-todo-dependency 1.0.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/inject.js +347 -0
- package/package.json +30 -0
- package/service.js +113 -0
- package/view/index.html +359 -0
package/inject.js
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
;(function () {
|
|
2
|
+
if (window.__TODO_DEP_INJECTED__) return
|
|
3
|
+
window.__TODO_DEP_INJECTED__ = true
|
|
4
|
+
|
|
5
|
+
const PLUGIN_NAME = '@aicupa/plugin-todo-dependency'
|
|
6
|
+
const isZh = (navigator.language || '').startsWith('zh')
|
|
7
|
+
const T = isZh
|
|
8
|
+
? {
|
|
9
|
+
modalTitle: '设置依赖', searchPlaceholder: '搜索 Todo...',
|
|
10
|
+
cancel: '取消', save: '保存', done: '已完成',
|
|
11
|
+
selCount: n => `已选 ${n} 个依赖`, noMatch: '无匹配结果',
|
|
12
|
+
depLabel: '依赖',
|
|
13
|
+
}
|
|
14
|
+
: {
|
|
15
|
+
modalTitle: 'Set Dependencies', searchPlaceholder: 'Search Todos...',
|
|
16
|
+
cancel: 'Cancel', save: 'Save', done: 'Done',
|
|
17
|
+
selCount: n => `${n} selected`, noMatch: 'No matching results',
|
|
18
|
+
depLabel: 'Depends on',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── Service call via CustomEvent ──
|
|
22
|
+
let callSeq = 0
|
|
23
|
+
function callPluginService(method, params) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const cbEvent = 'tdep-cb-' + (++callSeq)
|
|
26
|
+
const timer = setTimeout(() => { window.removeEventListener(cbEvent, handler); reject(new Error('timeout')) }, 5000)
|
|
27
|
+
const handler = (e) => {
|
|
28
|
+
clearTimeout(timer)
|
|
29
|
+
window.removeEventListener(cbEvent, handler)
|
|
30
|
+
const d = e.detail
|
|
31
|
+
if (d?.ok === false) reject(new Error(d.error || 'failed'))
|
|
32
|
+
else resolve(d)
|
|
33
|
+
}
|
|
34
|
+
window.addEventListener(cbEvent, handler)
|
|
35
|
+
window.dispatchEvent(new CustomEvent('plugin-call-service', {
|
|
36
|
+
detail: { pluginName: PLUGIN_NAME, method, params, callbackEvent: cbEvent },
|
|
37
|
+
}))
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function unwrap(res) {
|
|
42
|
+
const inner = res?.result || res
|
|
43
|
+
return inner?.result !== undefined ? inner.result : inner
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Context menu result listener ──
|
|
47
|
+
let currentFilePath = ''
|
|
48
|
+
|
|
49
|
+
window.addEventListener('plugin-command-done', e => {
|
|
50
|
+
const d = e.detail
|
|
51
|
+
if (!d || d.command !== 'setDependency') return
|
|
52
|
+
const r = d.result?.result || d.result
|
|
53
|
+
if (!r?.target) return
|
|
54
|
+
currentFilePath = r.target.filePath || currentFilePath
|
|
55
|
+
allTodos = r.allTodos || []
|
|
56
|
+
showModal(r.target)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// ── Tree update listener — refresh dep data ──
|
|
60
|
+
window.addEventListener('plugin-tree-updated', () => {
|
|
61
|
+
if (currentFilePath) refreshDepData()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
async function refreshDepData() {
|
|
65
|
+
try {
|
|
66
|
+
const res = await callPluginService('scanAllDeps', { filePath: currentFilePath })
|
|
67
|
+
const data = unwrap(res)
|
|
68
|
+
if (!data) return
|
|
69
|
+
depData = buildDepDisplay(data.todos, data.edges)
|
|
70
|
+
try { localStorage.setItem(DEP_STORAGE_KEY, JSON.stringify(depData)) } catch {}
|
|
71
|
+
} catch {}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildDepDisplay(todos, edges) {
|
|
75
|
+
const display = {}
|
|
76
|
+
for (const [depId, todoId] of edges) {
|
|
77
|
+
if (!display[todoId]) display[todoId] = { deps: [] }
|
|
78
|
+
const dep = todos[depId]
|
|
79
|
+
display[todoId].deps.push({ id: depId, content: dep ? dep.content : '#' + depId })
|
|
80
|
+
}
|
|
81
|
+
return display
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Styles ──
|
|
85
|
+
function injectStyles() {
|
|
86
|
+
if (document.getElementById('tdep-styles')) return
|
|
87
|
+
const s = document.createElement('style')
|
|
88
|
+
s.id = 'tdep-styles'
|
|
89
|
+
s.textContent = `
|
|
90
|
+
.tdep-tag {
|
|
91
|
+
font-size: 10px; color: #999; margin-left: 6px; padding: 1px 6px;
|
|
92
|
+
background: rgba(128,128,128,0.1); border-radius: 4px;
|
|
93
|
+
line-height: 1.4; overflow: hidden; text-overflow: ellipsis;
|
|
94
|
+
white-space: nowrap; pointer-events: none; max-width: 300px;
|
|
95
|
+
display: inline-block; vertical-align: middle;
|
|
96
|
+
animation: tdepFadeIn 0.15s ease-out;
|
|
97
|
+
}
|
|
98
|
+
@keyframes tdepFadeIn { from { opacity: 0; } }
|
|
99
|
+
|
|
100
|
+
.tdep-backdrop {
|
|
101
|
+
position: fixed; inset: 0; background: rgba(0,0,0,0.35);
|
|
102
|
+
display: flex; align-items: center; justify-content: center; z-index: 2147483646;
|
|
103
|
+
}
|
|
104
|
+
.tdep-card {
|
|
105
|
+
background: #fff; border-radius: 12px; width: 440px; max-height: 75vh;
|
|
106
|
+
display: flex; flex-direction: column;
|
|
107
|
+
box-shadow: 0 8px 40px rgba(0,0,0,0.18);
|
|
108
|
+
animation: tdepIn 0.15s ease-out;
|
|
109
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
110
|
+
color: #333;
|
|
111
|
+
}
|
|
112
|
+
@keyframes tdepIn { from { opacity: 0; transform: scale(0.95) translateY(8px); } }
|
|
113
|
+
|
|
114
|
+
.tdep-header { padding: 16px 20px 12px; }
|
|
115
|
+
.tdep-title { font-size: 15px; font-weight: 600; }
|
|
116
|
+
.tdep-subtitle {
|
|
117
|
+
font-size: 12px; color: #888; margin-top: 4px;
|
|
118
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.tdep-search { padding: 0 20px 10px; }
|
|
122
|
+
.tdep-search input {
|
|
123
|
+
width: 100%; padding: 7px 12px; border: 1px solid #e0e0e0; border-radius: 6px;
|
|
124
|
+
font-size: 13px; outline: none; background: #fafafa; color: inherit;
|
|
125
|
+
font-family: inherit;
|
|
126
|
+
}
|
|
127
|
+
.tdep-search input:focus { border-color: #1890ff; }
|
|
128
|
+
|
|
129
|
+
.tdep-body {
|
|
130
|
+
flex: 1; overflow-y: auto; max-height: 50vh;
|
|
131
|
+
border-top: 1px solid #f0f0f0; border-bottom: 1px solid #f0f0f0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.tdep-item {
|
|
135
|
+
display: flex; align-items: center; padding: 8px 20px; cursor: pointer;
|
|
136
|
+
gap: 10px; font-size: 13px;
|
|
137
|
+
}
|
|
138
|
+
.tdep-item:hover { background: #f5f7fa; }
|
|
139
|
+
.tdep-item.checked { background: #e6f7ff; }
|
|
140
|
+
|
|
141
|
+
.tdep-item input[type="checkbox"] {
|
|
142
|
+
width: 16px; height: 16px; flex-shrink: 0; accent-color: #1890ff; cursor: pointer;
|
|
143
|
+
}
|
|
144
|
+
.tdep-item-id { font-size: 11px; color: #999; flex-shrink: 0; min-width: 48px; }
|
|
145
|
+
.tdep-item-content {
|
|
146
|
+
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
147
|
+
}
|
|
148
|
+
.tdep-item-done { font-size: 10px; color: #52c41a; flex-shrink: 0; }
|
|
149
|
+
|
|
150
|
+
.tdep-empty { padding: 32px 20px; text-align: center; color: #bbb; font-size: 13px; }
|
|
151
|
+
|
|
152
|
+
.tdep-footer {
|
|
153
|
+
padding: 12px 20px; display: flex; justify-content: space-between; align-items: center;
|
|
154
|
+
}
|
|
155
|
+
.tdep-count { font-size: 12px; color: #999; }
|
|
156
|
+
.tdep-actions { display: flex; gap: 8px; }
|
|
157
|
+
|
|
158
|
+
.tdep-btn {
|
|
159
|
+
padding: 6px 16px; border: none; border-radius: 6px;
|
|
160
|
+
font-size: 12px; cursor: pointer; background: #f0f0f0; color: #666;
|
|
161
|
+
font-family: inherit;
|
|
162
|
+
}
|
|
163
|
+
.tdep-btn:hover { background: #e0e0e0; }
|
|
164
|
+
.tdep-btn:active { transform: scale(0.97); }
|
|
165
|
+
.tdep-btn-primary { background: #1890ff; color: #fff; }
|
|
166
|
+
.tdep-btn-primary:hover { background: #40a9ff; }
|
|
167
|
+
`
|
|
168
|
+
document.head.appendChild(s)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Dep data (hover-based lazy display) ──
|
|
172
|
+
const DEP_STORAGE_KEY = 'todo_dep_display'
|
|
173
|
+
let depData = {}
|
|
174
|
+
|
|
175
|
+
function loadDepData() {
|
|
176
|
+
try { depData = JSON.parse(localStorage.getItem(DEP_STORAGE_KEY)) || {} } catch { depData = {} }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let hoverWrap = null
|
|
180
|
+
|
|
181
|
+
function showDepTag(wrapEl) {
|
|
182
|
+
const todoId = wrapEl.getAttribute('data-todowrapid')
|
|
183
|
+
const info = depData[todoId]
|
|
184
|
+
if (!info?.deps?.length) return
|
|
185
|
+
|
|
186
|
+
const textEl = wrapEl.querySelector('[data-todoid]')
|
|
187
|
+
if (!textEl || textEl.querySelector('.tdep-tag')) return
|
|
188
|
+
|
|
189
|
+
const tag = document.createElement('span')
|
|
190
|
+
tag.className = 'tdep-tag'
|
|
191
|
+
tag.textContent = T.depLabel + ': ' + info.deps.map(d => d.content).join(' | ')
|
|
192
|
+
tag.title = info.deps.map(d => '#' + d.id + ' ' + d.content).join('\n')
|
|
193
|
+
textEl.appendChild(tag)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function removeDepTag(wrapEl) {
|
|
197
|
+
const textEl = wrapEl.querySelector('[data-todoid]')
|
|
198
|
+
if (!textEl) return
|
|
199
|
+
const tag = textEl.querySelector('.tdep-tag')
|
|
200
|
+
if (tag) tag.remove()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function initHover() {
|
|
204
|
+
document.addEventListener('mouseover', e => {
|
|
205
|
+
const el = e.target.closest?.('[data-todowrapid]')
|
|
206
|
+
if (el === hoverWrap) return
|
|
207
|
+
|
|
208
|
+
if (hoverWrap) { removeDepTag(hoverWrap); hoverWrap = null }
|
|
209
|
+
if (!el) return
|
|
210
|
+
|
|
211
|
+
hoverWrap = el
|
|
212
|
+
showDepTag(el)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
document.addEventListener('mouseout', e => {
|
|
216
|
+
if (!hoverWrap) return
|
|
217
|
+
const related = e.relatedTarget
|
|
218
|
+
if (related && hoverWrap.contains(related)) return
|
|
219
|
+
if (related && related.closest?.('[data-todowrapid]') === hoverWrap) return
|
|
220
|
+
|
|
221
|
+
removeDepTag(hoverWrap)
|
|
222
|
+
hoverWrap = null
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Modal ──
|
|
227
|
+
let modalEl = null
|
|
228
|
+
let modalTarget = null
|
|
229
|
+
let allTodos = []
|
|
230
|
+
let selectedIds = new Set()
|
|
231
|
+
|
|
232
|
+
function escHtml(s) {
|
|
233
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function ensureModal() {
|
|
237
|
+
if (modalEl) return modalEl
|
|
238
|
+
const el = document.createElement('div')
|
|
239
|
+
el.id = 'tdep-modal'
|
|
240
|
+
el.className = 'tdep-backdrop'
|
|
241
|
+
el.style.display = 'none'
|
|
242
|
+
el.innerHTML = `
|
|
243
|
+
<div class="tdep-card">
|
|
244
|
+
<div class="tdep-header">
|
|
245
|
+
<div class="tdep-title">${escHtml(T.modalTitle)}</div>
|
|
246
|
+
<div class="tdep-subtitle" id="tdep-subtitle"></div>
|
|
247
|
+
</div>
|
|
248
|
+
<div class="tdep-search">
|
|
249
|
+
<input type="text" id="tdep-search" placeholder="${escHtml(T.searchPlaceholder)}" spellcheck="false">
|
|
250
|
+
</div>
|
|
251
|
+
<div class="tdep-body" id="tdep-list"></div>
|
|
252
|
+
<div class="tdep-footer">
|
|
253
|
+
<span class="tdep-count" id="tdep-count"></span>
|
|
254
|
+
<div class="tdep-actions">
|
|
255
|
+
<button class="tdep-btn" id="tdep-cancel">${escHtml(T.cancel)}</button>
|
|
256
|
+
<button class="tdep-btn tdep-btn-primary" id="tdep-save">${escHtml(T.save)}</button>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
`
|
|
261
|
+
document.body.appendChild(el)
|
|
262
|
+
|
|
263
|
+
el.addEventListener('click', e => { if (e.target === el) hideModal() })
|
|
264
|
+
el.querySelector('#tdep-cancel').addEventListener('click', hideModal)
|
|
265
|
+
el.querySelector('#tdep-save').addEventListener('click', handleSave)
|
|
266
|
+
el.querySelector('#tdep-search').addEventListener('input', e => renderList(e.target.value))
|
|
267
|
+
document.addEventListener('keydown', e => {
|
|
268
|
+
if (e.key === 'Escape' && modalEl?.style.display !== 'none') hideModal()
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
modalEl = el
|
|
272
|
+
return el
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function showModal(target) {
|
|
276
|
+
modalTarget = target
|
|
277
|
+
selectedIds = new Set((target.depIds || []).map(Number))
|
|
278
|
+
ensureModal()
|
|
279
|
+
|
|
280
|
+
document.getElementById('tdep-subtitle').textContent = '#' + target.id + ' ' + target.content
|
|
281
|
+
document.getElementById('tdep-search').value = ''
|
|
282
|
+
renderList('')
|
|
283
|
+
updateCount()
|
|
284
|
+
modalEl.style.display = 'flex'
|
|
285
|
+
document.getElementById('tdep-search').focus()
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function hideModal() {
|
|
289
|
+
if (modalEl) modalEl.style.display = 'none'
|
|
290
|
+
modalTarget = null
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function renderList(query) {
|
|
294
|
+
const list = document.getElementById('tdep-list')
|
|
295
|
+
const q = query.toLowerCase()
|
|
296
|
+
const filtered = q
|
|
297
|
+
? allTodos.filter(i => i.content.toLowerCase().includes(q) || String(i.id).includes(q))
|
|
298
|
+
: allTodos
|
|
299
|
+
|
|
300
|
+
if (!filtered.length) {
|
|
301
|
+
list.innerHTML = '<div class="tdep-empty">' + escHtml(T.noMatch) + '</div>'
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
list.innerHTML = filtered.map(i => {
|
|
306
|
+
const chk = selectedIds.has(i.id)
|
|
307
|
+
return '<label class="tdep-item' + (chk ? ' checked' : '') + '" data-id="' + i.id + '">'
|
|
308
|
+
+ '<input type="checkbox"' + (chk ? ' checked' : '') + '>'
|
|
309
|
+
+ '<span class="tdep-item-id">#' + i.id + '</span>'
|
|
310
|
+
+ '<span class="tdep-item-content">' + escHtml(i.content) + '</span>'
|
|
311
|
+
+ (i.done ? '<span class="tdep-item-done">' + escHtml(T.done) + '</span>' : '')
|
|
312
|
+
+ '</label>'
|
|
313
|
+
}).join('')
|
|
314
|
+
|
|
315
|
+
list.querySelectorAll('.tdep-item').forEach(el => {
|
|
316
|
+
el.addEventListener('change', () => {
|
|
317
|
+
const id = Number(el.dataset.id)
|
|
318
|
+
const cb = el.querySelector('input')
|
|
319
|
+
if (cb.checked) { selectedIds.add(id); el.classList.add('checked') }
|
|
320
|
+
else { selectedIds.delete(id); el.classList.remove('checked') }
|
|
321
|
+
updateCount()
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function updateCount() {
|
|
327
|
+
const el = document.getElementById('tdep-count')
|
|
328
|
+
if (el) el.textContent = T.selCount(selectedIds.size)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function handleSave() {
|
|
332
|
+
if (!modalTarget) return
|
|
333
|
+
try {
|
|
334
|
+
await callPluginService('saveDeps', {
|
|
335
|
+
todoId: modalTarget.id,
|
|
336
|
+
depIds: [...selectedIds],
|
|
337
|
+
filePath: modalTarget.filePath || currentFilePath,
|
|
338
|
+
})
|
|
339
|
+
} catch (e) {}
|
|
340
|
+
hideModal()
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ── Init ──
|
|
344
|
+
injectStyles()
|
|
345
|
+
loadDepData()
|
|
346
|
+
initHover()
|
|
347
|
+
})()
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aicupa/plugin-todo-dependency",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Set and visualize dependencies between todo items",
|
|
5
|
+
"description_zh": "设置并可视化待办事项之间的依赖关系",
|
|
6
|
+
"main": "./service",
|
|
7
|
+
"view": "./view",
|
|
8
|
+
"viewjs": "./inject.js",
|
|
9
|
+
"viewSize": {
|
|
10
|
+
"width": 900,
|
|
11
|
+
"height": 620
|
|
12
|
+
},
|
|
13
|
+
"pluginContributes": {
|
|
14
|
+
"contextMenus": [
|
|
15
|
+
{
|
|
16
|
+
"title": "Set Dependencies",
|
|
17
|
+
"title_zh": "设置依赖",
|
|
18
|
+
"command": "setDependency"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"registry": "https://registry.npmjs.org/",
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@aicupa/api": "^1.0.4"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/service.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {import('@aicupa/api').PluginApi} api
|
|
3
|
+
* @returns {import('@aicupa/api').Plugin}
|
|
4
|
+
*/
|
|
5
|
+
module.exports = (api) => {
|
|
6
|
+
function flattenTodos(nodes, result) {
|
|
7
|
+
if (!Array.isArray(nodes)) return
|
|
8
|
+
for (const node of nodes) {
|
|
9
|
+
if (node.todo) {
|
|
10
|
+
result.push({
|
|
11
|
+
id: node.todo.id,
|
|
12
|
+
content: node.todo.content,
|
|
13
|
+
done: node.todo.done,
|
|
14
|
+
level: node.todo.level,
|
|
15
|
+
depIds: node.todo.depIds || [],
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
if (node.children?.length) flattenTodos(node.children, result)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getTreeNodes(data) {
|
|
23
|
+
if (Array.isArray(data)) return data
|
|
24
|
+
if (data?.todotree?.tree) return data.todotree.tree
|
|
25
|
+
if (data?.tree) return data.tree
|
|
26
|
+
return []
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
async setDependency({ node, filePath }) {
|
|
31
|
+
try {
|
|
32
|
+
const data = await api.getTree(filePath)
|
|
33
|
+
const allTodos = []
|
|
34
|
+
flattenTodos(getTreeNodes(data), allTodos)
|
|
35
|
+
const targetInTree = allTodos.find(t => t.id === node.todo.id)
|
|
36
|
+
return {
|
|
37
|
+
ok: true,
|
|
38
|
+
target: {
|
|
39
|
+
id: node.todo.id,
|
|
40
|
+
content: targetInTree?.content || node.todo.content,
|
|
41
|
+
depIds: targetInTree?.depIds || [],
|
|
42
|
+
filePath,
|
|
43
|
+
},
|
|
44
|
+
allTodos: allTodos.filter(t => t.id !== node.todo.id),
|
|
45
|
+
}
|
|
46
|
+
} catch (e) {
|
|
47
|
+
return { ok: false, error: e.message }
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
async saveDeps({ todoId, depIds, filePath }) {
|
|
52
|
+
try {
|
|
53
|
+
const content = await api.readFile(filePath)
|
|
54
|
+
const data = JSON.parse(content)
|
|
55
|
+
const todotree = data.todotree
|
|
56
|
+
|
|
57
|
+
function findAndUpdate(nodes) {
|
|
58
|
+
for (const node of nodes) {
|
|
59
|
+
if (node.todo && node.todo.id === todoId) {
|
|
60
|
+
if (depIds.length > 0) {
|
|
61
|
+
node.todo.depIds = depIds
|
|
62
|
+
} else {
|
|
63
|
+
delete node.todo.depIds
|
|
64
|
+
}
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
67
|
+
if (node.children?.length && findAndUpdate(node.children)) return true
|
|
68
|
+
}
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
findAndUpdate(todotree.tree)
|
|
73
|
+
await api.store('todotree', todotree, filePath)
|
|
74
|
+
await api.reload(filePath)
|
|
75
|
+
return { ok: true }
|
|
76
|
+
} catch (e) {
|
|
77
|
+
return { ok: false, error: e.message }
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
async scanAllDeps({ filePath }) {
|
|
82
|
+
try {
|
|
83
|
+
const data = await api.getTree(filePath)
|
|
84
|
+
const todos = []
|
|
85
|
+
flattenTodos(getTreeNodes(data), todos)
|
|
86
|
+
|
|
87
|
+
const edges = []
|
|
88
|
+
const involvedIds = new Set()
|
|
89
|
+
const todoMap = {}
|
|
90
|
+
|
|
91
|
+
for (const t of todos) {
|
|
92
|
+
todoMap[t.id] = t
|
|
93
|
+
if (t.depIds?.length) {
|
|
94
|
+
involvedIds.add(t.id)
|
|
95
|
+
for (const depId of t.depIds) {
|
|
96
|
+
involvedIds.add(depId)
|
|
97
|
+
edges.push([depId, t.id])
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const involved = {}
|
|
103
|
+
for (const id of involvedIds) {
|
|
104
|
+
if (todoMap[id]) involved[id] = todoMap[id]
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { ok: true, result: { todos: involved, edges } }
|
|
108
|
+
} catch (e) {
|
|
109
|
+
return { ok: false, error: e.message }
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
package/view/index.html
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
+
<title>Todo 依赖图</title>
|
|
8
|
+
<style>
|
|
9
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
10
|
+
html, body { height: 100vh; overflow: hidden; }
|
|
11
|
+
|
|
12
|
+
#graphUI {
|
|
13
|
+
display: flex; height: 100vh;
|
|
14
|
+
flex-direction: column; overflow: hidden;
|
|
15
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
16
|
+
color: #333;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
body.dark #graphUI { color: #e0e0e0; }
|
|
20
|
+
|
|
21
|
+
.toolbar {
|
|
22
|
+
padding: 10px 16px; background: #fff; border-bottom: 1px solid #e8e8e8;
|
|
23
|
+
display: flex; align-items: center; gap: 10px; flex-shrink: 0;
|
|
24
|
+
}
|
|
25
|
+
.dark .toolbar { background: #16213e; border-bottom-color: #2a2a4a; }
|
|
26
|
+
.toolbar-title { font-size: 14px; font-weight: 600; flex: 1; }
|
|
27
|
+
|
|
28
|
+
.btn {
|
|
29
|
+
padding: 6px 16px; border: none; border-radius: 6px;
|
|
30
|
+
font-size: 12px; cursor: pointer; transition: background 0.15s;
|
|
31
|
+
}
|
|
32
|
+
.btn:active { transform: scale(0.97); }
|
|
33
|
+
.btn-ghost { background: transparent; color: #666; }
|
|
34
|
+
.btn-ghost:hover { background: #f0f0f0; }
|
|
35
|
+
.dark .btn-ghost { color: #aaa; }
|
|
36
|
+
.dark .btn-ghost:hover { background: #2a2a4a; }
|
|
37
|
+
|
|
38
|
+
.warning {
|
|
39
|
+
background: #fffbe6; border-bottom: 1px solid #ffe58f; color: #ad6800;
|
|
40
|
+
padding: 6px 16px; font-size: 12px; flex-shrink: 0;
|
|
41
|
+
}
|
|
42
|
+
.dark .warning { background: #332b00; border-bottom-color: #554400; color: #ffc53d; }
|
|
43
|
+
|
|
44
|
+
#graphArea { flex: 1; overflow: auto; position: relative; background: #f7f8fa; }
|
|
45
|
+
.dark #graphArea { background: #1a1a2e; }
|
|
46
|
+
#graphContainer { position: relative; min-width: 100%; min-height: 100%; }
|
|
47
|
+
|
|
48
|
+
.node {
|
|
49
|
+
position: absolute; width: 200px; background: #fff;
|
|
50
|
+
border: 2px solid #d9d9d9; border-radius: 10px;
|
|
51
|
+
padding: 10px 12px; cursor: default;
|
|
52
|
+
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
|
53
|
+
transition: box-shadow 0.2s, transform 0.15s;
|
|
54
|
+
}
|
|
55
|
+
.node:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.1); transform: translateY(-1px); }
|
|
56
|
+
.dark .node { background: #16213e; box-shadow: 0 1px 4px rgba(0,0,0,0.3); }
|
|
57
|
+
.node.done { opacity: 0.55; }
|
|
58
|
+
.node.done .node-content { text-decoration: line-through; }
|
|
59
|
+
.node.not-found { border-style: dashed; opacity: 0.65; }
|
|
60
|
+
|
|
61
|
+
.node-header { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
|
|
62
|
+
.node-id {
|
|
63
|
+
font-size: 10px; font-weight: 700; color: #fff;
|
|
64
|
+
background: #1890ff; padding: 1px 7px; border-radius: 10px;
|
|
65
|
+
flex-shrink: 0; letter-spacing: 0.3px;
|
|
66
|
+
}
|
|
67
|
+
.node-badge { font-size: 10px; padding: 1px 6px; border-radius: 10px; flex-shrink: 0; }
|
|
68
|
+
.badge-pending { background: #fff7e6; color: #d46b08; }
|
|
69
|
+
.badge-done { background: #f6ffed; color: #389e0d; }
|
|
70
|
+
.dark .badge-pending { background: #332b00; color: #ffc53d; }
|
|
71
|
+
.dark .badge-done { background: #0a2e0a; color: #73d13d; }
|
|
72
|
+
|
|
73
|
+
.node-content {
|
|
74
|
+
font-size: 12px; line-height: 1.5; word-break: break-word;
|
|
75
|
+
display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2;
|
|
76
|
+
-webkit-box-orient: vertical; overflow: hidden;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.level-default { border-color: #1890ff; }
|
|
80
|
+
.level-secondary { border-color: #bbb; }
|
|
81
|
+
.level-success { border-color: #52c41a; }
|
|
82
|
+
.level-warning { border-color: #faad14; }
|
|
83
|
+
.level-danger { border-color: #ff4d4f; }
|
|
84
|
+
.level-default .node-id { background: #1890ff; }
|
|
85
|
+
.level-secondary .node-id { background: #999; }
|
|
86
|
+
.level-success .node-id { background: #52c41a; }
|
|
87
|
+
.level-warning .node-id { background: #e8a100; }
|
|
88
|
+
.level-danger .node-id { background: #ff4d4f; }
|
|
89
|
+
|
|
90
|
+
#edgeSvg { position: absolute; top: 0; left: 0; pointer-events: none; }
|
|
91
|
+
|
|
92
|
+
.empty-state {
|
|
93
|
+
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
94
|
+
height: 100%; color: #aaa; padding: 40px 24px; text-align: center; user-select: none;
|
|
95
|
+
}
|
|
96
|
+
.empty-icon { width: 64px; height: 64px; margin-bottom: 16px; opacity: 0.35; }
|
|
97
|
+
.empty-title { font-size: 15px; font-weight: 500; margin-bottom: 10px; color: #888; }
|
|
98
|
+
.dark .empty-title { color: #777; }
|
|
99
|
+
.empty-hint { font-size: 12px; line-height: 2; color: #aaa; }
|
|
100
|
+
</style>
|
|
101
|
+
</head>
|
|
102
|
+
|
|
103
|
+
<body>
|
|
104
|
+
<div id="graphUI">
|
|
105
|
+
<div class="toolbar">
|
|
106
|
+
<span class="toolbar-title" data-i18n="title"></span>
|
|
107
|
+
<button class="btn btn-ghost" id="refreshBtn" data-i18n="refresh"></button>
|
|
108
|
+
</div>
|
|
109
|
+
<div id="warnings"></div>
|
|
110
|
+
<div id="graphArea">
|
|
111
|
+
<div id="graphContainer">
|
|
112
|
+
<div class="empty-state" id="emptyState">
|
|
113
|
+
<svg class="empty-icon" viewBox="0 0 80 80" fill="none" stroke="currentColor" stroke-width="2">
|
|
114
|
+
<rect x="8" y="10" width="24" height="16" rx="4"/>
|
|
115
|
+
<rect x="48" y="10" width="24" height="16" rx="4"/>
|
|
116
|
+
<rect x="28" y="50" width="24" height="16" rx="4"/>
|
|
117
|
+
<line x1="26" y1="26" x2="36" y2="50" stroke-dasharray="3,2"/>
|
|
118
|
+
<line x1="54" y1="26" x2="44" y2="50" stroke-dasharray="3,2"/>
|
|
119
|
+
<polygon points="36,50 33,45 39,45" fill="currentColor" stroke="none"/>
|
|
120
|
+
<polygon points="44,50 41,45 47,45" fill="currentColor" stroke="none"/>
|
|
121
|
+
</svg>
|
|
122
|
+
<div class="empty-title" data-i18n="emptyTitle"></div>
|
|
123
|
+
<div class="empty-hint" data-i18n="emptyHint"></div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<script>
|
|
130
|
+
// ── i18n ──
|
|
131
|
+
const I18N = {
|
|
132
|
+
zh: {
|
|
133
|
+
title: '依赖图谱', refresh: '刷新',
|
|
134
|
+
emptyTitle: '暂无依赖关系',
|
|
135
|
+
emptyHint: '右键 Todo 节点,选择「设置依赖」<br>即可建立待办之间的依赖关系',
|
|
136
|
+
done: '已完成', pending: '待完成', notFound: '未找到该 Todo',
|
|
137
|
+
cycleWarning: '检测到循环依赖,部分节点的层级可能不准确',
|
|
138
|
+
},
|
|
139
|
+
en: {
|
|
140
|
+
title: 'Dependency Graph', refresh: 'Refresh',
|
|
141
|
+
emptyTitle: 'No dependencies yet',
|
|
142
|
+
emptyHint: 'Right-click a Todo and select "Set Dependencies"<br>to create dependency relationships',
|
|
143
|
+
done: 'Done', pending: 'Pending', notFound: 'Todo not found',
|
|
144
|
+
cycleWarning: 'Cycle detected — some nodes may be positioned incorrectly',
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
let t = I18N.zh
|
|
148
|
+
function detectLang(raw) {
|
|
149
|
+
t = (raw || navigator.language || 'en').toLowerCase().startsWith('zh') ? I18N.zh : I18N.en
|
|
150
|
+
}
|
|
151
|
+
function applyI18n() {
|
|
152
|
+
document.querySelectorAll('[data-i18n]').forEach(el => {
|
|
153
|
+
const v = t[el.dataset.i18n]; if (v) el.innerHTML = v
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Plugin communication ──
|
|
158
|
+
let callId = 0
|
|
159
|
+
const pendingCalls = {}
|
|
160
|
+
let currentFilePath = ''
|
|
161
|
+
let isDark = false
|
|
162
|
+
|
|
163
|
+
function callPlugin(method, params) {
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
const id = ++callId
|
|
166
|
+
const timer = setTimeout(() => { delete pendingCalls[id]; reject(new Error('timeout')) }, 5000)
|
|
167
|
+
pendingCalls[id] = {
|
|
168
|
+
resolve: v => { clearTimeout(timer); resolve(v) },
|
|
169
|
+
reject: e => { clearTimeout(timer); reject(e) },
|
|
170
|
+
}
|
|
171
|
+
window.parent.postMessage({ type: 'plugin-call', id, method, params }, '*')
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function unwrap(res) {
|
|
176
|
+
const inner = res?.result || res
|
|
177
|
+
return inner?.result !== undefined ? inner.result : inner
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
window.addEventListener('message', event => {
|
|
181
|
+
const msg = event.data
|
|
182
|
+
if (!msg) return
|
|
183
|
+
|
|
184
|
+
if (msg.type === 'plugin-init') {
|
|
185
|
+
currentFilePath = msg.filePath || ''
|
|
186
|
+
detectLang(msg.lang); applyI18n()
|
|
187
|
+
if (msg.theme) {
|
|
188
|
+
document.body.style.color = msg.theme.color
|
|
189
|
+
document.body.style.backgroundColor = msg.theme.backgroundColor
|
|
190
|
+
const bg = msg.theme.backgroundColor
|
|
191
|
+
isDark = typeof bg === 'string' && (
|
|
192
|
+
bg.includes('rgb')
|
|
193
|
+
? parseInt(bg.split(',')[0].replace(/\D/g, '')) < 128
|
|
194
|
+
: bg.startsWith('#') && parseInt(bg.slice(1, 3), 16) < 128
|
|
195
|
+
)
|
|
196
|
+
if (isDark) document.body.classList.add('dark')
|
|
197
|
+
}
|
|
198
|
+
autoScan()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (msg.type === 'plugin-result' && pendingCalls[msg.id]) {
|
|
202
|
+
const { resolve, reject } = pendingCalls[msg.id]
|
|
203
|
+
delete pendingCalls[msg.id]
|
|
204
|
+
msg.error ? reject(new Error(msg.error)) : resolve(msg.result)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (msg.type === 'plugin-tree-update') {
|
|
208
|
+
autoScan()
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// ── Graph ──
|
|
213
|
+
document.getElementById('refreshBtn').addEventListener('click', autoScan)
|
|
214
|
+
|
|
215
|
+
async function autoScan() {
|
|
216
|
+
if (!currentFilePath) return
|
|
217
|
+
document.getElementById('warnings').innerHTML = ''
|
|
218
|
+
let data
|
|
219
|
+
try {
|
|
220
|
+
const res = await callPlugin('scanAllDeps', { filePath: currentFilePath })
|
|
221
|
+
data = unwrap(res)
|
|
222
|
+
} catch { return }
|
|
223
|
+
if (!data) return
|
|
224
|
+
|
|
225
|
+
const { todos, edges } = data
|
|
226
|
+
const nodeIds = Object.keys(todos).map(Number)
|
|
227
|
+
if (!nodeIds.length) { showEmpty(); return }
|
|
228
|
+
|
|
229
|
+
const { layers, hasCycle } = assignLayers(nodeIds, edges)
|
|
230
|
+
if (hasCycle) {
|
|
231
|
+
document.getElementById('warnings').innerHTML = '<div class="warning">' + t.cycleWarning + '</div>'
|
|
232
|
+
}
|
|
233
|
+
const layout = layoutGraph(nodeIds, layers)
|
|
234
|
+
renderGraph(nodeIds, edges, layout.positions, todos, layout)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function showEmpty() {
|
|
238
|
+
const c = document.getElementById('graphContainer')
|
|
239
|
+
const e = document.getElementById('emptyState')
|
|
240
|
+
c.innerHTML = ''; c.style.width = ''; c.style.height = ''
|
|
241
|
+
c.appendChild(e); e.style.display = 'flex'
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Layout algorithm ──
|
|
245
|
+
function assignLayers(nodeIds, edges) {
|
|
246
|
+
const adj = {}, inDeg = {}
|
|
247
|
+
for (const id of nodeIds) { adj[id] = []; inDeg[id] = 0 }
|
|
248
|
+
for (const [f, tt] of edges) {
|
|
249
|
+
if (adj[f] && inDeg[tt] !== undefined) { adj[f].push(tt); inDeg[tt]++ }
|
|
250
|
+
}
|
|
251
|
+
const layers = {}, queue = []
|
|
252
|
+
for (const id of nodeIds) { if (inDeg[id] === 0) { queue.push(id); layers[id] = 0 } }
|
|
253
|
+
let head = 0
|
|
254
|
+
while (head < queue.length) {
|
|
255
|
+
const cur = queue[head++]
|
|
256
|
+
for (const next of adj[cur]) {
|
|
257
|
+
const nl = layers[cur] + 1
|
|
258
|
+
if (layers[next] === undefined || nl > layers[next]) layers[next] = nl
|
|
259
|
+
if (--inDeg[next] === 0) queue.push(next)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const hasCycle = nodeIds.some(id => layers[id] === undefined)
|
|
263
|
+
if (hasCycle) {
|
|
264
|
+
const maxL = Math.max(0, ...Object.values(layers).filter(v => v !== undefined))
|
|
265
|
+
for (const id of nodeIds) { if (layers[id] === undefined) layers[id] = maxL + 1 }
|
|
266
|
+
}
|
|
267
|
+
return { layers, hasCycle }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const NODE_W = 200, NODE_H = 72, GAP_X = 44, GAP_Y = 70, PAD = 36
|
|
271
|
+
function layoutGraph(nodeIds, layers) {
|
|
272
|
+
if (!nodeIds.length) return { positions: {}, width: 0, height: 0 }
|
|
273
|
+
const maxLayer = Math.max(...Object.values(layers))
|
|
274
|
+
const groups = Array.from({ length: maxLayer + 1 }, () => [])
|
|
275
|
+
for (const id of nodeIds) groups[layers[id]].push(id)
|
|
276
|
+
const maxCount = Math.max(...groups.map(g => g.length))
|
|
277
|
+
const totalW = Math.max(maxCount * NODE_W + (maxCount - 1) * GAP_X + PAD * 2, 400)
|
|
278
|
+
const totalH = PAD * 2 + (maxLayer + 1) * NODE_H + maxLayer * GAP_Y
|
|
279
|
+
const positions = {}
|
|
280
|
+
for (let l = 0; l <= maxLayer; l++) {
|
|
281
|
+
const g = groups[l]
|
|
282
|
+
const gw = g.length * NODE_W + (g.length - 1) * GAP_X
|
|
283
|
+
const sx = (totalW - gw) / 2
|
|
284
|
+
for (let i = 0; i < g.length; i++) {
|
|
285
|
+
positions[g[i]] = { x: sx + i * (NODE_W + GAP_X), y: PAD + l * (NODE_H + GAP_Y) }
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return { positions, width: totalW, height: totalH }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function svgEl(tag, attrs) {
|
|
292
|
+
const el = document.createElementNS('http://www.w3.org/2000/svg', tag)
|
|
293
|
+
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v)
|
|
294
|
+
return el
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function renderGraph(nodeIds, edges, positions, todoData, layout) {
|
|
298
|
+
const container = document.getElementById('graphContainer')
|
|
299
|
+
container.innerHTML = ''
|
|
300
|
+
container.style.width = layout.width + 'px'
|
|
301
|
+
container.style.height = layout.height + 'px'
|
|
302
|
+
|
|
303
|
+
const edgeColor = isDark ? '#4a4a6a' : '#c8c8d0'
|
|
304
|
+
const arrowColor = isDark ? '#6a6a8a' : '#aaa'
|
|
305
|
+
const svg = svgEl('svg', { id: 'edgeSvg', width: layout.width, height: layout.height })
|
|
306
|
+
const defs = svgEl('defs', {})
|
|
307
|
+
const marker = svgEl('marker', {
|
|
308
|
+
id: 'arrow', markerWidth: 10, markerHeight: 7,
|
|
309
|
+
refX: 9, refY: 3.5, orient: 'auto', markerUnits: 'strokeWidth',
|
|
310
|
+
})
|
|
311
|
+
marker.appendChild(svgEl('polygon', { points: '0 0.5,9 3.5,0 6.5', fill: arrowColor }))
|
|
312
|
+
defs.appendChild(marker); svg.appendChild(defs)
|
|
313
|
+
|
|
314
|
+
for (const [from, to] of edges) {
|
|
315
|
+
const p1 = positions[from], p2 = positions[to]
|
|
316
|
+
if (!p1 || !p2) continue
|
|
317
|
+
const x1 = p1.x + NODE_W / 2, y1 = p1.y + NODE_H
|
|
318
|
+
const x2 = p2.x + NODE_W / 2, y2 = p2.y
|
|
319
|
+
const cp = Math.max(Math.abs(y2 - y1) * 0.35, 20)
|
|
320
|
+
svg.appendChild(svgEl('path', {
|
|
321
|
+
d: `M${x1},${y1} C${x1},${y1 + cp} ${x2},${y2 - cp} ${x2},${y2}`,
|
|
322
|
+
fill: 'none', stroke: edgeColor, 'stroke-width': 2, 'marker-end': 'url(#arrow)',
|
|
323
|
+
}))
|
|
324
|
+
}
|
|
325
|
+
container.appendChild(svg)
|
|
326
|
+
|
|
327
|
+
for (const id of nodeIds) {
|
|
328
|
+
const pos = positions[id]; if (!pos) continue
|
|
329
|
+
const todo = todoData[id]
|
|
330
|
+
const el = document.createElement('div'); el.className = 'node'
|
|
331
|
+
if (todo) {
|
|
332
|
+
el.classList.add('level-' + (todo.level || 'default'))
|
|
333
|
+
if (todo.done) el.classList.add('done')
|
|
334
|
+
} else el.classList.add('not-found')
|
|
335
|
+
el.style.cssText = `left:${pos.x}px;top:${pos.y}px;width:${NODE_W}px;`
|
|
336
|
+
|
|
337
|
+
const header = document.createElement('div'); header.className = 'node-header'
|
|
338
|
+
const badge = document.createElement('span'); badge.className = 'node-id'
|
|
339
|
+
badge.textContent = '#' + id; header.appendChild(badge)
|
|
340
|
+
if (todo) {
|
|
341
|
+
const sb = document.createElement('span')
|
|
342
|
+
sb.className = 'node-badge ' + (todo.done ? 'badge-done' : 'badge-pending')
|
|
343
|
+
sb.textContent = todo.done ? t.done : t.pending
|
|
344
|
+
header.appendChild(sb)
|
|
345
|
+
}
|
|
346
|
+
el.appendChild(header)
|
|
347
|
+
const content = document.createElement('div'); content.className = 'node-content'
|
|
348
|
+
if (todo) content.textContent = todo.content
|
|
349
|
+
else { content.textContent = t.notFound; content.style.color = '#ff4d4f'; content.style.fontStyle = 'italic' }
|
|
350
|
+
el.appendChild(content); container.appendChild(el)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── Init ──
|
|
355
|
+
detectLang(); applyI18n()
|
|
356
|
+
</script>
|
|
357
|
+
</body>
|
|
358
|
+
|
|
359
|
+
</html>
|