@chronicle.app/foodnoms 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Louis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # @chronicle.app/foodnoms
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`.
4
+
5
+ ```sh
6
+ chronicle extract foodnoms --limit 0
7
+ ```
8
+
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
+
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.
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.
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.
16
+
17
+ Tests use a synthetic FoodNoms database with an explicit `account`, so they never read the host's FoodNoms data or iCloud account.
@@ -0,0 +1,29 @@
1
+ import { Record } from '@chronicle.app/etl';
2
+ import { SqliteExtractor } from '@chronicle.app/etl-sqlite';
3
+ import FoodNomsTransformer from './FoodNomsTransformer.js';
4
+ export declare class FoodNomsExtractor extends SqliteExtractor<typeof FoodNomsExtractor> {
5
+ static source: string;
6
+ static strategy: string;
7
+ static description: string;
8
+ static recordTypes: string[];
9
+ static default: boolean;
10
+ static schema: any;
11
+ static defaultTransformer: typeof FoodNomsTransformer;
12
+ /**
13
+ * `entryID` is a 16-byte CloudKit record UUID stored as a BLOB, so it comes
14
+ * out of SQLite as `hex(entryID)` (32 hex chars) and is reshaped into the
15
+ * canonical dashed UUID. (`foodID`/`versionID`/`mealTypeID` are plain TEXT and
16
+ * must NOT go through here — they are already string identifiers.)
17
+ */
18
+ private hexToUuid;
19
+ /**
20
+ * FoodNoms stores DATETIME columns as naive `YYYY-MM-DD HH:MM:SS.sss` strings
21
+ * in UTC (CloudKit sync semantics; `tzID` only records the display zone). The
22
+ * `since`/`until` config values are real Dates, so format them to that same
23
+ * shape — a raw `toISOString()` would carry a `T`/`Z` and never compare
24
+ * correctly against the column's space-separated text.
25
+ */
26
+ private toDbDate;
27
+ extract(): AsyncGenerator<Record>;
28
+ }
29
+ //# sourceMappingURL=FoodNomsExtractor.d.ts.map
@@ -0,0 +1,159 @@
1
+ import { SqliteExtractor, timeRangeConditions } from '@chronicle.app/etl-sqlite';
2
+ import { getICloudAccount } from '@chronicle.app/icloud';
3
+ import { z } from 'zod';
4
+ import FoodNomsTransformer from './FoodNomsTransformer.js';
5
+ /**
6
+ * FoodNoms' built-in meal slots. Their names aren't stored in the database (only
7
+ * user-customized slots get a mealTypeRecord row), so these defaults map the
8
+ * stock mealTypeID values. Confirmed against this store two ways: id 3 is the
9
+ * one row that exists and carries an 18:00–22:15 time range (Dinner), and the
10
+ * per-id entry times line up (id 1 ≈ 09:00, 2 ≈ 12:00, 3 ≈ 19:00, 4 spread).
11
+ * Custom ids (e.g. 6, 7) have no name anywhere and stay unmapped.
12
+ */
13
+ const BUILTIN_MEAL_TYPES = {
14
+ '1': 'Breakfast',
15
+ '2': 'Lunch',
16
+ '3': 'Dinner',
17
+ '4': 'Snacks',
18
+ };
19
+ export class FoodNomsExtractor extends SqliteExtractor {
20
+ static source = 'foodnoms';
21
+ static strategy = 'app-db';
22
+ static description = 'Logged meals';
23
+ static recordTypes = ['meals'];
24
+ static default = true;
25
+ static schema = SqliteExtractor.schema.omit({ input: true }).extend({
26
+ input: z
27
+ .string()
28
+ .describe('Path to the FoodNoms SQLite database file')
29
+ .default(`${process.env.HOME}/Library/Containers/com.algebraiclabs.foodnoms/Data/Documents/db.db`),
30
+ account: z
31
+ .object({
32
+ accountID: z.string(),
33
+ email: z.string(),
34
+ displayName: z.string(),
35
+ dsid: z.string().optional(),
36
+ })
37
+ .nullable()
38
+ .optional(),
39
+ }); // Type assertion to override constraint
40
+ static defaultTransformer = FoodNomsTransformer;
41
+ /**
42
+ * `entryID` is a 16-byte CloudKit record UUID stored as a BLOB, so it comes
43
+ * out of SQLite as `hex(entryID)` (32 hex chars) and is reshaped into the
44
+ * canonical dashed UUID. (`foodID`/`versionID`/`mealTypeID` are plain TEXT and
45
+ * must NOT go through here — they are already string identifiers.)
46
+ */
47
+ hexToUuid(hex) {
48
+ if (!hex)
49
+ return null;
50
+ const cleaned = hex.toLowerCase();
51
+ return `${cleaned.slice(0, 8)}-${cleaned.slice(8, 12)}-${cleaned.slice(12, 16)}-${cleaned.slice(16, 20)}-${cleaned.slice(20, 32)}`;
52
+ }
53
+ /**
54
+ * FoodNoms stores DATETIME columns as naive `YYYY-MM-DD HH:MM:SS.sss` strings
55
+ * in UTC (CloudKit sync semantics; `tzID` only records the display zone). The
56
+ * `since`/`until` config values are real Dates, so format them to that same
57
+ * shape — a raw `toISOString()` would carry a `T`/`Z` and never compare
58
+ * correctly against the column's space-separated text.
59
+ */
60
+ toDbDate(date) {
61
+ return date.toISOString().replace('T', ' ').replace('Z', '');
62
+ }
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, {
70
+ convert: date => this.toDbDate(date),
71
+ sinceOp: '>=',
72
+ untilOp: '<=',
73
+ });
74
+ conditions.push(...range.conditions);
75
+ values.push(...range.values);
76
+ const limit = this.getEffectiveLimit();
77
+ if (limit !== null) {
78
+ values.push(limit);
79
+ }
80
+ const query = `
81
+ SELECT
82
+ f.id,
83
+ hex(f.entryID) as entryID,
84
+ f.date,
85
+ f.dateCreated,
86
+ f.dateLastUpdated,
87
+ f.tzID,
88
+ f.quantity,
89
+ f.calories,
90
+ f.foodID,
91
+ f.versionID,
92
+ f.barcode,
93
+ f.source,
94
+ f.secondarySource,
95
+ f.name,
96
+ f.brandOwner,
97
+ f.baseUnit,
98
+ f.baseAmount,
99
+ f.nutrients,
100
+ f.mealTypeID,
101
+ f.day,
102
+ m.name as mealTypeName,
103
+ m.timeRangeStart as mealTimeRangeStart,
104
+ m.timeRangeEnd as mealTimeRangeEnd,
105
+ m.sortIndex as mealSortIndex
106
+ FROM foodEntryRecord f
107
+ LEFT JOIN mealTypeRecord m ON f.mealTypeID = m.mealTypeID
108
+ WHERE ${conditions.join(' AND ')}
109
+ ORDER BY f.date DESC
110
+ ${limit === null ? '' : 'LIMIT ?'}
111
+ `;
112
+ const rows = this.db.prepare(query).all(...values);
113
+ const account = this.config.account === undefined ? await getICloudAccount() : this.config.account;
114
+ for (const row of rows) {
115
+ // Parse nutrients JSON if present
116
+ let nutrients = null;
117
+ if (row.nutrients) {
118
+ try {
119
+ nutrients = JSON.parse(row.nutrients);
120
+ }
121
+ catch (error) {
122
+ this.logger.warn(`Failed to parse nutrients JSON: ${error}`);
123
+ }
124
+ }
125
+ // entryID is the only blob UUID; foodID/versionID/mealTypeID are plain
126
+ // text and pass through untouched. The naive UTC date strings get a 'Z'
127
+ // so `new Date(...)` reads them as the UTC instants they are.
128
+ const processedRow = {
129
+ ...row,
130
+ entryID: this.hexToUuid(row.entryID),
131
+ nutrients,
132
+ date: row.date ? row.date + 'Z' : null,
133
+ dateCreated: row.dateCreated ? row.dateCreated + 'Z' : null,
134
+ dateLastUpdated: row.dateLastUpdated ? row.dateLastUpdated + 'Z' : null,
135
+ };
136
+ // Resolve the meal slot's name. mealTypeRecord only holds rows the user
137
+ // has customized (the built-ins live in the app), so the join is usually
138
+ // empty and we fall back to FoodNoms' built-in id→name defaults. A real
139
+ // (renamed/custom) name from the join always wins; unknown custom ids
140
+ // resolve to null rather than a synthesized label.
141
+ const mealTypeId = row.mealTypeID;
142
+ const joinName = row.mealTypeName?.trim() || null;
143
+ const mealTypeName = joinName ?? BUILTIN_MEAL_TYPES[mealTypeId] ?? null;
144
+ const mealType = mealTypeId
145
+ ? {
146
+ id: mealTypeId,
147
+ name: mealTypeName,
148
+ timeRangeStart: row.mealTimeRangeStart,
149
+ timeRangeEnd: row.mealTimeRangeEnd,
150
+ sortIndex: row.mealSortIndex,
151
+ }
152
+ : null;
153
+ yield this.createRecord(processedRow, {
154
+ mealType,
155
+ account,
156
+ });
157
+ }
158
+ }
159
+ }
@@ -0,0 +1,16 @@
1
+ import { ChronicleTransformer, Record } from '@chronicle.app/etl';
2
+ import { ActionAndChildren } from '@chronicle.app/schema';
3
+ export default class FoodNomsTransformer extends ChronicleTransformer {
4
+ transform(record: Record): Promise<ActionAndChildren[]>;
5
+ private buildMealActions;
6
+ /** Coarse, revisable eat-vs-drink inference; see DRINK_WORDS. */
7
+ private isDrink;
8
+ /**
9
+ * FoodNoms is a local single-user app, and the database carries no identity of
10
+ * its own, so the eater is the device owner — the same iCloud Person the other
11
+ * macOS plugins resolve, which lets "me" merge across sources.
12
+ */
13
+ private buildUser;
14
+ private buildMeal;
15
+ }
16
+ //# sourceMappingURL=FoodNomsTransformer.d.ts.map
@@ -0,0 +1,148 @@
1
+ import { ChronicleTransformer } from '@chronicle.app/etl';
2
+ import { buildICloudPersonSchema } from '@chronicle.app/icloud';
3
+ /**
4
+ * Conservative seed list of common beverages, matched whole-word against an
5
+ * entry's name. FoodNoms records NO food-vs-drink flag — verified there is no
6
+ * category column anywhere in the database — so eat-vs-drink can only be
7
+ * inferred. This list is intentionally coarse and high-precision (it will miss
8
+ * non-English/brand-name drinks and may catch e.g. "coffee cake"); it is a
9
+ * placeholder, not truth. The authoritative drink vocabulary is meant to be the
10
+ * user's own historical `drank X` log: when that data is imported, the set of
11
+ * objects it labels as drunk should supersede and extend this seed. The action
12
+ * is keyed without `@type` (see buildMealActions) precisely so this label can be
13
+ * re-derived without changing a consumption's identity.
14
+ */
15
+ const DRINK_WORDS = new Set([
16
+ 'water',
17
+ 'coffee',
18
+ 'espresso',
19
+ 'latte',
20
+ 'cappuccino',
21
+ 'americano',
22
+ 'macchiato',
23
+ 'mocha',
24
+ 'frappuccino',
25
+ 'tea',
26
+ 'chai',
27
+ 'matcha',
28
+ 'juice',
29
+ 'soda',
30
+ 'pop',
31
+ 'cola',
32
+ 'lemonade',
33
+ 'kombucha',
34
+ 'smoothie',
35
+ 'milkshake',
36
+ 'milk',
37
+ 'beer',
38
+ 'lager',
39
+ 'ale',
40
+ 'ipa',
41
+ 'stout',
42
+ 'cider',
43
+ 'wine',
44
+ 'prosecco',
45
+ 'champagne',
46
+ 'sake',
47
+ 'cocktail',
48
+ 'margarita',
49
+ 'martini',
50
+ 'mojito',
51
+ 'whiskey',
52
+ 'whisky',
53
+ 'vodka',
54
+ 'gin',
55
+ 'rum',
56
+ 'tequila',
57
+ 'brandy',
58
+ 'liqueur',
59
+ ]);
60
+ export default class FoodNomsTransformer extends ChronicleTransformer {
61
+ async transform(record) {
62
+ const actions = [];
63
+ if (record.extraction.recordType === 'meals') {
64
+ actions.push(...(await this.buildMealActions(record)));
65
+ }
66
+ return actions;
67
+ }
68
+ async buildMealActions(record) {
69
+ const meal = this.buildMeal(record.data, record.context);
70
+ const user = await this.buildUser(record.context.account);
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.
73
+ //
74
+ // Identity is the consumption of this entry — keyed on the real entryID — and
75
+ // `@type` is deliberately left OUT of the @key: eat-vs-drink is an inferred,
76
+ // revisable label (see DRINK_WORDS), so reclassifying must not mint a new
77
+ // action. This also keeps FoodNoms consumptions shaped like the historical
78
+ // `ate X` / `drank X` log so the two can sit on one timeline.
79
+ const shared = {
80
+ timestamp: record.data.date,
81
+ '@key': ['source', 'sourceId'],
82
+ source: 'foodnoms',
83
+ sourceId: record.data.entryID,
84
+ agent: user,
85
+ object: meal,
86
+ };
87
+ const action = this.isDrink(record.data.name)
88
+ ? { '@type': 'DrankAction', ...shared }
89
+ : { '@type': 'EatAction', ...shared };
90
+ return [action];
91
+ }
92
+ /** Coarse, revisable eat-vs-drink inference; see DRINK_WORDS. */
93
+ isDrink(name) {
94
+ if (!name)
95
+ return false;
96
+ return name
97
+ .toLowerCase()
98
+ .split(/[^a-z]+/)
99
+ .some(word => DRINK_WORDS.has(word));
100
+ }
101
+ /**
102
+ * FoodNoms is a local single-user app, and the database carries no identity of
103
+ * its own, so the eater is the device owner — the same iCloud Person the other
104
+ * macOS plugins resolve, which lets "me" merge across sources.
105
+ */
106
+ buildUser(account) {
107
+ return buildICloudPersonSchema(account);
108
+ }
109
+ 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.
115
+ const meal = {
116
+ '@type': 'Meal',
117
+ '@key': ['@type', 'source', 'sourceId'],
118
+ source: 'foodnoms',
119
+ sourceId: data.entryID,
120
+ name: data.name,
121
+ };
122
+ // Classify the meal slot (Breakfast/Lunch/Dinner/Snacks) when known.
123
+ // `category` is the closed-classification characterization property; the slot
124
+ // name is resolved upstream (built-in id→name map, or a custom mealTypeRecord
125
+ // name) and is null for unnamed custom slots, which go uncategorized.
126
+ if (context.mealType?.name) {
127
+ meal.category = [context.mealType.name];
128
+ }
129
+ // Add meal type name and brand to description if available
130
+ const descriptions = [];
131
+ if (context.mealType?.name) {
132
+ descriptions.push(context.mealType.name);
133
+ }
134
+ if (data.brandOwner) {
135
+ descriptions.push(`Brand: ${data.brandOwner}`);
136
+ }
137
+ if (data.quantity && data.baseUnit) {
138
+ descriptions.push(`${data.quantity} ${data.baseUnit}`);
139
+ }
140
+ if (data.calories) {
141
+ descriptions.push(`${data.calories} calories`);
142
+ }
143
+ if (descriptions.length > 0) {
144
+ meal.description = descriptions.join(' - ');
145
+ }
146
+ return meal;
147
+ }
148
+ }
@@ -0,0 +1,3 @@
1
+ export { FoodNomsExtractor } from './connectors/FoodNomsExtractor.js';
2
+ export { default as FoodNomsTransformer } from './connectors/FoodNomsTransformer.js';
3
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { FoodNomsExtractor } from './connectors/FoodNomsExtractor.js';
2
+ export { default as FoodNomsTransformer } from './connectors/FoodNomsTransformer.js';
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@chronicle.app/foodnoms",
3
+ "version": "0.3.0",
4
+ "main": "./dist/index.js",
5
+ "types": "./dist/index.d.ts",
6
+ "files": [
7
+ "dist/**/*.js",
8
+ "dist/**/*.d.ts"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsc -b tsconfig.lib.json",
12
+ "typecheck": "tsc -p tsconfig.json",
13
+ "lint": "eslint src --ext .ts,.js",
14
+ "test": "node --test src/*.test.js"
15
+ },
16
+ "keywords": [
17
+ "chronicle",
18
+ "foodnoms"
19
+ ],
20
+ "author": "Andrew Louis",
21
+ "license": "MIT",
22
+ "type": "module",
23
+ "description": "Chronicle plugin for FoodNoms meal tracking data",
24
+ "devDependencies": {
25
+ "@chronicle.app/etl": "0.3.0",
26
+ "@chronicle.app/schema": "0.3.0",
27
+ "@chronicle.app/tsconfig": "0.3.0",
28
+ "@types/node": "^22.10.1",
29
+ "typescript": "^5.7.2"
30
+ },
31
+ "homepage": "https://chronicle.app",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/chronicle-app/chronicle.git",
35
+ "directory": "plugins/foodnoms"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "registry": "https://registry.npmjs.org/"
40
+ },
41
+ "dependencies": {
42
+ "@chronicle.app/etl-sqlite": "0.3.0",
43
+ "@chronicle.app/icloud": "0.3.0",
44
+ "zod": "^3.24.1"
45
+ },
46
+ "peerDependencies": {
47
+ "@chronicle.app/etl": ">=0.1.0 <1.0.0",
48
+ "@chronicle.app/schema": ">=0.1.0 <1.0.0"
49
+ },
50
+ "engines": {
51
+ "node": ">=22.13.0"
52
+ },
53
+ "exports": {
54
+ ".": {
55
+ "types": "./dist/index.d.ts",
56
+ "import": "./dist/index.js"
57
+ }
58
+ },
59
+ "chronicle": {
60
+ "plugin": true,
61
+ "sources": {
62
+ "foodnoms": {
63
+ "strategies": {
64
+ "app-db": {
65
+ "delivery": "local",
66
+ "recordTypes": [
67
+ "meals"
68
+ ],
69
+ "default": true
70
+ }
71
+ },
72
+ "platforms": [
73
+ "darwin"
74
+ ]
75
+ }
76
+ }
77
+ }
78
+ }