@chronicle.app/foodnoms 0.4.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
|
@@ -10,8 +10,8 @@ Records are food entries, newest first, joined to their meal slot. `since`/`unti
|
|
|
10
10
|
|
|
11
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,
|
|
@@ -111,8 +130,8 @@ export class FoodNomsExtractor extends SqliteExtractor {
|
|
|
111
130
|
END as mealSortIndex
|
|
112
131
|
FROM foodEntryRecord f
|
|
113
132
|
LEFT JOIN mealTypeRecord m ON f.mealTypeID = m.mealTypeID
|
|
114
|
-
WHERE ${
|
|
115
|
-
ORDER BY
|
|
133
|
+
WHERE ${where}
|
|
134
|
+
ORDER BY ${EATEN_AT} DESC
|
|
116
135
|
${limit === null ? '' : 'LIMIT ?'}
|
|
117
136
|
`;
|
|
118
137
|
const rows = this.db.prepare(query).all(...values);
|
|
@@ -136,6 +155,7 @@ export class FoodNomsExtractor extends SqliteExtractor {
|
|
|
136
155
|
entryID: this.hexToUuid(row.entryID),
|
|
137
156
|
nutrients,
|
|
138
157
|
date: row.date ? row.date + 'Z' : null,
|
|
158
|
+
eatenAt: row.eatenAt ? row.eatenAt + 'Z' : null,
|
|
139
159
|
dateCreated: row.dateCreated ? row.dateCreated + 'Z' : null,
|
|
140
160
|
dateLastUpdated: row.dateLastUpdated ? row.dateLastUpdated + 'Z' : null,
|
|
141
161
|
};
|
|
@@ -162,4 +182,32 @@ export class FoodNomsExtractor extends SqliteExtractor {
|
|
|
162
182
|
});
|
|
163
183
|
}
|
|
164
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
|
+
}
|
|
165
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: new Date(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,
|
|
@@ -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
|
}
|