@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 `
|
|
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 `
|
|
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
|
|
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/
|
|
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/
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const range = timeRangeConditions(
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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 ${
|
|
109
|
-
ORDER BY
|
|
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/
|
|
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': '
|
|
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
|
-
//
|
|
111
|
-
// `
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
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
|
+
"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.
|
|
26
|
-
"@chronicle.app/schema": "0.
|
|
27
|
-
"@chronicle.app/tsconfig": "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.
|
|
43
|
-
"@chronicle.app/
|
|
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
|
}
|