@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 +21 -0
- package/README.md +105 -0
- package/dist/activityTypes.d.ts +3 -0
- package/dist/activityTypes.js +50 -0
- package/dist/connectors/ArcTimelineExtractor.d.ts +41 -0
- package/dist/connectors/ArcTimelineExtractor.js +264 -0
- package/dist/connectors/ArcTimelineTransformer.d.ts +13 -0
- package/dist/connectors/ArcTimelineTransformer.js +140 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/track.d.ts +3 -0
- package/dist/track.js +31 -0
- package/dist/types.d.ts +93 -0
- package/dist/types.js +1 -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,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,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
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/track.d.ts
ADDED
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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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
|
+
}
|