@chronicle.app/foodnoms 0.3.0 → 0.5.0

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @chronicle.app/foodnoms
2
2
 
3
- Read the FoodNoms app's SQLite database with `FoodNomsExtractor`, then emit one `EatAction` or `DrankAction` per logged food entry with `FoodNomsTransformer`. `input` defaults to `~/Library/Containers/com.algebraiclabs.foodnoms/Data/Documents/db.db`; reading it needs Full Disk Access. The connection is read-only via built-in `node:sqlite`.
3
+ Read the FoodNoms app's SQLite database with `FoodNomsExtractor`, then emit one `EatAction` or `DrinkAction` per logged food entry with `FoodNomsTransformer`. `input` defaults to `~/Library/Containers/com.algebraiclabs.foodnoms/Data/Documents/db.db`; reading it needs Full Disk Access. The connection is read-only via built-in `node:sqlite`.
4
4
 
5
5
  ```sh
6
6
  chronicle extract foodnoms --limit 0
@@ -8,10 +8,10 @@ chronicle extract foodnoms --limit 0
8
8
 
9
9
  Records are food entries, newest first, joined to their meal slot. `since`/`until` are inclusive bounds on the entry date, and `limit: 0` means unlimited. FoodNoms stores dates as UTC text; the extractor adds a `Z` and turns the entry's blob ID into a dashed UUID.
10
10
 
11
- Each action happens at the entry's `timestamp` and is keyed by the entry's ID without its type. FoodNoms has no food-or-drink flag, so an entry whose name contains a common drink word becomes a `DrankAction`, and anything else an `EatAction`. Keeping the type out of the key lets that guess change without changing the action's identity.
11
+ Each action happens at the entry's `timestamp` and is keyed by the entry's ID without its type. FoodNoms has no food-or-drink flag, so an entry whose name contains a common drink word becomes a `DrinkAction`, and anything else an `EatAction`. Keeping the type out of the key lets that guess change without changing the action's identity.
12
12
 
13
- The object is a `Meal` keyed by the same entry ID and named after the food. Its `category` is the meal slot: the built-in slots map to Breakfast, Lunch, Dinner and Snacks, a customized slot uses its own name, and an unnamed custom slot has none. The `description` joins the slot, brand, amount and calories.
13
+ The object is a `Meal`, named after the food and keyed by the action it was eaten in (`action.sourceId`), so the entry's ID is the action's alone. Its `category` is the meal slot: the built-in slots map to Breakfast, Lunch, Dinner and Snacks, a customized slot uses its own name, and an unnamed custom slot has none. The `description` joins the slot, brand, amount and calories.
14
14
 
15
- The agent is the iCloud account owner from `@chronicle.app/icloud`. An explicit `account` object (or null) describes exported data without a host account lookup.
15
+ The agent is the iCloud account owner from `@chronicle.app/apple`. An explicit `account` object (or null) describes exported data without a host account lookup.
16
16
 
17
17
  Tests use a synthetic FoodNoms database with an explicit `account`, so they never read the host's FoodNoms data or iCloud account.
@@ -1,4 +1,4 @@
1
- import { Record } from '@chronicle.app/etl';
1
+ import { Record, type Survey, type SurveyOptions } from '@chronicle.app/etl';
2
2
  import { SqliteExtractor } from '@chronicle.app/etl-sqlite';
3
3
  import FoodNomsTransformer from './FoodNomsTransformer.js';
4
4
  export declare class FoodNomsExtractor extends SqliteExtractor<typeof FoodNomsExtractor> {
@@ -24,6 +24,16 @@ export declare class FoodNomsExtractor extends SqliteExtractor<typeof FoodNomsEx
24
24
  * correctly against the column's space-separated text.
25
25
  */
26
26
  private toDbDate;
27
+ /**
28
+ * Entries eaten within since and until, both inclusive. A food in a saved
29
+ * collection (a recipe, a saved meal) is an entry too, but one never eaten,
30
+ * with no time or day, so it's left out.
31
+ */
32
+ private entryFilter;
33
+ /** Whether the entry table has a column, which an older FoodNoms may not. */
34
+ private hasColumn;
27
35
  extract(): AsyncGenerator<Record>;
36
+ /** The items a run reads, each one food or drink logged, and when they were eaten. */
37
+ survey(options?: SurveyOptions): Promise<Survey | null>;
28
38
  }
29
39
  //# sourceMappingURL=FoodNomsExtractor.d.ts.map
@@ -1,5 +1,5 @@
1
- import { SqliteExtractor, timeRangeConditions } from '@chronicle.app/etl-sqlite';
2
- import { getICloudAccount } from '@chronicle.app/icloud';
1
+ import { SqliteExtractor, countsByMonth, monthSql, timeRangeConditions, } from '@chronicle.app/etl-sqlite';
2
+ import { getICloudAccount } from '@chronicle.app/apple';
3
3
  import { z } from 'zod';
4
4
  import FoodNomsTransformer from './FoodNomsTransformer.js';
5
5
  /**
@@ -16,6 +16,11 @@ const BUILTIN_MEAL_TYPES = {
16
16
  '3': 'Dinner',
17
17
  '4': 'Snacks',
18
18
  };
19
+ // When an entry was eaten: its time, or for one logged to a day without a
20
+ // time, the middle of that day in UTC, so it falls on that day in any zone
21
+ // within twelve hours. `day` is the day it was logged to, a Julian day number
22
+ // in FoodNoms, which strftime reads as it does a date.
23
+ const EATEN_AT = `COALESCE(f.date, strftime('%Y-%m-%d 12:00:00.000', f.day))`;
19
24
  export class FoodNomsExtractor extends SqliteExtractor {
20
25
  static source = 'foodnoms';
21
26
  static strategy = 'app-db';
@@ -60,19 +65,32 @@ export class FoodNomsExtractor extends SqliteExtractor {
60
65
  toDbDate(date) {
61
66
  return date.toISOString().replace('T', ' ').replace('Z', '');
62
67
  }
63
- async *extract() {
64
- if (!this.db) {
65
- throw new Error('Database not initialized');
66
- }
67
- const conditions = [`1=1`];
68
- const values = [];
69
- const range = timeRangeConditions('f.date', this.config, {
68
+ /**
69
+ * Entries eaten within since and until, both inclusive. A food in a saved
70
+ * collection (a recipe, a saved meal) is an entry too, but one never eaten,
71
+ * with no time or day, so it's left out.
72
+ */
73
+ entryFilter() {
74
+ const range = timeRangeConditions(EATEN_AT, this.config, {
70
75
  convert: date => this.toDbDate(date),
71
76
  sinceOp: '>=',
72
77
  untilOp: '<=',
73
78
  });
74
- conditions.push(...range.conditions);
75
- values.push(...range.values);
79
+ const collected = this.hasColumn('collectionEditID') ? ['f.collectionEditID IS NULL'] : [];
80
+ return {
81
+ where: [`1=1`, ...collected, ...range.conditions].join(' AND '),
82
+ values: range.values,
83
+ };
84
+ }
85
+ /** Whether the entry table has a column, which an older FoodNoms may not. */
86
+ hasColumn(name) {
87
+ return Boolean(this.db.prepare(`SELECT 1 FROM pragma_table_info('foodEntryRecord') WHERE name = ?`).get(name));
88
+ }
89
+ async *extract() {
90
+ if (!this.db) {
91
+ throw new Error('Database not initialized');
92
+ }
93
+ const { where, values } = this.entryFilter();
76
94
  const limit = this.getEffectiveLimit();
77
95
  if (limit !== null) {
78
96
  values.push(limit);
@@ -82,6 +100,7 @@ export class FoodNomsExtractor extends SqliteExtractor {
82
100
  f.id,
83
101
  hex(f.entryID) as entryID,
84
102
  f.date,
103
+ ${EATEN_AT} as eatenAt,
85
104
  f.dateCreated,
86
105
  f.dateLastUpdated,
87
106
  f.tzID,
@@ -102,11 +121,17 @@ export class FoodNomsExtractor extends SqliteExtractor {
102
121
  m.name as mealTypeName,
103
122
  m.timeRangeStart as mealTimeRangeStart,
104
123
  m.timeRangeEnd as mealTimeRangeEnd,
105
- m.sortIndex as mealSortIndex
124
+ -- FoodNoms can store sortIndex near Int64.min (e.g. -9223372036854775805),
125
+ -- which node:sqlite refuses to read as a number, so out-of-range values
126
+ -- come through as text.
127
+ CASE
128
+ WHEN m.sortIndex BETWEEN -9007199254740991 AND 9007199254740991 THEN m.sortIndex
129
+ ELSE CAST(m.sortIndex AS TEXT)
130
+ END as mealSortIndex
106
131
  FROM foodEntryRecord f
107
132
  LEFT JOIN mealTypeRecord m ON f.mealTypeID = m.mealTypeID
108
- WHERE ${conditions.join(' AND ')}
109
- ORDER BY f.date DESC
133
+ WHERE ${where}
134
+ ORDER BY ${EATEN_AT} DESC
110
135
  ${limit === null ? '' : 'LIMIT ?'}
111
136
  `;
112
137
  const rows = this.db.prepare(query).all(...values);
@@ -130,6 +155,7 @@ export class FoodNomsExtractor extends SqliteExtractor {
130
155
  entryID: this.hexToUuid(row.entryID),
131
156
  nutrients,
132
157
  date: row.date ? row.date + 'Z' : null,
158
+ eatenAt: row.eatenAt ? row.eatenAt + 'Z' : null,
133
159
  dateCreated: row.dateCreated ? row.dateCreated + 'Z' : null,
134
160
  dateLastUpdated: row.dateLastUpdated ? row.dateLastUpdated + 'Z' : null,
135
161
  };
@@ -156,4 +182,32 @@ export class FoodNomsExtractor extends SqliteExtractor {
156
182
  });
157
183
  }
158
184
  }
185
+ /** The items a run reads, each one food or drink logged, and when they were eaten. */
186
+ async survey(options = {}) {
187
+ if (!this.db)
188
+ return null;
189
+ const { where, values } = this.entryFilter();
190
+ const from = `FROM foodEntryRecord f
191
+ LEFT JOIN mealTypeRecord m ON f.mealTypeID = m.mealTypeID
192
+ WHERE ${where}`;
193
+ const row = this.db
194
+ .prepare(`SELECT COUNT(*) AS n, MIN(${EATEN_AT}) AS first, MAX(${EATEN_AT}) AS last ${from}`)
195
+ .get(...values);
196
+ // strftime reads the naive date strings as UTC too.
197
+ const month = monthSql(`CAST(strftime('%s', ${EATEN_AT}) AS INTEGER)`);
198
+ const byMonth = options.byMonth &&
199
+ countsByMonth(this.db
200
+ .prepare(`SELECT ${month} AS month, COUNT(*) AS n ${from} GROUP BY month`)
201
+ .all(...values));
202
+ // Naive UTC date strings, read as the UTC instants they are, as extract() does.
203
+ return {
204
+ meals: {
205
+ count: row.n,
206
+ label: 'items',
207
+ ...(row.first !== null && { earliest: new Date(row.first + 'Z') }),
208
+ ...(row.last !== null && { latest: new Date(row.last + 'Z') }),
209
+ ...(byMonth && { byMonth }),
210
+ },
211
+ };
212
+ }
159
213
  }
@@ -1,5 +1,5 @@
1
1
  import { ChronicleTransformer } from '@chronicle.app/etl';
2
- import { buildICloudPersonSchema } from '@chronicle.app/icloud';
2
+ import { buildICloudPersonSchema } from '@chronicle.app/apple';
3
3
  /**
4
4
  * Conservative seed list of common beverages, matched whole-word against an
5
5
  * entry's name. FoodNoms records NO food-vs-drink flag — verified there is no
@@ -69,7 +69,8 @@ export default class FoodNomsTransformer extends ChronicleTransformer {
69
69
  const meal = this.buildMeal(record.data, record.context);
70
70
  const user = await this.buildUser(record.context.account);
71
71
  // One consume action per logged food entry. FoodNoms gives a single point in
72
- // time (`date`), not a span, so it lands on `timestamp` rather than endTime.
72
+ // time (`date`), not a span, so it lands on `timestamp` rather than endTime:
73
+ // when it was eaten, which for an entry logged to a day alone is that day's.
73
74
  //
74
75
  // Identity is the consumption of this entry — keyed on the real entryID — and
75
76
  // `@type` is deliberately left OUT of the @key: eat-vs-drink is an inferred,
@@ -77,7 +78,7 @@ export default class FoodNomsTransformer extends ChronicleTransformer {
77
78
  // action. This also keeps FoodNoms consumptions shaped like the historical
78
79
  // `ate X` / `drank X` log so the two can sit on one timeline.
79
80
  const shared = {
80
- timestamp: record.data.date,
81
+ timestamp: new Date(record.data.eatenAt ?? record.data.date),
81
82
  '@key': ['source', 'sourceId'],
82
83
  source: 'foodnoms',
83
84
  sourceId: record.data.entryID,
@@ -85,7 +86,7 @@ export default class FoodNomsTransformer extends ChronicleTransformer {
85
86
  object: meal,
86
87
  };
87
88
  const action = this.isDrink(record.data.name)
88
- ? { '@type': 'DrankAction', ...shared }
89
+ ? { '@type': 'DrinkAction', ...shared }
89
90
  : { '@type': 'EatAction', ...shared };
90
91
  return [action];
91
92
  }
@@ -107,16 +108,17 @@ export default class FoodNomsTransformer extends ChronicleTransformer {
107
108
  return buildICloudPersonSchema(account);
108
109
  }
109
110
  buildMeal(data, context) {
110
- // Key on `entryID`, the per-log CloudKit UUID FoodNoms assigns each entry.
111
- // `foodID` looks like a shared food reference but for the dominant namespaces
112
- // (`openai:` AI lookups, `local:` manual foods) it is a fresh UUID minted per
113
- // entry, so it neither dedupes repeats nor is a real cross-log identity —
114
- // using it would elevate a synthetic id to entity identity.
111
+ // Keyed by the consumption it was eaten in (`action.sourceId`, the entry's
112
+ // `entryID`, the per-log CloudKit UUID FoodNoms assigns each entry), which
113
+ // has that ID as its own. `foodID` looks like a shared food reference but
114
+ // for the dominant namespaces (`openai:` AI lookups, `local:` manual foods)
115
+ // it is a fresh UUID minted per entry, so it neither dedupes repeats nor is
116
+ // a real cross-log identity — using it would elevate a synthetic id to
117
+ // entity identity.
115
118
  const meal = {
116
119
  '@type': 'Meal',
117
- '@key': ['@type', 'source', 'sourceId'],
120
+ '@key': ['@type', 'source', 'action.sourceId'],
118
121
  source: 'foodnoms',
119
- sourceId: data.entryID,
120
122
  name: data.name,
121
123
  };
122
124
  // Classify the meal slot (Breakfast/Lunch/Dinner/Snacks) when known.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chronicle.app/foodnoms",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "files": [
@@ -22,9 +22,9 @@
22
22
  "type": "module",
23
23
  "description": "Chronicle plugin for FoodNoms meal tracking data",
24
24
  "devDependencies": {
25
- "@chronicle.app/etl": "0.3.0",
26
- "@chronicle.app/schema": "0.3.0",
27
- "@chronicle.app/tsconfig": "0.3.0",
25
+ "@chronicle.app/etl": "0.5.0",
26
+ "@chronicle.app/schema": "0.5.0",
27
+ "@chronicle.app/tsconfig": "0.5.0",
28
28
  "@types/node": "^22.10.1",
29
29
  "typescript": "^5.7.2"
30
30
  },
@@ -39,8 +39,8 @@
39
39
  "registry": "https://registry.npmjs.org/"
40
40
  },
41
41
  "dependencies": {
42
- "@chronicle.app/etl-sqlite": "0.3.0",
43
- "@chronicle.app/icloud": "0.3.0",
42
+ "@chronicle.app/etl-sqlite": "0.5.0",
43
+ "@chronicle.app/apple": "0.5.0",
44
44
  "zod": "^3.24.1"
45
45
  },
46
46
  "peerDependencies": {
@@ -66,11 +66,19 @@
66
66
  "recordTypes": [
67
67
  "meals"
68
68
  ],
69
- "default": true
69
+ "default": true,
70
+ "locations": [
71
+ {
72
+ "path": "~/Library/Containers/com.algebraiclabs.foodnoms/Data/Documents/db.db"
73
+ }
74
+ ]
70
75
  }
71
76
  },
72
77
  "platforms": [
73
78
  "darwin"
79
+ ],
80
+ "requires": [
81
+ "full-disk-access"
74
82
  ]
75
83
  }
76
84
  }