skill-calorie 0.2.0 → 0.2.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 (130) hide show
  1. package/SKILL.md +54 -26
  2. package/dist/analysis/anomaly/common.d.ts.map +1 -1
  3. package/dist/analysis/anomaly/common.js +2 -1
  4. package/dist/analysis/anomaly/common.js.map +1 -1
  5. package/dist/analysis/cross.d.ts.map +1 -1
  6. package/dist/analysis/cross.js +3 -2
  7. package/dist/analysis/cross.js.map +1 -1
  8. package/dist/analysis/deficit.d.ts.map +1 -1
  9. package/dist/analysis/deficit.js +4 -0
  10. package/dist/analysis/deficit.js.map +1 -1
  11. package/dist/analysis/diet.js +2 -2
  12. package/dist/analysis/diet.js.map +1 -1
  13. package/dist/analysis/exercise.js +5 -5
  14. package/dist/analysis/exercise.js.map +1 -1
  15. package/dist/analysis/review.js +2 -2
  16. package/dist/analysis/review.js.map +1 -1
  17. package/dist/analysis/series.d.ts +7 -1
  18. package/dist/analysis/series.d.ts.map +1 -1
  19. package/dist/analysis/series.js +22 -6
  20. package/dist/analysis/series.js.map +1 -1
  21. package/dist/analysis/utils.d.ts +7 -0
  22. package/dist/analysis/utils.d.ts.map +1 -1
  23. package/dist/analysis/utils.js +7 -0
  24. package/dist/analysis/utils.js.map +1 -1
  25. package/dist/analysis/weightCompare3.js +2 -2
  26. package/dist/analysis/weightCompare3.js.map +1 -1
  27. package/dist/cli/cmd_read.d.ts +21 -3
  28. package/dist/cli/cmd_read.d.ts.map +1 -1
  29. package/dist/cli/cmd_read.js +460 -54
  30. package/dist/cli/cmd_read.js.map +1 -1
  31. package/dist/cli/keys.d.ts +89 -1
  32. package/dist/cli/keys.d.ts.map +1 -1
  33. package/dist/cli/keys.js +30 -0
  34. package/dist/cli/keys.js.map +1 -1
  35. package/dist/cli/write.d.ts +12 -9
  36. package/dist/cli/write.d.ts.map +1 -1
  37. package/dist/cli/write.js +133 -48
  38. package/dist/cli/write.js.map +1 -1
  39. package/dist/output.d.ts +55 -1
  40. package/dist/output.d.ts.map +1 -1
  41. package/dist/output.js +203 -5
  42. package/dist/output.js.map +1 -1
  43. package/dist/render/dietDocs.d.ts +45 -0
  44. package/dist/render/dietDocs.d.ts.map +1 -0
  45. package/dist/render/dietDocs.js +561 -0
  46. package/dist/render/dietDocs.js.map +1 -0
  47. package/dist/render/envelope.d.ts +32 -6
  48. package/dist/render/envelope.d.ts.map +1 -1
  49. package/dist/render/envelope.js +71 -0
  50. package/dist/render/envelope.js.map +1 -1
  51. package/dist/render/exercisePort.d.ts +161 -0
  52. package/dist/render/exercisePort.d.ts.map +1 -0
  53. package/dist/render/exercisePort.js +395 -0
  54. package/dist/render/exercisePort.js.map +1 -0
  55. package/dist/render/goal.d.ts.map +1 -1
  56. package/dist/render/goal.js +13 -2
  57. package/dist/render/goal.js.map +1 -1
  58. package/dist/render/goalPlate.d.ts +1 -1
  59. package/dist/render/goalPlate.d.ts.map +1 -1
  60. package/dist/render/goalPlate.js +5 -1
  61. package/dist/render/goalPlate.js.map +1 -1
  62. package/dist/render/helpCenter.d.ts +104 -0
  63. package/dist/render/helpCenter.d.ts.map +1 -0
  64. package/dist/render/helpCenter.js +386 -0
  65. package/dist/render/helpCenter.js.map +1 -0
  66. package/dist/render/html.d.ts.map +1 -1
  67. package/dist/render/html.js +2 -1
  68. package/dist/render/html.js.map +1 -1
  69. package/dist/render/index.d.ts +3 -0
  70. package/dist/render/index.d.ts.map +1 -1
  71. package/dist/render/index.js +2 -0
  72. package/dist/render/index.js.map +1 -1
  73. package/dist/render/nutritionPort.d.ts +89 -0
  74. package/dist/render/nutritionPort.d.ts.map +1 -0
  75. package/dist/render/nutritionPort.js +183 -0
  76. package/dist/render/nutritionPort.js.map +1 -0
  77. package/dist/render/nutritionPortDocs.d.ts +6 -0
  78. package/dist/render/nutritionPortDocs.d.ts.map +1 -0
  79. package/dist/render/nutritionPortDocs.js +317 -0
  80. package/dist/render/nutritionPortDocs.js.map +1 -0
  81. package/dist/render/photo.d.ts.map +1 -1
  82. package/dist/render/photo.js +5 -1
  83. package/dist/render/photo.js.map +1 -1
  84. package/dist/render/planPlate.d.ts +2 -1
  85. package/dist/render/planPlate.d.ts.map +1 -1
  86. package/dist/render/planPlate.js +6 -1
  87. package/dist/render/planPlate.js.map +1 -1
  88. package/dist/render/receipt.d.ts +47 -1
  89. package/dist/render/receipt.d.ts.map +1 -1
  90. package/dist/render/receipt.js +67 -3
  91. package/dist/render/receipt.js.map +1 -1
  92. package/dist/render/sportDocs.d.ts +14 -0
  93. package/dist/render/sportDocs.d.ts.map +1 -0
  94. package/dist/render/sportDocs.js +540 -0
  95. package/dist/render/sportDocs.js.map +1 -0
  96. package/dist/render/sportPortDocs.d.ts +8 -0
  97. package/dist/render/sportPortDocs.d.ts.map +1 -0
  98. package/dist/render/sportPortDocs.js +468 -0
  99. package/dist/render/sportPortDocs.js.map +1 -0
  100. package/dist/render/trendDocs.d.ts +11 -0
  101. package/dist/render/trendDocs.d.ts.map +1 -0
  102. package/dist/render/trendDocs.js +445 -0
  103. package/dist/render/trendDocs.js.map +1 -0
  104. package/dist/render/trendMiscPort.d.ts +127 -0
  105. package/dist/render/trendMiscPort.d.ts.map +1 -0
  106. package/dist/render/trendMiscPort.js +277 -0
  107. package/dist/render/trendMiscPort.js.map +1 -0
  108. package/dist/render/trendMiscPortDocs.d.ts +10 -0
  109. package/dist/render/trendMiscPortDocs.d.ts.map +1 -0
  110. package/dist/render/trendMiscPortDocs.js +397 -0
  111. package/dist/render/trendMiscPortDocs.js.map +1 -0
  112. package/dist/render/wizardPort.d.ts +125 -0
  113. package/dist/render/wizardPort.d.ts.map +1 -0
  114. package/dist/render/wizardPort.js +393 -0
  115. package/dist/render/wizardPort.js.map +1 -0
  116. package/dist/render/wizardPortDocs.d.ts +6 -0
  117. package/dist/render/wizardPortDocs.d.ts.map +1 -0
  118. package/dist/render/wizardPortDocs.js +274 -0
  119. package/dist/render/wizardPortDocs.js.map +1 -0
  120. package/dist/triggers/routing.d.ts +21 -21
  121. package/dist/triggers/routing.d.ts.map +1 -1
  122. package/dist/triggers/routing.js +60 -30
  123. package/dist/triggers/routing.js.map +1 -1
  124. package/package.json +1 -1
  125. package/templates/diet.html +2 -2
  126. package/templates/exercise.html +2 -2
  127. package/templates/goal.html +2 -2
  128. package/templates/help.html +2 -2
  129. package/templates/home.html +2 -2
  130. package/templates/photo-gallery.html +2 -2
package/dist/cli/write.js CHANGED
@@ -9,7 +9,7 @@ import { getNutritionGoal, setNutritionGoal, updateWaterGoal } from '../fetch/nu
9
9
  import { pauseAllGoals, resumeAllGoals, setWeightGoal } from '../fetch/goal.js';
10
10
  import { CALIPER_FIELDS, ValidationError, addComposition, addMeasurement, deleteComposition, deleteMeasurement, } from '../fetch/body.js';
11
11
  import { SOURCE_CHOICES, SOURCE_LABELS } from '../kcal.js';
12
- import { buildCrudReceipt } from '../render/receipt.js';
12
+ import { buildCrudReceipt, withM5 } from '../render/receipt.js';
13
13
  import { CalorieRenderError } from '../render/errors.js';
14
14
  import { shiftISODate, todayISO } from '../analysis/utils.js';
15
15
  import { isCalorieWriteKey } from './keys.js';
@@ -74,7 +74,6 @@ function esc(s) {
74
74
  }
75
75
  /** #101 · 删除口径单一来源(prose 词条 + `items[].status` 状态串同源派生)。
76
76
  * `recoverable` 恒 false:全仓 77 键 0 个 restore/undo/recover 入口,故不得出现「可恢复」承诺。 */
77
- const SOFT_STILL_COUNTED = '(软删除:行保留,仍计入历史统计;暂无恢复入口)';
78
77
  const SOFT_EXCLUDED_INNER = '软删除:行保留,已从查询与统计中排除;暂无恢复入口';
79
78
  const SOFT_EXCLUDED = '(' + SOFT_EXCLUDED_INNER + ')';
80
79
  const HARD_INNER = '硬删除,不可恢复';
@@ -83,6 +82,58 @@ const HARD_WORDING = '(' + HARD_INNER + ')';
83
82
  function deleteStatus(kind, base = '已删除') {
84
83
  return base + (kind === 'soft' ? '(软,不可恢复)' : '(硬,不可恢复)');
85
84
  }
85
+ /* ------------------------------------------------ #97 · M5 回执契约(追加字段,正本 t97-m5-contract.md) */
86
+ /** 影响行数来源:SQLite `total_changes()` 在本键写库前后的增量。
87
+ * 真实库行数(INSERT/UPDATE/DELETE/软删标记一律计;`INSERT OR REPLACE` 命中已有行=1,
88
+ * `INSERT OR IGNORE` 命中已有行=0——实测 node:sqlite v24),非自报。 */
89
+ function totalChanges(db) {
90
+ const row = db.prepare('SELECT total_changes() AS n').get();
91
+ return row && typeof row.n === 'number' ? Number(row.n) : 0;
92
+ }
93
+ /** 写入字段摘要(CLI 参数名口径):create 键=该键写入字段全集(缺省值也算写入);
94
+ * update 键=本次实际变更字段;delete 键=空数组(无写入字段,旧版同样只印 id/日期/影响)。 */
95
+ const F = {
96
+ diet: ['foodName', 'calories', 'protein', 'carbs', 'fat', 'grams', 'note', 'date', 'time'],
97
+ water: ['ml', 'note', 'date', 'time'],
98
+ weight: ['kg', 'note', 'date', 'time'],
99
+ weightBatch: ['kg', 'date'],
100
+ exercise: ['type', 'calories', 'minutes', 'date', 'time', 'note', 'reps', 'category', 'difficulty', 'distance', 'heartRate', 'maxHeartRate', 'steps', 'setIndex', 'loadKg', 'backfill'],
101
+ photo: ['srcPaths', 'tag', 'note', 'date', 'time'],
102
+ product: ['productName', 'brand', 'calories', 'protein', 'fat', 'saturatedFat', 'carbohydrates', 'sugar', 'dietaryFiber', 'sodium', 'note'],
103
+ };
104
+ /** `calorie.goal.set` 本次**实际被 SET 的列** → CLI 参数名(正本 §3.4「update 键=本次实际变更字段」)。
105
+ * 与 `fetch/nutritionGoal.ts:68-78` 的两条 SQL 同源:传 `water` 走 6 列 `INSERT OR REPLACE`
106
+ * (含 `water_goal`),不传则 SQL 里**没有** `water_goal` 列——该列的值只因 REPLACE 落列默认
107
+ * (`schema.ts:32` `water_goal INTEGER DEFAULT 2000`),**不属本次 SET 的字段**,故不得报 `water`。
108
+ * 记账列 `updated_at` 无 CLI 参数,按 §3.4 不计入摘要。
109
+ * 注意:`INSERT OR REPLACE` 连带把 `weight_goal`/`goal_deadline`/`goal_paused`/`start_weight`/
110
+ * `start_date` 重置为默认/NULL——那是**实际行为缺陷**(红队 D-4),已转 #127;本票只让回执如实
111
+ * 反映「本次 SET 了哪些列」,**不改写语义**(不做 UPSERT 化)。 */
112
+ const goalSetWrittenFields = (hasWater) => ['calorie', 'protein', 'carbs', 'fat', ...(hasWater ? ['water'] : [])];
113
+ /** 库列名 → CLI 参数名(update 键按实际变更列回报写入字段摘要)。 */
114
+ const COL_CLI = {
115
+ food_name: 'foodName', grams: 'grams', calories: 'calories', protein: 'protein', carbs: 'carbs',
116
+ fat: 'fat', note: 'note', date: 'date', time: 'time',
117
+ exercise_type: 'type', calories_burned: 'calories', duration_minutes: 'minutes', category: 'category',
118
+ difficulty: 'difficulty', distance_km: 'distance', avg_heart_rate: 'heartRate',
119
+ max_heart_rate: 'maxHeartRate', steps: 'steps', reps: 'reps', load_kg: 'loadKg',
120
+ set_index: 'setIndex', is_backfill: 'backfill',
121
+ product_name: 'productName', brand: 'brand', saturated_fat: 'saturatedFat', carbohydrates: 'carbohydrates',
122
+ sugar: 'sugar', dietary_fiber: 'dietaryFiber', sodium: 'sodium',
123
+ };
124
+ const cliNames = (cols) => cols.map((c) => COL_CLI[c] ?? c);
125
+ /** 只取「实际提供」的参数名(写入字段摘要;`undefined` 不算写入)。 */
126
+ function provided(params, names) {
127
+ return names.filter((n) => params[n] !== undefined);
128
+ }
129
+ /** `input` 里实际有值的键(体脂/围度等动态字段表)。 */
130
+ function definedKeys(input) {
131
+ return Object.keys(input).filter((k) => input[k] !== undefined);
132
+ }
133
+ /** 围度库列名 → CLI 参数名(`MEASURE_CAMEL` 反向;写入字段摘要统一走 CLI 名口径)。 */
134
+ function measureCliNames(keys) {
135
+ return keys.map((k) => Object.keys(MEASURE_CAMEL).find((c) => MEASURE_CAMEL[c] === k) ?? k);
136
+ }
86
137
  /** C6 #43 · 写收据 HTML 结构化分项:摘要 + 操作元 + items 逐条(id/日期/状态/原因/明细)。 */
87
138
  function receiptHtml(scene, summary, op, recordId, items) {
88
139
  const list = (items ?? []).length > 0
@@ -177,12 +228,17 @@ function oneExercise(item, i) {
177
228
  isBackfill: (g('backfill') ?? g('isBackfill')) === true,
178
229
  };
179
230
  }
180
- /** 写分发(唯一出口 cmd_read 内调用;未知键上游已拦,此处再拦一道)。 */
231
+ /** 写分发(唯一出口 cmd_read 内调用;未知键上游已拦,此处再拦一道)。
232
+ * #97 · M5:`affectedRows` 在此统一注入——写库前后各取一次 SQLite `total_changes()`,
233
+ * 增量即「影响 N 行」(35 键单一来源,逐键不各自自报)。 */
181
234
  export function dispatchWrite(key, params, db) {
182
235
  if (!isCalorieWriteKey(key))
183
236
  fail(3, '未知 calorie 写键:' + key);
237
+ const before = totalChanges(db);
184
238
  try {
185
- return dispatchInner(key, params, db);
239
+ const res = dispatchInner(key, params, db);
240
+ const receipt = withM5(res.data.receipt, { affectedRows: totalChanges(db) - before });
241
+ return { data: { ...res.data, receipt }, html: res.html };
186
242
  }
187
243
  catch (e) {
188
244
  if (e instanceof ValidationError)
@@ -225,11 +281,13 @@ function dispatchInner(key, params, db) {
225
281
  if (r.duplicate) {
226
282
  return out(R('记一餐', 'create', String(r.message ?? '重复记录已跳过'), '记一餐', 'food_log (写库回执)', {
227
283
  recordId: r.dupId ?? null, noChange: true,
284
+ ids: r.dupId ? [r.dupId] : [], idSource: r.dupId ? 'record' : 'none', writtenFields: [],
228
285
  }));
229
286
  }
230
287
  const remain = r.remainingCal === null || r.remainingCal === undefined ? '' : ' · 今日剩 ' + r.remainingCal + ' 卡';
231
288
  return out(R('记一餐', 'create', '已记一餐:' + r.food_name + ' ' + r.date + ' ' + r.time + '(' + r.meal + ')' + remain, '记一餐', 'food_log (写库回执)', {
232
- recordId: r.id, items: [{ id: r.id ?? undefined, date: r.date, status: '成功', reason: '', detail: r.food_name }],
289
+ recordId: r.id, ids: r.id === null ? [] : [r.id], writtenFields: [...F.diet],
290
+ items: [{ id: r.id ?? undefined, date: r.date, status: '成功', reason: '', detail: r.food_name }],
233
291
  }));
234
292
  }
235
293
  case 'calorie.diet.update': {
@@ -254,7 +312,7 @@ function dispatchInner(key, params, db) {
254
312
  assertISO(fields['date'], 'date');
255
313
  const r = updateMeal(db, id, fields);
256
314
  return out(R('改饮食记录', 'update', '已更新饮食 #' + id + '(' + (r.changed.length ? r.changed.join('、') : '无实际变化') + ')', '改饮食记录', 'food_log (写库回执)', {
257
- recordId: id, noChange: r.changed.length === 0,
315
+ recordId: id, ids: [id], writtenFields: cliNames(Object.keys(fields)), noChange: r.changed.length === 0,
258
316
  items: [{ id, status: '已更新', reason: '', detail: r.changed.join(',') || '无变化' }],
259
317
  }));
260
318
  }
@@ -262,7 +320,7 @@ function dispatchInner(key, params, db) {
262
320
  const id = needId(params);
263
321
  const r = deleteMeal(db, id);
264
322
  return out(R('删饮食记录', 'delete', '已删除饮食 #' + id + '(' + r.food_name + ' ' + r.calories + ' 卡 · ' + HARD_INNER + ')', '删饮食记录', 'food_log (写库回执)', {
265
- recordId: id, items: [{ id, status: deleteStatus('hard'), reason: '', detail: r.food_name }],
323
+ recordId: id, ids: [id], writtenFields: [], items: [{ id, status: deleteStatus('hard'), reason: '', detail: r.food_name }],
266
324
  }));
267
325
  }
268
326
  case 'calorie.diet.batch': {
@@ -282,7 +340,8 @@ function dispatchInner(key, params, db) {
282
340
  };
283
341
  }));
284
342
  return out(R('批量补记饮食', 'create', '批量记饮食:新增 ' + r.added + ',跳过 ' + r.skipped + ',失败 ' + r.failed, '批量补记饮食', 'food_log (写库回执)', {
285
- noChange: r.added === 0,
343
+ noChange: r.added === 0, ids: [], idSource: 'condition',
344
+ writtenFields: r.added > 0 ? [...F.diet] : [],
286
345
  items: r.failures.slice(0, 20).map(([idx, reason]) => ({ status: '失败', reason, detail: '第' + idx + '条' })),
287
346
  }));
288
347
  }
@@ -296,7 +355,8 @@ function dispatchInner(key, params, db) {
296
355
  if (r.copied + r.skipped === 0)
297
356
  throw new CalorieRenderError('missing-data', '来源无饮食记录(' + from + ')');
298
357
  return out(R('复制昨日饮食', 'create', '已复制饮食 ' + from + '→' + to + ':复制 ' + r.copied + ',跳过 ' + r.skipped, '复制昨日饮食', 'food_log (写库回执)', {
299
- noChange: r.copied === 0,
358
+ noChange: r.copied === 0, ids: [], idSource: 'condition',
359
+ writtenFields: r.copied > 0 ? [...F.diet] : [],
300
360
  }));
301
361
  }
302
362
  case 'calorie.diet.update-by-date': {
@@ -316,7 +376,9 @@ function dispatchInner(key, params, db) {
316
376
  const r = updateMealsByDate(db, date, fields);
317
377
  if (r.matched === 0)
318
378
  throw new CalorieRenderError('missing-data', '无饮食记录(' + date + ')');
319
- return out(R('改某日饮食', 'update', '已更新 ' + date + ' 饮食 ' + r.updated + ' 条(' + r.changedFields.join('、') + ')', '改某日饮食', 'food_log (写库回执)', {}));
379
+ return out(R('改某日饮食', 'update', '已更新 ' + date + ' 饮食 ' + r.updated + ' 条(' + r.changedFields.join('、') + ')', '改某日饮食', 'food_log (写库回执)', {
380
+ ids: [], idSource: 'condition', writtenFields: cliNames(r.changedFields),
381
+ }));
320
382
  }
321
383
  case 'calorie.diet.remove-by-date': {
322
384
  const date = needStr(params, 'date');
@@ -324,7 +386,7 @@ function dispatchInner(key, params, db) {
324
386
  const r = deleteMealsByDate(db, date);
325
387
  if (r.deleted === 0)
326
388
  throw new CalorieRenderError('missing-data', '无饮食记录(' + date + ')');
327
- return out(R('删某日饮食', 'delete', '已删除 ' + date + ' 饮食 ' + r.deleted + ' 条' + HARD_WORDING, '删某日饮食', 'food_log (写库回执)', {}));
389
+ return out(R('删某日饮食', 'delete', '已删除 ' + date + ' 饮食 ' + r.deleted + ' 条' + HARD_WORDING, '删某日饮食', 'food_log (写库回执)', { ids: [], idSource: 'condition', writtenFields: [] }));
328
390
  }
329
391
  case 'calorie.diet.remove-by-range': {
330
392
  const start = needStr(params, 'start');
@@ -336,7 +398,7 @@ function dispatchInner(key, params, db) {
336
398
  const r = deleteMealsByRange(db, start, end);
337
399
  if (r.deleted === 0)
338
400
  throw new CalorieRenderError('missing-data', '无饮食记录(' + start + '~' + end + ')');
339
- return out(R('批量删饮食', 'delete', '已删除 ' + start + '~' + end + ' 饮食 ' + r.deleted + ' 条' + HARD_WORDING, '批量删饮食', 'food_log (写库回执)', {}));
401
+ return out(R('批量删饮食', 'delete', '已删除 ' + start + '~' + end + ' 饮食 ' + r.deleted + ' 条' + HARD_WORDING, '批量删饮食', 'food_log (写库回执)', { ids: [], idSource: 'condition', writtenFields: [] }));
340
402
  }
341
403
  case 'calorie.diet.remove-by-type': {
342
404
  const date = needStr(params, 'date');
@@ -347,7 +409,7 @@ function dispatchInner(key, params, db) {
347
409
  const r = deleteMealsByType(db, date, mealType);
348
410
  if (r.deleted === 0)
349
411
  throw new CalorieRenderError('missing-data', date + ' 无' + mealType + '记录');
350
- return out(R('删一餐', 'delete', '已删除 ' + date + ' ' + mealType + ' ' + r.deleted + ' 条' + HARD_WORDING, '删一餐', 'food_log (写库回执)', {}));
412
+ return out(R('删一餐', 'delete', '已删除 ' + date + ' ' + mealType + ' ' + r.deleted + ' 条' + HARD_WORDING, '删一餐', 'food_log (写库回执)', { ids: [], idSource: 'condition', writtenFields: [] }));
351
413
  }
352
414
  case 'calorie.water.log': {
353
415
  const ml = needNum(params, 'ml');
@@ -361,12 +423,17 @@ function dispatchInner(key, params, db) {
361
423
  note: optStr(params, 'note'), date, time: optStr(params, 'time'),
362
424
  });
363
425
  if (r.duplicate) {
364
- return out(R('记喝水', 'create', String(r.message ?? '重复记录已跳过'), '记喝水', 'food_log (写库回执)', { noChange: true }));
426
+ // `calorie.diet.add` 同口径(§3.3 `record`):重复跳过的原 id fetch 回传(`fetch/diet.ts:96`),
427
+ // 拿得到就报 `record`+`ids=[dupId]`,拿不到才退 `none`(返修 R-3/蓝队 D-3 统一)。
428
+ return out(R('记喝水', 'create', String(r.message ?? '重复记录已跳过'), '记喝水', 'food_log (写库回执)', {
429
+ recordId: r.dupId ?? null, noChange: true,
430
+ ids: r.dupId ? [r.dupId] : [], idSource: r.dupId ? 'record' : 'none', writtenFields: [],
431
+ }));
365
432
  }
366
433
  const day = date ?? todayISO();
367
434
  const sum = getDailySummary(db, day);
368
435
  return out(R('记喝水', 'create', '已记喝水 ' + ml + ' ml(' + day + ' 累计 ' + sum.waterMl + ' ml)', '记喝水', 'food_log (写库回执)', {
369
- recordId: r.id,
436
+ recordId: r.id, ids: r.id === null ? [] : [r.id], writtenFields: [...F.water],
370
437
  items: [{ id: r.id ?? undefined, date: r.date, status: '成功', reason: '', detail: ml + 'ml' }],
371
438
  }));
372
439
  }
@@ -380,7 +447,8 @@ function dispatchInner(key, params, db) {
380
447
  const r = logWeight(db, kg, optStr(params, 'note') ?? '', date, optStr(params, 'time'));
381
448
  const bmiText = r.bmi === null ? 'BMI 待补身高(补档案:calorie-cmd-read calorie.profile.set)' : 'BMI ' + r.bmi;
382
449
  return out(R('记体重', 'create', '已记体重 ' + r.kg + ' kg(' + bmiText + ' · ' + r.date + ' ' + r.time + ')', '记体重', 'weight_log (写库回执)', {
383
- recordId: r.id, items: [{ id: r.id, date: r.date, status: '成功', reason: '', detail: r.kg + 'kg' }],
450
+ recordId: r.id, ids: [r.id], writtenFields: [...F.weight],
451
+ items: [{ id: r.id, date: r.date, status: '成功', reason: '', detail: r.kg + 'kg' }],
384
452
  }));
385
453
  }
386
454
  case 'calorie.weight.update': {
@@ -396,13 +464,16 @@ function dispatchInner(key, params, db) {
396
464
  const r = updateWeight(db, id, kg, note);
397
465
  const bmiTextU = r.bmi === null ? 'BMI 待补身高(补档案:calorie-cmd-read calorie.profile.set)' : 'BMI ' + r.bmi;
398
466
  return out(R('改体重记录', 'update', '已更新体重 #' + id + ':' + r.oldWeight + '→' + r.newWeight + ' kg(' + bmiTextU + ')', '改体重记录', 'weight_log (写库回执)', {
399
- recordId: id, items: [{ id, status: '已更新', reason: '' }],
467
+ recordId: id, ids: [id], writtenFields: [...(kg !== undefined ? ['kg'] : []), ...(note !== undefined ? ['note'] : [])],
468
+ items: [{ id, status: '已更新', reason: '' }],
400
469
  }));
401
470
  }
402
471
  if (date !== undefined) {
403
472
  assertISO(date, 'date');
404
473
  const r = updateWeightByDate(db, date, kg, note);
405
- return out(R('改某日体重', 'update', '已更新 ' + date + ' 体重 ' + r.hitCount + ' 条', '改某日体重', 'weight_log (写库回执)', {}));
474
+ return out(R('改某日体重', 'update', '已更新 ' + date + ' 体重 ' + r.hitCount + ' 条', '改某日体重', 'weight_log (写库回执)', {
475
+ ids: [], idSource: 'condition', writtenFields: [...(kg !== undefined ? ['kg'] : []), ...(note !== undefined ? ['note'] : [])],
476
+ }));
406
477
  }
407
478
  fail(2, '缺参数 id 或 date(二选一)');
408
479
  throw new Error('unreachable');
@@ -417,13 +488,13 @@ function dispatchInner(key, params, db) {
417
488
  fail(2, 'id 须为正整数');
418
489
  const r = deleteWeight(db, id);
419
490
  return out(R('删体重记录', 'delete', '已删除体重 #' + id + '(' + r.date + ' ' + r.weight_kg + ' kg · ' + HARD_INNER + ')', '删体重记录', 'weight_log (写库回执)', {
420
- recordId: id, items: [{ id, status: deleteStatus('hard'), reason: '' }],
491
+ recordId: id, ids: [id], writtenFields: [], items: [{ id, status: deleteStatus('hard'), reason: '' }],
421
492
  }));
422
493
  }
423
494
  if (date !== undefined) {
424
495
  assertISO(date, 'date');
425
496
  const r = deleteWeightByDate(db, date);
426
- return out(R('删某日体重', 'delete', '已删除 ' + date + ' 体重 ' + r.deletedCount + ' 条' + HARD_WORDING, '删某日体重', 'weight_log (写库回执)', {}));
497
+ return out(R('删某日体重', 'delete', '已删除 ' + date + ' 体重 ' + r.deletedCount + ' 条' + HARD_WORDING, '删某日体重', 'weight_log (写库回执)', { ids: [], idSource: 'condition', writtenFields: [] }));
427
498
  }
428
499
  if (start !== undefined || end !== undefined) {
429
500
  if (start === undefined || end === undefined)
@@ -433,7 +504,7 @@ function dispatchInner(key, params, db) {
433
504
  if (start > end)
434
505
  fail(2, 'start 不得晚于 end');
435
506
  const r = deleteWeightRange(db, start, end);
436
- return out(R('批量删体重', 'delete', '已删除 ' + start + '~' + end + ' 体重 ' + r.deletedCount + ' 条' + HARD_WORDING, '批量删体重', 'weight_log (写库回执)', {}));
507
+ return out(R('批量删体重', 'delete', '已删除 ' + start + '~' + end + ' 体重 ' + r.deletedCount + ' 条' + HARD_WORDING, '批量删体重', 'weight_log (写库回执)', { ids: [], idSource: 'condition', writtenFields: [] }));
437
508
  }
438
509
  fail(2, '缺参数 id/date/start+end(三选一)');
439
510
  throw new Error('unreachable');
@@ -447,7 +518,8 @@ function dispatchInner(key, params, db) {
447
518
  return { date: o['date'] === undefined ? undefined : String(o['date']), kg: o['kg'] };
448
519
  }));
449
520
  return out(R('批量补录体重', 'create', '批量记体重:写入 ' + r.wrote + ',跳过 ' + r.skipped + ',失败 ' + r.failed, '批量补录体重', 'weight_log (写库回执)', {
450
- noChange: r.wrote === 0,
521
+ noChange: r.wrote === 0, ids: [], idSource: 'condition',
522
+ writtenFields: r.wrote > 0 ? [...F.weightBatch] : [],
451
523
  items: r.items.filter((x) => x.status === '失败').slice(0, 20).map((x) => ({ status: '失败', reason: x.reason, detail: String(x.date) })),
452
524
  }));
453
525
  }
@@ -461,7 +533,8 @@ function dispatchInner(key, params, db) {
461
533
  if (r.copied + r.skipped === 0)
462
534
  throw new CalorieRenderError('missing-data', '昨日无运动记录可复制');
463
535
  return out(R('复制昨日运动', 'create', '已复制昨日运动→' + target + ':复制 ' + r.copied + ',跳过 ' + r.skipped, '复制昨日运动', 'exercise_log (写库回执)', {
464
- noChange: r.copied === 0,
536
+ noChange: r.copied === 0, ids: [], idSource: 'condition',
537
+ writtenFields: r.copied > 0 ? [...F.exercise] : [],
465
538
  }));
466
539
  }
467
540
  if (params['items'] !== undefined) {
@@ -470,13 +543,15 @@ function dispatchInner(key, params, db) {
470
543
  fail(2, 'items 至多 200 条');
471
544
  const r = batchAdd(db, items.map((e, i) => oneExercise((e ?? {}), '(第' + i + '条)')));
472
545
  return out(R('记运动', 'create', '批量记运动:新增 ' + r.added + ' 条', '批量补记运动', 'exercise_log (写库回执)', {
473
- recordId: r.ids[0] ?? null,
546
+ recordId: r.ids[0] ?? null, ids: r.ids, idSource: r.ids.length > 0 ? 'record' : 'condition',
547
+ writtenFields: r.added > 0 ? [...F.exercise] : [],
474
548
  }));
475
549
  }
476
550
  const input = oneExercise(params, '');
477
551
  const r = addRecord(db, input);
478
552
  return out(R('记运动', 'create', '已记运动:' + input.exerciseType + ' ' + input.caloriesBurned + ' 卡' + (input.minutes ? ' · ' + input.minutes + ' 分钟' : '') + '(' + input.date + ')', '记运动', 'exercise_log (写库回执)', {
479
- recordId: r.id, items: [{ id: r.id, date: input.date, status: '成功', reason: '', detail: input.exerciseType }],
553
+ recordId: r.id, ids: [r.id], writtenFields: [...F.exercise],
554
+ items: [{ id: r.id, date: input.date, status: '成功', reason: '', detail: input.exerciseType }],
480
555
  }));
481
556
  }
482
557
  case 'calorie.exercise.update': {
@@ -514,7 +589,8 @@ function dispatchInner(key, params, db) {
514
589
  fail(2, 'id 须为正整数');
515
590
  updateRecord(db, id, fields);
516
591
  return out(R('改运动记录', 'update', '已更新运动 #' + id + '(' + Object.keys(fields).join('、') + ')', '改运动记录', 'exercise_log (写库回执)', {
517
- recordId: id, items: [{ id, status: '已更新', reason: '' }],
592
+ recordId: id, ids: [id], writtenFields: cliNames(Object.keys(fields)),
593
+ items: [{ id, status: '已更新', reason: '' }],
518
594
  }));
519
595
  }
520
596
  if (date !== undefined) {
@@ -522,7 +598,9 @@ function dispatchInner(key, params, db) {
522
598
  const r = updateDay(db, date, fields);
523
599
  if (r.matched === 0)
524
600
  throw new CalorieRenderError('missing-data', '无运动记录(' + date + ')');
525
- return out(R('改某日运动', 'update', '已更新 ' + date + ' 运动 ' + r.matched + ' 条', '改某日运动', 'exercise_log (写库回执)', {}));
601
+ return out(R('改某日运动', 'update', '已更新 ' + date + ' 运动 ' + r.matched + ' 条', '改某日运动', 'exercise_log (写库回执)', {
602
+ ids: [], idSource: 'condition', writtenFields: cliNames(Object.keys(fields)),
603
+ }));
526
604
  }
527
605
  fail(2, '缺参数 id 或 date(二选一)');
528
606
  throw new Error('unreachable');
@@ -536,8 +614,8 @@ function dispatchInner(key, params, db) {
536
614
  if (!Number.isInteger(id) || id <= 0)
537
615
  fail(2, 'id 须为正整数');
538
616
  deleteRecord(db, id);
539
- return out(R('删运动记录', 'delete', '已删除运动 #' + id + SOFT_STILL_COUNTED, '删运动记录', 'exercise_log (写库回执)', {
540
- recordId: id, items: [{ id, status: deleteStatus('soft'), reason: '' }],
617
+ return out(R('删运动记录', 'delete', '已删除运动 #' + id + SOFT_EXCLUDED, '删运动记录', 'exercise_log (写库回执)', {
618
+ recordId: id, ids: [id], writtenFields: ['is_deleted'], items: [{ id, status: deleteStatus('soft'), reason: '' }],
541
619
  }));
542
620
  }
543
621
  if (date !== undefined) {
@@ -545,7 +623,7 @@ function dispatchInner(key, params, db) {
545
623
  const n = deleteDay(db, date);
546
624
  if (n === 0)
547
625
  throw new CalorieRenderError('missing-data', '无运动记录(' + date + ')');
548
- return out(R('删某日运动', 'delete', '已删除 ' + date + ' 运动 ' + n + ' 条' + SOFT_STILL_COUNTED, '删某日运动', 'exercise_log (写库回执)', {}));
626
+ return out(R('删某日运动', 'delete', '已删除 ' + date + ' 运动 ' + n + ' 条' + SOFT_EXCLUDED, '删某日运动', 'exercise_log (写库回执)', { ids: [], idSource: 'condition', writtenFields: ['is_deleted'] }));
549
627
  }
550
628
  if (from !== undefined || to !== undefined) {
551
629
  if (from === undefined || to === undefined)
@@ -557,7 +635,7 @@ function dispatchInner(key, params, db) {
557
635
  const n = deleteRange(db, from, to);
558
636
  if (n === 0)
559
637
  throw new CalorieRenderError('missing-data', '无运动记录(' + from + '~' + to + ')');
560
- return out(R('批量删运动', 'delete', '已删除 ' + from + '~' + to + ' 运动 ' + n + ' 条' + SOFT_STILL_COUNTED, '批量删运动', 'exercise_log (写库回执)', {}));
638
+ return out(R('批量删运动', 'delete', '已删除 ' + from + '~' + to + ' 运动 ' + n + ' 条' + SOFT_EXCLUDED, '批量删运动', 'exercise_log (写库回执)', { ids: [], idSource: 'condition', writtenFields: ['is_deleted'] }));
561
639
  }
562
640
  fail(2, '缺参数 id/date/from+to(三选一)');
563
641
  throw new Error('unreachable');
@@ -584,7 +662,9 @@ function dispatchInner(key, params, db) {
584
662
  catch {
585
663
  distance = null;
586
664
  }
587
- const receipt = buildAddReceipt(added, { tag, note: optStr(params, 'note'), distance, failedCount: srcPaths.length - added.length || undefined });
665
+ const receipt = withM5(buildAddReceipt(added, { tag, note: optStr(params, 'note'), distance, failedCount: srcPaths.length - added.length || undefined }), {
666
+ ids: added.map((a) => a.id), writtenFields: [...F.photo],
667
+ });
588
668
  return { data: { ok: true, message: receipt.summary, receipt }, html: receiptHtml(receipt.scene, receipt.summary, receipt.op, receipt.recordId, receipt.items) };
589
669
  }
590
670
  case 'calorie.photo.remove': {
@@ -594,7 +674,7 @@ function dispatchInner(key, params, db) {
594
674
  if (!snap)
595
675
  throw new CalorieRenderError('missing-data', '身材照 #' + id + ' 不存在');
596
676
  deletePhoto(db, dir, id);
597
- const receipt = buildDeleteReceipt(snap);
677
+ const receipt = withM5(buildDeleteReceipt(snap), { ids: [id], writtenFields: [] });
598
678
  return { data: { ok: true, message: receipt.summary, receipt }, html: receiptHtml(receipt.scene, receipt.summary, receipt.op, receipt.recordId, receipt.items) };
599
679
  }
600
680
  case 'calorie.photo.tag': {
@@ -628,7 +708,7 @@ function dispatchInner(key, params, db) {
628
708
  scene = '删照片标签';
629
709
  }
630
710
  const after = getPhotoRow(db, id)?.tag_list ?? before;
631
- const receipt = buildTagReceipt(id, before, after, scene);
711
+ const receipt = withM5(buildTagReceipt(id, before, after, scene), { ids: [id], writtenFields: op === 'set' ? ['tags'] : ['tag'] });
632
712
  return { data: { ok: true, message: receipt.summary, receipt }, html: receiptHtml(receipt.scene, receipt.summary, receipt.op, receipt.recordId, receipt.items) };
633
713
  }
634
714
  case 'calorie.product.add': {
@@ -643,7 +723,8 @@ function dispatchInner(key, params, db) {
643
723
  sodium: needNum(params, 'sodium'), note: optStr(params, 'note'),
644
724
  });
645
725
  return out(R('存食品', 'create', '已存食品 #' + r.id + '(' + productName.trim() + ')', '存食品', 'nutrition_products (写库回执)', {
646
- recordId: r.id, items: [{ id: r.id, status: '成功', reason: '', detail: productName.trim() }],
726
+ recordId: r.id, ids: [r.id], writtenFields: [...F.product],
727
+ items: [{ id: r.id, status: '成功', reason: '', detail: productName.trim() }],
647
728
  }));
648
729
  }
649
730
  case 'calorie.product.update': {
@@ -668,7 +749,8 @@ function dispatchInner(key, params, db) {
668
749
  if (!r.updated)
669
750
  throw new CalorieRenderError('missing-data', '食品 #' + id + ' 不存在');
670
751
  return out(R('改食品', 'update', '已更新食品 #' + id + '(' + Object.keys(fields).join('、') + ')', '改食品', 'nutrition_products (写库回执)', {
671
- recordId: id, items: [{ id, status: '已更新', reason: '' }],
752
+ recordId: id, ids: [id], writtenFields: cliNames(Object.keys(fields)),
753
+ items: [{ id, status: '已更新', reason: '' }],
672
754
  }));
673
755
  }
674
756
  case 'calorie.product.deprecate': {
@@ -680,7 +762,7 @@ function dispatchInner(key, params, db) {
680
762
  fail(2, String(r.error ?? '废弃失败'));
681
763
  }
682
764
  return out(R('下架食品', 'update', '已下架食品 #' + id + '(' + (r.name ?? '') + ' · ' + SOFT_EXCLUDED_INNER + ')', '下架食品', 'nutrition_products (写库回执)', {
683
- recordId: id, items: [{ id, status: deleteStatus('soft', '已下架'), reason: '' }],
765
+ recordId: id, ids: [id], writtenFields: ['is_deprecated'], items: [{ id, status: deleteStatus('soft', '已下架'), reason: '' }],
684
766
  }));
685
767
  }
686
768
  case 'calorie.profile.set':
@@ -713,7 +795,7 @@ function dispatchInner(key, params, db) {
713
795
  const r = updateProfile(db, picked);
714
796
  const after = r.after;
715
797
  return out(R(scene, 'update', '已' + scene + '(身高 ' + (after.height_cm ?? '—') + ' · 年龄 ' + (after.age ?? '—') + ' · 活动量 ' + (after.activity_level ?? '—') + ')', wake, 'user_profile (写库回执)', {
716
- recordId: 1, noChange: r.changed.length === 0,
798
+ recordId: 1, ids: [1], idSource: 'singleton', writtenFields: Object.keys(picked), noChange: r.changed.length === 0,
717
799
  }));
718
800
  }
719
801
  case 'calorie.profile.activity': {
@@ -722,7 +804,7 @@ function dispatchInner(key, params, db) {
722
804
  fail(2, '缺参数 activityLevel');
723
805
  const r = setActivityLevel(db, level);
724
806
  return out(R('设活动量', 'update', '已设活动量:' + (r.before ?? '—') + '→' + r.after, '设活动量', 'user_profile (写库回执)', {
725
- recordId: 1, noChange: r.before === r.after,
807
+ recordId: 1, ids: [1], idSource: 'singleton', writtenFields: ['activityLevel'], noChange: r.before === r.after,
726
808
  }));
727
809
  }
728
810
  case 'calorie.goal.set': {
@@ -743,7 +825,7 @@ function dispatchInner(key, params, db) {
743
825
  const r = setNutritionGoal(db, { calorie, protein, carbs, fat, water });
744
826
  const tail = r.consistent ? ' · 宏量自洽' : ' · ⚠宏量换算差 ' + r.diffKcal + ' 卡(>50 建议复核)';
745
827
  return out(R('定营养目标', had ? 'update' : 'create', '已定营养目标:' + r.calorieGoal + ' 卡·蛋白 ' + r.proteinGoal + '·碳水 ' + r.carbsGoal + '·脂肪 ' + r.fatGoal + (r.waterGoal === null ? '' : '·饮水 ' + r.waterGoal) + tail, '定营养目标', 'daily_goal (写库回执)', {
746
- recordId: 1,
828
+ recordId: 1, ids: [1], idSource: 'singleton', writtenFields: goalSetWrittenFields(water !== undefined),
747
829
  }));
748
830
  }
749
831
  case 'calorie.goal.water': {
@@ -752,7 +834,7 @@ function dispatchInner(key, params, db) {
752
834
  throw new CalorieRenderError('missing-data', '尚无营养目标行(先定营养目标)');
753
835
  const r = updateWaterGoal(db, water);
754
836
  return out(R('定饮水目标', 'update', '已定饮水目标:' + (r.oldWaterGoal ?? '—') + '→' + r.newWaterGoal + ' ml', '定饮水目标', 'daily_goal (写库回执)', {
755
- recordId: 1, noChange: r.oldWaterGoal === r.newWaterGoal,
837
+ recordId: 1, ids: [1], idSource: 'singleton', writtenFields: ['water'], noChange: r.oldWaterGoal === r.newWaterGoal,
756
838
  }));
757
839
  }
758
840
  case 'calorie.goal.weight': {
@@ -763,19 +845,20 @@ function dispatchInner(key, params, db) {
763
845
  kg, deadline: params['deadline'], startKg: params['startKg'], startDate: params['startDate'],
764
846
  });
765
847
  return out(R('定体重目标', 'update', '已定体重目标 ' + r.weightGoal + ' kg' + (r.deadline ? '(截止 ' + r.deadline + ')' : '') + (r.startKg !== null ? ' · 起点 ' + r.startKg + ' kg' : ''), '定体重目标', 'daily_goal (写库回执)', {
766
- recordId: 1,
848
+ recordId: 1, ids: [1], idSource: 'singleton',
849
+ writtenFields: provided(params, ['kg', 'deadline', 'startKg', 'startDate']),
767
850
  }));
768
851
  }
769
852
  case 'calorie.goal.pause': {
770
853
  const r = pauseAllGoals(db);
771
854
  return out(R('暂停所有目标', 'update', '已暂停所有目标(记录照常,仅目标暂停)', '暂停所有目标', 'daily_goal (写库回执)', {
772
- recordId: r.id,
855
+ recordId: r.id, ids: [r.id], idSource: 'singleton', writtenFields: ['goal_paused'],
773
856
  }));
774
857
  }
775
858
  case 'calorie.goal.resume': {
776
859
  const r = resumeAllGoals(db);
777
860
  return out(R('重启所有目标', 'update', '已重启所有目标(恢复正常)', '重启所有目标', 'daily_goal (写库回执)', {
778
- recordId: r.id,
861
+ recordId: r.id, ids: [r.id], idSource: 'singleton', writtenFields: ['goal_paused'],
779
862
  }));
780
863
  }
781
864
  case 'calorie.body.composition-add': {
@@ -796,14 +879,15 @@ function dispatchInner(key, params, db) {
796
879
  const r = addComposition(db, input);
797
880
  const label = SOURCE_LABELS[source] ?? source;
798
881
  return out(R('记体脂', 'create', '已记体脂:' + date + ' ' + label + ' ' + r.bodyFatPct + '%', '记体脂', 'body_composition (写库回执)', {
799
- recordId: r.id, items: [{ id: r.id, date, status: '成功', reason: '', detail: r.bodyFatPct + '%' }],
882
+ recordId: r.id, ids: [r.id], writtenFields: definedKeys(input),
883
+ items: [{ id: r.id, date, status: '成功', reason: '', detail: r.bodyFatPct + '%' }],
800
884
  }));
801
885
  }
802
886
  case 'calorie.body.composition-remove': {
803
887
  const id = needId(params);
804
888
  deleteComposition(db, id);
805
889
  return out(R('删体脂', 'delete', '已删除体脂记录 #' + id + SOFT_EXCLUDED, '删体脂', 'body_composition (写库回执)', {
806
- recordId: id, items: [{ id, status: deleteStatus('soft'), reason: '' }],
890
+ recordId: id, ids: [id], writtenFields: ['is_deprecated'], items: [{ id, status: deleteStatus('soft'), reason: '' }],
807
891
  }));
808
892
  }
809
893
  case 'calorie.body.measure-add': {
@@ -825,14 +909,15 @@ function dispatchInner(key, params, db) {
825
909
  return camel + ' ' + String(input[f]);
826
910
  }).join('、');
827
911
  return out(R('记围度', 'create', '已记围度:' + date + '(' + filledCn + ')', '记围度', 'body_measurements (写库回执)', {
828
- recordId: r.id, items: [{ id: r.id, date, status: '成功', reason: '', detail: filledCn }],
912
+ recordId: r.id, ids: [r.id], writtenFields: measureCliNames(definedKeys(input)),
913
+ items: [{ id: r.id, date, status: '成功', reason: '', detail: filledCn }],
829
914
  }));
830
915
  }
831
916
  case 'calorie.body.measure-remove': {
832
917
  const id = needId(params);
833
918
  deleteMeasurement(db, id);
834
919
  return out(R('删围度', 'delete', '已删除围度记录 #' + id + SOFT_EXCLUDED, '删围度', 'body_measurements (写库回执)', {
835
- recordId: id, items: [{ id, status: deleteStatus('soft'), reason: '' }],
920
+ recordId: id, ids: [id], writtenFields: ['is_deprecated'], items: [{ id, status: deleteStatus('soft'), reason: '' }],
836
921
  }));
837
922
  }
838
923
  default: