@chronicle.app/arc-timeline 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,105 @@
1
+ # @chronicle.app/arc-timeline
2
+
3
+ Chronicle plugin for the location timeline from [Arc Timeline](https://apps.apple.com/ca/app/arc-timeline-4/id6740688708) (the iOS app, built on LocoKit).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @chronicle.app/arc-timeline
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ Point the extractor at the Arc Timeline **daily backup** — the per-day files the
14
+ iOS app writes automatically (no manual export step). On macOS these sync to
15
+ iCloud Drive under:
16
+
17
+ ```
18
+ ~/Library/Mobile Documents/iCloud~com~bigpaua~Arc-Timeline-Editor/Documents/Exports
19
+ ```
20
+
21
+ `--input` accepts that `Exports` folder, its `Daily` subfolder, or the
22
+ `Daily/JSON` directory that actually holds the `YYYY-MM-DD.json.gz` files — the
23
+ extractor resolves the right subdirectory.
24
+
25
+ ### Basic extraction
26
+
27
+ ```bash
28
+ chronicle extract arc-timeline --input /path/to/Exports
29
+ ```
30
+
31
+ ### With date filtering
32
+
33
+ ```bash
34
+ chronicle extract arc-timeline \
35
+ --input /path/to/Exports \
36
+ --since 2024-01-01 \
37
+ --until 2024-12-31 \
38
+ --limit 1000
39
+ ```
40
+
41
+ ## Configuration
42
+
43
+ - `--input`: Path to the daily backup (the `Exports`, `Daily`, or `Daily/JSON` directory); defaults to Arc's iCloud Drive `Exports` folder
44
+ - `--identity`: Override the person whose timeline this is (e.g. an email); defaults to the macOS iCloud account
45
+ - `--since`: Extract items whose start date is on or after this date
46
+ - `--until`: Extract items whose start date is on or before this date
47
+ - `--limit`: Maximum number of records to extract
48
+
49
+ ## File Format
50
+
51
+ The daily backup (schema `2.2.0`, written by the Arc Timeline Editor app on
52
+ LocoKit) is a flat directory of one gzipped file per day:
53
+
54
+ ```
55
+ Daily/JSON/
56
+ YYYY-MM-DD.json.gz a self-contained day: { items, places, samples, notes, metadata }
57
+ ```
58
+
59
+ Each day's file inlines everything for that day, so there are no cross-file
60
+ joins within a day. A timeline item is `{ base, trip | visit }`. `base` holds
61
+ the fields common to every item (id, start/end dates, `isVisit`, step count,
62
+ heart rate, the `previousItemId`/`nextItemId` linked list). Exactly one of:
63
+
64
+ - `visit` — a stay: `placeId` (joined to that day's `places`), coordinates, street address.
65
+ - `trip` — movement: distance, speed, and a `classifiedActivityType` enum code.
66
+
67
+ The extractor reads the day files newest-first and joins each visit to that
68
+ day's `places`. It emits one record per item:
69
+
70
+ - `recordType: "visit"` for stays
71
+ - `recordType: "travel"` for trips
72
+
73
+ An item that spans midnight appears in both adjacent days' files; it is deduped
74
+ on the stable item id, and a trip's GPS samples (which are split at the day
75
+ boundary) are merged from each day its span covers.
76
+
77
+ ## Schema
78
+
79
+ | Source | Chronicle |
80
+ | -------------- | ------------------------------------------------------------------------------------ |
81
+ | visit item | `VisitAction` → `object: Venue`, `startTime`/`endTime` |
82
+ | trip item | `TravelAction` → `Journey` (`travelMode`, `distance`, `path`), `startTime`/`endTime` |
83
+ | `places` entry | `Venue` (name, coordinates, `address`, `category`, `sameAs` to Foursquare/Google) |
84
+
85
+ A dictionary-backed place becomes a `Venue`; a visit Arc never matched to a
86
+ place falls back to a coordinate-keyed `Place`. A venue's street address,
87
+ locality, and country are folded into the freeform `address` (the geocoder
88
+ resolves structured admin areas later); `category` comes from the place's
89
+ Google primary type. A trip's GPS `samples` become the `Journey.path` track,
90
+ and its `classifiedActivityType` enum is mapped to a `travelMode` name.
91
+
92
+ ### Not yet ingested
93
+
94
+ The `notes` text is not read yet. The storage shape for free-text day notes is
95
+ still being decided.
96
+
97
+ ## Development
98
+
99
+ ```bash
100
+ npm install
101
+ npm run build
102
+ ```
103
+
104
+ Tests use synthetic daily backup files and an explicit `identity`, so they never
105
+ read the host's Arc backup or iCloud account.
@@ -0,0 +1,3 @@
1
+ export declare const ARC_ACTIVITY_TYPES: Record<number, string>;
2
+ export declare function activityTypeName(code: number | undefined): string | undefined;
3
+ //# sourceMappingURL=activityTypes.d.ts.map
@@ -0,0 +1,50 @@
1
+ // LocoKit2 `ActivityType` enum (Int rawValues) — the classifier codes Arc
2
+ // stores in a trip's `classifiedActivityType` / `confirmedActivityType`.
3
+ //
4
+ // Source of truth (LocoKit2 is the engine behind Arc Timeline, sourceVersion 9):
5
+ // https://github.com/sobri909/LocoKit2/blob/main/Sources/LocoKit2/ActivityTypes/ActivityType.swift
6
+ //
7
+ // `unknown` (-1) and `bogus` (0) carry no classification, so they are
8
+ // intentionally omitted: an unrecognised or unclassified code yields no mode.
9
+ // Multi-word values use the enum's own `displayName` spelling.
10
+ export const ARC_ACTIVITY_TYPES = {
11
+ 1: 'stationary',
12
+ 2: 'walking',
13
+ 3: 'running',
14
+ 4: 'cycling',
15
+ 5: 'car',
16
+ 6: 'airplane',
17
+ 20: 'train',
18
+ 21: 'bus',
19
+ 22: 'motorcycle',
20
+ 23: 'boat',
21
+ 24: 'tram',
22
+ 25: 'tractor',
23
+ 26: 'tuk-tuk',
24
+ 27: 'songthaew',
25
+ 28: 'scooter',
26
+ 29: 'metro',
27
+ 30: 'cable car',
28
+ 31: 'funicular',
29
+ 32: 'chairlift',
30
+ 33: 'ski lift',
31
+ 34: 'taxi',
32
+ 35: 'hot air balloon',
33
+ 50: 'skateboarding',
34
+ 51: 'inline skating',
35
+ 52: 'snowboarding',
36
+ 53: 'skiing',
37
+ 54: 'horseback',
38
+ 55: 'swimming',
39
+ 56: 'golf',
40
+ 57: 'wheelchair',
41
+ 58: 'rowing',
42
+ 59: 'kayaking',
43
+ 60: 'surfing',
44
+ 61: 'hiking',
45
+ };
46
+ export function activityTypeName(code) {
47
+ if (code === undefined)
48
+ return undefined;
49
+ return ARC_ACTIVITY_TYPES[code];
50
+ }
@@ -0,0 +1,41 @@
1
+ import { Extractor, Record } from '@chronicle.app/etl';
2
+ import { z } from 'zod';
3
+ import ArcTimelineTransformer from './ArcTimelineTransformer.js';
4
+ export declare class ArcTimelineExtractor extends Extractor<typeof ArcTimelineExtractor> {
5
+ static source: string;
6
+ static description: string;
7
+ static delivery: "local";
8
+ static strategy: string;
9
+ static recordTypes: string[];
10
+ static default: boolean;
11
+ static defaultTransformer: typeof ArcTimelineTransformer;
12
+ static schema: z.ZodObject<{
13
+ since: z.ZodOptional<z.ZodDate>;
14
+ until: z.ZodOptional<z.ZodDate>;
15
+ limit: z.ZodOptional<z.ZodNumber>;
16
+ } & {
17
+ input: z.ZodDefault<z.ZodString>;
18
+ identity: z.ZodOptional<z.ZodString>;
19
+ }, "strip", z.ZodTypeAny, {
20
+ input: string;
21
+ identity?: string | undefined;
22
+ since?: Date | undefined;
23
+ until?: Date | undefined;
24
+ limit?: number | undefined;
25
+ }, {
26
+ input?: string | undefined;
27
+ identity?: string | undefined;
28
+ since?: Date | undefined;
29
+ until?: Date | undefined;
30
+ limit?: number | undefined;
31
+ }>;
32
+ extract(): AsyncGenerator<Record>;
33
+ private resolvePerson;
34
+ private loadSamples;
35
+ determineCount(): Promise<number | null>;
36
+ private indexPlaces;
37
+ private findDailyDir;
38
+ private listDayFiles;
39
+ private readDayFile;
40
+ }
41
+ //# sourceMappingURL=ArcTimelineExtractor.d.ts.map
@@ -0,0 +1,264 @@
1
+ import { Extractor } from '@chronicle.app/etl';
2
+ import { buildICloudPersonSchema } from '@chronicle.app/icloud';
3
+ import { readFile, readdir } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { gunzipSync } from 'node:zlib';
6
+ import { z } from 'zod';
7
+ import ArcTimelineTransformer from './ArcTimelineTransformer.js';
8
+ // A daily backup file is named for the UTC day it covers ("YYYY-MM-DD.json.gz").
9
+ const DAILY_FILE = /^(\d{4}-\d{2}-\d{2})\.json\.gz$/;
10
+ // Arc Timeline's iCloud Drive container, where the app writes its daily backup.
11
+ const DEFAULT_EXPORTS = `${process.env.HOME}/Library/Mobile Documents/iCloud~com~bigpaua~Arc-Timeline-Editor/Documents/Exports`;
12
+ // The "YYYY-MM-DD" UTC key for a date; daily files are named by this key, so a
13
+ // trip's samples can be loaded from just the day file(s) covering its span.
14
+ function dayKey(date) {
15
+ return date.toISOString().slice(0, 10);
16
+ }
17
+ // The day keys (inclusive) that an item's [start, end] span touches — a trip
18
+ // that crosses midnight has its samples split across each day's file.
19
+ function coveringDayKeys(startDate, endDate) {
20
+ const start = new Date(startDate);
21
+ const endMs = new Date(endDate).getTime();
22
+ const keys = [];
23
+ for (let ms = Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate()); ms <= endMs; ms += 86_400_000) {
24
+ keys.push(dayKey(new Date(ms)));
25
+ }
26
+ return keys;
27
+ }
28
+ export class ArcTimelineExtractor extends Extractor {
29
+ // The CLI name matches the plugin's; records keep the `arc` namespace.
30
+ static source = 'arc-timeline';
31
+ static description = 'Location timeline: visits and trips';
32
+ static delivery = 'local';
33
+ // The strategy, in Arc's own vocabulary: the app's iCloud backup (the daily
34
+ // YYYY-MM-DD.json.gz folder) — "timeline" named the product, not the strategy.
35
+ static strategy = 'icloud-backup';
36
+ static recordTypes = ['visits', 'travels'];
37
+ static default = true;
38
+ static defaultTransformer = ArcTimelineTransformer;
39
+ static schema = Extractor.schema.extend({
40
+ input: z
41
+ .string()
42
+ .default(DEFAULT_EXPORTS)
43
+ .describe("Path to the Arc Timeline daily backup — the folder of YYYY-MM-DD.json.gz files, or a parent such as the iCloud Exports or Exports/Daily directory; defaults to Arc's iCloud Drive Exports folder"),
44
+ identity: z
45
+ .string()
46
+ .optional()
47
+ .describe('Override the person whose timeline this is (e.g. an email); defaults to the macOS iCloud account'),
48
+ });
49
+ async *extract() {
50
+ const config = this.config;
51
+ const person = await this.resolvePerson(config.identity);
52
+ // Pass 1: select the items to emit, reading day files newest-first. Each
53
+ // day's file is self-contained (its `places` resolve its visits), but an
54
+ // item that spans midnight appears in two adjacent files, so dedupe on the
55
+ // stable item id. `--limit N` yields the N most recent items, and the early
56
+ // break keeps a limited run from reading the whole archive.
57
+ const dir = await this.findDailyDir(config.input);
58
+ const dayFiles = await this.listDayFiles(dir);
59
+ const sinceKey = config.since ? dayKey(config.since) : undefined;
60
+ const untilKey = config.until ? dayKey(config.until) : undefined;
61
+ const seen = new Set();
62
+ const selected = [];
63
+ for (const { path, key } of dayFiles) {
64
+ // Day keys sort chronologically, so (newest-first) every remaining file
65
+ // is older than `since`; and any file newer than `until` holds nothing in
66
+ // range (an item's earliest day file is its start day).
67
+ if (sinceKey && key < sinceKey)
68
+ break;
69
+ if (untilKey && key > untilKey)
70
+ continue;
71
+ const day = await this.readDayFile(path);
72
+ const places = this.indexPlaces(day.places);
73
+ for (const item of day.items.reverse()) {
74
+ const { base } = item;
75
+ if (!base || base.deleted)
76
+ continue;
77
+ if (seen.has(base.id))
78
+ continue;
79
+ if (config.since || config.until) {
80
+ const start = new Date(base.startDate);
81
+ if (config.since && start < config.since)
82
+ continue;
83
+ if (config.until && start > config.until)
84
+ continue;
85
+ }
86
+ if (item.visit || base.isVisit) {
87
+ const place = item.visit?.placeId ? places.get(item.visit.placeId) : undefined;
88
+ selected.push({ item, recordType: 'visits', place });
89
+ }
90
+ else if (item.trip) {
91
+ selected.push({ item, recordType: 'travels' });
92
+ }
93
+ else {
94
+ continue;
95
+ }
96
+ seen.add(base.id);
97
+ if (this.shouldStopExtracting(selected.length))
98
+ break;
99
+ }
100
+ if (this.shouldStopExtracting(selected.length))
101
+ break;
102
+ }
103
+ // Load GPS samples for just the trips we'll emit, keyed by their item id.
104
+ const trips = selected.filter(s => s.recordType === 'travels').map(s => s.item);
105
+ const samplesByItem = await this.loadSamples(dir, trips);
106
+ this.logInitStep(`Emitting ${selected.length} items (${trips.length} trips, ${samplesByItem.size} with samples)`);
107
+ // Pass 2: emit. Trips carry their ordered samples; the transformer encodes
108
+ // them into the Journey's path.
109
+ for (const { item, recordType, place } of selected) {
110
+ if (recordType === 'visits') {
111
+ yield this.createRecord(item, { recordType, person, place });
112
+ }
113
+ else {
114
+ yield this.createRecord(item, {
115
+ recordType,
116
+ person,
117
+ samples: samplesByItem.get(item.base.id),
118
+ });
119
+ }
120
+ }
121
+ }
122
+ // The person whose timeline this is: an explicit `--identity` override, else
123
+ // the macOS iCloud account (so Arc data merges onto the same person as other
124
+ // iCloud-sourced data). An override is keyed the same way, so it merges too.
125
+ async resolvePerson(identity) {
126
+ if (identity) {
127
+ return {
128
+ '@type': 'Person',
129
+ '@key': ['@type', 'source', 'sourceId'],
130
+ source: 'icloud',
131
+ sourceId: identity,
132
+ handle: identity,
133
+ };
134
+ }
135
+ return ((await buildICloudPersonSchema()) ?? {
136
+ '@type': 'Person',
137
+ '@key': ['@type', 'source'],
138
+ source: 'icloud',
139
+ });
140
+ }
141
+ // GPS samples for the given trips, grouped by `timelineItemId` and ordered by
142
+ // time. Only the day buckets covering the trips' spans are read, so a
143
+ // `--limit` run touches a handful of files; a midnight-spanning trip's samples
144
+ // are merged from each day its span touches.
145
+ async loadSamples(root, trips) {
146
+ const byItem = new Map();
147
+ if (trips.length === 0)
148
+ return byItem;
149
+ const wantIds = new Set(trips.map(t => t.base.id));
150
+ const dayKeys = new Set();
151
+ for (const t of trips) {
152
+ for (const key of coveringDayKeys(t.base.startDate, t.base.endDate)) {
153
+ dayKeys.add(key);
154
+ }
155
+ }
156
+ for (const key of dayKeys) {
157
+ let day;
158
+ try {
159
+ day = await this.readDayFile(join(root, `${key}.json.gz`));
160
+ }
161
+ catch {
162
+ continue; // Day file missing — trip predates the backup, or a gap.
163
+ }
164
+ for (const s of day.samples) {
165
+ if (!s.timelineItemId || !wantIds.has(s.timelineItemId))
166
+ continue;
167
+ const arr = byItem.get(s.timelineItemId);
168
+ if (arr)
169
+ arr.push(s);
170
+ else
171
+ byItem.set(s.timelineItemId, [s]);
172
+ }
173
+ }
174
+ for (const samples of byItem.values()) {
175
+ samples.sort((a, b) => a.date.localeCompare(b.date));
176
+ }
177
+ return byItem;
178
+ }
179
+ async determineCount() {
180
+ const config = this.config;
181
+ try {
182
+ const dir = await this.findDailyDir(config.input);
183
+ const dayFiles = await this.listDayFiles(dir);
184
+ const sinceKey = config.since ? dayKey(config.since) : undefined;
185
+ const untilKey = config.until ? dayKey(config.until) : undefined;
186
+ // Count distinct items in range (midnight-spanning items appear in two
187
+ // files; the id set keeps the total honest).
188
+ const seen = new Set();
189
+ for (const { path, key } of dayFiles) {
190
+ if (sinceKey && key < sinceKey)
191
+ break;
192
+ if (untilKey && key > untilKey)
193
+ continue;
194
+ const day = await this.readDayFile(path);
195
+ for (const item of day.items) {
196
+ const { base } = item;
197
+ if (!base || base.deleted)
198
+ continue;
199
+ if (config.since && new Date(base.startDate) < config.since)
200
+ continue;
201
+ if (config.until && new Date(base.startDate) > config.until)
202
+ continue;
203
+ seen.add(base.id);
204
+ }
205
+ }
206
+ return config.limit ? Math.min(seen.size, config.limit) : seen.size;
207
+ }
208
+ catch {
209
+ return null;
210
+ }
211
+ }
212
+ // The day's place dictionary, keyed by place id, joined to visits via
213
+ // `visit.placeId`. Scoped to the day, but complete for that day's visits.
214
+ indexPlaces(places) {
215
+ const byId = new Map();
216
+ for (const place of places) {
217
+ if (place?.id)
218
+ byId.set(place.id, place);
219
+ }
220
+ return byId;
221
+ }
222
+ // Resolve the input to the directory that actually holds the day files. The
223
+ // input may be that directory, or a parent the user finds it easier to point
224
+ // at — the iCloud "Exports" folder or its "Daily" subfolder.
225
+ async findDailyDir(input) {
226
+ const candidates = [
227
+ input,
228
+ join(input, 'JSON'), // …/Daily
229
+ join(input, 'Daily', 'JSON'), // …/Exports
230
+ ];
231
+ for (const dir of candidates) {
232
+ let entries;
233
+ try {
234
+ entries = await readdir(dir);
235
+ }
236
+ catch {
237
+ continue; // Not a directory — try the next candidate.
238
+ }
239
+ if (entries.some(name => DAILY_FILE.test(name)))
240
+ return dir;
241
+ }
242
+ throw new Error(`No Arc Timeline daily backup files (YYYY-MM-DD.json.gz) found under ${input} ` +
243
+ `(looked in there, in JSON/, and in Daily/JSON/)`);
244
+ }
245
+ // The daily backup files in a resolved directory, sorted by their day key
246
+ // descending (newest first).
247
+ async listDayFiles(dir) {
248
+ const entries = await readdir(dir);
249
+ return entries
250
+ .map(name => DAILY_FILE.exec(name))
251
+ .filter((m) => m !== null)
252
+ .map(m => ({ path: join(dir, m[0]), key: m[1] }))
253
+ .sort((a, b) => b.key.localeCompare(a.key));
254
+ }
255
+ async readDayFile(path) {
256
+ const buf = await readFile(path);
257
+ const parsed = JSON.parse(gunzipSync(buf).toString('utf-8'));
258
+ return {
259
+ items: Array.isArray(parsed.items) ? parsed.items : [],
260
+ places: Array.isArray(parsed.places) ? parsed.places : [],
261
+ samples: Array.isArray(parsed.samples) ? parsed.samples : [],
262
+ };
263
+ }
264
+ }
@@ -0,0 +1,13 @@
1
+ import { ChronicleTransformer, Record } from '@chronicle.app/etl';
2
+ import { ActionAndChildren } from '@chronicle.app/schema';
3
+ export default class ArcTimelineTransformer extends ChronicleTransformer {
4
+ transform(record: Record): Promise<ActionAndChildren[]>;
5
+ private buildVisit;
6
+ private buildTravel;
7
+ private buildPlace;
8
+ private buildVenue;
9
+ private buildLocation;
10
+ private buildPlaceSameAs;
11
+ private buildPlaceFromVisit;
12
+ }
13
+ //# sourceMappingURL=ArcTimelineTransformer.d.ts.map
@@ -0,0 +1,140 @@
1
+ import { ChronicleTransformer } from '@chronicle.app/etl';
2
+ import { activityTypeName } from '../activityTypes.js';
3
+ import { buildTrackFeature } from '../track.js';
4
+ export default class ArcTimelineTransformer extends ChronicleTransformer {
5
+ async transform(record) {
6
+ const context = record.context;
7
+ const item = record.data;
8
+ if (context.recordType === 'visits') {
9
+ return this.buildVisit(item, context.person, context.place);
10
+ }
11
+ if (context.recordType === 'travels') {
12
+ return this.buildTravel(item, context.person, context.samples);
13
+ }
14
+ return [];
15
+ }
16
+ buildVisit(item, person, place) {
17
+ const { visit } = item;
18
+ if (!visit)
19
+ return [];
20
+ const action = {
21
+ '@type': 'VisitAction',
22
+ '@key': ['@type', 'source', 'sourceId'],
23
+ source: 'arc',
24
+ sourceId: item.base.id,
25
+ agent: person,
26
+ object: this.buildPlace(visit, place),
27
+ startTime: new Date(item.base.startDate),
28
+ endTime: new Date(item.base.endDate),
29
+ };
30
+ return [action];
31
+ }
32
+ buildTravel(item, person, samples) {
33
+ const { trip } = item;
34
+ // The action stays sparse; the detail lives on the Journey it produces.
35
+ // A user-confirmed activity type wins over the classifier's guess. The path
36
+ // is the trip's GPS trail as a GeoJSON track (when enough fixes exist).
37
+ const mode = activityTypeName(trip?.confirmedActivityType ?? trip?.classifiedActivityType);
38
+ const path = buildTrackFeature(samples);
39
+ const journey = {
40
+ '@type': 'Journey',
41
+ '@key': ['@type', 'source', 'sourceId'],
42
+ source: 'arc',
43
+ sourceId: item.base.id,
44
+ ...(trip?.distance !== undefined && { distance: trip.distance }),
45
+ ...(mode && { travelMode: mode }),
46
+ ...(path && { path }),
47
+ };
48
+ const action = {
49
+ '@type': 'TravelAction',
50
+ '@key': ['@type', 'source', 'sourceId'],
51
+ source: 'arc',
52
+ sourceId: item.base.id,
53
+ agent: person,
54
+ result: journey,
55
+ startTime: new Date(item.base.startDate),
56
+ endTime: new Date(item.base.endDate),
57
+ };
58
+ return [action];
59
+ }
60
+ buildPlace(visit, place) {
61
+ if (place)
62
+ return this.buildVenue(place);
63
+ return this.buildPlaceFromVisit(visit);
64
+ }
65
+ // A dictionary-backed Arc place is a named point of interest → a Venue. It
66
+ // carries the venue's category and sameAs edges to the foreign venue ids Arc
67
+ // resolved against. Raw locality/country stay as plain strings folded into the
68
+ // freeform address — they have no keyed City/Country entity here; the geocoder
69
+ // resolves those later, and raw strings live on `address`.
70
+ buildVenue(place) {
71
+ const sameAs = this.buildPlaceSameAs(place);
72
+ const address = [place.streetAddress, place.locality, place.countryCode?.toUpperCase()]
73
+ .filter(Boolean)
74
+ .join(', ');
75
+ return {
76
+ '@type': 'Venue',
77
+ '@key': ['@type', 'source', 'sourceId'],
78
+ source: 'arc',
79
+ sourceId: place.id,
80
+ name: place.name,
81
+ // The venue's coordinates/address are its own Location value — a keyless
82
+ // (content-interned) geo node, not entity properties on the Venue.
83
+ location: this.buildLocation(place.latitude, place.longitude, address),
84
+ ...(place.googlePrimaryType && { category: [place.googlePrimaryType] }),
85
+ ...(sameAs.length > 0 && { sameAs }),
86
+ };
87
+ }
88
+ // The geo as a `Location` value — a keyless StructuredValue that shred
89
+ // content-interns into location_values (deduped by payload). Raw
90
+ // locality/country are folded into the freeform address by the caller; the
91
+ // geocoder resolves structured admin areas later.
92
+ buildLocation(latitude, longitude, address) {
93
+ return {
94
+ '@type': 'Location',
95
+ latitude,
96
+ longitude,
97
+ ...(address && { address }),
98
+ };
99
+ }
100
+ // Arc's place dictionary carries the external venue ids it resolved a place
101
+ // against. Emit a sameAs edge per id so the Arc venue collapses into the same
102
+ // entity as the foreign source's own Venue (which keys identically on
103
+ // [@type, source, sourceId]). The id is a real handle from the export — never
104
+ // synthesized — and each edge carries the title so a yet-unseen foreign venue
105
+ // still has a name when this edge is what mints it.
106
+ buildPlaceSameAs(place) {
107
+ const edges = [
108
+ { source: 'foursquare', sourceId: place.foursquarePlaceId },
109
+ { source: 'google-places', sourceId: place.googlePlaceId },
110
+ ];
111
+ return edges
112
+ .filter((edge) => Boolean(edge.sourceId))
113
+ .map(edge => ({
114
+ '@type': 'Venue',
115
+ '@key': ['@type', 'source', 'sourceId'],
116
+ source: edge.source,
117
+ sourceId: edge.sourceId,
118
+ name: place.name,
119
+ }));
120
+ }
121
+ buildPlaceFromVisit(visit) {
122
+ // No dictionary entry — a coordinate-identified place. Key on the visit's
123
+ // coordinates via computed keyfields (a natural key, never synthesized) so
124
+ // repeat visits to the same spot merge; the geo itself is a nested Location
125
+ // value, like every other place's.
126
+ const name = visit.customTitle ?? visit.streetAddress;
127
+ return {
128
+ '@type': 'Place',
129
+ '@key': [
130
+ '@type',
131
+ 'source',
132
+ { key: 'lat', value: String(visit.latitude) },
133
+ { key: 'lng', value: String(visit.longitude) },
134
+ ],
135
+ source: 'arc',
136
+ ...(name && { name }),
137
+ location: this.buildLocation(visit.latitude, visit.longitude, visit.streetAddress),
138
+ };
139
+ }
140
+ }
@@ -0,0 +1,3 @@
1
+ export { ArcTimelineExtractor } from './connectors/ArcTimelineExtractor.js';
2
+ export { default as ArcTimelineTransformer } from './connectors/ArcTimelineTransformer.js';
3
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { ArcTimelineExtractor } from './connectors/ArcTimelineExtractor.js';
2
+ export { default as ArcTimelineTransformer } from './connectors/ArcTimelineTransformer.js';
@@ -0,0 +1,3 @@
1
+ import { ArcSample } from './types.js';
2
+ export declare function buildTrackFeature(samples: ArcSample[] | undefined): string | undefined;
3
+ //# sourceMappingURL=track.d.ts.map
package/dist/track.js ADDED
@@ -0,0 +1,31 @@
1
+ // A trip's GPS trail as a GeoJSON `Feature` (LineString) with per-point time,
2
+ // speed, and course carried in `properties` via the Mapbox `coordTimes`
3
+ // convention — so it round-trips to GPX (togpx) and renders directly. Stored
4
+ // verbatim as the Journey's `path` (a JSON string in a Text property).
5
+ //
6
+ // Returns undefined when there aren't at least two located fixes to form a line.
7
+ export function buildTrackFeature(samples) {
8
+ if (!samples || samples.length === 0)
9
+ return undefined;
10
+ const fixes = samples
11
+ .filter(s => typeof s.latitude === 'number' && typeof s.longitude === 'number')
12
+ .sort((a, b) => a.date.localeCompare(b.date));
13
+ if (fixes.length < 2)
14
+ return undefined;
15
+ const coordinates = [];
16
+ const coordTimes = [];
17
+ const speeds = [];
18
+ const courses = [];
19
+ for (const s of fixes) {
20
+ coordinates.push([s.longitude, s.latitude]);
21
+ coordTimes.push(s.date);
22
+ // Arc encodes unknown speed/course as -1; null keeps the arrays aligned.
23
+ speeds.push(typeof s.speed === 'number' && s.speed >= 0 ? s.speed : null);
24
+ courses.push(typeof s.course === 'number' && s.course >= 0 ? s.course : null);
25
+ }
26
+ return JSON.stringify({
27
+ type: 'Feature',
28
+ geometry: { type: 'LineString', coordinates },
29
+ properties: { coordTimes, speeds, courses },
30
+ });
31
+ }
@@ -0,0 +1,93 @@
1
+ import { AgentAndChildren } from '@chronicle.app/schema';
2
+ export interface ArcItemBase {
3
+ id: string;
4
+ startDate: string;
5
+ endDate: string;
6
+ isVisit: boolean;
7
+ source: string;
8
+ sourceVersion?: string;
9
+ deleted?: boolean;
10
+ disabled?: boolean;
11
+ locked?: boolean;
12
+ samplesChanged?: boolean;
13
+ lastSaved?: string;
14
+ previousItemId?: string;
15
+ nextItemId?: string;
16
+ stepCount?: number;
17
+ averageHeartRate?: number;
18
+ maxHeartRate?: number;
19
+ activeEnergyBurned?: number;
20
+ }
21
+ export interface ArcTrip {
22
+ itemId: string;
23
+ distance?: number;
24
+ speed?: number;
25
+ classifiedActivityType?: number;
26
+ confirmedActivityType?: number;
27
+ uncertainActivityType?: boolean;
28
+ lastSaved?: string;
29
+ }
30
+ export interface ArcVisit {
31
+ itemId: string;
32
+ placeId?: string;
33
+ latitude: number;
34
+ longitude: number;
35
+ streetAddress?: string;
36
+ customTitle?: string;
37
+ radiusMean?: number;
38
+ radiusSD?: number;
39
+ confirmedPlace?: boolean;
40
+ uncertainPlace?: boolean;
41
+ lastSaved?: string;
42
+ }
43
+ export interface ArcItem {
44
+ base: ArcItemBase;
45
+ trip?: ArcTrip;
46
+ visit?: ArcVisit;
47
+ }
48
+ export interface ArcSample {
49
+ timelineItemId?: string;
50
+ date: string;
51
+ latitude?: number;
52
+ longitude?: number;
53
+ speed?: number;
54
+ course?: number;
55
+ }
56
+ export interface ArcPlace {
57
+ id: string;
58
+ name: string;
59
+ latitude: number;
60
+ longitude: number;
61
+ streetAddress?: string;
62
+ locality?: string;
63
+ countryCode?: string;
64
+ secondsFromGMT?: number;
65
+ visitCount?: number;
66
+ visitDays?: number;
67
+ lastVisitDate?: string;
68
+ isStale?: boolean;
69
+ source?: string;
70
+ lastSaved?: string;
71
+ foursquarePlaceId?: string;
72
+ foursquareCategoryId?: string;
73
+ googlePlaceId?: string;
74
+ googlePrimaryType?: string;
75
+ mapboxPlaceId?: string;
76
+ mapboxCategory?: string;
77
+ mapboxMakiIcon?: string;
78
+ }
79
+ export interface DailyExport {
80
+ items: ArcItem[];
81
+ places: ArcPlace[];
82
+ samples: ArcSample[];
83
+ periodStart?: string;
84
+ periodEnd?: string;
85
+ periodId?: string;
86
+ }
87
+ export interface ArcRecordContext {
88
+ recordType: 'visits' | 'travels';
89
+ person: AgentAndChildren;
90
+ place?: ArcPlace;
91
+ samples?: ArcSample[];
92
+ }
93
+ //# sourceMappingURL=types.d.ts.map
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@chronicle.app/arc-timeline",
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
+ "arc-timeline"
19
+ ],
20
+ "author": "Andrew Louis",
21
+ "license": "MIT",
22
+ "type": "module",
23
+ "description": "Chronicle plugin for location tracking data from Arc timeline database",
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/arc-timeline"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "registry": "https://registry.npmjs.org/"
40
+ },
41
+ "dependencies": {
42
+ "@chronicle.app/icloud": "0.3.0",
43
+ "zod": "^3.24.1"
44
+ },
45
+ "peerDependencies": {
46
+ "@chronicle.app/etl": ">=0.1.0 <1.0.0",
47
+ "@chronicle.app/schema": ">=0.1.0 <1.0.0"
48
+ },
49
+ "engines": {
50
+ "node": ">=22.13.0"
51
+ },
52
+ "exports": {
53
+ ".": {
54
+ "types": "./dist/index.d.ts",
55
+ "import": "./dist/index.js"
56
+ }
57
+ },
58
+ "chronicle": {
59
+ "plugin": true,
60
+ "sources": {
61
+ "arc-timeline": {
62
+ "strategies": {
63
+ "icloud-backup": {
64
+ "delivery": "local",
65
+ "recordTypes": [
66
+ "visits",
67
+ "travels"
68
+ ],
69
+ "default": true
70
+ }
71
+ },
72
+ "platforms": [
73
+ "darwin"
74
+ ]
75
+ }
76
+ }
77
+ }
78
+ }