lantern-harness 0.1.2 → 0.1.3

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 (81) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +6 -6
  3. package/builtin/session/lh-spaces.mjs +102 -0
  4. package/cli/data-root.mjs +80 -3
  5. package/cli/lantern.mjs +2 -1
  6. package/cli/server.mjs +4 -0
  7. package/cli/spaces.mjs +91 -5
  8. package/config/app.defaults.json +4 -2
  9. package/dist/500.html +9 -5
  10. package/dist/_astro/Base.ByB0D4ZA.css +1 -0
  11. package/dist/_astro/{Base.astro_astro_type_script_index_0_lang.lxEwQ1R4.js → Base.astro_astro_type_script_index_0_lang.Cg_8YLVA.js} +1 -1
  12. package/dist/_astro/CaseGenerateApp.BlZyjWEr.js +9 -0
  13. package/dist/_astro/CasesLibrary.C76La3Xj.js +1 -0
  14. package/dist/_astro/ChatApp.gfsPLvan.js +269 -0
  15. package/dist/_astro/ColumnResize.Bs6GL3DJ.js +1 -0
  16. package/dist/_astro/DatePicker.BdwFRioL.js +1 -0
  17. package/dist/_astro/{DocsFileBrowser.DkE26UTR.js → DocsFileBrowser.BxAyrkkw.js} +1 -1
  18. package/dist/_astro/KnowledgeLibrary.Cv488Hot.js +1 -0
  19. package/dist/_astro/NavLibraries.ALZ9e12z.js +1 -0
  20. package/dist/_astro/PluginsPage.B2uxCiHL.js +1 -0
  21. package/dist/_astro/{PluginsPage.BNzpPg2H.js → PluginsPage.DEkyOfQE.js} +2 -2
  22. package/dist/_astro/PromptsLibrary.BUC1OiCH.js +1 -0
  23. package/dist/_astro/RequirementsLibrary.D3olQI63.js +1 -0
  24. package/dist/_astro/{SearchableSelect.DMS2XmXc.js → SearchableSelect.DP74JRWW.js} +1 -1
  25. package/dist/_astro/SelectDropdown.D2i7-lQC.js +1 -0
  26. package/dist/_astro/{SettingsDialog.DC0e7580.js → SettingsDialog.CrMDjSLJ.js} +1 -1
  27. package/dist/_astro/SlideOverDrawer.C7UVeCX1.js +1 -0
  28. package/dist/_astro/SpaceSwitcher.Wf8EPYUx.js +7 -0
  29. package/dist/_astro/TasksBoard.2j7rGT9O.js +1 -0
  30. package/dist/_astro/TemplatesLibrary.J0x7yIcY.js +1 -0
  31. package/dist/_astro/TodosBoard.BugeB7_7.js +1 -0
  32. package/dist/_astro/app-config.vqDkEMS3.js +1 -0
  33. package/dist/_astro/doc-roots.DAJP9Svr.js +1 -0
  34. package/dist/_astro/i18n.9qZsrs3u.js +7 -0
  35. package/dist/_astro/{open-docs-in-session.D3JCwMWB.js → open-docs-in-session.fLwnA5qh.js} +1 -1
  36. package/dist/_astro/plugins.DSUPSizB.js +1 -0
  37. package/dist/_astro/project-docs.Cd2gKPKh.js +1 -0
  38. package/dist/_astro/{settings.BoQ0e0m1.js → settings.CkbipdPI.js} +2 -2
  39. package/dist/_astro/{useListAnim.CnbC_9MC.js → useListAnim.Y3N2budK.js} +1 -1
  40. package/dist/cases/generate/index.html +10 -6
  41. package/dist/cases/library/index.html +10 -6
  42. package/dist/docs/changelog/index.html +69 -6
  43. package/dist/docs/install/index.html +22 -8
  44. package/dist/docs/manual/index.html +74 -11
  45. package/dist/index.html +10 -6
  46. package/dist/knowledge/index.html +10 -6
  47. package/dist/knowledge//345/212/237/350/203/275/346/265/213/350/257/225/347/224/250/344/276/213/347/274/226/345/206/231/350/247/204/350/214/203/index.html +9 -5
  48. package/dist/plugins/index.html +10 -6
  49. package/dist/prompts/index.html +10 -6
  50. package/dist/requirements/index.html +10 -6
  51. package/dist/tasks/index.html +253 -0
  52. package/dist/templates/index.html +10 -6
  53. package/dist/templates//345/212/237/350/203/275/346/265/213/350/257/225/346/212/245/345/221/212//345/212/237/350/203/275/346/265/213/350/257/225/346/212/245/345/221/212/346/250/241/346/235/277/index.html +9 -5
  54. package/dist/templates//346/200/247/350/203/275/346/265/213/350/257/225/346/212/245/345/221/212//346/200/247/350/203/275/346/265/213/350/257/225/346/212/245/345/221/212/346/250/241/346/235/277/index.html +9 -5
  55. package/dist/templates//346/200/247/350/203/275/346/265/213/350/257/225/347/224/250/344/276/213//346/200/247/350/203/275/346/265/213/350/257/225/347/224/250/344/276/213/346/250/241/346/235/277/index.html +9 -5
  56. package/dist/templates//346/216/245/345/217/243/346/265/213/350/257/225/346/212/245/345/221/212//346/216/245/345/217/243/346/265/213/350/257/225/346/212/245/345/221/212/346/250/241/346/235/277/index.html +9 -5
  57. package/dist/templates//350/207/252/345/212/250/345/214/226/346/265/213/350/257/225/347/224/250/344/276/213//350/207/252/345/212/250/345/214/226/346/265/213/350/257/225/347/224/250/344/276/213/346/250/241/346/235/277/index.html +9 -5
  58. package/dist/todos/index.html +253 -0
  59. package/docs//345/205/250/345/261/200/346/217/222/344/273/266.md +2 -2
  60. package/docs//345/256/211/350/243/205/346/211/213/345/206/214.md +10 -8
  61. package/docs//346/223/215/344/275/234/346/211/213/345/206/214.md +73 -23
  62. package/lh-plugins/lh-project-docs.mjs +2 -0
  63. package/lh-plugins/lh-tasks.mjs +223 -0
  64. package/lh-plugins/lh-todos.mjs +297 -0
  65. package/lh-plugins/storage-domain.mjs +1 -0
  66. package/package.json +1 -1
  67. package/dist/_astro/Base.B9f_iFAZ.css +0 -1
  68. package/dist/_astro/CaseGenerateApp.Br4uKjun.js +0 -9
  69. package/dist/_astro/CasesLibrary.CpPoVQ4P.js +0 -1
  70. package/dist/_astro/ChatApp.CYSPlSNP.js +0 -258
  71. package/dist/_astro/KnowledgeLibrary.DZinlAWn.js +0 -1
  72. package/dist/_astro/NavLibraries.EayBsg5g.js +0 -1
  73. package/dist/_astro/PluginsPage.CY61glLU.js +0 -1
  74. package/dist/_astro/PromptsLibrary.DfrrCjs7.js +0 -1
  75. package/dist/_astro/RequirementsLibrary.Dt0Gj6Nr.js +0 -1
  76. package/dist/_astro/SelectDropdown.Dc7QaPbH.js +0 -1
  77. package/dist/_astro/SpaceSwitcher.Yy2w8MDF.js +0 -7
  78. package/dist/_astro/TemplatesLibrary.uK-SjK1z.js +0 -1
  79. package/dist/_astro/app-config.CpjKWnzM.js +0 -1
  80. package/dist/_astro/i18n.BVvhz-vw.js +0 -7
  81. package/dist/_astro/plugins.CrpY8UI9.js +0 -1
@@ -0,0 +1,223 @@
1
+ /**
2
+ * 任务管理存储(空间级,独立插件)。
3
+ *
4
+ * 数据落在 `<当前数据空间>/.lantern/local/tasks.json`:跟空间走、不跟 git 走。
5
+ * 待办通过 `taskId` 关联到这里;任务周期 / 待办数量 / 完成进度都是派生值,不落库。
6
+ */
7
+ import crypto from 'node:crypto';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ import { getDataRoot } from '../builtin/session/space-context.mjs';
12
+ import { spaceLocalFile } from '../builtin/session/space-layout.mjs';
13
+
14
+ const PREFIX = '/__tb_tasks';
15
+ const FILE_NAME = 'tasks.json';
16
+ const VERSION = 1;
17
+
18
+ export const TASK_STATUSES = ['pool', 'in_progress', 'done', 'closed'];
19
+ export const TASK_PRIORITIES = ['urgent_important', 'important', 'urgent', 'normal'];
20
+
21
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
22
+
23
+ function sendJson(res, status, body) {
24
+ res.statusCode = status;
25
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
26
+ res.end(JSON.stringify(body));
27
+ }
28
+
29
+ function readBody(req) {
30
+ return new Promise((resolve, reject) => {
31
+ const chunks = [];
32
+ req.on('data', (c) => chunks.push(c));
33
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
34
+ req.on('error', reject);
35
+ });
36
+ }
37
+
38
+ function storeFile(root) {
39
+ return spaceLocalFile(root, FILE_NAME);
40
+ }
41
+
42
+ function readStore(root) {
43
+ const fp = storeFile(root);
44
+ try {
45
+ const raw = JSON.parse(fs.readFileSync(fp, 'utf8'));
46
+ const items = Array.isArray(raw?.items) ? raw.items.map(normalizeStored).filter(Boolean) : [];
47
+ return { version: VERSION, items };
48
+ } catch {
49
+ return { version: VERSION, items: [] };
50
+ }
51
+ }
52
+
53
+ /** 先写临时文件再 rename:中途失败也不会留下半份 JSON */
54
+ function writeStore(root, store) {
55
+ const fp = storeFile(root);
56
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
57
+ const tmp = `${fp}.${process.pid}.tmp`;
58
+ fs.writeFileSync(tmp, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
59
+ fs.renameSync(tmp, fp);
60
+ }
61
+
62
+ function newId() {
63
+ return `tk_${crypto.randomBytes(8).toString('hex')}`;
64
+ }
65
+
66
+ function text(value, max = 2000) {
67
+ return typeof value === 'string' ? value.trim().slice(0, max) : '';
68
+ }
69
+
70
+ function dateOrEmpty(value) {
71
+ const s = text(value, 10);
72
+ return DATE_RE.test(s) ? s : '';
73
+ }
74
+
75
+ function oneOf(value, allowed, fallback) {
76
+ const s = text(value, 40);
77
+ return allowed.includes(s) ? s : fallback;
78
+ }
79
+
80
+ /** 读回来的旧数据也走一遍归一化,避免手工改坏的文件把页面拖垮 */
81
+ function normalizeStored(raw) {
82
+ if (!raw || typeof raw !== 'object') return null;
83
+ const title = text(raw.title, 200);
84
+ if (!title) return null;
85
+ const status = oneOf(raw.status, TASK_STATUSES, 'pool');
86
+ const now = new Date().toISOString();
87
+ return {
88
+ id: text(raw.id, 64) || newId(),
89
+ title,
90
+ status,
91
+ priority: oneOf(raw.priority, TASK_PRIORITIES, ''),
92
+ startDate: dateOrEmpty(raw.startDate),
93
+ dueDate: dateOrEmpty(raw.dueDate),
94
+ note: text(raw.note),
95
+ source: raw.source === 'ai' ? 'ai' : 'manual',
96
+ sessionId: text(raw.sessionId, 120),
97
+ createdAt: text(raw.createdAt, 40) || now,
98
+ updatedAt: text(raw.updatedAt, 40) || now,
99
+ doneAt: text(raw.doneAt, 40) || null,
100
+ };
101
+ }
102
+
103
+ /** 入参归一化:只认白名单字段,缺省值由调用方(新建/更新)决定 */
104
+ function normalizePatch(raw, base) {
105
+ const patch = {};
106
+ if (!raw || typeof raw !== 'object') return patch;
107
+ if ('title' in raw) patch.title = text(raw.title, 200);
108
+ if ('status' in raw) patch.status = oneOf(raw.status, TASK_STATUSES, base.status);
109
+ if ('priority' in raw) patch.priority = oneOf(raw.priority, TASK_PRIORITIES, '');
110
+ if ('startDate' in raw) patch.startDate = dateOrEmpty(raw.startDate);
111
+ if ('dueDate' in raw) patch.dueDate = dateOrEmpty(raw.dueDate);
112
+ if ('note' in raw) patch.note = text(raw.note);
113
+ return patch;
114
+ }
115
+
116
+ /**
117
+ * @param {{ mode?: 'manual' | 'ai', sessionId?: string }} [meta]
118
+ */
119
+ function buildItem(raw, meta = {}) {
120
+ const now = new Date().toISOString();
121
+ const status = oneOf(raw?.status, TASK_STATUSES, 'pool');
122
+ return {
123
+ id: newId(),
124
+ title: text(raw?.title, 200),
125
+ status,
126
+ priority: oneOf(raw?.priority, TASK_PRIORITIES, ''),
127
+ startDate: dateOrEmpty(raw?.startDate),
128
+ dueDate: dateOrEmpty(raw?.dueDate),
129
+ note: text(raw?.note),
130
+ source: meta.mode === 'ai' ? 'ai' : 'manual',
131
+ sessionId: text(meta.sessionId, 120),
132
+ createdAt: now,
133
+ updatedAt: now,
134
+ doneAt: status === 'done' ? now : null,
135
+ };
136
+ }
137
+
138
+ /**
139
+ * @returns {import('vite').Plugin}
140
+ */
141
+ export function lhTasksPlugin() {
142
+ return {
143
+ name: 'lh-tasks',
144
+ configureServer(server) {
145
+ server.middlewares.use(async (req, res, next) => {
146
+ const parsed = new URL(req.url ?? '/', 'http://127.0.0.1');
147
+ if (!parsed.pathname.startsWith(PREFIX)) return next();
148
+ // 数据根惰性读取:切换数据空间后无需重启
149
+ const root = getDataRoot();
150
+
151
+ try {
152
+ const method = req.method || 'GET';
153
+ const itemId = parsed.pathname.slice(PREFIX.length).replace(/^\//, '');
154
+
155
+ if (method === 'GET' && parsed.searchParams.get('health') === '1') {
156
+ return sendJson(res, 200, { ok: true, file: storeFile(root) });
157
+ }
158
+
159
+ if (method === 'GET' && !itemId) {
160
+ return sendJson(res, 200, { ok: true, ...readStore(root) });
161
+ }
162
+
163
+ if (method === 'POST') {
164
+ const body = JSON.parse((await readBody(req)) || '{}');
165
+ const rawItems = Array.isArray(body.items) ? body.items : [body];
166
+ const meta = { mode: body.mode === 'ai' ? 'ai' : 'manual', sessionId: body.sessionId };
167
+ const store = readStore(root);
168
+ const created = [];
169
+ for (const raw of rawItems) {
170
+ const item = buildItem(raw, meta);
171
+ if (!item.title) {
172
+ return sendJson(res, 400, { ok: false, error: '任务名称不能为空' });
173
+ }
174
+ created.push(item);
175
+ }
176
+ store.items.push(...created);
177
+ writeStore(root, store);
178
+ return sendJson(res, 200, { ok: true, items: created, total: store.items.length });
179
+ }
180
+
181
+ if (method === 'PUT') {
182
+ const body = JSON.parse((await readBody(req)) || '{}');
183
+ const id = text(body.id ?? itemId, 64);
184
+ if (!id) return sendJson(res, 400, { ok: false, error: '缺少任务 id' });
185
+ const store = readStore(root);
186
+ const idx = store.items.findIndex((x) => x.id === id);
187
+ if (idx < 0) return sendJson(res, 404, { ok: false, error: '任务不存在' });
188
+
189
+ const base = store.items[idx];
190
+ const patch = normalizePatch(body, base);
191
+ if ('title' in patch && !patch.title) {
192
+ return sendJson(res, 400, { ok: false, error: '任务名称不能为空' });
193
+ }
194
+ const next = { ...base, ...patch, updatedAt: new Date().toISOString() };
195
+ if (next.status === 'done' && base.status !== 'done') next.doneAt = next.updatedAt;
196
+ if (next.status !== 'done') next.doneAt = null;
197
+ store.items[idx] = next;
198
+ writeStore(root, store);
199
+ return sendJson(res, 200, { ok: true, item: next });
200
+ }
201
+
202
+ if (method === 'DELETE') {
203
+ const body = JSON.parse((await readBody(req)) || '{}');
204
+ const ids = (Array.isArray(body.ids) ? body.ids : [body.id ?? itemId])
205
+ .map((x) => text(x, 64))
206
+ .filter(Boolean);
207
+ if (!ids.length) return sendJson(res, 400, { ok: false, error: '缺少任务 id' });
208
+ const store = readStore(root);
209
+ const drop = new Set(ids);
210
+ const before = store.items.length;
211
+ store.items = store.items.filter((x) => !drop.has(x.id));
212
+ writeStore(root, store);
213
+ return sendJson(res, 200, { ok: true, removed: before - store.items.length });
214
+ }
215
+
216
+ return sendJson(res, 405, { ok: false, error: 'Method Not Allowed' });
217
+ } catch (err) {
218
+ return sendJson(res, 500, { ok: false, error: String(err?.message ?? err) });
219
+ }
220
+ });
221
+ },
222
+ };
223
+ }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * 待办事项存储(空间级)。
3
+ *
4
+ * 数据落在 `<当前数据空间>/.lantern/local/todos.json`:跟空间走、不跟 git 走。
5
+ * 页面与工作台 AI 工具共用同一份文件,所以这里只做最小校验,
6
+ * 派生字段(待办周期 / 剩余天数 / 是否延期)一律由前端计算,不落库。
7
+ */
8
+ import crypto from 'node:crypto';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ import { getDataRoot } from '../builtin/session/space-context.mjs';
13
+ import { spaceLocalFile } from '../builtin/session/space-layout.mjs';
14
+
15
+ const PREFIX = '/__tb_todos';
16
+ const FILE_NAME = 'todos.json';
17
+ const VERSION = 1;
18
+
19
+ export const TODO_STATUSES = ['pending', 'in_progress', 'done', 'cancelled'];
20
+ export const TODO_PRIORITIES = ['urgent_important', 'important', 'urgent', 'normal'];
21
+
22
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
23
+
24
+ function sendJson(res, status, body) {
25
+ res.statusCode = status;
26
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
27
+ res.end(JSON.stringify(body));
28
+ }
29
+
30
+ function readBody(req) {
31
+ return new Promise((resolve, reject) => {
32
+ const chunks = [];
33
+ req.on('data', (c) => chunks.push(c));
34
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
35
+ req.on('error', reject);
36
+ });
37
+ }
38
+
39
+ function storeFile(root) {
40
+ return spaceLocalFile(root, FILE_NAME);
41
+ }
42
+
43
+ function readStore(root) {
44
+ const fp = storeFile(root);
45
+ try {
46
+ const raw = JSON.parse(fs.readFileSync(fp, 'utf8'));
47
+ const items = Array.isArray(raw?.items) ? raw.items.map(normalizeStored).filter(Boolean) : [];
48
+ return { version: VERSION, items };
49
+ } catch {
50
+ return { version: VERSION, items: [] };
51
+ }
52
+ }
53
+
54
+ /** 先写临时文件再 rename:中途失败也不会留下半份 JSON */
55
+ function writeStore(root, store) {
56
+ const fp = storeFile(root);
57
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
58
+ const tmp = `${fp}.${process.pid}.tmp`;
59
+ fs.writeFileSync(tmp, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
60
+ fs.renameSync(tmp, fp);
61
+ }
62
+
63
+ function newId() {
64
+ return `td_${crypto.randomBytes(8).toString('hex')}`;
65
+ }
66
+
67
+ function text(value, max = 2000) {
68
+ return typeof value === 'string' ? value.trim().slice(0, max) : '';
69
+ }
70
+
71
+ function dateOrEmpty(value) {
72
+ const s = text(value, 10);
73
+ return DATE_RE.test(s) ? s : '';
74
+ }
75
+
76
+ function oneOf(value, allowed, fallback) {
77
+ const s = text(value, 40);
78
+ return allowed.includes(s) ? s : fallback;
79
+ }
80
+
81
+ /** 结束待办的状态:这两个状态才算「结束了」,结束日期随之落库 */
82
+ const CLOSED_STATUSES = new Set(['done', 'cancelled']);
83
+
84
+ /** 本机当天日期(不用 UTC,避免晚上 8 点后被算成第二天) */
85
+ function todayLocal() {
86
+ const now = new Date();
87
+ const y = now.getFullYear();
88
+ const m = String(now.getMonth() + 1).padStart(2, '0');
89
+ const d = String(now.getDate()).padStart(2, '0');
90
+ return `${y}-${m}-${d}`;
91
+ }
92
+
93
+ /**
94
+ * 结束日期是派生值(页面与工具都不再手填):
95
+ * - 已完成 / 已取消:沿用已有结束日期,没有就记为当天;
96
+ * - 待开始 / 进行中:清空,界面上显示「进行中」。
97
+ * @param {string} status
98
+ * @param {string} [existing] 库里已存的结束日期
99
+ */
100
+ function endDateFor(status, existing) {
101
+ if (!CLOSED_STATUSES.has(status)) return '';
102
+ return existing || todayLocal();
103
+ }
104
+
105
+ /** 任务管理的存储文件名(与 lh-tasks.mjs 保持一致) */
106
+ const TASKS_FILE_NAME = 'tasks.json';
107
+
108
+ /**
109
+ * 待办关联任务后,把任务从「任务池 / 已完成」拉回「进行中」:
110
+ * 关联上就说明这件事已经在做了。已关闭的任务不动(也不在选择范围内)。
111
+ * 读取失败 / 任务不存在时静默跳过——任务管理插件可能没启用。
112
+ */
113
+ function promoteLinkedTask(root, taskId) {
114
+ if (!taskId) return;
115
+ const fp = spaceLocalFile(root, TASKS_FILE_NAME);
116
+ let store;
117
+ try {
118
+ store = JSON.parse(fs.readFileSync(fp, 'utf8'));
119
+ } catch {
120
+ return;
121
+ }
122
+ const items = Array.isArray(store?.items) ? store.items : [];
123
+ const task = items.find((x) => x && x.id === taskId);
124
+ if (!task) return;
125
+ if (task.status !== 'pool' && task.status !== 'done') return;
126
+ task.status = 'in_progress';
127
+ task.doneAt = null;
128
+ task.updatedAt = new Date().toISOString();
129
+ const tmp = `${fp}.${process.pid}.tmp`;
130
+ fs.writeFileSync(tmp, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
131
+ fs.renameSync(tmp, fp);
132
+ }
133
+
134
+ /** 读回来的旧数据也走一遍归一化,避免手工改坏的文件把页面拖垮 */
135
+ function normalizeStored(raw) {
136
+ if (!raw || typeof raw !== 'object') return null;
137
+ const title = text(raw.title, 200);
138
+ if (!title) return null;
139
+ const status = oneOf(raw.status, TODO_STATUSES, 'pending');
140
+ const now = new Date().toISOString();
141
+ // 旧数据可能没有结束日期:已完成的退回 doneAt 的日期,再没有才用当天
142
+ const doneAt = text(raw.doneAt, 40) || null;
143
+ const storedEnd = dateOrEmpty(raw.taskEndDate) || dateOrEmpty(doneAt);
144
+ return {
145
+ id: text(raw.id, 64) || newId(),
146
+ title,
147
+ status,
148
+ priority: oneOf(raw.priority, TODO_PRIORITIES, ''),
149
+ linkedTask: text(raw.linkedTask, 200),
150
+ taskId: text(raw.taskId, 64),
151
+ startDate: dateOrEmpty(raw.startDate),
152
+ dueDate: dateOrEmpty(raw.dueDate),
153
+ taskEndDate: endDateFor(status, storedEnd),
154
+ note: text(raw.note),
155
+ source: raw.source === 'ai' ? 'ai' : 'manual',
156
+ sessionId: text(raw.sessionId, 120),
157
+ createdAt: text(raw.createdAt, 40) || now,
158
+ updatedAt: text(raw.updatedAt, 40) || now,
159
+ doneAt,
160
+ };
161
+ }
162
+
163
+ /** 入参归一化:只认白名单字段,缺省值由调用方(新建/更新)决定 */
164
+ function normalizePatch(raw, base) {
165
+ const patch = {};
166
+ if (!raw || typeof raw !== 'object') return patch;
167
+ if ('title' in raw) patch.title = text(raw.title, 200);
168
+ if ('status' in raw) patch.status = oneOf(raw.status, TODO_STATUSES, base.status);
169
+ if ('priority' in raw) patch.priority = oneOf(raw.priority, TODO_PRIORITIES, '');
170
+ if ('linkedTask' in raw) patch.linkedTask = text(raw.linkedTask, 200);
171
+ if ('taskId' in raw) patch.taskId = text(raw.taskId, 64);
172
+ if ('startDate' in raw) patch.startDate = dateOrEmpty(raw.startDate);
173
+ if ('dueDate' in raw) patch.dueDate = dateOrEmpty(raw.dueDate);
174
+ // taskEndDate 不接收手填:由状态派生,见 endDateFor()
175
+ if ('note' in raw) patch.note = text(raw.note);
176
+ return patch;
177
+ }
178
+
179
+ /**
180
+ * @param {{ mode?: 'manual' | 'ai', sessionId?: string }} [meta]
181
+ */
182
+ function buildItem(raw, meta = {}) {
183
+ const now = new Date().toISOString();
184
+ const status = oneOf(raw?.status, TODO_STATUSES, 'pending');
185
+ return {
186
+ id: newId(),
187
+ title: text(raw?.title, 200),
188
+ status,
189
+ priority: oneOf(raw?.priority, TODO_PRIORITIES, ''),
190
+ linkedTask: text(raw?.linkedTask, 200),
191
+ taskId: text(raw?.taskId, 64),
192
+ startDate: dateOrEmpty(raw?.startDate),
193
+ dueDate: dateOrEmpty(raw?.dueDate),
194
+ taskEndDate: endDateFor(status, ''),
195
+ note: text(raw?.note),
196
+ source: meta.mode === 'ai' ? 'ai' : 'manual',
197
+ sessionId: text(meta.sessionId, 120),
198
+ createdAt: now,
199
+ updatedAt: now,
200
+ doneAt: status === 'done' ? now : null,
201
+ };
202
+ }
203
+
204
+ /**
205
+ * @returns {import('vite').Plugin}
206
+ */
207
+ export function lhTodosPlugin() {
208
+ return {
209
+ name: 'lh-todos',
210
+ configureServer(server) {
211
+ server.middlewares.use(async (req, res, next) => {
212
+ const parsed = new URL(req.url ?? '/', 'http://127.0.0.1');
213
+ if (!parsed.pathname.startsWith(PREFIX)) return next();
214
+ // 数据根惰性读取:切换数据空间后无需重启
215
+ const root = getDataRoot();
216
+
217
+ try {
218
+ const method = req.method || 'GET';
219
+ const itemId = parsed.pathname.slice(PREFIX.length).replace(/^\//, '');
220
+
221
+ if (method === 'GET' && parsed.searchParams.get('health') === '1') {
222
+ return sendJson(res, 200, { ok: true, file: storeFile(root) });
223
+ }
224
+
225
+ if (method === 'GET' && !itemId) {
226
+ return sendJson(res, 200, { ok: true, ...readStore(root) });
227
+ }
228
+
229
+ if (method === 'POST') {
230
+ const body = JSON.parse((await readBody(req)) || '{}');
231
+ const rawItems = Array.isArray(body.items) ? body.items : [body];
232
+ const meta = { mode: body.mode === 'ai' ? 'ai' : 'manual', sessionId: body.sessionId };
233
+ const store = readStore(root);
234
+ const created = [];
235
+ for (const raw of rawItems) {
236
+ const item = buildItem(raw, meta);
237
+ if (!item.title) {
238
+ return sendJson(res, 400, { ok: false, error: '待办名称不能为空' });
239
+ }
240
+ created.push(item);
241
+ }
242
+ store.items.push(...created);
243
+ writeStore(root, store);
244
+ // 新建时就带了关联任务:任务池 / 已完成 → 进行中
245
+ for (const item of created) promoteLinkedTask(root, item.taskId);
246
+ return sendJson(res, 200, { ok: true, items: created, total: store.items.length });
247
+ }
248
+
249
+ if (method === 'PUT') {
250
+ const body = JSON.parse((await readBody(req)) || '{}');
251
+ const id = text(body.id ?? itemId, 64);
252
+ if (!id) return sendJson(res, 400, { ok: false, error: '缺少待办 id' });
253
+ const store = readStore(root);
254
+ const idx = store.items.findIndex((x) => x.id === id);
255
+ if (idx < 0) return sendJson(res, 404, { ok: false, error: '待办不存在' });
256
+
257
+ const base = store.items[idx];
258
+ const patch = normalizePatch(body, base);
259
+ if ('title' in patch && !patch.title) {
260
+ return sendJson(res, 400, { ok: false, error: '待办名称不能为空' });
261
+ }
262
+ const next = { ...base, ...patch, updatedAt: new Date().toISOString() };
263
+ if (next.status === 'done' && base.status !== 'done') next.doneAt = next.updatedAt;
264
+ if (next.status !== 'done') next.doneAt = null;
265
+ // 结束日期跟着状态走:完成 / 取消记为当天,回到待开始 / 进行中则清空。
266
+ // 已经是结束态时沿用原值,改别的字段不会把结束日期刷新成今天。
267
+ const wasClosed = CLOSED_STATUSES.has(base.status);
268
+ next.taskEndDate = endDateFor(next.status, wasClosed ? base.taskEndDate : '');
269
+ store.items[idx] = next;
270
+ writeStore(root, store);
271
+ // 只有「这次新关联了任务」才把任务拉回进行中,改别的字段不碰任务状态
272
+ if (next.taskId && next.taskId !== base.taskId) promoteLinkedTask(root, next.taskId);
273
+ return sendJson(res, 200, { ok: true, item: next });
274
+ }
275
+
276
+ if (method === 'DELETE') {
277
+ const body = JSON.parse((await readBody(req)) || '{}');
278
+ const ids = (Array.isArray(body.ids) ? body.ids : [body.id ?? itemId])
279
+ .map((x) => text(x, 64))
280
+ .filter(Boolean);
281
+ if (!ids.length) return sendJson(res, 400, { ok: false, error: '缺少待办 id' });
282
+ const store = readStore(root);
283
+ const drop = new Set(ids);
284
+ const before = store.items.length;
285
+ store.items = store.items.filter((x) => !drop.has(x.id));
286
+ writeStore(root, store);
287
+ return sendJson(res, 200, { ok: true, removed: before - store.items.length });
288
+ }
289
+
290
+ return sendJson(res, 405, { ok: false, error: 'Method Not Allowed' });
291
+ } catch (err) {
292
+ return sendJson(res, 500, { ok: false, error: String(err?.message ?? err) });
293
+ }
294
+ });
295
+ },
296
+ };
297
+ }
@@ -30,6 +30,7 @@ export const KNOWN_STORAGE_DOMAINS = [
30
30
  { id: 'schedules', rel: `${LOCAL_REL}/schedules.json`, kind: 'file', roleZh: '主机定时提醒' },
31
31
  { id: 'spill', rel: `${LOCAL_REL}/spill`, kind: 'dir', roleZh: '工具结果溢出' },
32
32
  { id: 'memories', rel: `${LOCAL_REL}/memories`, kind: 'dir', roleZh: '会话记忆' },
33
+ { id: 'todos', rel: `${LOCAL_REL}/todos.json`, kind: 'file', roleZh: '待办事项' },
33
34
  { id: 'workspaces', rel: `${LOCAL_REL}/workspaces.json`, kind: 'file', roleZh: '工作区注册表' },
34
35
  {
35
36
  id: 'app-config',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lantern-harness",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "AI 工作台:对话与插件工具、内容库、模板库、会话记忆,数据留在本机。",