tripkit 1.2.0 → 1.4.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.
@@ -0,0 +1,389 @@
1
+ /**
2
+ * TripKit media ingest.
3
+ *
4
+ * Turns a folder of geotagged photos/videos from a completed trip into per-stop
5
+ * `media[]` entries on a trip YAML. Two-step, review-in-the-middle flow:
6
+ *
7
+ * 1) build: scan a folder, read EXIF GPS + timestamp, auto-match each item to
8
+ * the nearest stop within the matching day, and write a review file
9
+ * (`<trip>.media-review.yaml`). Low-confidence / geotag-less / undated
10
+ * items land in an `unmatched:` bucket. This file is the AI-assist /
11
+ * human handoff: edit captions, fix matches, then apply.
12
+ * 2) apply: merge the reviewed items back into the trip YAML's stops[].media[].
13
+ *
14
+ * Dependencies:
15
+ * - exifr (required for build) — EXIF GPS/timestamp extraction.
16
+ * - sharp (optional) — thumbnail generation; skipped gracefully if absent.
17
+ *
18
+ * Exported entry points are driven by convert.js's `media` subcommand.
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const yaml = require('js-yaml');
24
+
25
+ const PHOTO_EXT = new Set(['.jpg', '.jpeg', '.png', '.heic', '.heif', '.webp', '.tif', '.tiff']);
26
+ const VIDEO_EXT = new Set(['.mp4', '.mov', '.m4v', '.avi', '.mkv', '.webm']);
27
+
28
+ // Distance (miles) within which a photo is confidently the same place as a stop.
29
+ const NEAR_MILES = 3;
30
+ // Distance above which we don't trust the auto-match — route to `unmatched` for review.
31
+ const FAR_MILES = 25;
32
+
33
+ const MONTHS = {
34
+ january: 0, february: 1, march: 2, april: 3, may: 4, june: 5,
35
+ july: 6, august: 7, september: 8, october: 9, november: 10, december: 11,
36
+ jan: 0, feb: 1, mar: 2, apr: 3, jun: 5, jul: 6, aug: 7, sep: 8, sept: 8, oct: 9, nov: 10, dec: 11
37
+ };
38
+
39
+ function haversineMiles(lat1, lng1, lat2, lng2) {
40
+ const toRad = (d) => d * Math.PI / 180;
41
+ const R = 3958.8;
42
+ const dLat = toRad(lat2 - lat1);
43
+ const dLng = toRad(lng2 - lng1);
44
+ const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
45
+ return 2 * R * Math.asin(Math.sqrt(a));
46
+ }
47
+
48
+ function extOf(file) { return path.extname(file).toLowerCase(); }
49
+ function mediaType(file) {
50
+ const e = extOf(file);
51
+ if (PHOTO_EXT.has(e)) return 'photo';
52
+ if (VIDEO_EXT.has(e)) return 'video';
53
+ return null;
54
+ }
55
+
56
+ // Recursively collect supported media files under a folder (skips a "thumb" subdir).
57
+ function scanMedia(dir) {
58
+ const out = [];
59
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
60
+ if (entry.name.startsWith('.')) continue;
61
+ const full = path.join(dir, entry.name);
62
+ if (entry.isDirectory()) {
63
+ if (entry.name === 'thumb') continue;
64
+ out.push(...scanMedia(full));
65
+ } else if (mediaType(entry.name)) {
66
+ out.push(full);
67
+ }
68
+ }
69
+ return out.sort();
70
+ }
71
+
72
+ // Parse a day's free-form `date` (e.g. "Saturday, April 6") into {month, day},
73
+ // pulling the year from trip.dates (e.g. "April 4–9, 2026"). Returns a YYYY-MM-DD
74
+ // string or null if it can't be parsed (matching then falls back to GPS-only).
75
+ function dayCalendarDate(dayDateStr, year) {
76
+ if (typeof dayDateStr !== 'string') return null;
77
+ const m = dayDateStr.toLowerCase().match(/([a-z]+)\s+(\d{1,2})/);
78
+ if (!m) return null;
79
+ const month = MONTHS[m[1]];
80
+ const day = parseInt(m[2], 10);
81
+ if (month == null || !Number.isFinite(day)) return null;
82
+ const y = year || new Date().getUTCFullYear();
83
+ return `${y}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
84
+ }
85
+
86
+ function yearFromTrip(trip) {
87
+ const src = (trip && (trip.dates || '')) + '';
88
+ const m = src.match(/(\d{4})/);
89
+ return m ? parseInt(m[1], 10) : null;
90
+ }
91
+
92
+ // Build a lookup of YYYY-MM-DD -> day index, when day dates are parseable.
93
+ function buildDayDateIndex(data) {
94
+ const year = yearFromTrip(data.trip);
95
+ const index = new Map();
96
+ (data.days || []).forEach((d, di) => {
97
+ const cal = dayCalendarDate(d.date, year);
98
+ if (cal) index.set(cal, di);
99
+ });
100
+ return index;
101
+ }
102
+
103
+ function takenDateKey(takenAt) {
104
+ if (!takenAt) return null;
105
+ // takenAt may be a Date (from exifr) or an ISO-ish string.
106
+ const iso = takenAt instanceof Date ? takenAt.toISOString() : String(takenAt);
107
+ const m = iso.match(/(\d{4})-(\d{2})-(\d{2})/);
108
+ return m ? `${m[1]}-${m[2]}-${m[3]}` : null;
109
+ }
110
+
111
+ // Flatten stops across all days into a single list with day context.
112
+ function allStops(data) {
113
+ const stops = [];
114
+ (data.days || []).forEach((d, di) => {
115
+ (d.stops || []).forEach((s, si) => {
116
+ if (Number.isFinite(s.lat) && Number.isFinite(s.lng)) {
117
+ stops.push({ di, si, name: s.name, lat: s.lat, lng: s.lng });
118
+ }
119
+ });
120
+ });
121
+ return stops;
122
+ }
123
+
124
+ // Find the nearest stop to a coordinate, optionally restricted to one day.
125
+ function nearestStop(stops, lat, lng, restrictDi) {
126
+ let best = null;
127
+ for (const s of stops) {
128
+ if (restrictDi != null && s.di !== restrictDi) continue;
129
+ const miles = haversineMiles(lat, lng, s.lat, s.lng);
130
+ if (!best || miles < best.miles) best = { ...s, miles };
131
+ }
132
+ return best;
133
+ }
134
+
135
+ // Try to load exifr; give a clear, actionable error if it's missing.
136
+ function loadExifr() {
137
+ try {
138
+ return require('exifr');
139
+ } catch (e) {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ // Try to load sharp for thumbnails; null means "skip thumbnails gracefully".
145
+ function loadSharp() {
146
+ try {
147
+ return require('sharp');
148
+ } catch (e) {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ async function extractExif(exifr, file) {
154
+ if (mediaType(file) === 'video') {
155
+ // exifr targets images; most video GPS lives in container metadata it can't read.
156
+ // Route videos through review (no GPS/time) rather than guessing.
157
+ return { lat: null, lng: null, takenAt: null };
158
+ }
159
+ try {
160
+ const gps = await exifr.gps(file).catch(() => null);
161
+ let takenAt = null;
162
+ try {
163
+ const meta = await exifr.parse(file, ['DateTimeOriginal', 'CreateDate']);
164
+ takenAt = (meta && (meta.DateTimeOriginal || meta.CreateDate)) || null;
165
+ } catch (_) { /* ignore */ }
166
+ return {
167
+ lat: gps && Number.isFinite(gps.latitude) ? gps.latitude : null,
168
+ lng: gps && Number.isFinite(gps.longitude) ? gps.longitude : null,
169
+ takenAt
170
+ };
171
+ } catch (e) {
172
+ return { lat: null, lng: null, takenAt: null };
173
+ }
174
+ }
175
+
176
+ function isoOrNull(takenAt) {
177
+ if (!takenAt) return null;
178
+ if (takenAt instanceof Date) return takenAt.toISOString().replace(/\.\d{3}Z$/, 'Z');
179
+ return String(takenAt);
180
+ }
181
+
182
+ // Produce a thumbnail next to the media in a `thumb/` subfolder. Returns the
183
+ // thumb path (relative to the media folder root) or null on failure/skip.
184
+ async function makeThumb(sharp, file, mediaRoot) {
185
+ if (!sharp || mediaType(file) !== 'photo') return null;
186
+ try {
187
+ const thumbDir = path.join(mediaRoot, 'thumb');
188
+ fs.mkdirSync(thumbDir, { recursive: true });
189
+ const base = path.basename(file, path.extname(file)) + '.jpg';
190
+ const thumbAbs = path.join(thumbDir, base);
191
+ await sharp(file).rotate().resize(480, 360, { fit: 'inside', withoutEnlargement: true }).jpeg({ quality: 72 }).toFile(thumbAbs);
192
+ return path.join('thumb', base);
193
+ } catch (e) {
194
+ return null;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * BUILD: scan a media folder and emit a review file.
200
+ *
201
+ * @param {string} folder media folder to scan
202
+ * @param {string} tripFile path to the trip YAML (read-only here)
203
+ * @param {object} io { log, warn, c } reporting helpers from convert.js
204
+ * @returns {Promise<string>} path to the written review file
205
+ */
206
+ async function buildReview(folder, tripFile, io) {
207
+ const { log, warn, c } = io;
208
+ const exifr = loadExifr();
209
+ if (!exifr) {
210
+ throw new Error('Missing dependency "exifr" (required for media ingest). Run: npm install exifr');
211
+ }
212
+ const sharp = loadSharp();
213
+ if (!sharp) {
214
+ warn('sharp not installed — skipping thumbnail generation (full-res images will be used). Run "npm install sharp" to enable thumbnails.');
215
+ }
216
+
217
+ if (!fs.existsSync(folder) || !fs.statSync(folder).isDirectory()) {
218
+ throw new Error(`Media folder not found: ${folder}`);
219
+ }
220
+ const data = yaml.load(fs.readFileSync(tripFile, 'utf8'));
221
+ if (!data || !Array.isArray(data.days)) {
222
+ throw new Error(`Trip file has no days[]: ${tripFile}`);
223
+ }
224
+
225
+ const files = scanMedia(folder);
226
+ if (files.length === 0) {
227
+ throw new Error(`No supported media files found in ${folder}`);
228
+ }
229
+ log(`Scanning ${files.length} media file${files.length === 1 ? '' : 's'} in ${folder}…`);
230
+
231
+ const stops = allStops(data);
232
+ const dayIndex = buildDayDateIndex(data);
233
+ // src paths in the YAML are stored relative to the trip file's directory.
234
+ const tripDir = path.dirname(path.resolve(tripFile));
235
+
236
+ const matched = []; // { di, si, item }
237
+ const unmatched = [];
238
+ let withGps = 0, withTime = 0;
239
+
240
+ for (const file of files) {
241
+ const { lat, lng, takenAt } = await extractExif(exifr, file);
242
+ if (Number.isFinite(lat) && Number.isFinite(lng)) withGps++;
243
+ if (takenAt) withTime++;
244
+
245
+ const thumbRel = await makeThumb(sharp, file, folder);
246
+ const srcRel = path.relative(tripDir, path.resolve(file)) || path.basename(file);
247
+ const thumbSrc = thumbRel ? path.relative(tripDir, path.resolve(path.join(folder, thumbRel))) : null;
248
+
249
+ const item = {
250
+ src: srcRel.split(path.sep).join('/'),
251
+ type: mediaType(file),
252
+ caption: '',
253
+ };
254
+ if (thumbSrc) item.thumb = thumbSrc.split(path.sep).join('/');
255
+ if (Number.isFinite(lat) && Number.isFinite(lng)) { item.lat = round6(lat); item.lng = round6(lng); }
256
+ const iso = isoOrNull(takenAt);
257
+ if (iso) item.taken_at = iso;
258
+
259
+ // Restrict the nearest-stop search to the matching day when we can date the photo.
260
+ const dayKey = takenDateKey(takenAt);
261
+ const restrictDi = dayKey != null && dayIndex.has(dayKey) ? dayIndex.get(dayKey) : null;
262
+
263
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
264
+ unmatched.push({ ...item, reason: 'no GPS in EXIF' });
265
+ continue;
266
+ }
267
+ const near = nearestStop(stops, lat, lng, restrictDi);
268
+ if (!near) {
269
+ unmatched.push({ ...item, reason: restrictDi != null ? 'no stops on matched day' : 'no stops with coordinates' });
270
+ continue;
271
+ }
272
+ if (near.miles > FAR_MILES) {
273
+ unmatched.push({ ...item, reason: `nearest stop "${near.name}" is ${Math.round(near.miles)} mi away`, suggested_stop: near.name });
274
+ continue;
275
+ }
276
+ item.confidence = near.miles <= NEAR_MILES ? 'high' : 'medium';
277
+ matched.push({ di: near.di, si: near.si, stop: near.name, day: data.days[near.di].number, item });
278
+ }
279
+
280
+ // Group matched items by stop for a readable review file.
281
+ const byStop = new Map();
282
+ for (const m of matched) {
283
+ const key = `${m.di}:${m.si}`;
284
+ if (!byStop.has(key)) byStop.set(key, { day: m.day, stop: m.stop, di: m.di, si: m.si, media: [] });
285
+ byStop.get(key).media.push(m.item);
286
+ }
287
+
288
+ const review = {
289
+ _instructions: [
290
+ 'Review auto-matched media below, then run: tripkit media apply <this-file> <trip.yaml>',
291
+ 'Each entry targets a stop by (day, stop_index). Edit `caption`, fix the stop_index,',
292
+ 'or move items between stops/unmatched as needed. Items in `unmatched` are ignored',
293
+ 'on apply until you give them a `day` and `stop_index`.'
294
+ ],
295
+ trip_file: path.relative(tripDir, path.resolve(tripFile)).split(path.sep).join('/') || path.basename(tripFile),
296
+ summary: {
297
+ total: files.length,
298
+ with_gps: withGps,
299
+ with_timestamp: withTime,
300
+ matched: matched.length,
301
+ unmatched: unmatched.length,
302
+ thumbnails: sharp ? 'generated' : 'skipped (sharp not installed)'
303
+ },
304
+ stops: Array.from(byStop.values()).map(g => ({
305
+ day: g.day,
306
+ stop_index: g.si,
307
+ stop: g.stop,
308
+ media: g.media
309
+ })),
310
+ unmatched
311
+ };
312
+
313
+ const reviewPath = tripFile.replace(/\.ya?ml$/i, '') + '.media-review.yaml';
314
+ fs.writeFileSync(reviewPath, yaml.dump(review, { lineWidth: 100, noRefs: true }), 'utf8');
315
+
316
+ log(`${c.green('✓')} Matched ${matched.length}/${files.length} to stops; ${unmatched.length} need review.`);
317
+ log(` ${withGps} had GPS, ${withTime} had a timestamp.`);
318
+ log(`Wrote ${c.bold(reviewPath)}`);
319
+ log(c.dim(' Edit captions / fix matches, then: ') + `tripkit media apply ${path.basename(reviewPath)} ${path.basename(tripFile)}`);
320
+ return reviewPath;
321
+ }
322
+
323
+ function round6(n) { return Math.round(n * 1e6) / 1e6; }
324
+
325
+ // Strip review-only bookkeeping fields before writing into the trip YAML.
326
+ function cleanItem(item) {
327
+ const out = {};
328
+ if (item.src) out.src = item.src;
329
+ if (item.type) out.type = item.type;
330
+ if (item.thumb) out.thumb = item.thumb;
331
+ if (item.caption) out.caption = item.caption;
332
+ if (Number.isFinite(item.lat)) out.lat = item.lat;
333
+ if (Number.isFinite(item.lng)) out.lng = item.lng;
334
+ if (item.taken_at) out.taken_at = item.taken_at;
335
+ return out;
336
+ }
337
+
338
+ /**
339
+ * APPLY: merge a reviewed media file into a trip YAML's stops[].media[].
340
+ *
341
+ * @param {string} reviewFile path to the *.media-review.yaml
342
+ * @param {string} tripFile path to the trip YAML to update in place
343
+ * @param {object} io { log, warn, c }
344
+ * @returns {Promise<void>}
345
+ */
346
+ async function applyReview(reviewFile, tripFile, io) {
347
+ const { log, warn, c } = io;
348
+ const review = yaml.load(fs.readFileSync(reviewFile, 'utf8'));
349
+ const data = yaml.load(fs.readFileSync(tripFile, 'utf8'));
350
+ if (!data || !Array.isArray(data.days)) throw new Error(`Trip file has no days[]: ${tripFile}`);
351
+
352
+ const dayByNumber = new Map();
353
+ data.days.forEach((d, di) => { if (Number.isInteger(d.number)) dayByNumber.set(d.number, di); });
354
+
355
+ let applied = 0, skipped = 0;
356
+ const targets = [];
357
+ for (const entry of (review.stops || [])) targets.push(entry);
358
+ // Allow promoting unmatched items by giving them day + stop_index in the review file.
359
+ for (const u of (review.unmatched || [])) {
360
+ if (Number.isInteger(u.day) && Number.isInteger(u.stop_index)) {
361
+ targets.push({ day: u.day, stop_index: u.stop_index, media: [u] });
362
+ } else {
363
+ skipped++;
364
+ }
365
+ }
366
+
367
+ for (const entry of targets) {
368
+ const di = dayByNumber.has(entry.day) ? dayByNumber.get(entry.day) : null;
369
+ if (di == null) { warn(`No day numbered ${entry.day} — skipping ${(entry.media || []).length} item(s).`); skipped += (entry.media || []).length; continue; }
370
+ const day = data.days[di];
371
+ const stop = (day.stops || [])[entry.stop_index];
372
+ if (!stop) { warn(`Day ${entry.day} has no stop_index ${entry.stop_index} — skipping.`); skipped += (entry.media || []).length; continue; }
373
+ if (!Array.isArray(stop.media)) stop.media = [];
374
+ const existing = new Set(stop.media.map(m => m.src));
375
+ for (const raw of (entry.media || [])) {
376
+ const item = cleanItem(raw);
377
+ if (!item.src) { skipped++; continue; }
378
+ if (existing.has(item.src)) continue; // idempotent re-apply
379
+ stop.media.push(item);
380
+ existing.add(item.src);
381
+ applied++;
382
+ }
383
+ }
384
+
385
+ fs.writeFileSync(tripFile, yaml.dump(data, { lineWidth: 100, noRefs: true }), 'utf8');
386
+ log(`${c.green('✓')} Applied ${applied} media item${applied === 1 ? '' : 's'} into ${c.bold(tripFile)}${skipped ? c.dim(` (${skipped} skipped)`) : ''}`);
387
+ }
388
+
389
+ module.exports = { buildReview, applyReview };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tripkit",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Open-source framework for AI-assisted trip planning with beautiful interactive visualizers",
5
5
  "main": "convert.js",
6
6
  "bin": {
@@ -9,7 +9,9 @@
9
9
  "files": [
10
10
  "convert.js",
11
11
  "validate.js",
12
+ "media-ingest.js",
12
13
  "scripts/check-skill-coverage.js",
14
+ "scripts/test-media-lifecycle.js",
13
15
  "renderers/html/tripkit-renderer.html",
14
16
  "schema/tripkit.schema.yaml",
15
17
  "examples/oregon-spring-2026.yaml",
@@ -17,7 +19,8 @@
17
19
  "examples/nyc-long-weekend-2026.yaml",
18
20
  "examples/new-england-fall-2026.yaml",
19
21
  "agent/questionnaire.yaml",
20
- "agent/AGENT-SKILL.md",
22
+ "agent/SKILL.md",
23
+ "docs/MEDIA-GUIDE.md",
21
24
  "README.md",
22
25
  "CHANGELOG.md",
23
26
  "LICENSE"
@@ -25,7 +28,7 @@
25
28
  "scripts": {
26
29
  "convert": "node convert.js",
27
30
  "example": "node convert.js examples/oregon-spring-2026.yaml examples/oregon-spring-2026.html",
28
- "test": "node convert.js examples/oregon-spring-2026.yaml /tmp/tripkit-test.html && test -s /tmp/tripkit-test.html && node scripts/check-skill-coverage.js && echo 'OK'",
31
+ "test": "node convert.js examples/oregon-spring-2026.yaml /tmp/tripkit-test.html && test -s /tmp/tripkit-test.html && node scripts/check-skill-coverage.js && node scripts/test-media-lifecycle.js && echo 'OK'",
29
32
  "prepublishOnly": "npm test"
30
33
  },
31
34
  "keywords": [
@@ -52,6 +55,10 @@
52
55
  "node": ">=18"
53
56
  },
54
57
  "dependencies": {
58
+ "exifr": "^7.1.3",
55
59
  "js-yaml": "^4.1.0"
60
+ },
61
+ "optionalDependencies": {
62
+ "sharp": "^0.35.2"
56
63
  }
57
64
  }