@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 +21 -0
- package/README.md +17 -0
- package/dist/connectors/FoodNomsExtractor.d.ts +29 -0
- package/dist/connectors/FoodNomsExtractor.js +159 -0
- package/dist/connectors/FoodNomsTransformer.d.ts +16 -0
- package/dist/connectors/FoodNomsTransformer.js +148 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/package.json +78 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
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
|
+
}
|