@chronicle.app/moves-app 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 +141 -0
- package/dist/activityTypes.d.ts +3 -0
- package/dist/activityTypes.js +31 -0
- package/dist/connectors/MovesExtractor.d.ts +36 -0
- package/dist/connectors/MovesExtractor.js +194 -0
- package/dist/connectors/MovesTransformer.d.ts +12 -0
- package/dist/connectors/MovesTransformer.js +117 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/time.d.ts +6 -0
- package/dist/time.js +35 -0
- package/dist/track.d.ts +3 -0
- package/dist/track.js +33 -0
- package/dist/types.d.ts +50 -0
- package/dist/types.js +1 -0
- package/package.json +74 -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,141 @@
|
|
|
1
|
+
# @chronicle.app/moves-app
|
|
2
|
+
|
|
3
|
+
Chronicle plugin for the location storyline from [Moves](<https://en.wikipedia.org/wiki/Moves_(application)>) — the automatic activity tracker ProtoGeo built, Facebook bought in 2014, and shut down in July 2018.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @chronicle.app/moves-app
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
Point the extractor at your unzipped Moves export — the archive the
|
|
14
|
+
moves-export.com service handed you before the shutdown, which is the only way
|
|
15
|
+
this data ever left the app.
|
|
16
|
+
|
|
17
|
+
### Basic extraction
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
chronicle extract moves-app --input /path/to/moves_export
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### With date filtering
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
chronicle extract moves-app \
|
|
27
|
+
--input /path/to/moves_export \
|
|
28
|
+
--since 2015-01-01 \
|
|
29
|
+
--until 2015-12-31 \
|
|
30
|
+
--limit 1000
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Configuration
|
|
34
|
+
|
|
35
|
+
- `--input` (required): Path to the unzipped export
|
|
36
|
+
- `--since`: Extract records that start on or after this date
|
|
37
|
+
- `--until`: Extract records that start on or before this date
|
|
38
|
+
- `--limit`: Maximum number of records to extract (the most recent ones)
|
|
39
|
+
|
|
40
|
+
## File Format
|
|
41
|
+
|
|
42
|
+
The export holds the same history at five roll-ups (daily, weekly, monthly,
|
|
43
|
+
yearly, full) and in four cuts (storyline, places, activities, summary), in
|
|
44
|
+
several formats. This plugin reads the JSON **storyline**, which is the
|
|
45
|
+
superset: it inlines each day's places and its activities with their GPS track,
|
|
46
|
+
so nothing has to be joined across files.
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
json/daily/storyline/storyline_YYYYMMDD.json one day per file
|
|
50
|
+
json/full/storyline.json every day in one array
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`--input` accepts the export folder, its `json` folder, the daily storyline
|
|
54
|
+
folder, or a storyline JSON file directly — the extractor resolves it. The daily
|
|
55
|
+
folder wins when both are present, because a windowed or limited run then only
|
|
56
|
+
opens the files it needs.
|
|
57
|
+
|
|
58
|
+
A day is an ordered list of segments, and a segment is one of:
|
|
59
|
+
|
|
60
|
+
- `place` — a stay: the place it was at, plus any activity recorded there
|
|
61
|
+
- `move` — movement between two stays, split into legs
|
|
62
|
+
- `off` — tracking was off, but the phone still recorded activity
|
|
63
|
+
|
|
64
|
+
A `move`/`off` segment splits into legs: walk to the station, take the train,
|
|
65
|
+
walk to the office is one segment of three activities, each with its own type,
|
|
66
|
+
distance, and track. The leg is the unit that carries a single mode of travel,
|
|
67
|
+
so the leg — not the segment — is what becomes a journey. The extractor emits
|
|
68
|
+
one record per stay and one per leg:
|
|
69
|
+
|
|
70
|
+
- `recordType: "places"` for stays
|
|
71
|
+
- `recordType: "moves"` for legs of movement
|
|
72
|
+
|
|
73
|
+
A segment that spans midnight is written into both days' files. It is deduped on
|
|
74
|
+
its start time, which is also its identity downstream, so both copies land on one
|
|
75
|
+
record.
|
|
76
|
+
|
|
77
|
+
## Schema
|
|
78
|
+
|
|
79
|
+
| Source | Chronicle |
|
|
80
|
+
| ------------------ | ------------------------------------------------------------------------------------ |
|
|
81
|
+
| `place` segment | `VisitAction` → `object: Venue` or `Place`, `startTime`/`endTime` |
|
|
82
|
+
| leg of movement | `TravelAction` → `Journey` (`travelMode`, `distance`, `path`), `startTime`/`endTime` |
|
|
83
|
+
| `place` dictionary | `Venue`/`Place` (name, coordinates, `sameAs` to Foursquare/Facebook) |
|
|
84
|
+
|
|
85
|
+
The source is `moves-app`, after the service's own domain (`moves-app.com`, and
|
|
86
|
+
`dev.moves-app.com` for the format docs the export's PDF points at). Not bare
|
|
87
|
+
`moves`: a source name is a namespace, and it sits beside `moves` the record
|
|
88
|
+
type. Not `facebook-moves` either, though Facebook bought the company in 2014 —
|
|
89
|
+
these ids were minted by the Moves service throughout, and the same place
|
|
90
|
+
records carry `facebookPlaceId` values that belong to the genuinely separate
|
|
91
|
+
`facebook` id space.
|
|
92
|
+
|
|
93
|
+
Moves numbered its places but never its segments, so a stay and a leg are keyed
|
|
94
|
+
on the instant they began — a person is in one place, and on one leg of one
|
|
95
|
+
journey, at a time. The start is spelled as a UTC ISO string so it hashes the
|
|
96
|
+
same on every replica, and the type is part of the keyset, so a stay, the leg
|
|
97
|
+
that ended it, and that leg's `Journey` never collide.
|
|
98
|
+
|
|
99
|
+
A place keys on the Moves place id, which Moves issued and reused on every
|
|
100
|
+
visit. A named one is a `Venue`; an unnamed one (a spot Moves detected but never
|
|
101
|
+
named) is a `Place`. Where Moves matched a place against Foursquare or Facebook
|
|
102
|
+
it kept that service's id, which becomes a `sameAs` edge — so the Moves venue
|
|
103
|
+
collapses onto the same entity as that service's own `Venue`, the way an Arc
|
|
104
|
+
Timeline place does.
|
|
105
|
+
|
|
106
|
+
A leg's `activity` becomes the journey's `travelMode`, in the words Chronicle
|
|
107
|
+
already uses for those modes (Moves' `underground` is written `metro`, as Arc
|
|
108
|
+
Timeline writes it). Where Moves has no counterpart its own word stands:
|
|
109
|
+
`transport` is Moves saying "motorized, but I could not tell which", and
|
|
110
|
+
narrowing it to a car would be an invention. A leg's GPS `trackPoints` become
|
|
111
|
+
the journey's `path`, a GeoJSON `LineString` with per-point times in
|
|
112
|
+
`properties.coordTimes`.
|
|
113
|
+
|
|
114
|
+
The export carries no account identity — no name, no handle, no user id — so the
|
|
115
|
+
self is the per-source singleton, merged onto the real person through `@me`.
|
|
116
|
+
|
|
117
|
+
### Not ingested
|
|
118
|
+
|
|
119
|
+
- **Steps and calories.** Moves counted both, per leg and per day, and neither
|
|
120
|
+
has a home in the schema today.
|
|
121
|
+
- **Activity recorded while at a place.** Moves logged the walking done inside a
|
|
122
|
+
venue as an activity on the `place` segment. It has no track and goes
|
|
123
|
+
nowhere — it is a step count, which is the point above.
|
|
124
|
+
- **Foursquare category ids.** The export carries the ids, not the names, and a
|
|
125
|
+
raw id is not a category anyone can read.
|
|
126
|
+
- **A place's `type`** (`home`, `work`, `user`) — Moves' note on where the place
|
|
127
|
+
came from, which has no schema property.
|
|
128
|
+
- **The other cuts and roll-ups.** `places`, `activities`, and `summary` are
|
|
129
|
+
projections of the storyline; the weekly, monthly, and yearly folders are the
|
|
130
|
+
same days re-bucketed.
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
npm install
|
|
136
|
+
npm run build
|
|
137
|
+
npm test
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Tests use a small synthetic export written to a temporary folder, so they never
|
|
141
|
+
read the host's Moves data or any account.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Moves' activity vocabulary, mapped to the travel-mode names Chronicle already
|
|
2
|
+
// carries for the same modes (the Arc Timeline plugin writes `metro`, so Moves'
|
|
3
|
+
// `underground` is written the same way — one word per mode across sources).
|
|
4
|
+
//
|
|
5
|
+
// Where Moves has no counterpart the source's own word stands: `transport` is
|
|
6
|
+
// Moves saying "motorized, but I could not tell which", and narrowing it to a
|
|
7
|
+
// car would be an invention. `escalator` is Moves' own too.
|
|
8
|
+
//
|
|
9
|
+
// `group` is the coarse bucket Moves rolled an activity up under (walking,
|
|
10
|
+
// running, cycling, transport); it is derivable from the activity, so only the
|
|
11
|
+
// specific one is carried.
|
|
12
|
+
export const MOVES_TRAVEL_MODES = {
|
|
13
|
+
walking: 'walking',
|
|
14
|
+
running: 'running',
|
|
15
|
+
cycling: 'cycling',
|
|
16
|
+
transport: 'transport',
|
|
17
|
+
train: 'train',
|
|
18
|
+
bus: 'bus',
|
|
19
|
+
tram: 'tram',
|
|
20
|
+
underground: 'metro',
|
|
21
|
+
airplane: 'airplane',
|
|
22
|
+
boat: 'boat',
|
|
23
|
+
funicular: 'funicular',
|
|
24
|
+
escalator: 'escalator',
|
|
25
|
+
snowboarding: 'snowboarding',
|
|
26
|
+
};
|
|
27
|
+
export function travelModeName(activity) {
|
|
28
|
+
if (!activity)
|
|
29
|
+
return undefined;
|
|
30
|
+
return MOVES_TRAVEL_MODES[activity] ?? activity;
|
|
31
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Extractor, Record } from '@chronicle.app/etl';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import MovesTransformer from './MovesTransformer.js';
|
|
4
|
+
export declare class MovesExtractor extends Extractor<typeof MovesExtractor> {
|
|
5
|
+
static source: string;
|
|
6
|
+
static description: string;
|
|
7
|
+
static delivery: "export";
|
|
8
|
+
static strategy: string;
|
|
9
|
+
static recordTypes: string[];
|
|
10
|
+
static default: boolean;
|
|
11
|
+
static defaultTransformer: typeof MovesTransformer;
|
|
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.ZodString;
|
|
18
|
+
}, "strip", z.ZodTypeAny, {
|
|
19
|
+
input: string;
|
|
20
|
+
since?: Date | undefined;
|
|
21
|
+
until?: Date | undefined;
|
|
22
|
+
limit?: number | undefined;
|
|
23
|
+
}, {
|
|
24
|
+
input: string;
|
|
25
|
+
since?: Date | undefined;
|
|
26
|
+
until?: Date | undefined;
|
|
27
|
+
limit?: number | undefined;
|
|
28
|
+
}>;
|
|
29
|
+
extract(): AsyncGenerator<Record>;
|
|
30
|
+
determineCount(): Promise<number | null>;
|
|
31
|
+
private selectFrom;
|
|
32
|
+
private readDays;
|
|
33
|
+
private resolveInput;
|
|
34
|
+
private listDayFiles;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=MovesExtractor.d.ts.map
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { Extractor, selfAgent } from '@chronicle.app/etl';
|
|
2
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { dayKey, parseMovesTime, shiftDayKey } from '../time.js';
|
|
6
|
+
import MovesTransformer from './MovesTransformer.js';
|
|
7
|
+
// A daily storyline file is named for the local day it covers.
|
|
8
|
+
const DAILY_FILE = /^storyline_(\d{8})\.json$/;
|
|
9
|
+
export class MovesExtractor extends Extractor {
|
|
10
|
+
static source = 'moves-app';
|
|
11
|
+
static description = 'Location storyline: places and moves';
|
|
12
|
+
static delivery = 'export';
|
|
13
|
+
// The strategy, in Moves' own vocabulary: the archive the moves-export.com
|
|
14
|
+
// service handed you, which is the only way this data ever left the app.
|
|
15
|
+
static strategy = 'moves-export';
|
|
16
|
+
static recordTypes = ['places', 'moves'];
|
|
17
|
+
static default = true;
|
|
18
|
+
static defaultTransformer = MovesTransformer;
|
|
19
|
+
static schema = Extractor.schema.extend({
|
|
20
|
+
input: z
|
|
21
|
+
.string()
|
|
22
|
+
.describe('Path to the unzipped Moves export — the export folder, its json/ folder, the daily storyline folder, or a storyline JSON file'),
|
|
23
|
+
});
|
|
24
|
+
async *extract() {
|
|
25
|
+
const config = this.config;
|
|
26
|
+
// The export carries no account identity — no name, no handle, no user id.
|
|
27
|
+
// So the self is the per-source singleton, merged onto the real person
|
|
28
|
+
// through "@me" like every other source's self.
|
|
29
|
+
const person = selfAgent({
|
|
30
|
+
source: 'moves-app',
|
|
31
|
+
});
|
|
32
|
+
const selected = [];
|
|
33
|
+
// A segment that spans midnight is written into both days, so the same
|
|
34
|
+
// segment is read twice. Its start instant tells the copies apart — the
|
|
35
|
+
// instant, not the string Moves wrote it as, which is the same identity the
|
|
36
|
+
// transformer keys on and holds even if the two copies carry different
|
|
37
|
+
// offsets.
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
for await (const day of this.readDays(config.input)) {
|
|
40
|
+
for (const segment of [...(day.segments ?? [])].reverse()) {
|
|
41
|
+
for (const item of this.selectFrom(segment)) {
|
|
42
|
+
const start = parseMovesTime(item.data.startTime);
|
|
43
|
+
if (!start)
|
|
44
|
+
continue;
|
|
45
|
+
const marker = `${item.recordType}:${start.toISOString()}`;
|
|
46
|
+
if (seen.has(marker))
|
|
47
|
+
continue;
|
|
48
|
+
if (config.since && start < config.since)
|
|
49
|
+
continue;
|
|
50
|
+
if (config.until && start > config.until)
|
|
51
|
+
continue;
|
|
52
|
+
seen.add(marker);
|
|
53
|
+
selected.push(item);
|
|
54
|
+
if (this.shouldStopExtracting(selected.length))
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
if (this.shouldStopExtracting(selected.length))
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
if (this.shouldStopExtracting(selected.length))
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
this.logInitStep(`Emitting ${selected.length} records ` +
|
|
64
|
+
`(${selected.filter(s => s.recordType === 'places').length} places, ` +
|
|
65
|
+
`${selected.filter(s => s.recordType === 'moves').length} moves)`);
|
|
66
|
+
for (const { data, recordType } of selected) {
|
|
67
|
+
yield this.createRecord(data, { recordType, person });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async determineCount() {
|
|
71
|
+
const config = this.config;
|
|
72
|
+
try {
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
for await (const day of this.readDays(config.input)) {
|
|
75
|
+
for (const segment of day.segments ?? []) {
|
|
76
|
+
for (const item of this.selectFrom(segment)) {
|
|
77
|
+
const start = parseMovesTime(item.data.startTime);
|
|
78
|
+
if (!start)
|
|
79
|
+
continue;
|
|
80
|
+
if (config.since && start < config.since)
|
|
81
|
+
continue;
|
|
82
|
+
if (config.until && start > config.until)
|
|
83
|
+
continue;
|
|
84
|
+
seen.add(`${item.recordType}:${start.toISOString()}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const limit = this.getEffectiveLimit();
|
|
89
|
+
return limit === null ? seen.size : Math.min(seen.size, limit);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// The records a segment yields. A stay is one record. Movement splits into
|
|
96
|
+
// its legs — each carries a single mode of travel, its own distance, and its
|
|
97
|
+
// own track, which is what a Journey holds. A leg recorded while at a place
|
|
98
|
+
// (walking around inside a venue) went nowhere, so it yields nothing.
|
|
99
|
+
selectFrom(segment) {
|
|
100
|
+
if (segment.type === 'place') {
|
|
101
|
+
return segment.place ? [{ data: segment, recordType: 'places' }] : [];
|
|
102
|
+
}
|
|
103
|
+
return (segment.activities ?? []).map(activity => ({
|
|
104
|
+
data: activity,
|
|
105
|
+
recordType: 'moves',
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
// The storyline days, newest first, so `--limit N` yields the N most recent
|
|
109
|
+
// records. A day file is read and released one at a time; the single full
|
|
110
|
+
// file is read whole, because it is one JSON array.
|
|
111
|
+
async *readDays(input) {
|
|
112
|
+
const config = this.config;
|
|
113
|
+
const source = await this.resolveInput(input);
|
|
114
|
+
// The since/until window, widened by a day at each edge: a file is named
|
|
115
|
+
// for the local day it covers and the window is UTC, so a file just outside
|
|
116
|
+
// the window can still hold a segment inside it. The per-segment check is
|
|
117
|
+
// what decides; this only skips files that cannot contribute.
|
|
118
|
+
const sinceKey = config.since ? shiftDayKey(dayKey(config.since), -1) : undefined;
|
|
119
|
+
const untilKey = config.until ? shiftDayKey(dayKey(config.until), 1) : undefined;
|
|
120
|
+
if (source.kind === 'file') {
|
|
121
|
+
const days = JSON.parse(await readFile(source.path, 'utf-8'));
|
|
122
|
+
for (const day of [...days].reverse()) {
|
|
123
|
+
if (sinceKey && day.date < sinceKey)
|
|
124
|
+
break;
|
|
125
|
+
if (untilKey && day.date > untilKey)
|
|
126
|
+
continue;
|
|
127
|
+
yield day;
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
for (const { path, key } of source.files) {
|
|
132
|
+
// Day keys sort chronologically, so every remaining file is older still.
|
|
133
|
+
if (sinceKey && key < sinceKey)
|
|
134
|
+
break;
|
|
135
|
+
if (untilKey && key > untilKey)
|
|
136
|
+
continue;
|
|
137
|
+
const days = JSON.parse(await readFile(path, 'utf-8'));
|
|
138
|
+
for (const day of [...days].reverse())
|
|
139
|
+
yield day;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Resolve the input to the storyline itself. The input may be the folder of
|
|
143
|
+
// daily files, a single storyline JSON file, or a parent that is easier to
|
|
144
|
+
// point at — the export root or its json/ folder. The daily folder wins when
|
|
145
|
+
// both are present: it holds the same data, and a limited or windowed run
|
|
146
|
+
// only opens the files it needs.
|
|
147
|
+
async resolveInput(input) {
|
|
148
|
+
const dailyDirs = [
|
|
149
|
+
input,
|
|
150
|
+
join(input, 'daily', 'storyline'),
|
|
151
|
+
join(input, 'json', 'daily', 'storyline'),
|
|
152
|
+
];
|
|
153
|
+
for (const dir of dailyDirs) {
|
|
154
|
+
const files = await this.listDayFiles(dir);
|
|
155
|
+
if (files.length > 0)
|
|
156
|
+
return { kind: 'daily', files };
|
|
157
|
+
}
|
|
158
|
+
const fullFiles = [
|
|
159
|
+
input,
|
|
160
|
+
join(input, 'full', 'storyline.json'),
|
|
161
|
+
join(input, 'json', 'full', 'storyline.json'),
|
|
162
|
+
];
|
|
163
|
+
for (const path of fullFiles) {
|
|
164
|
+
if (!path.endsWith('.json'))
|
|
165
|
+
continue;
|
|
166
|
+
try {
|
|
167
|
+
if ((await stat(path)).isFile())
|
|
168
|
+
return { kind: 'file', path };
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
continue; // Not there — try the next candidate.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
throw new Error(`No Moves storyline found under ${input} — expected a folder of ` +
|
|
175
|
+
`storyline_YYYYMMDD.json files or a storyline.json, in there, in ` +
|
|
176
|
+
`daily/storyline/, in full/, or under json/`);
|
|
177
|
+
}
|
|
178
|
+
// The daily storyline files in a folder, newest first. An empty list means
|
|
179
|
+
// this folder is not the one.
|
|
180
|
+
async listDayFiles(dir) {
|
|
181
|
+
let entries;
|
|
182
|
+
try {
|
|
183
|
+
entries = await readdir(dir);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return []; // Not a directory — the caller tries the next candidate.
|
|
187
|
+
}
|
|
188
|
+
return entries
|
|
189
|
+
.map(name => DAILY_FILE.exec(name))
|
|
190
|
+
.filter((match) => match !== null)
|
|
191
|
+
.map(match => ({ path: join(dir, match[0]), key: match[1] }))
|
|
192
|
+
.sort((a, b) => b.key.localeCompare(a.key));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ChronicleTransformer, Record } from '@chronicle.app/etl';
|
|
2
|
+
import { ActionAndChildren } from '@chronicle.app/schema';
|
|
3
|
+
export default class MovesTransformer extends ChronicleTransformer {
|
|
4
|
+
transform(record: Record): Promise<ActionAndChildren[]>;
|
|
5
|
+
private buildVisit;
|
|
6
|
+
private buildTravel;
|
|
7
|
+
private spanKey;
|
|
8
|
+
private buildPlace;
|
|
9
|
+
private buildLocation;
|
|
10
|
+
private buildPlaceSameAs;
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=MovesTransformer.d.ts.map
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { ChronicleTransformer } from '@chronicle.app/etl';
|
|
2
|
+
import { travelModeName } from '../activityTypes.js';
|
|
3
|
+
import { movesTimeToIso } from '../time.js';
|
|
4
|
+
import { buildTrackFeature } from '../track.js';
|
|
5
|
+
export default class MovesTransformer extends ChronicleTransformer {
|
|
6
|
+
async transform(record) {
|
|
7
|
+
const context = record.context;
|
|
8
|
+
if (context.recordType === 'places') {
|
|
9
|
+
return this.buildVisit(record.data, context.person);
|
|
10
|
+
}
|
|
11
|
+
if (context.recordType === 'moves') {
|
|
12
|
+
return this.buildTravel(record.data, context.person);
|
|
13
|
+
}
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
buildVisit(segment, person) {
|
|
17
|
+
const { place } = segment;
|
|
18
|
+
const startTime = movesTimeToIso(segment.startTime);
|
|
19
|
+
const endTime = movesTimeToIso(segment.endTime);
|
|
20
|
+
if (!place || !startTime)
|
|
21
|
+
return [];
|
|
22
|
+
const action = {
|
|
23
|
+
'@type': 'VisitAction',
|
|
24
|
+
'@key': this.spanKey(startTime),
|
|
25
|
+
source: 'moves-app',
|
|
26
|
+
agent: person,
|
|
27
|
+
object: this.buildPlace(place),
|
|
28
|
+
startTime,
|
|
29
|
+
...(endTime && { endTime }),
|
|
30
|
+
};
|
|
31
|
+
return [action];
|
|
32
|
+
}
|
|
33
|
+
buildTravel(activity, person) {
|
|
34
|
+
const startTime = movesTimeToIso(activity.startTime);
|
|
35
|
+
const endTime = movesTimeToIso(activity.endTime);
|
|
36
|
+
if (!startTime)
|
|
37
|
+
return [];
|
|
38
|
+
// The action stays sparse; the detail lives on the Journey it produces.
|
|
39
|
+
const mode = travelModeName(activity.activity);
|
|
40
|
+
const path = buildTrackFeature(activity.trackPoints);
|
|
41
|
+
const journey = {
|
|
42
|
+
'@type': 'Journey',
|
|
43
|
+
'@key': this.spanKey(startTime),
|
|
44
|
+
source: 'moves-app',
|
|
45
|
+
...(activity.distance !== undefined && { distance: activity.distance }),
|
|
46
|
+
...(mode && { travelMode: mode }),
|
|
47
|
+
...(path && { path }),
|
|
48
|
+
};
|
|
49
|
+
const action = {
|
|
50
|
+
'@type': 'TravelAction',
|
|
51
|
+
'@key': this.spanKey(startTime),
|
|
52
|
+
source: 'moves-app',
|
|
53
|
+
agent: person,
|
|
54
|
+
result: journey,
|
|
55
|
+
startTime,
|
|
56
|
+
...(endTime && { endTime }),
|
|
57
|
+
};
|
|
58
|
+
return [action];
|
|
59
|
+
}
|
|
60
|
+
// A segment carries no id in the export — Moves numbered its places, never
|
|
61
|
+
// its segments. What identifies one is when it began: a person is in one
|
|
62
|
+
// place, and on one leg of one journey, at a time. So the start instant is
|
|
63
|
+
// the natural key, spelled as a UTC ISO string so it hashes the same on every
|
|
64
|
+
// replica. The type is part of the keyset, so a stay, the leg that ended it,
|
|
65
|
+
// and that leg's Journey never collide.
|
|
66
|
+
spanKey(startTime) {
|
|
67
|
+
return ['@type', 'source', { key: 'startTime', value: startTime }];
|
|
68
|
+
}
|
|
69
|
+
// A named place is a point of interest → a Venue; an unnamed one is a spot
|
|
70
|
+
// Moves detected but never named → a Place. Either way it keys on the Moves
|
|
71
|
+
// place id, which is the id Moves issued and reused on every visit, and either
|
|
72
|
+
// way it carries whatever identity edges the place has: the name decides the
|
|
73
|
+
// type, never whether a foreign id is reported.
|
|
74
|
+
buildPlace(place) {
|
|
75
|
+
const sameAs = this.buildPlaceSameAs(place);
|
|
76
|
+
const common = {
|
|
77
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
78
|
+
source: 'moves-app',
|
|
79
|
+
sourceId: String(place.id),
|
|
80
|
+
// The place's coordinates are its own Location value — a keyless
|
|
81
|
+
// (content-interned) geo node, not entity properties on the place.
|
|
82
|
+
location: this.buildLocation(place),
|
|
83
|
+
...(sameAs.length > 0 && { sameAs }),
|
|
84
|
+
};
|
|
85
|
+
return place.name
|
|
86
|
+
? { '@type': 'Venue', ...common, name: place.name }
|
|
87
|
+
: { '@type': 'Place', ...common };
|
|
88
|
+
}
|
|
89
|
+
buildLocation(place) {
|
|
90
|
+
return {
|
|
91
|
+
'@type': 'Location',
|
|
92
|
+
latitude: place.location.lat,
|
|
93
|
+
longitude: place.location.lon,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// Moves matched some places against Foursquare and Facebook and kept those
|
|
97
|
+
// services' ids. Emit a sameAs edge per id so the Moves place collapses into
|
|
98
|
+
// the same entity as that service's own Venue (which keys identically on
|
|
99
|
+
// [@type, source, sourceId]). The id is a real handle from the export — never
|
|
100
|
+
// synthesized — and each edge carries the title when there is one, so a
|
|
101
|
+
// yet-unseen foreign venue still has a name when this edge is what mints it.
|
|
102
|
+
buildPlaceSameAs(place) {
|
|
103
|
+
const edges = [
|
|
104
|
+
{ source: 'foursquare', sourceId: place.foursquareId },
|
|
105
|
+
{ source: 'facebook', sourceId: place.facebookPlaceId },
|
|
106
|
+
];
|
|
107
|
+
return edges
|
|
108
|
+
.filter((edge) => Boolean(edge.sourceId))
|
|
109
|
+
.map(edge => ({
|
|
110
|
+
'@type': 'Venue',
|
|
111
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
112
|
+
source: edge.source,
|
|
113
|
+
sourceId: edge.sourceId,
|
|
114
|
+
...(place.name && { name: place.name }),
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/time.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { MovesTime } from './types.js';
|
|
2
|
+
export declare function parseMovesTime(value: MovesTime): Date | undefined;
|
|
3
|
+
export declare function movesTimeToIso(value: MovesTime): string | undefined;
|
|
4
|
+
export declare function dayKey(date: Date): string;
|
|
5
|
+
export declare function shiftDayKey(key: string, days: number): string;
|
|
6
|
+
//# sourceMappingURL=time.d.ts.map
|
package/dist/time.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Moves writes times in the ISO 8601 *basic* format — "20140510T134349-0300"
|
|
2
|
+
// (local wall clock plus the offset it was read at) or "20141021T042519Z".
|
|
3
|
+
// JavaScript's Date only parses the extended format, so the separators go back
|
|
4
|
+
// in before parsing. The offset is part of the string, so the instant is exact
|
|
5
|
+
// and does not depend on where the import runs.
|
|
6
|
+
const BASIC_TIME = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z|[+-]\d{4})$/;
|
|
7
|
+
export function parseMovesTime(value) {
|
|
8
|
+
const match = BASIC_TIME.exec(value);
|
|
9
|
+
if (!match)
|
|
10
|
+
return undefined;
|
|
11
|
+
const [, year, month, day, hour, minute, second, zone] = match;
|
|
12
|
+
const offset = zone === 'Z' ? 'Z' : `${zone.slice(0, 3)}:${zone.slice(3)}`;
|
|
13
|
+
const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}${offset}`);
|
|
14
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
15
|
+
}
|
|
16
|
+
// The same instant as an ISO string, which is what the payload carries and what
|
|
17
|
+
// the dedupe keysets hash. A segment has no id in the export, so its start is
|
|
18
|
+
// its identity — the value has to be a plain string, spelled the same way on
|
|
19
|
+
// every run and every replica.
|
|
20
|
+
export function movesTimeToIso(value) {
|
|
21
|
+
return parseMovesTime(value)?.toISOString();
|
|
22
|
+
}
|
|
23
|
+
// The "YYYYMMDD" day key a daily storyline file is named for.
|
|
24
|
+
export function dayKey(date) {
|
|
25
|
+
return date.toISOString().slice(0, 10).replaceAll('-', '');
|
|
26
|
+
}
|
|
27
|
+
// A day key shifted by whole days, used to widen a since/until window before it
|
|
28
|
+
// is applied to file names. A file is named for the *local* day it covers, and
|
|
29
|
+
// the window is given as UTC, so the two disagree by up to a day at the edges.
|
|
30
|
+
// The exact filter is the per-segment start-time check; this one only decides
|
|
31
|
+
// which files are worth opening, so it errs one day wide on each side.
|
|
32
|
+
export function shiftDayKey(key, days) {
|
|
33
|
+
const ms = Date.UTC(Number(key.slice(0, 4)), Number(key.slice(4, 6)) - 1, Number(key.slice(6, 8)));
|
|
34
|
+
return dayKey(new Date(ms + days * 86_400_000));
|
|
35
|
+
}
|
package/dist/track.d.ts
ADDED
package/dist/track.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { movesTimeToIso } from './time.js';
|
|
2
|
+
// A leg's GPS trail as a GeoJSON `Feature` (LineString) with per-point time
|
|
3
|
+
// carried in `properties.coordTimes`, the Mapbox convention — so it round-trips
|
|
4
|
+
// to GPX and renders directly. Stored verbatim as the Journey's `path` (a JSON
|
|
5
|
+
// string in a Text property), the same encoding the Arc Timeline plugin writes.
|
|
6
|
+
//
|
|
7
|
+
// Moves records only position and time per fix; there is no speed or course to
|
|
8
|
+
// carry, so those arrays are absent rather than filled with nulls.
|
|
9
|
+
//
|
|
10
|
+
// Returns undefined when there are fewer than two fixes — a line needs two
|
|
11
|
+
// points, and Moves leaves the track empty on legs it inferred without GPS.
|
|
12
|
+
export function buildTrackFeature(points) {
|
|
13
|
+
if (!points || points.length < 2)
|
|
14
|
+
return undefined;
|
|
15
|
+
const coordinates = [];
|
|
16
|
+
const coordTimes = [];
|
|
17
|
+
for (const point of points) {
|
|
18
|
+
if (typeof point.lat !== 'number' || typeof point.lon !== 'number')
|
|
19
|
+
continue;
|
|
20
|
+
const time = movesTimeToIso(point.time);
|
|
21
|
+
if (!time)
|
|
22
|
+
continue;
|
|
23
|
+
coordinates.push([point.lon, point.lat]);
|
|
24
|
+
coordTimes.push(time);
|
|
25
|
+
}
|
|
26
|
+
if (coordinates.length < 2)
|
|
27
|
+
return undefined;
|
|
28
|
+
return JSON.stringify({
|
|
29
|
+
type: 'Feature',
|
|
30
|
+
geometry: { type: 'LineString', coordinates },
|
|
31
|
+
properties: { coordTimes },
|
|
32
|
+
});
|
|
33
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { AgentAndChildren } from '@chronicle.app/schema';
|
|
2
|
+
export type MovesTime = string;
|
|
3
|
+
export interface MovesLocation {
|
|
4
|
+
lat: number;
|
|
5
|
+
lon: number;
|
|
6
|
+
}
|
|
7
|
+
export interface MovesPlace {
|
|
8
|
+
id: number;
|
|
9
|
+
name?: string;
|
|
10
|
+
type: 'home' | 'work' | 'user' | 'foursquare' | 'facebook' | 'unknown';
|
|
11
|
+
location: MovesLocation;
|
|
12
|
+
foursquareId?: string;
|
|
13
|
+
foursquareCategoryIds?: string[];
|
|
14
|
+
facebookPlaceId?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface MovesTrackPoint {
|
|
17
|
+
lat: number;
|
|
18
|
+
lon: number;
|
|
19
|
+
time: MovesTime;
|
|
20
|
+
}
|
|
21
|
+
export interface MovesActivity {
|
|
22
|
+
activity: string;
|
|
23
|
+
group?: string;
|
|
24
|
+
manual?: boolean;
|
|
25
|
+
startTime: MovesTime;
|
|
26
|
+
endTime: MovesTime;
|
|
27
|
+
duration?: number;
|
|
28
|
+
distance?: number;
|
|
29
|
+
steps?: number;
|
|
30
|
+
calories?: number;
|
|
31
|
+
trackPoints?: MovesTrackPoint[];
|
|
32
|
+
}
|
|
33
|
+
export interface MovesSegment {
|
|
34
|
+
type: 'place' | 'move' | 'off';
|
|
35
|
+
startTime: MovesTime;
|
|
36
|
+
endTime: MovesTime;
|
|
37
|
+
place?: MovesPlace;
|
|
38
|
+
activities?: MovesActivity[];
|
|
39
|
+
lastUpdate?: MovesTime;
|
|
40
|
+
}
|
|
41
|
+
export interface MovesDay {
|
|
42
|
+
date: string;
|
|
43
|
+
segments?: MovesSegment[];
|
|
44
|
+
lastUpdate?: MovesTime;
|
|
45
|
+
}
|
|
46
|
+
export interface MovesRecordContext {
|
|
47
|
+
recordType: 'places' | 'moves';
|
|
48
|
+
person: AgentAndChildren;
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=types.d.ts.map
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chronicle.app/moves-app",
|
|
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
|
+
"moves-app"
|
|
19
|
+
],
|
|
20
|
+
"author": "Andrew Louis",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"description": "Chronicle plugin for the location storyline from a Moves data export",
|
|
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/moves-app"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public",
|
|
39
|
+
"registry": "https://registry.npmjs.org/"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"zod": "^3.24.1"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@chronicle.app/etl": ">=0.1.0 <1.0.0",
|
|
46
|
+
"@chronicle.app/schema": ">=0.1.0 <1.0.0"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=22.13.0"
|
|
50
|
+
},
|
|
51
|
+
"exports": {
|
|
52
|
+
".": {
|
|
53
|
+
"types": "./dist/index.d.ts",
|
|
54
|
+
"import": "./dist/index.js"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"chronicle": {
|
|
58
|
+
"plugin": true,
|
|
59
|
+
"sources": {
|
|
60
|
+
"moves-app": {
|
|
61
|
+
"strategies": {
|
|
62
|
+
"moves-export": {
|
|
63
|
+
"delivery": "export",
|
|
64
|
+
"recordTypes": [
|
|
65
|
+
"places",
|
|
66
|
+
"moves"
|
|
67
|
+
],
|
|
68
|
+
"default": true
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|