create-evda 0.1.8 → 0.1.10
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/CHANGELOG.md +109 -1
- package/README.md +20 -7
- package/bin/evda.mjs +277 -7
- package/create-evda.mjs +17 -4
- package/dist/pairing.mjs +2932 -1684
- package/hooks/evda-day-writer-guard.mjs +7 -0
- package/lib/acknowledge.mjs +187 -0
- package/lib/adopt.mjs +488 -0
- package/lib/agents.mjs +1 -1
- package/lib/attested-host.mjs +269 -0
- package/lib/browse.mjs +354 -0
- package/lib/circles.mjs +5 -1
- package/lib/clients.mjs +29 -2
- package/lib/connect.mjs +9 -2
- package/lib/connecting-apps.mjs +56 -0
- package/lib/connector-tools.mjs +85 -0
- package/lib/corrections.mjs +34 -0
- package/lib/credentials-migrate.mjs +120 -0
- package/lib/day-file.mjs +173 -15
- package/lib/day-writer.mjs +110 -0
- package/lib/doctor.mjs +45 -3
- package/lib/every-chat.mjs +128 -0
- package/lib/filechanges.mjs +203 -68
- package/lib/gather-apply.mjs +176 -26
- package/lib/gather.mjs +119 -17
- package/lib/html-notes.mjs +124 -0
- package/lib/keep-hook.mjs +61 -8
- package/lib/keychain.mjs +181 -0
- package/lib/manifest.mjs +5 -1
- package/lib/mcp-auth.mjs +41 -1
- package/lib/mcp-http.mjs +42 -4
- package/lib/mcp.mjs +1198 -76
- package/lib/measurement-log.mjs +249 -0
- package/lib/messages.mjs +5 -2
- package/lib/migrate-people.mjs +54 -20
- package/lib/pairing.mjs +243 -5
- package/lib/parity.mjs +47 -13
- package/lib/person-folders.mjs +89 -0
- package/lib/plugin-hook.mjs +109 -0
- package/lib/plugin.mjs +208 -0
- package/lib/presets.mjs +26 -5
- package/lib/publish.mjs +55 -10
- package/lib/qr.mjs +317 -0
- package/lib/record.mjs +74 -0
- package/lib/routines.mjs +54 -4
- package/lib/sigstore-trust.json +20 -0
- package/lib/skills.mjs +1 -1
- package/lib/sync-lock.mjs +143 -0
- package/lib/sync-rules.mjs +154 -1
- package/lib/takeout.mjs +169 -0
- package/lib/tdx-attestation.mjs +412 -0
- package/lib/tdx-trust.json +10 -0
- package/lib/tools.mjs +1040 -31
- package/lib/touches.mjs +427 -0
- package/lib/update-check.mjs +30 -0
- package/lib/watch.mjs +160 -0
- package/lib/yaml.mjs +8 -4
- package/package.json +2 -1
- package/skills/evda/SKILL.md +14 -0
- package/templates/AGENTS.md +81 -10
- package/templates/evda-digest.md +5 -1
package/lib/sync-rules.mjs
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
// And one kind cannot sync yet: a file larger than the relay's blob lane takes (64 MB, less the
|
|
22
22
|
// seal), which fails the coverage check until the plan excludes it.
|
|
23
23
|
|
|
24
|
-
import {
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { lstatSync, readdirSync, statfsSync } from "node:fs";
|
|
25
26
|
import { join } from "node:path";
|
|
26
27
|
|
|
27
28
|
export const EXCLUDE_NAMES = [".DS_Store", "node_modules", ".git", ".Trash", "Icon\r"];
|
|
@@ -91,3 +92,155 @@ export function scanSkip(extra = []) {
|
|
|
91
92
|
return isPlaceholder(name) || st.size > MAX_SYNC_BYTES;
|
|
92
93
|
};
|
|
93
94
|
}
|
|
95
|
+
|
|
96
|
+
// ─── What travels (HQ-2017) ─────────────────────────────────────────────────────────────────────
|
|
97
|
+
//
|
|
98
|
+
// Only text travels by default. His decision, 2026-09-30: "I don't think it needs to carry media. We
|
|
99
|
+
// don't need media to be the thing that travels. That can be one device." A pairing sealed every
|
|
100
|
+
// voice memo, video and PDF of a 1.8 GB iCloud record into the device database (21 GB, the disk full).
|
|
101
|
+
//
|
|
102
|
+
// One rule, read by the scan (which files are read), the minter and the catch-up (what a Change
|
|
103
|
+
// carries) and parity (what a shadow must hold), on the laptop's `evda sync` and on the shadow tick:
|
|
104
|
+
//
|
|
105
|
+
// text a name ending in one of TEXT_EXTENSIONS. Minted with its bytes, as before.
|
|
106
|
+
// carried any other file under a path the manifest names in `sync.carry`. Its bytes ride the
|
|
107
|
+
// blob lane, as every binary did before.
|
|
108
|
+
// reference everything else. Its Change names the file (path, name, size, sha256) and carries no
|
|
109
|
+
// bytes; nothing is sealed into the blob store and nothing is uploaded.
|
|
110
|
+
//
|
|
111
|
+
// Decided from the NAME alone, never from the bytes, because reading a file iCloud holds only in the
|
|
112
|
+
// cloud is what downloads it.
|
|
113
|
+
|
|
114
|
+
export const TEXT_EXTENSIONS = new Set([
|
|
115
|
+
"md", "markdown", "txt", "text", "canvas", "base", "json", "jsonl", "yaml", "yml", "csv", "tsv",
|
|
116
|
+
"html", "htm", "css", "js", "mjs", "cjs", "ts", "tsx", "jsx", "py", "sh", "zsh", "toml", "xml",
|
|
117
|
+
"ini", "tex", "bib", "org", "rst", "sql", "log",
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
const extOf = (rel) => {
|
|
121
|
+
const name = rel.slice(rel.lastIndexOf("/") + 1);
|
|
122
|
+
const dot = name.lastIndexOf(".");
|
|
123
|
+
return dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** Whether a path is text by its name. */
|
|
127
|
+
export const isTextPath = (rel) => TEXT_EXTENSIONS.has(extOf(rel));
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The paths whose bytes travel whatever they are, from the manifest: `"sync": { "carry": [...] }`,
|
|
131
|
+
* each a path relative to the record covering itself and everything under it. Anything else in
|
|
132
|
+
* that key is ignored.
|
|
133
|
+
*/
|
|
134
|
+
export function carryList(manifest) {
|
|
135
|
+
const list = manifest?.sync?.carry;
|
|
136
|
+
return Array.isArray(list) ? list.filter((p) => typeof p === "string").map(norm).filter((p) => p !== "") : [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** `(rel) -> "text" | "carried" | "reference"`, for a carry list from `carryList`. */
|
|
140
|
+
export function fileClass(carry = []) {
|
|
141
|
+
const paths = carry.map(norm).filter((p) => p !== "");
|
|
142
|
+
return (rel) => {
|
|
143
|
+
if (isTextPath(rel)) return "text";
|
|
144
|
+
const r = norm(rel);
|
|
145
|
+
return paths.some((p) => r === p || r.startsWith(`${p}/`)) ? "carried" : "reference";
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── Files iCloud holds only in the cloud (HQ-2017) ─────────────────────────────────────────────
|
|
150
|
+
//
|
|
151
|
+
// A dataless file is listed with its full size and has no bytes on this disk; opening it makes iCloud
|
|
152
|
+
// download it. macOS marks it with SF_DATALESS in st_flags. Node's stat does not expose st_flags, so
|
|
153
|
+
// a file is a candidate when it has a size and no blocks (the strong sign), and a candidate is
|
|
154
|
+
// confirmed by reading its flags with stat(1), which does not open it. A file whose bytes are local
|
|
155
|
+
// is never a candidate, so stat(1) runs only for the few that are.
|
|
156
|
+
export const SF_DATALESS = 0x40000000;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The paths among `rels` (relative to `root`) that are dataless, read without opening any of them.
|
|
160
|
+
* `statBin` is stat(1); `EVDA_STAT_BIN` replaces it, so a test can stand in for iCloud.
|
|
161
|
+
*/
|
|
162
|
+
export function datalessAmong(root, rels, { env = process.env, statOf = (rel) => lstatSync(join(root, rel)) } = {}) {
|
|
163
|
+
if (process.platform !== "darwin" && !env.EVDA_STAT_BIN) return new Set();
|
|
164
|
+
const candidates = [];
|
|
165
|
+
for (const rel of rels) {
|
|
166
|
+
let st;
|
|
167
|
+
try { st = statOf(rel); } catch { continue; }
|
|
168
|
+
if (st.isFile() && st.size > 0 && st.blocks === 0) candidates.push(rel);
|
|
169
|
+
}
|
|
170
|
+
const out = new Set();
|
|
171
|
+
const bin = env.EVDA_STAT_BIN || "/usr/bin/stat";
|
|
172
|
+
for (let i = 0; i < candidates.length; i += 200) {
|
|
173
|
+
const chunk = candidates.slice(i, i + 200);
|
|
174
|
+
let text = "";
|
|
175
|
+
try {
|
|
176
|
+
text = execFileSync(bin, ["-f", "%f", ...chunk.map((r) => join(root, r))], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
177
|
+
} catch (e) {
|
|
178
|
+
text = typeof e?.stdout === "string" ? e.stdout : "";
|
|
179
|
+
}
|
|
180
|
+
const flags = text.split("\n").filter((l) => l.trim() !== "");
|
|
181
|
+
chunk.forEach((rel, j) => {
|
|
182
|
+
const f = Number(flags[j]);
|
|
183
|
+
if (Number.isFinite(f) && (f & SF_DATALESS) !== 0) out.add(rel);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── The owner's size limit (HQ-2017) ───────────────────────────────────────────────────────────
|
|
190
|
+
//
|
|
191
|
+
// `"sync": { "maxBytes": N }` in evda.json: a file larger than N bytes is never opened by a sync or a
|
|
192
|
+
// tick, whatever its class, and is reported. Missing, zero, negative or not a number reads as
|
|
193
|
+
// MAX_SYNC_BYTES; above it reads as MAX_SYNC_BYTES, since the relay refuses anything larger anyway.
|
|
194
|
+
// On 2026-09-30 a sync read a 686 MB video whole, to seal it.
|
|
195
|
+
|
|
196
|
+
/** The size limit a manifest sets, in bytes, clamped to the relay's. */
|
|
197
|
+
export function maxSyncBytes(manifest) {
|
|
198
|
+
const n = manifest?.sync?.maxBytes;
|
|
199
|
+
if (typeof n !== "number" || !Number.isFinite(n) || n <= 0) return MAX_SYNC_BYTES;
|
|
200
|
+
return Math.min(Math.floor(n), MAX_SYNC_BYTES);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const mbOf = (n) => (n < 1024 ** 2 ? `${n} bytes` : `${(n / 1024 ** 2).toFixed(1)} MB`);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* What a scan does with each listed file, decided before a byte is read: a Map from path to
|
|
207
|
+
* "read" (text or carried: minted with its bytes), "reference" (hashed for its identity, minted
|
|
208
|
+
* without its bytes) or { skip, kind } (never opened, and reported). A dataless file is skipped, text
|
|
209
|
+
* or not: its bytes are not on this disk, and reading them is what downloads them. A file over
|
|
210
|
+
* `maxBytes` is skipped too, marked `kind: "size"`: reading it whole is the cost being avoided.
|
|
211
|
+
*/
|
|
212
|
+
export function scanClasses(root, carry = [], { env = process.env, maxBytes = MAX_SYNC_BYTES } = {}) {
|
|
213
|
+
const classOf = fileClass(carry);
|
|
214
|
+
const limit = Number.isFinite(maxBytes) && maxBytes > 0 ? Math.min(maxBytes, MAX_SYNC_BYTES) : MAX_SYNC_BYTES;
|
|
215
|
+
return async (stats) => {
|
|
216
|
+
const rels = stats.map((s) => s.path);
|
|
217
|
+
const dataless = datalessAmong(root, rels, { env });
|
|
218
|
+
const out = new Map();
|
|
219
|
+
for (const s of stats) {
|
|
220
|
+
const rel = s.path;
|
|
221
|
+
if (dataless.has(rel)) out.set(rel, { skip: "in iCloud only; not downloaded" });
|
|
222
|
+
else if (s.size > limit) out.set(rel, { skip: `${mbOf(s.size)}, over the ${mbOf(limit)} size limit; not read`, kind: "size" });
|
|
223
|
+
else out.set(rel, classOf(rel) === "reference" ? "reference" : "read");
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ─── The disk floor (HQ-2017) ───────────────────────────────────────────────────────────────────
|
|
230
|
+
//
|
|
231
|
+
// A sync or tick stops before the disk its state is on falls below a floor, and says why in one line.
|
|
232
|
+
// The default is 5 GiB; `EVDA_DISK_FLOOR_BYTES` sets another (a test sets one above what is free).
|
|
233
|
+
export const DISK_FLOOR_BYTES = 5 * 1024 ** 3;
|
|
234
|
+
|
|
235
|
+
const gib = (n) => `${(n / 1024 ** 3).toFixed(1)} GB`;
|
|
236
|
+
|
|
237
|
+
/** `(bytes) -> null | "<one line>"`: whether writing `bytes` more under `dir` keeps the floor. */
|
|
238
|
+
export function diskRoom(dir, { env = process.env, freeOf = (d) => { const s = statfsSync(d); return Number(s.bavail) * Number(s.bsize); } } = {}) {
|
|
239
|
+
const set = Number(env.EVDA_DISK_FLOOR_BYTES);
|
|
240
|
+
const floor = env.EVDA_DISK_FLOOR_BYTES !== undefined && Number.isFinite(set) && set >= 0 ? set : DISK_FLOOR_BYTES;
|
|
241
|
+
return (bytes = 0) => {
|
|
242
|
+
const free = freeOf(dir);
|
|
243
|
+
if (free - bytes >= floor) return null;
|
|
244
|
+
return `Stopped: this disk has ${gib(free)} free, and syncing more would take it below the ${gib(floor)} floor. Nothing more was written; free some space and sync again.`;
|
|
245
|
+
};
|
|
246
|
+
}
|
package/lib/takeout.mjs
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Google Takeout's sidecars (HQ-1368). Takeout writes a `.json` file beside each photo and each Keep
|
|
2
|
+
// note, holding what the app knew about it: when a photo was taken, its caption, when a note was made
|
|
3
|
+
// and last edited. Gathered as files of their own they became captures while their photo or note went
|
|
4
|
+
// somewhere else (the 2026-09-27 trial). So a gather pairs each sidecar with the file it describes,
|
|
5
|
+
// folds the sidecar's date and caption into that file's entry, and never places the sidecar itself.
|
|
6
|
+
//
|
|
7
|
+
// The names Takeout gives them, all paired here:
|
|
8
|
+
// IMG_1.jpg.json the older name
|
|
9
|
+
// IMG_1.jpg.supplemental-metadata.json the newer one
|
|
10
|
+
// IMG_1.jpg.supplemental-metad.json, .supp.json cut short: Takeout caps a name near 51 characters
|
|
11
|
+
// <the first 40-odd characters of a long name>.json the photo's own name cut short
|
|
12
|
+
// IMG_1.jpg(1).json, IMG_1.jpg.supplemental-metadata(1).json the sidecar of IMG_1(1).jpg
|
|
13
|
+
// Note.json a Keep note's, beside Note.html
|
|
14
|
+
// A photo's `-edited` copy has no sidecar of its own and shares its original's.
|
|
15
|
+
//
|
|
16
|
+
// What is folded, and what is not: a photo's date taken (`photoTakenTime.timestamp`, UTC epoch seconds,
|
|
17
|
+
// kept to the second) and whether it has a caption; a Keep note's created and edited times. Its place
|
|
18
|
+
// (`geoData`) and the people Google tagged in it are never read into the proposal or the record. The
|
|
19
|
+
// caption is the owner's words, so the proposal only says there is one; apply reads it again from the
|
|
20
|
+
// sidecar, unchanged since the gather, and keeps it on the record's gathered list beside the photo.
|
|
21
|
+
//
|
|
22
|
+
// pairSidecars(files, read) { folded: Map<rel, facts>, sidecars: [{ rel, why, of }] }
|
|
23
|
+
// captionOf(buf) the caption a photo sidecar holds, or null
|
|
24
|
+
//
|
|
25
|
+
// EVDA_GATHER_FALSIFY=sidecars-as-entries turns the pairing off, so the tests can show their checks fail
|
|
26
|
+
// without it (TAKEOUT_FALSIFY_MODES).
|
|
27
|
+
|
|
28
|
+
import { createHash } from "node:crypto";
|
|
29
|
+
import { basename, dirname, extname } from "node:path";
|
|
30
|
+
import { secretShape } from "./references.mjs";
|
|
31
|
+
|
|
32
|
+
export const TAKEOUT_FALSIFY_MODES = ["sidecars-as-entries"];
|
|
33
|
+
const falsified = (mode) => (process.env.EVDA_GATHER_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
|
|
34
|
+
const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
|
|
35
|
+
|
|
36
|
+
// The files a sidecar describes: what Takeout exports from Photos, and Keep's notes.
|
|
37
|
+
const MEDIA_EXT = new Set([".jpg", ".jpeg", ".png", ".gif", ".heic", ".heif", ".webp", ".tif", ".tiff", ".bmp", ".dng", ".raw", ".cr2", ".nef", ".arw", ".mp4", ".mov", ".m4v", ".avi", ".3gp", ".mkv", ".mpg", ".webm", ".mp"]);
|
|
38
|
+
const NOTE_EXT = new Set([".html", ".htm", ".txt", ".md"]);
|
|
39
|
+
const SUPPLEMENTAL = "supplemental-metadata";
|
|
40
|
+
// Takeout's sidecar names run to about 51 characters; a photo's own name is cut from about 46.
|
|
41
|
+
const CUT_AT = 30;
|
|
42
|
+
const SIDECAR_MAX = 1024 * 1024;
|
|
43
|
+
|
|
44
|
+
// The name a sidecar describes, from the sidecar's own name: { target, n, marked }, or null for a name
|
|
45
|
+
// that is not `.json`. `n` is Takeout's "(1)" copy number; `marked` says the name carries the
|
|
46
|
+
// supplemental-metadata marker, whole or cut short.
|
|
47
|
+
export function sidecarTarget(name) {
|
|
48
|
+
if (!/\.json$/i.test(name)) return null;
|
|
49
|
+
let stem = name.slice(0, -5);
|
|
50
|
+
let n = null;
|
|
51
|
+
const copy = /\((\d+)\)$/.exec(stem);
|
|
52
|
+
if (copy) { n = copy[1]; stem = stem.slice(0, copy.index); }
|
|
53
|
+
let marked = false;
|
|
54
|
+
const tail = /\.([a-z-]*)$/i.exec(stem);
|
|
55
|
+
if (tail && SUPPLEMENTAL.startsWith(tail[1].toLowerCase())) {
|
|
56
|
+
const before = stem.slice(0, tail.index);
|
|
57
|
+
if (/\.[a-z0-9]{2,5}$/i.test(before) || tail[1].length >= 4) { stem = before; marked = true; }
|
|
58
|
+
}
|
|
59
|
+
return { target: stem, n, marked };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// What kind of Takeout file a parsed JSON value is: "photo", "keep", "album", or null.
|
|
63
|
+
export function takeoutShape(v) {
|
|
64
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return null;
|
|
65
|
+
if (v.photoTakenTime || (v.creationTime && (v.title !== undefined || v.imageViews !== undefined))) return "photo";
|
|
66
|
+
if (v.createdTimestampUsec !== undefined || v.userEditedTimestampUsec !== undefined) return "keep";
|
|
67
|
+
if (v.albumData || (v.title !== undefined && v.date?.timestamp !== undefined && v.access !== undefined)) return "album";
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Seconds or microseconds since the epoch, as Takeout writes them, to an ISO time in UTC.
|
|
72
|
+
const iso = (ms) => new Date(ms).toISOString().replace(/\.000Z$/, "Z");
|
|
73
|
+
const seconds = (x) => (/^\d{1,12}$/.test(String(x ?? "").trim()) ? Number(String(x).trim()) : null);
|
|
74
|
+
const usec = (x) => (/^\d{1,18}$/.test(String(x ?? "").trim()) ? Math.floor(Number(String(x).trim()) / 1000) : null);
|
|
75
|
+
|
|
76
|
+
// The facts a sidecar folds into its file's entry. Nothing from geoData or people is read.
|
|
77
|
+
function factsOf(v, shape) {
|
|
78
|
+
const out = {};
|
|
79
|
+
if (shape === "photo") {
|
|
80
|
+
const e = seconds(v.photoTakenTime?.timestamp);
|
|
81
|
+
if (e !== null) { out.takenEpoch = e; out.taken = iso(e * 1000); }
|
|
82
|
+
if (captionText(v)) out.caption = true;
|
|
83
|
+
} else if (shape === "keep") {
|
|
84
|
+
const c = usec(v.createdTimestampUsec);
|
|
85
|
+
const e = usec(v.userEditedTimestampUsec);
|
|
86
|
+
if (c !== null && c > 0) out.created = iso(c);
|
|
87
|
+
if (e !== null && e > 0) out.edited = iso(e);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A caption, unless it is empty or shaped like a secret (then it is folded nowhere).
|
|
93
|
+
function captionText(v) {
|
|
94
|
+
const t = typeof v?.description === "string" ? v.description.trim() : "";
|
|
95
|
+
if (!t || t.split("\n").some(secretShape)) return null;
|
|
96
|
+
return t;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function captionOf(buf) {
|
|
100
|
+
try { return captionText(JSON.parse(buf.toString("utf8"))); } catch { return null; }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const withN = (name, n) => {
|
|
104
|
+
const ext = extname(name);
|
|
105
|
+
return ext ? `${name.slice(0, -ext.length)}(${n})${ext}` : `${name}(${n})`;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Pair every sidecar in a walked source with the file it describes. `files` are the walk's
|
|
109
|
+
// { rel, abs, size }, `read(abs)` their bytes. Returns { folded, sidecars }: `folded` maps a file's rel
|
|
110
|
+
// to the facts its sidecar gave it, `sidecars` lists every sidecar with why it was not placed and, when
|
|
111
|
+
// it has one, the file it describes (`of`).
|
|
112
|
+
export function pairSidecars(files, read) {
|
|
113
|
+
const folded = new Map();
|
|
114
|
+
const sidecars = [];
|
|
115
|
+
if (falsified("sidecars-as-entries")) return { folded, sidecars };
|
|
116
|
+
const byDir = new Map();
|
|
117
|
+
for (const f of files) {
|
|
118
|
+
const d = dirname(f.rel);
|
|
119
|
+
byDir.set(d, [...(byDir.get(d) ?? []), f]);
|
|
120
|
+
}
|
|
121
|
+
for (const [dir, here] of byDir) {
|
|
122
|
+
const others = here.filter((f) => !/\.json$/i.test(f.rel));
|
|
123
|
+
const names = new Map(others.map((f) => [basename(f.rel), f]));
|
|
124
|
+
for (const f of here) {
|
|
125
|
+
const name = basename(f.rel);
|
|
126
|
+
const t = sidecarTarget(name);
|
|
127
|
+
if (!t) continue;
|
|
128
|
+
let v = null;
|
|
129
|
+
let bad = null;
|
|
130
|
+
if (f.size > SIDECAR_MAX) bad = "too large to be a sidecar";
|
|
131
|
+
else {
|
|
132
|
+
const text = read(f.abs).toString("utf8");
|
|
133
|
+
try { v = JSON.parse(text); } catch { bad = "not valid JSON"; }
|
|
134
|
+
}
|
|
135
|
+
const shape = v ? takeoutShape(v) : null;
|
|
136
|
+
const named = t.marked || MEDIA_EXT.has(extname(t.target).toLowerCase()) || NOTE_EXT.has(extname(t.target).toLowerCase());
|
|
137
|
+
// A .json that is neither shaped nor named like a sidecar is an ordinary file of the pile.
|
|
138
|
+
if (!shape && !named) continue;
|
|
139
|
+
if (shape === "album") { sidecars.push({ rel: f.rel, why: "Google Photos' details of the album this folder holds; not an entry of its own", sidecar: true }); continue; }
|
|
140
|
+
// The file it describes, in this folder.
|
|
141
|
+
let match = null;
|
|
142
|
+
if (t.n) match = names.get(withN(t.target, t.n)) ?? null;
|
|
143
|
+
else match = names.get(t.target) ?? null;
|
|
144
|
+
if (!match && !t.n) {
|
|
145
|
+
const same = others.filter((o) => basename(o.rel, extname(o.rel)) === t.target);
|
|
146
|
+
const notes = same.filter((o) => NOTE_EXT.has(extname(o.rel).toLowerCase()));
|
|
147
|
+
match = (notes.length === 1 ? notes[0] : same.length === 1 ? same[0] : null);
|
|
148
|
+
}
|
|
149
|
+
if (!match && !t.n && t.target.length >= CUT_AT) {
|
|
150
|
+
const cut = others.filter((o) => basename(o.rel).startsWith(t.target));
|
|
151
|
+
if (cut.length === 1) match = cut[0];
|
|
152
|
+
}
|
|
153
|
+
if (!match) {
|
|
154
|
+
sidecars.push({ rel: f.rel, why: bad ? `a Takeout sidecar that is ${bad}, and no file it describes is beside it; nothing read from it` : `a Takeout sidecar for ${t.n ? withN(t.target, t.n) : t.target}, which is not in the pile; placed nowhere`, sidecar: true, unmatched: true });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const facts = bad ? {} : factsOf(v, shape ?? "photo");
|
|
158
|
+
sidecars.push({ rel: f.rel, of: match.rel, why: bad ? `a Takeout sidecar for ${basename(match.rel)} that is ${bad}; nothing read from it, and ${basename(match.rel)} is placed without it` : `Google Takeout's details of ${basename(match.rel)}, folded into its entry`, sidecar: true, ...(bad ? { malformed: true } : {}) });
|
|
159
|
+
if (bad) continue;
|
|
160
|
+
const fold = { sidecar: f.rel, sha256: sha256(read(f.abs)), ...facts };
|
|
161
|
+
folded.set(match.rel, fold);
|
|
162
|
+
// A photo's edited copy has no sidecar of its own: it is the same moment.
|
|
163
|
+
const ext = extname(match.rel);
|
|
164
|
+
const edited = names.get(`${basename(match.rel, ext)}-edited${ext}`);
|
|
165
|
+
if (edited && !folded.has(edited.rel)) folded.set(edited.rel, { ...fold, sharedWith: match.rel });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { folded, sidecars };
|
|
169
|
+
}
|