dsh-todo-time-log 0.6.0 → 0.6.2

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/lib/index.js CHANGED
@@ -25,12 +25,14 @@ import {
25
25
  TYPES,
26
26
  MAX_FILES,
27
27
  MAX_PROJECT_LENGTH,
28
+ IMPORT_MODES,
28
29
  normalizeFilePath,
29
30
  normalizeProjectName,
30
31
  } from './store.js';
31
32
  import { formatLocalDateTime, TIME_FORMAT, EMPTY_TIME } from './time.js';
32
33
  import { parseQuickAdd, parseQuickAddLine, hasQuickAddMarkers } from './parse.js';
33
34
  import { createFileIndex } from './files.js';
35
+ import { EXPORT_FORMATS, exportTodoData, parseImportText } from './transfer.js';
34
36
 
35
37
  /**
36
38
  * A todo record exactly as `store.js` persists it (see the data-model comment
@@ -283,6 +285,8 @@ export const DEFAULT_CONFIG = Object.freeze({
283
285
  });
284
286
 
285
287
  const MAX_BODY_BYTES = 1024 * 1024;
288
+ /** Import payloads carry a whole backup (5000 records can exceed 1 MiB). */
289
+ const MAX_IMPORT_BODY_BYTES = 8 * 1024 * 1024;
286
290
 
287
291
  /** Tool/HTTP-facing priority enum: '' = 未设置 (default). */
288
292
  export const PRIORITY_VALUES = Object.freeze(['', ...PRIORITIES]);
@@ -1026,7 +1030,14 @@ function httpStatusForCode(code) {
1026
1030
  case 'TYPE_INVALID':
1027
1031
  case 'FILES_INVALID':
1028
1032
  case 'TOO_MANY_FILES':
1033
+ case 'IMPORT_EMPTY':
1034
+ case 'IMPORT_INVALID':
1035
+ case 'IMPORT_FORMAT_UNSUPPORTED':
1036
+ case 'IMPORT_VERSION_UNSUPPORTED':
1037
+ case 'BACKUP_INVALID':
1029
1038
  return 400;
1039
+ case 'PAYLOAD_TOO_LARGE':
1040
+ return 413;
1030
1041
  case 'NOT_FOUND':
1031
1042
  return 404;
1032
1043
  case 'ALREADY_COMPLETED':
@@ -1034,6 +1045,8 @@ function httpStatusForCode(code) {
1034
1045
  case 'TOO_MANY_TODOS':
1035
1046
  return 409;
1036
1047
  case 'PERSIST_FAILED':
1048
+ case 'IMPORT_FAILED':
1049
+ case 'BACKUP_FAILED':
1037
1050
  return 500;
1038
1051
  default:
1039
1052
  return 500;
@@ -1060,22 +1073,30 @@ function sendJson(res, status, payload) {
1060
1073
  * Read and parse a JSON request body (an empty body means `{}`).
1061
1074
  *
1062
1075
  * @param {import('node:http').IncomingMessage} req
1076
+ * @param {number} [limit] maximum accepted byte size (import allows more)
1063
1077
  * @returns {Promise<Record<string, any>>}
1064
1078
  */
1065
- function readJsonBody(req) {
1079
+ function readJsonBody(req, limit = MAX_BODY_BYTES) {
1066
1080
  return new Promise((resolve, reject) => {
1067
1081
  const chunks = [];
1068
1082
  let size = 0;
1083
+ let failed = false;
1069
1084
  req.on('data', (chunk) => {
1085
+ if (failed) return;
1070
1086
  size += chunk.length;
1071
- if (size > MAX_BODY_BYTES) {
1072
- reject(Object.assign(new Error('请求体过大'), { code: 'BAD_REQUEST' }));
1073
- req.destroy();
1087
+ if (size > limit) {
1088
+ if (!failed) {
1089
+ failed = true;
1090
+ reject(Object.assign(new Error('请求体过大'), { code: 'PAYLOAD_TOO_LARGE' }));
1091
+ }
1092
+ // Keep draining (and discarding) the rest of the upload so the client
1093
+ // can read the 413 instead of seeing a connection reset.
1074
1094
  return;
1075
1095
  }
1076
1096
  chunks.push(chunk);
1077
1097
  });
1078
1098
  req.on('end', () => {
1099
+ if (failed) return;
1079
1100
  if (chunks.length === 0) {
1080
1101
  resolve({});
1081
1102
  return;
@@ -1211,19 +1232,31 @@ export function createHttpHandler(store, options = {}) {
1211
1232
  });
1212
1233
  return;
1213
1234
  }
1214
- const EXTRA_PATHS = ['quick-add', 'quick_add', 'files', 'workspaces', 'projects'];
1235
+ const EXTRA_PATHS = [
1236
+ 'quick-add',
1237
+ 'quick_add',
1238
+ 'files',
1239
+ 'workspaces',
1240
+ 'projects',
1241
+ 'export',
1242
+ 'import',
1243
+ 'restore',
1244
+ ];
1215
1245
  if (!CRUD_ACTIONS.includes(action) && !EXTRA_PATHS.includes(action)) {
1216
1246
  sendJson(res, 404, {
1217
1247
  ok: false,
1218
1248
  error: {
1219
1249
  code: 'NOT_FOUND',
1220
- message: `未知接口:${action};可用接口:${[...CRUD_ACTIONS, 'quick-add', 'files'].join(', ')}`,
1250
+ message: `未知接口:${action};可用接口:${[...CRUD_ACTIONS, ...EXTRA_PATHS].join(', ')}`,
1221
1251
  },
1222
1252
  });
1223
1253
  return;
1224
1254
  }
1225
1255
 
1226
- const body = await readJsonBody(req);
1256
+ const body = await readJsonBody(
1257
+ req,
1258
+ action === 'import' ? MAX_IMPORT_BODY_BYTES : MAX_BODY_BYTES,
1259
+ );
1227
1260
 
1228
1261
  if (action === 'workspaces') {
1229
1262
  const candidates = listWorkspaces();
@@ -1326,6 +1359,95 @@ export function createHttpHandler(store, options = {}) {
1326
1359
  sendJson(res, 200, { ok: true, value: listing });
1327
1360
  return;
1328
1361
  }
1362
+ if (action === 'export') {
1363
+ const requested = body.format === undefined || body.format === null || body.format === ''
1364
+ ? 'json'
1365
+ : String(body.format).trim().toLowerCase();
1366
+ if (!EXPORT_FORMATS.includes(requested)) {
1367
+ sendJson(res, 400, {
1368
+ ok: false,
1369
+ error: { code: 'EXPORT_FORMAT_UNSUPPORTED', message: '只支持导出 JSON 或 CSV' },
1370
+ });
1371
+ return;
1372
+ }
1373
+ // Merge what other instances wrote before serializing: a download must
1374
+ // never be a stale snapshot. A failure here still exports what we have.
1375
+ await store.refresh();
1376
+ const listed = await store.list({ filter: 'all' });
1377
+ const exported = exportTodoData(listed.todos ?? [], { format: requested });
1378
+ sendJson(res, 200, { ok: true, value: exported });
1379
+ return;
1380
+ }
1381
+
1382
+ if (action === 'import') {
1383
+ const content = typeof body.content === 'string'
1384
+ ? body.content
1385
+ : (typeof body.text === 'string' ? body.text : '');
1386
+ if (content.trim() === '') {
1387
+ sendJson(res, 400, {
1388
+ ok: false,
1389
+ error: { code: 'IMPORT_EMPTY', message: '导入内容为空' },
1390
+ });
1391
+ return;
1392
+ }
1393
+ const parsed = parseImportText(content, { format: body.format });
1394
+ if (parsed.ok !== true) {
1395
+ sendJson(res, httpStatusForCode(parsed.code), {
1396
+ ok: false,
1397
+ error: { code: parsed.code, message: parsed.message },
1398
+ });
1399
+ return;
1400
+ }
1401
+ const mode = IMPORT_MODES.includes(body.mode) ? body.mode : 'merge';
1402
+ const dryRun = body.dryRun === true;
1403
+ const result = await store.importTodos(parsed.records, { mode, dryRun });
1404
+ if (result.ok !== true) {
1405
+ sendJson(res, httpStatusForCode(result.code), {
1406
+ ok: false,
1407
+ error: { code: result.code, message: result.message },
1408
+ });
1409
+ return;
1410
+ }
1411
+ sendJson(res, 200, {
1412
+ ok: true,
1413
+ value: {
1414
+ format: parsed.format,
1415
+ mode,
1416
+ dryRun,
1417
+ rowCount: parsed.rowCount,
1418
+ summary: result.summary,
1419
+ invalid: parsed.invalid,
1420
+ invalidCount: parsed.invalidCount,
1421
+ notices: [...(parsed.notices ?? []), ...(result.notices ?? [])],
1422
+ backup: result.backup ?? null,
1423
+ persisted: result.persisted !== false,
1424
+ },
1425
+ });
1426
+ return;
1427
+ }
1428
+
1429
+ if (action === 'restore') {
1430
+ const result = await store.restoreBackup(body.file);
1431
+ if (result.ok !== true) {
1432
+ sendJson(res, httpStatusForCode(result.code), {
1433
+ ok: false,
1434
+ error: { code: result.code, message: result.message },
1435
+ });
1436
+ return;
1437
+ }
1438
+ sendJson(res, 200, {
1439
+ ok: true,
1440
+ value: {
1441
+ restored: result.restored,
1442
+ dropped: result.dropped,
1443
+ backup: result.backup ?? null,
1444
+ notices: result.notices ?? [],
1445
+ persisted: result.persisted !== false,
1446
+ },
1447
+ });
1448
+ return;
1449
+ }
1450
+
1329
1451
  const result = await executeTodoAction(store, action, body);
1330
1452
  if (result.ok === false) {
1331
1453
  sendJson(res, httpStatusForCode(result.code), {
@@ -1337,9 +1459,11 @@ export function createHttpHandler(store, options = {}) {
1337
1459
  const { ok: _ok, ...value } = result;
1338
1460
  sendJson(res, 200, { ok: true, value });
1339
1461
  } catch (error) {
1340
- const code = error?.code === 'BAD_REQUEST' ? 'BAD_REQUEST' : 'INTERNAL';
1462
+ const code = error?.code === 'BAD_REQUEST'
1463
+ ? 'BAD_REQUEST'
1464
+ : error?.code === 'PAYLOAD_TOO_LARGE' ? 'PAYLOAD_TOO_LARGE' : 'INTERNAL';
1341
1465
  const message = error?.message ?? String(error);
1342
- sendJson(res, code === 'BAD_REQUEST' ? 400 : 500, { ok: false, error: { code, message } });
1466
+ sendJson(res, httpStatusForCode(code), { ok: false, error: { code, message } });
1343
1467
  }
1344
1468
  };
1345
1469
  }
package/lib/store.js CHANGED
@@ -70,6 +70,37 @@ export const MAX_FILE_PATH_LENGTH = 300;
70
70
  export const UNCATEGORIZED_PROJECT = '';
71
71
  export const MAX_TODOS = 5000;
72
72
 
73
+ /**
74
+ * Extract the todo rows from an already-parsed store document. Accepts the
75
+ * persisted envelope (`{ version, todos }`) and a bare `Todo[]`, and rejects a
76
+ * version this build does not understand instead of guessing at it.
77
+ *
78
+ * Shared by backup restore and import so both read exactly the same shapes the
79
+ * store itself writes.
80
+ *
81
+ * @param {any} parsed
82
+ * @returns {{ ok: true, rows: any[], version: number | null } | { ok: false, code: string, message: string, version?: number }}
83
+ */
84
+ export function extractStoreRows(parsed) {
85
+ if (Array.isArray(parsed)) return { ok: true, rows: parsed, version: null };
86
+ if (parsed === null || typeof parsed !== 'object') {
87
+ return { ok: false, code: 'SHAPE_INVALID', message: '不是待办数据' };
88
+ }
89
+ const version = parsed.version;
90
+ if (typeof version === 'number' && !SUPPORTED_STORE_VERSIONS.includes(version)) {
91
+ return {
92
+ ok: false,
93
+ code: 'VERSION_UNSUPPORTED',
94
+ version,
95
+ message: `数据版本 ${version} 不受支持(支持 1-${STORE_VERSION})`,
96
+ };
97
+ }
98
+ if (!Array.isArray(parsed.todos)) {
99
+ return { ok: false, code: 'SHAPE_INVALID', message: '缺少 todos 数组' };
100
+ }
101
+ return { ok: true, rows: parsed.todos, version: typeof version === 'number' ? version : null };
102
+ }
103
+
73
104
  export const ERROR_CODES = Object.freeze({
74
105
  TITLE_REQUIRED: 'TITLE_REQUIRED',
75
106
  TITLE_TOO_LONG: 'TITLE_TOO_LONG',
@@ -84,9 +115,18 @@ export const ERROR_CODES = Object.freeze({
84
115
  ALREADY_PENDING: 'ALREADY_PENDING',
85
116
  TOO_MANY_TODOS: 'TOO_MANY_TODOS',
86
117
  PERSIST_FAILED: 'PERSIST_FAILED',
118
+ IMPORT_EMPTY: 'IMPORT_EMPTY',
119
+ IMPORT_INVALID: 'IMPORT_INVALID',
120
+ IMPORT_FAILED: 'IMPORT_FAILED',
121
+ BACKUP_INVALID: 'BACKUP_INVALID',
122
+ BACKUP_FAILED: 'BACKUP_FAILED',
87
123
  BAD_REQUEST: 'BAD_REQUEST',
88
124
  });
89
125
 
126
+ /** Conflict strategies accepted by `TodoStore.importTodos()`. */
127
+ export const IMPORT_MODES = Object.freeze(['merge', 'add', 'replace']);
128
+ export const DEFAULT_IMPORT_MODE = 'merge';
129
+
90
130
  function failure(code, message) {
91
131
  return { ok: false, code, message };
92
132
  }
@@ -498,6 +538,33 @@ async function writeFileAtomic(file, text) {
498
538
  }
499
539
  }
500
540
 
541
+ /** An Error carrying the same `code` vocabulary as the result envelopes. */
542
+ function codedError(code, message) {
543
+ return Object.assign(new Error(message), { code });
544
+ }
545
+
546
+ /** Local-time stamp used in backup file names: `20261106-085732-123`. */
547
+ function backupStamp(date) {
548
+ const pad = (value, width = 2) => String(value).padStart(width, '0');
549
+ return (
550
+ `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-`
551
+ + `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}-`
552
+ + pad(date.getMilliseconds(), 3)
553
+ );
554
+ }
555
+
556
+ /** Human-readable one-line summary of an import plan. */
557
+ function importSummary(stats) {
558
+ const parts = [];
559
+ if (stats.added > 0) parts.push(`新增 ${stats.added} 条`);
560
+ if (stats.updated > 0) parts.push(`更新 ${stats.updated} 条`);
561
+ if (stats.skipped > 0) parts.push(`跳过 ${stats.skipped} 条`);
562
+ if (stats.removed > 0) parts.push(`移除 ${stats.removed} 条`);
563
+ if (parts.length === 0) return '没有需要变更的待办';
564
+ const prefix = stats.mode === 'replace' ? '已覆盖全部数据:' : '导入完成:';
565
+ return `${prefix}${parts.join(',')}(当前共 ${stats.total} 条)`;
566
+ }
567
+
501
568
  /**
502
569
  * The single owner of todo state for one data file.
503
570
  *
@@ -900,6 +967,386 @@ export class TodoStore {
900
967
  );
901
968
  }
902
969
 
970
+ /**
971
+ * Merge what other processes wrote since our last known state without writing
972
+ * anything. Used before an export so a download is never a stale snapshot, and
973
+ * by the import preview so its counts match the current disk state.
974
+ *
975
+ * @returns {Promise<{ ok: true, count: number }>}
976
+ */
977
+ async refresh() {
978
+ await this.#ensureLoaded();
979
+ try {
980
+ await this.#persistChain;
981
+ } catch {
982
+ /* the failed write was already reported to its own caller */
983
+ }
984
+ await this.#mergeIfDiskChanged();
985
+ return { ok: true, count: this.#todos.size };
986
+ }
987
+
988
+ /**
989
+ * Import already-normalized records in one write.
990
+ *
991
+ * Modes:
992
+ * merge - same id: the record with the newer `updatedAt` wins; unknown ids
993
+ * are added (the default).
994
+ * add - same id: the existing record wins (skip); unknown ids are added.
995
+ * replace - the imported records replace the whole list; the current file is
996
+ * backed up first.
997
+ *
998
+ * `dryRun: true` computes the same plan against the refreshed state without
999
+ * writing anything or touching the in-memory list.
1000
+ *
1001
+ * @param {object[]} records
1002
+ * @param {{ mode?: string, dryRun?: boolean }} [options]
1003
+ * @returns {Promise<object>}
1004
+ */
1005
+ async importTodos(records, options = {}) {
1006
+ await this.#ensureLoaded();
1007
+ const mode = IMPORT_MODES.includes(options.mode) ? options.mode : DEFAULT_IMPORT_MODE;
1008
+ const dryRun = options.dryRun === true;
1009
+ if (!Array.isArray(records) || records.length === 0) {
1010
+ return failure(ERROR_CODES.IMPORT_EMPTY, '没有可导入的待办');
1011
+ }
1012
+ if (records.length > MAX_TODOS) {
1013
+ return failure(ERROR_CODES.TOO_MANY_TODOS, `单次导入不能超过 ${MAX_TODOS} 条待办`);
1014
+ }
1015
+ // Re-run the same repair/validation every other write path uses, so a
1016
+ // direct caller cannot smuggle an invalid record into the store.
1017
+ const normalized = [];
1018
+ for (const record of records) {
1019
+ const repaired = normalizeTodo(record, { now: this.now() });
1020
+ if (repaired.todo !== null) normalized.push(repaired.todo);
1021
+ }
1022
+ if (normalized.length === 0) {
1023
+ return failure(ERROR_CODES.IMPORT_INVALID, '没有可导入的有效待办');
1024
+ }
1025
+ if (dryRun) {
1026
+ await this.refresh();
1027
+ const plan = this.#planImport(normalized, mode);
1028
+ if (plan.stats.total > MAX_TODOS) {
1029
+ return failure(
1030
+ ERROR_CODES.TOO_MANY_TODOS,
1031
+ `导入后共 ${plan.stats.total} 条待办,超过上限 ${MAX_TODOS} 条`,
1032
+ );
1033
+ }
1034
+ return { ok: true, dryRun: true, summary: plan.stats, notices: [] };
1035
+ }
1036
+
1037
+ let applied;
1038
+ try {
1039
+ applied = await this.#persistImport(normalized, mode);
1040
+ } catch (error) {
1041
+ if (error?.code === ERROR_CODES.TOO_MANY_TODOS || error?.code === ERROR_CODES.BACKUP_FAILED) {
1042
+ return failure(error.code, error.message);
1043
+ }
1044
+ return failure(ERROR_CODES.IMPORT_FAILED, `导入失败:${error?.message ?? String(error)}`);
1045
+ }
1046
+ if (applied.persisted === false) {
1047
+ return {
1048
+ ok: true,
1049
+ dryRun: false,
1050
+ summary: applied.stats,
1051
+ backup: applied.backupFile,
1052
+ notices: [`导入已生效,但写入磁盘失败,将在下次写入时重试:${applied.error}`],
1053
+ persisted: false,
1054
+ };
1055
+ }
1056
+ return {
1057
+ ok: true,
1058
+ dryRun: false,
1059
+ summary: applied.stats,
1060
+ backup: applied.backupFile,
1061
+ notices: [importSummary(applied.stats)],
1062
+ persisted: true,
1063
+ };
1064
+ }
1065
+
1066
+ /**
1067
+ * Restore the whole list from a backup produced by this store
1068
+ * (`<dataFile>.bak-<stamp>`). The current state is backed up first, so an
1069
+ * undesired restore can itself be undone by restoring that safety copy.
1070
+ *
1071
+ * @param {string} file backup file name (basename only)
1072
+ * @returns {Promise<object>}
1073
+ */
1074
+ async restoreBackup(file) {
1075
+ await this.#ensureLoaded();
1076
+ const resolved = this.#resolveBackupFile(file);
1077
+ if (resolved.ok !== true) return resolved;
1078
+ let text;
1079
+ try {
1080
+ text = await fsp.readFile(resolved.path, 'utf8');
1081
+ } catch {
1082
+ return failure(ERROR_CODES.BACKUP_INVALID, `无法读取备份文件:${String(file)}`);
1083
+ }
1084
+ let parsed;
1085
+ try {
1086
+ parsed = JSON.parse(text);
1087
+ } catch {
1088
+ return failure(ERROR_CODES.BACKUP_INVALID, '备份文件不是合法 JSON,无法恢复');
1089
+ }
1090
+ const extracted = extractStoreRows(parsed);
1091
+ if (extracted.ok !== true) {
1092
+ return failure(ERROR_CODES.BACKUP_INVALID, `备份文件结构异常:${extracted.message}`);
1093
+ }
1094
+ const next = new Map();
1095
+ const notices = [];
1096
+ let dropped = 0;
1097
+ for (const row of extracted.rows) {
1098
+ const { todo, notices: rowNotices } = normalizeTodo(row, { now: this.now() });
1099
+ if (todo === null) {
1100
+ dropped += 1;
1101
+ continue;
1102
+ }
1103
+ let record = todo;
1104
+ if (next.has(record.id)) {
1105
+ record = { ...record, id: makeTodoId(this.now()) };
1106
+ const note = '备份中存在重复 id 的记录,已为后者重新生成 id';
1107
+ if (!notices.includes(note)) notices.push(note);
1108
+ }
1109
+ next.set(record.id, record);
1110
+ for (const notice of rowNotices) {
1111
+ if (!notices.includes(notice)) notices.push(notice);
1112
+ }
1113
+ }
1114
+ if (next.size === 0) {
1115
+ return failure(ERROR_CODES.BACKUP_INVALID, '备份文件里没有可恢复的待办');
1116
+ }
1117
+ let outcome;
1118
+ try {
1119
+ outcome = await this.#replaceAll(next);
1120
+ } catch (error) {
1121
+ if (error?.code === ERROR_CODES.BACKUP_FAILED) {
1122
+ return failure(error.code, error.message);
1123
+ }
1124
+ return failure(ERROR_CODES.PERSIST_FAILED, `恢复失败:${error?.message ?? String(error)}`);
1125
+ }
1126
+ notices.unshift(`已从备份恢复 ${next.size} 条待办`);
1127
+ if (dropped > 0) notices.push(`已丢弃 ${dropped} 条非法待办记录`);
1128
+ if (outcome.persisted === false) {
1129
+ notices.push(`恢复已生效,但写入磁盘失败,将在下次写入时重试:${outcome.error}`);
1130
+ }
1131
+ return {
1132
+ ok: true,
1133
+ restored: next.size,
1134
+ dropped,
1135
+ backup: outcome.backupFile,
1136
+ notices,
1137
+ persisted: outcome.persisted !== false,
1138
+ };
1139
+ }
1140
+
1141
+ /**
1142
+ * Compute the list import would produce, without mutating anything.
1143
+ * Runs while holding the write lock in `#persistImport`, so the plan is
1144
+ * computed against the merged on-disk state.
1145
+ */
1146
+ #planImport(records, mode) {
1147
+ const before = this.#todos;
1148
+ const next = mode === 'replace' ? new Map() : new Map(before);
1149
+ const stats = {
1150
+ mode,
1151
+ added: 0,
1152
+ updated: 0,
1153
+ skipped: 0,
1154
+ removed: 0,
1155
+ total: 0,
1156
+ changed: false,
1157
+ };
1158
+ const imported = new Set();
1159
+ for (const record of records) {
1160
+ if (imported.has(record.id)) {
1161
+ stats.skipped += 1;
1162
+ continue;
1163
+ }
1164
+ imported.add(record.id);
1165
+ const existing = next.get(record.id);
1166
+ const existedBefore = before.has(record.id);
1167
+ if (existing === undefined) {
1168
+ next.set(record.id, record);
1169
+ if (existedBefore) stats.updated += 1;
1170
+ else stats.added += 1;
1171
+ continue;
1172
+ }
1173
+ if (mode === 'replace' || mode === 'add') {
1174
+ stats.skipped += 1;
1175
+ continue;
1176
+ }
1177
+ const existingMs = parseTimestamp(existing.updatedAt);
1178
+ const incomingMs = parseTimestamp(record.updatedAt);
1179
+ if (incomingMs !== null && (existingMs === null || incomingMs > existingMs)) {
1180
+ next.set(record.id, record);
1181
+ stats.updated += 1;
1182
+ } else {
1183
+ stats.skipped += 1;
1184
+ }
1185
+ }
1186
+ if (mode === 'replace') {
1187
+ for (const id of before.keys()) {
1188
+ if (!next.has(id)) stats.removed += 1;
1189
+ }
1190
+ }
1191
+ stats.total = next.size;
1192
+ stats.changed = stats.added > 0 || stats.updated > 0 || stats.removed > 0;
1193
+ return { next, stats };
1194
+ }
1195
+
1196
+ /** Apply an import plan under the write lock, backing up the current file first. */
1197
+ async #persistImport(records, mode) {
1198
+ let outcome;
1199
+ const write = async () => {
1200
+ let applied = false;
1201
+ let plan = null;
1202
+ let backupFile = null;
1203
+ await this.#acquireLock();
1204
+ try {
1205
+ await this.#mergeIfDiskChanged();
1206
+ plan = this.#planImport(records, mode);
1207
+ if (plan.stats.total > MAX_TODOS) {
1208
+ throw codedError(
1209
+ ERROR_CODES.TOO_MANY_TODOS,
1210
+ `导入后共 ${plan.stats.total} 条待办,超过上限 ${MAX_TODOS} 条`,
1211
+ );
1212
+ }
1213
+ if (!plan.stats.changed) {
1214
+ outcome = { stats: plan.stats, backupFile: null, persisted: true };
1215
+ return outcome;
1216
+ }
1217
+ backupFile = await this.#createBackup();
1218
+ this.#todos = plan.next;
1219
+ for (const record of records) {
1220
+ // Only drop the tombstone for records this import actually applied;
1221
+ // a skipped older copy must not resurrect a locally deleted todo.
1222
+ if (plan.next.get(record.id) === record) this.#tombstones.delete(record.id);
1223
+ }
1224
+ applied = true;
1225
+ const text = this.serialize();
1226
+ await writeFileAtomic(this.dataFile, text);
1227
+ let stat = null;
1228
+ try {
1229
+ stat = await fsp.stat(this.dataFile);
1230
+ } catch {
1231
+ /* unknown mtime: the next write re-reads and the hash skips the merge */
1232
+ }
1233
+ this.#adoptDiskState(stat, text);
1234
+ this.#lastPersistError = null;
1235
+ outcome = { stats: plan.stats, backupFile, persisted: true };
1236
+ return outcome;
1237
+ } catch (error) {
1238
+ // A write failure after the in-memory list changed keeps the import in
1239
+ // memory (the next write retries), matching create/remove semantics.
1240
+ if (!applied || plan === null) throw error;
1241
+ this.#lastPersistError = error?.message ?? String(error);
1242
+ this.#warn(`todo-time-log: import persist failed: ${this.#lastPersistError}`);
1243
+ outcome = {
1244
+ stats: plan.stats,
1245
+ backupFile,
1246
+ persisted: false,
1247
+ error: this.#lastPersistError,
1248
+ };
1249
+ return outcome;
1250
+ } finally {
1251
+ await this.#releaseLock();
1252
+ }
1253
+ };
1254
+ const chained = this.#persistChain.then(write, write);
1255
+ this.#persistChain = chained.then(() => undefined, () => undefined);
1256
+ return chained;
1257
+ }
1258
+
1259
+ /** Replace the whole list under the write lock (backup restore). */
1260
+ async #replaceAll(nextMap) {
1261
+ let outcome;
1262
+ const write = async () => {
1263
+ let applied = false;
1264
+ let backupFile = null;
1265
+ await this.#acquireLock();
1266
+ try {
1267
+ backupFile = await this.#createBackup();
1268
+ this.#todos = nextMap;
1269
+ this.#tombstones.clear();
1270
+ applied = true;
1271
+ const text = this.serialize();
1272
+ await writeFileAtomic(this.dataFile, text);
1273
+ let stat = null;
1274
+ try {
1275
+ stat = await fsp.stat(this.dataFile);
1276
+ } catch {
1277
+ /* unknown mtime */
1278
+ }
1279
+ this.#adoptDiskState(stat, text);
1280
+ this.#lastPersistError = null;
1281
+ outcome = { backupFile, persisted: true };
1282
+ return outcome;
1283
+ } catch (error) {
1284
+ if (!applied) throw error;
1285
+ this.#lastPersistError = error?.message ?? String(error);
1286
+ this.#warn(`todo-time-log: restore persist failed: ${this.#lastPersistError}`);
1287
+ outcome = { backupFile, persisted: false, error: this.#lastPersistError };
1288
+ return outcome;
1289
+ } finally {
1290
+ await this.#releaseLock();
1291
+ }
1292
+ };
1293
+ const chained = this.#persistChain.then(write, write);
1294
+ this.#persistChain = chained.then(() => undefined, () => undefined);
1295
+ return chained;
1296
+ }
1297
+
1298
+ /**
1299
+ * Copy the current data file next to itself as `<dataFile>.bak-<stamp>`.
1300
+ * Called while the write lock is held. Returns the basename (safe to hand to
1301
+ * a client and post back to `restoreBackup`) or null when no file exists yet.
1302
+ */
1303
+ async #createBackup() {
1304
+ let stat;
1305
+ try {
1306
+ stat = await fsp.stat(this.dataFile);
1307
+ } catch {
1308
+ return null;
1309
+ }
1310
+ if (!stat.isFile()) return null;
1311
+ const base = `${this.dataFile}.bak-${backupStamp(new Date(this.now()))}`;
1312
+ // Two operations in the same millisecond (or a fixed test clock) must not
1313
+ // overwrite an earlier safety copy.
1314
+ let backupFile = base;
1315
+ let suffix = 0;
1316
+ for (;;) {
1317
+ try {
1318
+ await fsp.access(backupFile);
1319
+ } catch {
1320
+ break;
1321
+ }
1322
+ suffix += 1;
1323
+ backupFile = `${base}-${suffix}`;
1324
+ }
1325
+ try {
1326
+ await fsp.copyFile(this.dataFile, backupFile);
1327
+ } catch (error) {
1328
+ throw codedError(
1329
+ ERROR_CODES.BACKUP_FAILED,
1330
+ `导入前备份失败,已取消导入:${error?.message ?? String(error)}`,
1331
+ );
1332
+ }
1333
+ return path.basename(backupFile);
1334
+ }
1335
+
1336
+ /** Validate that `file` is a `.bak-*` sibling of the data file. */
1337
+ #resolveBackupFile(file) {
1338
+ const name = typeof file === 'string' ? file.trim() : '';
1339
+ if (name === '') return failure(ERROR_CODES.BACKUP_INVALID, '备份文件名不能为空');
1340
+ if (name !== path.basename(name) || name.includes('/') || name.includes('\\')) {
1341
+ return failure(ERROR_CODES.BACKUP_INVALID, '备份文件名不合法');
1342
+ }
1343
+ const base = path.basename(this.dataFile);
1344
+ if (!name.startsWith(`${base}.bak-`)) {
1345
+ return failure(ERROR_CODES.BACKUP_INVALID, '只允许恢复本插件生成的待办备份(.bak-*)');
1346
+ }
1347
+ return { ok: true, path: path.join(path.dirname(this.dataFile), name) };
1348
+ }
1349
+
903
1350
  #persist() {
904
1351
  const write = async () => {
905
1352
  await this.#acquireLock();