strom-research 1.6.0 → 1.7.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/README.md +6 -0
- package/assets/lang/cs.json +37 -3
- package/assets/lang/de.json +37 -3
- package/dist/cli/menu.js +28 -1
- package/dist/cli/ui.js +36 -2
- package/dist/commands/connectors.js +12 -8
- package/dist/commands/index.js +1 -0
- package/dist/commands/media.js +11 -3
- package/dist/commands/pack.js +131 -0
- package/dist/commands/session.js +5 -1
- package/dist/commands/sync.js +7 -1
- package/dist/core/pack.js +311 -0
- package/dist/core/sync.js +106 -2
- package/dist/core/tree.js +1 -1
- package/dist/core/views.js +10 -2
- package/dist/core/zip.js +226 -0
- package/dist/gedcom/export.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// A research handed to someone else: one ZIP file with the tree as it was last
|
|
2
|
+
// saved (its history too), the images its records stand on, the images nobody
|
|
3
|
+
// can fetch again (saved by hand, the family's material) and the connectors
|
|
4
|
+
// that fetched the rest. The other images are left out: the archive gives the
|
|
5
|
+
// same scan again when it is needed (strom fetch puts it back, checked).
|
|
6
|
+
// The one who gets it unpacks it with strom: into their trees, the images into
|
|
7
|
+
// their shared folder, the connectors into their plugins, and takes it over.
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import * as git from "./git.js";
|
|
13
|
+
import { connectorsDir } from "./connector.js";
|
|
14
|
+
import { StromError, UsageError } from "./errors.js";
|
|
15
|
+
import { createKey } from "./seal.js";
|
|
16
|
+
import { safeFolderName } from "./text.js";
|
|
17
|
+
import { Tree, VERSION } from "./tree.js";
|
|
18
|
+
import { verifyFull } from "./integrity.js";
|
|
19
|
+
import { readZip, unzipTo, ZipWriter } from "./zip.js";
|
|
20
|
+
export const PACK_MANIFEST = "strom-pack.json";
|
|
21
|
+
export const INSTALL_WINDOWS = "irm https://raw.githubusercontent.com/ACiDekCZ/strom-research/main/install/install.ps1 | iex";
|
|
22
|
+
export const INSTALL_UNIX = "curl -fsSL https://raw.githubusercontent.com/ACiDekCZ/strom-research/main/install/install.sh | sh";
|
|
23
|
+
/** What goes into a package of this tree: every image (all), or the ones its records stand on and the ones nobody can fetch again. */
|
|
24
|
+
export function packPlan(tree, shared, all) {
|
|
25
|
+
const media = tree.list("media");
|
|
26
|
+
const cited = new Set();
|
|
27
|
+
for (const s of tree.list("source")) {
|
|
28
|
+
if (s.retracted)
|
|
29
|
+
continue;
|
|
30
|
+
for (const id of s.media ?? [])
|
|
31
|
+
cited.add(id);
|
|
32
|
+
for (const c of s.clips ?? [])
|
|
33
|
+
cited.add(c.media);
|
|
34
|
+
}
|
|
35
|
+
// an image's other copies and parts go along: a view uses the sharpest
|
|
36
|
+
const citedImages = new Set(media.filter((m) => cited.has(m.id) && m.recordset && m.image !== undefined).map((m) => `${m.recordset}:${m.image}`));
|
|
37
|
+
const take = new Map();
|
|
38
|
+
const missing = [];
|
|
39
|
+
const add = (file, size) => {
|
|
40
|
+
if (fs.existsSync(path.join(shared, file)))
|
|
41
|
+
take.set(file, size);
|
|
42
|
+
else
|
|
43
|
+
missing.push(file);
|
|
44
|
+
};
|
|
45
|
+
let leftCount = 0;
|
|
46
|
+
let leftBytes = 0;
|
|
47
|
+
const connectors = new Set();
|
|
48
|
+
for (const m of media) {
|
|
49
|
+
if (m.fetched)
|
|
50
|
+
connectors.add(m.fetched.connector);
|
|
51
|
+
const needed = all || cited.has(m.id) || !m.fetched || citedImages.has(`${m.recordset}:${m.image}`);
|
|
52
|
+
if (needed)
|
|
53
|
+
add(m.file, m.size);
|
|
54
|
+
else {
|
|
55
|
+
leftCount++;
|
|
56
|
+
leftBytes += m.size;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// the family's material kept in the shared store
|
|
60
|
+
for (const i of tree.list("input"))
|
|
61
|
+
if (i.file?.startsWith("media:"))
|
|
62
|
+
add(i.file.slice(6), i.size ?? 0);
|
|
63
|
+
const have = [...connectors].filter((c) => fs.existsSync(path.join(connectorsDir(shared), c, "connector.json")));
|
|
64
|
+
return {
|
|
65
|
+
media: [...take].map(([file, size]) => ({ file, size })).sort((a, b) => a.file.localeCompare(b.file)),
|
|
66
|
+
connectors: have.sort(),
|
|
67
|
+
left: { count: leftCount, bytes: leftBytes, connectors: connectors.size - have.length },
|
|
68
|
+
missing,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** The files under a folder, and its empty folders ending in "/" (git needs them). */
|
|
72
|
+
function walk(dir, rel = "") {
|
|
73
|
+
const out = [];
|
|
74
|
+
const entries = fs.readdirSync(path.join(dir, rel), { withFileTypes: true });
|
|
75
|
+
for (const e of entries) {
|
|
76
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
77
|
+
if (e.isDirectory()) {
|
|
78
|
+
if (e.name === "node_modules")
|
|
79
|
+
continue;
|
|
80
|
+
out.push(...walk(dir, r));
|
|
81
|
+
}
|
|
82
|
+
else if (e.isFile() && e.name !== ".DS_Store")
|
|
83
|
+
out.push(r);
|
|
84
|
+
}
|
|
85
|
+
if (rel && !entries.length)
|
|
86
|
+
out.push(`${rel}/`);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
function addTree(zip, prefix, dir) {
|
|
90
|
+
for (const f of walk(dir)) {
|
|
91
|
+
if (f.endsWith("/"))
|
|
92
|
+
zip.addDir(`${prefix}/${f}`);
|
|
93
|
+
else
|
|
94
|
+
zip.addFile(`${prefix}/${f}`, path.join(dir, ...f.split("/")));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* What the one who gets the package starts with a double click once it is unpacked: strom installed where it is
|
|
99
|
+
* not (the official installer, nothing else), the research put in place from the folder the launcher lies in,
|
|
100
|
+
* then strom. `say`: the two sentences it may need, in the research language.
|
|
101
|
+
*/
|
|
102
|
+
export function launchers(say) {
|
|
103
|
+
const cmd = [
|
|
104
|
+
"@echo off",
|
|
105
|
+
"chcp 65001 >nul",
|
|
106
|
+
'cd /d "%~dp0"',
|
|
107
|
+
'if exist "%~dp0' + PACK_MANIFEST + '" goto found',
|
|
108
|
+
`echo ${say.extract}`,
|
|
109
|
+
"pause",
|
|
110
|
+
"exit /b 1",
|
|
111
|
+
":found",
|
|
112
|
+
'set "STROM="',
|
|
113
|
+
'for /f "delims=" %%i in (\'where strom.cmd 2^>nul\') do if not defined STROM set "STROM=%%i"',
|
|
114
|
+
'if not defined STROM if exist "%LOCALAPPDATA%\\Programs\\Strom\\strom.cmd" set "STROM=%LOCALAPPDATA%\\Programs\\Strom\\strom.cmd"',
|
|
115
|
+
"if defined STROM goto run",
|
|
116
|
+
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$env:STROM_INSTALL_ONLY='1'; ${INSTALL_WINDOWS}"`,
|
|
117
|
+
'if exist "%LOCALAPPDATA%\\Programs\\Strom\\strom.cmd" set "STROM=%LOCALAPPDATA%\\Programs\\Strom\\strom.cmd"',
|
|
118
|
+
"if defined STROM goto run",
|
|
119
|
+
`echo ${say.failed}`,
|
|
120
|
+
"pause",
|
|
121
|
+
"exit /b 1",
|
|
122
|
+
":run",
|
|
123
|
+
'call "%STROM%" unpack "%~dp0."',
|
|
124
|
+
'call "%STROM%"',
|
|
125
|
+
"",
|
|
126
|
+
].join("\r\n");
|
|
127
|
+
const sh = [
|
|
128
|
+
"#!/bin/bash",
|
|
129
|
+
'cd "$(dirname "$0")" || exit 1',
|
|
130
|
+
'if [ ! -f "' + PACK_MANIFEST + '" ]; then echo "' + say.extract.replace(/"/g, "'") + '"; read -r _; exit 1; fi',
|
|
131
|
+
'STROM="$(command -v strom || true)"',
|
|
132
|
+
'[ -z "$STROM" ] && [ -x "$HOME/.local/bin/strom" ] && STROM="$HOME/.local/bin/strom"',
|
|
133
|
+
'if [ -z "$STROM" ]; then',
|
|
134
|
+
` ${INSTALL_UNIX.replace(/\| sh$/, "| STROM_INSTALL_ONLY=1 sh")}`,
|
|
135
|
+
' [ -x "$HOME/.local/bin/strom" ] && STROM="$HOME/.local/bin/strom"',
|
|
136
|
+
"fi",
|
|
137
|
+
'if [ -z "$STROM" ]; then echo "' + say.failed.replace(/"/g, "'") + '"; read -r _; exit 1; fi',
|
|
138
|
+
'"$STROM" unpack "$(pwd)"',
|
|
139
|
+
'"$STROM"',
|
|
140
|
+
"",
|
|
141
|
+
].join("\n");
|
|
142
|
+
return [
|
|
143
|
+
{ name: "Windows.cmd", text: cmd, mode: 0o755 },
|
|
144
|
+
{ name: "macOS.command", text: sh, mode: 0o755 },
|
|
145
|
+
{ name: "Linux.sh", text: sh, mode: 0o755 },
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
/** Write the package. `readme`: the file name and text for the person who gets it; `say`: what its launchers may need to say. */
|
|
149
|
+
export function writePack(tree, shared, out, opts) {
|
|
150
|
+
const plan = packPlan(tree, shared, opts.all);
|
|
151
|
+
const folder = safeFolderName(tree.config.name) || "tree";
|
|
152
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "strom-pack-"));
|
|
153
|
+
const zip = new ZipWriter(out);
|
|
154
|
+
try {
|
|
155
|
+
// the tree as it was last saved, its history too — never what is half written
|
|
156
|
+
const clone = path.join(tmp, folder);
|
|
157
|
+
const r = git.runGit(tmp, ["clone", "--quiet", "--no-hardlinks", tree.root, clone]);
|
|
158
|
+
if (r.status !== 0)
|
|
159
|
+
throw new StromError(`could not copy the tree: ${r.stderr.trim()}`);
|
|
160
|
+
git.runGit(clone, ["remote", "remove", "origin"]);
|
|
161
|
+
git.runGit(clone, ["gc", "--quiet", "--prune=now"]);
|
|
162
|
+
const commit = git.head(clone) ?? "";
|
|
163
|
+
const manifest = {
|
|
164
|
+
kind: "strom-research-pack",
|
|
165
|
+
format: 1,
|
|
166
|
+
tree: { id: tree.config.id, name: tree.config.name, lang: tree.config.lang },
|
|
167
|
+
folder,
|
|
168
|
+
created: new Date().toISOString(),
|
|
169
|
+
strom: VERSION,
|
|
170
|
+
commit,
|
|
171
|
+
images: opts.all ? "all" : "records",
|
|
172
|
+
media: plan.media.map((m) => m.file),
|
|
173
|
+
connectors: plan.connectors,
|
|
174
|
+
left: { count: plan.left.count, bytes: plan.left.bytes },
|
|
175
|
+
};
|
|
176
|
+
// CRLF: Notepad of an older Windows shows it right too
|
|
177
|
+
zip.add(opts.readme.name, Buffer.from(opts.readme.text.replace(/\n/g, "\r\n"), "utf8"));
|
|
178
|
+
for (const l of launchers(opts.say))
|
|
179
|
+
zip.add(l.name, Buffer.from(l.text, "utf8"), { mode: l.mode });
|
|
180
|
+
zip.add(PACK_MANIFEST, Buffer.from(JSON.stringify(manifest, null, 2) + "\n", "utf8"));
|
|
181
|
+
addTree(zip, folder, clone);
|
|
182
|
+
for (const m of plan.media)
|
|
183
|
+
zip.addFile(`shared/${m.file}`, path.join(shared, ...m.file.split("/")));
|
|
184
|
+
for (const c of plan.connectors) {
|
|
185
|
+
addTree(zip, `shared/plugins/connectors/${c}`, path.join(connectorsDir(shared), c));
|
|
186
|
+
}
|
|
187
|
+
zip.close();
|
|
188
|
+
return { file: out, bytes: fs.statSync(out).size, manifest, plan };
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
zip.abort();
|
|
192
|
+
throw e;
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** A package given as the ZIP file or the folder a system unpacked it into: its manifest and where it lies. */
|
|
199
|
+
export function findPack(src) {
|
|
200
|
+
if (!fs.existsSync(src))
|
|
201
|
+
throw new UsageError(`there is no ${src}`, { hint: "give the ZIP file you were sent (drag it into the terminal)" });
|
|
202
|
+
const read = (text) => {
|
|
203
|
+
const m = JSON.parse(text);
|
|
204
|
+
if (m.kind !== "strom-research-pack" || !m.tree?.id || !m.folder)
|
|
205
|
+
throw new UsageError("this is not a research packed by strom");
|
|
206
|
+
if (m.format > 1)
|
|
207
|
+
throw new UsageError("this package is of a newer strom", { hint: "strom update" });
|
|
208
|
+
return m;
|
|
209
|
+
};
|
|
210
|
+
if (fs.statSync(src).isDirectory()) {
|
|
211
|
+
// the folder itself, or the one folder a system's unpacking put around it
|
|
212
|
+
const candidates = [src, ...fs.readdirSync(src, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => path.join(src, e.name))];
|
|
213
|
+
const dir = candidates.find((d) => fs.existsSync(path.join(d, PACK_MANIFEST)));
|
|
214
|
+
if (!dir)
|
|
215
|
+
throw new UsageError(`no ${PACK_MANIFEST} in ${src}: not a research packed by strom`, { hint: "give the ZIP file you were sent" });
|
|
216
|
+
return { manifest: read(fs.readFileSync(path.join(dir, PACK_MANIFEST), "utf8")), dir };
|
|
217
|
+
}
|
|
218
|
+
const entries = readZip(src);
|
|
219
|
+
const m = entries.find((e) => e.name === PACK_MANIFEST || e.name.endsWith(`/${PACK_MANIFEST}`));
|
|
220
|
+
if (!m)
|
|
221
|
+
throw new UsageError("this ZIP file holds no research packed by strom", { hint: "the sender makes one with strom pack" });
|
|
222
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "strom-unpack-"));
|
|
223
|
+
try {
|
|
224
|
+
unzipTo(src, dir, entries);
|
|
225
|
+
const root = path.join(dir, ...m.name.split("/").slice(0, -1));
|
|
226
|
+
return { manifest: read(fs.readFileSync(path.join(root, PACK_MANIFEST), "utf8")), zip: src, dir: root, tmp: dir };
|
|
227
|
+
}
|
|
228
|
+
catch (e) {
|
|
229
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
230
|
+
throw e;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Put a package in place: the tree into the trees folder (a free name), its images into the shared store (each
|
|
235
|
+
* checked against its name, the SHA-256 of its content), its connectors into the plugins where there is none of
|
|
236
|
+
* that name; then check the tree whole and take it over on this computer. Nothing is left half done.
|
|
237
|
+
*/
|
|
238
|
+
/** The tree's folder among the trees: its name, or with (2), (3)… when that is taken. */
|
|
239
|
+
export function freeRoot(treesDir, name) {
|
|
240
|
+
const base = safeFolderName(name) || "tree";
|
|
241
|
+
let root = path.join(treesDir, base);
|
|
242
|
+
for (let n = 2; fs.existsSync(root); n++)
|
|
243
|
+
root = path.join(treesDir, `${base} (${n})`);
|
|
244
|
+
return root;
|
|
245
|
+
}
|
|
246
|
+
export function unpack(pack, treesDir, shared, env) {
|
|
247
|
+
const src = pack.dir;
|
|
248
|
+
const m = pack.manifest;
|
|
249
|
+
// a system that unpacked the names in another code page: the one folder with a tree in it
|
|
250
|
+
let from = path.join(src, m.folder);
|
|
251
|
+
if (!fs.existsSync(path.join(from, "strom.json"))) {
|
|
252
|
+
const trees = fs.readdirSync(src, { withFileTypes: true }).filter((e) => e.isDirectory() && fs.existsSync(path.join(src, e.name, "strom.json")));
|
|
253
|
+
if (trees.length !== 1)
|
|
254
|
+
throw new UsageError(`the package has no tree ${m.folder}`);
|
|
255
|
+
from = path.join(src, trees[0].name);
|
|
256
|
+
}
|
|
257
|
+
const root = freeRoot(treesDir, m.tree.name);
|
|
258
|
+
fs.mkdirSync(treesDir, { recursive: true });
|
|
259
|
+
const media = [];
|
|
260
|
+
for (const f of m.media) {
|
|
261
|
+
const file = path.join(src, "shared", ...f.split("/"));
|
|
262
|
+
if (!fs.existsSync(file))
|
|
263
|
+
throw new StromError(`the package is missing ${f}`);
|
|
264
|
+
const sha = path.basename(f).replace(/\.[^.]+$/, "");
|
|
265
|
+
if (/^[0-9a-f]{64}$/.test(sha) && crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") !== sha)
|
|
266
|
+
throw new StromError(`${f} in the package is not the image its name says — damaged on the way?`, { hint: "ask for the package again" });
|
|
267
|
+
media.push({ from: file, to: path.join(shared, ...f.split("/")) });
|
|
268
|
+
}
|
|
269
|
+
fs.cpSync(from, root, { recursive: true, errorOnExist: true });
|
|
270
|
+
try {
|
|
271
|
+
const tree = Tree.open(root, env);
|
|
272
|
+
const bad = verifyFull(tree).findings.filter((f) => f.level === "error");
|
|
273
|
+
if (bad.length)
|
|
274
|
+
throw new StromError(`the tree in the package does not check out: ${bad[0].message}${bad.length > 1 ? ` (+${bad.length - 1})` : ""}`, { hint: "ask for the package again" });
|
|
275
|
+
let adopted = false;
|
|
276
|
+
if (!tree.key) {
|
|
277
|
+
tree.withTreeLock(() => {
|
|
278
|
+
createKey(env, tree.config.id);
|
|
279
|
+
tree.reloadKey();
|
|
280
|
+
tree.appendOp({ op: "seal.adopt", targets: [], files: [], summary: "Tree adopted on this computer (unpacked)" });
|
|
281
|
+
tree.commit("Seal: tree adopted on this computer");
|
|
282
|
+
});
|
|
283
|
+
adopted = true;
|
|
284
|
+
}
|
|
285
|
+
let imagesHad = 0;
|
|
286
|
+
for (const x of media) {
|
|
287
|
+
if (fs.existsSync(x.to)) {
|
|
288
|
+
imagesHad++;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
fs.mkdirSync(path.dirname(x.to), { recursive: true });
|
|
292
|
+
fs.copyFileSync(x.from, x.to);
|
|
293
|
+
}
|
|
294
|
+
const connectors = [];
|
|
295
|
+
const kept = [];
|
|
296
|
+
for (const c of m.connectors) {
|
|
297
|
+
const to = path.join(connectorsDir(shared), c);
|
|
298
|
+
if (fs.existsSync(to)) {
|
|
299
|
+
kept.push(c);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
fs.cpSync(path.join(src, "shared", "plugins", "connectors", c), to, { recursive: true });
|
|
303
|
+
connectors.push(c);
|
|
304
|
+
}
|
|
305
|
+
return { root, name: tree.config.name, images: media.length - imagesHad, imagesHad, connectors, connectorsKept: kept, adopted };
|
|
306
|
+
}
|
|
307
|
+
catch (e) {
|
|
308
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
309
|
+
throw e;
|
|
310
|
+
}
|
|
311
|
+
}
|
package/dist/core/sync.js
CHANGED
|
@@ -40,6 +40,45 @@ const kindOf = (k) => (k === "CHR" ? "BAPM" : k);
|
|
|
40
40
|
export const factKey = (f) => [kindOf(f.kind), f.date ?? "", exact(f.place), exact(f.value), f.date || f.place ? "" : fold(f.label)].join("|");
|
|
41
41
|
/** A name for a comparison: "? /Novák/", "Jan //" and "Jan /?/" are the names the research has. */
|
|
42
42
|
const nameKey = (n) => exact(n.replace(/[/?]/g, " "));
|
|
43
|
+
/** A place's name as the Strom app keys its coordinates (placeKey of its places.ts): no accents, case, stops or commas. */
|
|
44
|
+
export const placeKey = (s) => foldText(s.replace(/[.,;]/g, " ")).replace(/\s+/g, " ").trim();
|
|
45
|
+
/** The Strom app's own key of a place's coordinates in its JSON (places.ts). */
|
|
46
|
+
const appPlaceKey = (s) => s
|
|
47
|
+
.normalize("NFD")
|
|
48
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
.replace(/[.,;]/g, " ")
|
|
51
|
+
.replace(/\s+/g, " ")
|
|
52
|
+
.trim();
|
|
53
|
+
/** GEDCOM's N50.042 / E15.358 (also a bare or negative number): degrees, the south and west negative. */
|
|
54
|
+
function degrees(v, neg, max) {
|
|
55
|
+
const m = /^\s*([NSEW])?\s*(-?\d{1,3}(?:\.\d+)?)\s*$/i.exec(v ?? "");
|
|
56
|
+
if (!m)
|
|
57
|
+
return undefined;
|
|
58
|
+
const n = Number(m[2]) * (m[1]?.toUpperCase() === neg ? -1 : 1);
|
|
59
|
+
return Number.isFinite(n) && Math.abs(n) <= max ? n : undefined;
|
|
60
|
+
}
|
|
61
|
+
/** The places of a GEDCOM with their MAP > LATI/LONG, wherever a PLAC is. */
|
|
62
|
+
function gedPlaces(records) {
|
|
63
|
+
const out = new Map();
|
|
64
|
+
const walk = (n) => {
|
|
65
|
+
for (const c of n.children) {
|
|
66
|
+
const map = c.tag === "PLAC" ? c.children.find((x) => x.tag === "MAP") : undefined;
|
|
67
|
+
const lat = map ? degrees(val(map, "LATI"), "S", 90) : undefined;
|
|
68
|
+
const lon = map ? degrees(val(map, "LONG"), "W", 180) : undefined;
|
|
69
|
+
const name = c.value.replace(/\s+/g, " ").trim();
|
|
70
|
+
if (lat !== undefined && lon !== undefined && name && !out.has(placeKey(name)))
|
|
71
|
+
out.set(placeKey(name), { name, lat, lon });
|
|
72
|
+
walk(c);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
for (const r of records)
|
|
76
|
+
if (r.tag === "INDI" || r.tag === "FAM")
|
|
77
|
+
walk(r);
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
/** The same point: the Strom app keeps six decimals. */
|
|
81
|
+
const samePoint = (a, b) => Math.abs(a.lat - b.lat) < 5e-7 && Math.abs(a.lon - b.lon) < 5e-7;
|
|
43
82
|
/** The Strom app's "Birth (alternative record)": a second birth or death of ours coming back as an event of its own. */
|
|
44
83
|
const ALTERNATIVE = {
|
|
45
84
|
"birth (alternative record)": "BIRT", "death (alternative record)": "DEAT",
|
|
@@ -155,7 +194,7 @@ export function readGedcom(text) {
|
|
|
155
194
|
}
|
|
156
195
|
const treeId = val(head, "_STROM_TREE");
|
|
157
196
|
const at = val(head, "_STROM_HEAD");
|
|
158
|
-
return { format: "gedcom", ...(treeId ? { treeId } : {}), ...(at ? { head: at } : {}), persons, families, problems };
|
|
197
|
+
return { format: "gedcom", ...(treeId ? { treeId } : {}), ...(at ? { head: at } : {}), persons, families, places: gedPlaces(records), problems };
|
|
159
198
|
}
|
|
160
199
|
/** The Strom app's life events → our kinds. */
|
|
161
200
|
const APP_EVENTS = {
|
|
@@ -236,8 +275,19 @@ export function readStromJson(data) {
|
|
|
236
275
|
}
|
|
237
276
|
families.push({ partners, children: (u.childIds ?? []).map((c) => keys.get(c)).filter((k) => !!k), facts });
|
|
238
277
|
}
|
|
278
|
+
// the app's coordinates are kept for the place's name (its placeKey): the names are its facts'
|
|
279
|
+
const places = new Map();
|
|
280
|
+
const geo = d.places && typeof d.places === "object" ? d.places : {};
|
|
281
|
+
for (const f of [...persons.values()].flatMap((p) => p.facts).concat(families.flatMap((x) => x.facts))) {
|
|
282
|
+
const k = f.place ? placeKey(f.place) : "";
|
|
283
|
+
const g = f.place ? geo[appPlaceKey(f.place)] : undefined;
|
|
284
|
+
if (!g || places.has(k) || typeof g.lat !== "number" || typeof g.lon !== "number")
|
|
285
|
+
continue;
|
|
286
|
+
if (Math.abs(g.lat) <= 90 && Math.abs(g.lon) <= 180)
|
|
287
|
+
places.set(k, { name: f.place, lat: g.lat, lon: g.lon });
|
|
288
|
+
}
|
|
239
289
|
const r = d.research;
|
|
240
|
-
return { format: "strom-json", ...(r?.id ? { treeId: r.id } : {}), ...(r?.head ? { head: r.head } : {}), persons, families, problems: [] };
|
|
290
|
+
return { format: "strom-json", ...(r?.id ? { treeId: r.id } : {}), ...(r?.head ? { head: r.head } : {}), persons, families, places, problems: [] };
|
|
241
291
|
}
|
|
242
292
|
/** A file coming back: GEDCOM or the Strom app's JSON; empty or unreadable throws. */
|
|
243
293
|
export function readTreeFile(file) {
|
|
@@ -635,8 +685,32 @@ export function planSync(tree, incoming, edits, opts = {}) {
|
|
|
635
685
|
push({ kind: "child.gone", action: "report", family: fam.id, partners, child: c });
|
|
636
686
|
}
|
|
637
687
|
}
|
|
688
|
+
// places on the map: no record proves a position, so the user's is taken — set where the research has none,
|
|
689
|
+
// or changed in the app since it was given (the research's as it was given); anything else only when picked
|
|
690
|
+
const ourPlaces = researchPlaces(tree);
|
|
691
|
+
for (const [k, p] of incoming.places ?? []) {
|
|
692
|
+
const mine = ourPlaces.get(k);
|
|
693
|
+
const here = mine?.coords;
|
|
694
|
+
if (here && samePoint(here, p))
|
|
695
|
+
continue;
|
|
696
|
+
const given = base?.places?.get(k);
|
|
697
|
+
if (given && samePoint(given, p))
|
|
698
|
+
continue; // as it was given: the research moved it since
|
|
699
|
+
const action = !here || (given && samePoint(given, here)) ? "set" : "pick";
|
|
700
|
+
push({ kind: "place.coords", action, place: { ...p, ...(mine ? { id: mine.id } : {}), ...(here ? { was: here } : {}) } });
|
|
701
|
+
}
|
|
638
702
|
return { changes, base: !!base, ...(incoming.head ? { head: incoming.head } : {}), partial, identity };
|
|
639
703
|
}
|
|
704
|
+
/** The research's places by placeKey of each of their names — one with coordinates first, as the export gives them. */
|
|
705
|
+
function researchPlaces(tree) {
|
|
706
|
+
const out = new Map();
|
|
707
|
+
for (const p of tree.list("place"))
|
|
708
|
+
if (!p.retracted)
|
|
709
|
+
for (const n of p.names)
|
|
710
|
+
if (!out.has(placeKey(n.name)) || (p.coords && !out.get(placeKey(n.name)).coords))
|
|
711
|
+
out.set(placeKey(n.name), p);
|
|
712
|
+
return out;
|
|
713
|
+
}
|
|
640
714
|
/** Does the research's fact say all the file's fact says, and more: the same kind, and each of its date, place, value the same or missing in the file. */
|
|
641
715
|
/**
|
|
642
716
|
* The Strom app keeps a couple's marriages and divorces as the course of one union: two marriages with no divorce
|
|
@@ -798,6 +872,25 @@ export function applySync(tree, plan, incoming, source, only) {
|
|
|
798
872
|
applied.push({ do: "child.add", id: c.family, before: child });
|
|
799
873
|
break;
|
|
800
874
|
}
|
|
875
|
+
case "place.coords": {
|
|
876
|
+
const p = c.place;
|
|
877
|
+
if (!p)
|
|
878
|
+
break;
|
|
879
|
+
const coords = { lat: p.lat, lon: p.lon };
|
|
880
|
+
const mine = p.id ? tree.get(p.id) : undefined;
|
|
881
|
+
if (mine && !mine.retracted) {
|
|
882
|
+
update(tree, mine.id, "place", ({ unlocated: _found, ...rest }) => ({ ...rest, coords }), {
|
|
883
|
+
op: "place.edit",
|
|
884
|
+
summary: `${mine.id} at ${p.lat}, ${p.lon}: ${reason}`,
|
|
885
|
+
});
|
|
886
|
+
applied.push({ do: "place.edit", id: mine.id, before: JSON.stringify({ coords: mine.coords, unlocated: mine.unlocated }) });
|
|
887
|
+
}
|
|
888
|
+
else {
|
|
889
|
+
const made = create(tree, "place", { names: [{ name: p.name }], coords, jurisdictions: [], note: `the position from ${reason}` }, (id) => `+${id} place "${p.name}" (from ${source.id})`);
|
|
890
|
+
applied.push({ do: "place.add", id: made.id });
|
|
891
|
+
}
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
801
894
|
default:
|
|
802
895
|
break;
|
|
803
896
|
}
|
|
@@ -858,6 +951,17 @@ export function undoSync(tree, input) {
|
|
|
858
951
|
if (!tree.get(a.id)?.retracted)
|
|
859
952
|
retractPerson(tree, a.id, reason);
|
|
860
953
|
break;
|
|
954
|
+
case "place.edit": {
|
|
955
|
+
const b = JSON.parse(String(a.before ?? "{}"));
|
|
956
|
+
update(tree, a.id, "place", ({ coords: _now, unlocated: _u, ...p }) => ({ ...p, ...(b.coords ? { coords: b.coords } : {}), ...(b.unlocated ? { unlocated: b.unlocated } : {}) }), {
|
|
957
|
+
op: "place.edit",
|
|
958
|
+
summary: `${a.id} position back: ${reason}`,
|
|
959
|
+
});
|
|
960
|
+
break;
|
|
961
|
+
}
|
|
962
|
+
case "place.add":
|
|
963
|
+
update(tree, a.id, "place", (p) => ({ ...p, retracted: { at: now(), reason } }), { op: "place.retract", summary: `${a.id} retracted: ${reason}` });
|
|
964
|
+
break;
|
|
861
965
|
}
|
|
862
966
|
n++;
|
|
863
967
|
}
|
package/dist/core/tree.js
CHANGED
|
@@ -23,7 +23,7 @@ import * as git from "./git.js";
|
|
|
23
23
|
import { commitSeal, createKey, fingerprint, loadKey, sha256, signOp } from "./seal.js";
|
|
24
24
|
import { stringifyCanonical } from "./json.js";
|
|
25
25
|
export const TREE_FILE = "strom.json";
|
|
26
|
-
export const VERSION = "1.
|
|
26
|
+
export const VERSION = "1.7.0";
|
|
27
27
|
const GITIGNORE = `# Strom Research — local state and large files are not versioned
|
|
28
28
|
.strom/
|
|
29
29
|
/media/
|
package/dist/core/views.js
CHANGED
|
@@ -69,8 +69,16 @@ export function tiles(region, max = VIEW_MAX, overlap = 0.1) {
|
|
|
69
69
|
}
|
|
70
70
|
/** Make (or reuse) a view of an image file. `key` names it (M0012, I0003). */
|
|
71
71
|
export function makeView(tree, source, key, spec) {
|
|
72
|
-
if (!fs.existsSync(source))
|
|
73
|
-
|
|
72
|
+
if (!fs.existsSync(source)) {
|
|
73
|
+
// a research handed over without its images: the archive gives the same scan again
|
|
74
|
+
const m = /^M\d+$/.test(key) ? tree.get(key) : undefined;
|
|
75
|
+
const again = m?.fetched && m.recordset && m.image !== undefined
|
|
76
|
+
? `strom fetch ${m.fetched.connector} ${m.fetched.book} --recordset ${m.recordset} --images ${m.image}${m.part ? ` --crop ${[m.part.x, m.part.y, m.part.w, m.part.h].join(",")}` : ""}`
|
|
77
|
+
: undefined;
|
|
78
|
+
throw new UsageError(`the image file is missing: ${source}`, {
|
|
79
|
+
hint: again ? `fetch it again from the archive (checked: the same scan): ${again} — or the shared folder moved: strom config where` : "the shared folder may have moved: strom config where",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
74
82
|
const bytes = new Uint8Array(fs.readFileSync(source));
|
|
75
83
|
const size = imageSize(bytes);
|
|
76
84
|
if (!size) {
|