strom-research 1.0.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 +373 -0
- package/README.md +142 -0
- package/assets/lang/cs.json +302 -0
- package/assets/lang/de.json +302 -0
- package/assets/method/core.md +43 -0
- package/assets/method/enrich.md +11 -0
- package/assets/method/intake.md +30 -0
- package/assets/method/link.md +28 -0
- package/assets/method/locate.md +28 -0
- package/assets/method/narrate.md +13 -0
- package/assets/method/reading.md +62 -0
- package/assets/method/recording.md +59 -0
- package/assets/method/request.md +10 -0
- package/assets/method/verify.md +17 -0
- package/assets/plugins/README.md +23 -0
- package/assets/plugins/connectors/DISCOVERY.md +159 -0
- package/assets/plugins/connectors/README.md +376 -0
- package/assets/plugins/connectors/sdk.ts +168 -0
- package/assets/plugins/connectors/template.ts +38 -0
- package/assets/plugins/gitignore +4 -0
- package/dist/agents/files.js +313 -0
- package/dist/agents/global.js +257 -0
- package/dist/agents/launch.js +36 -0
- package/dist/agents/profiles.js +95 -0
- package/dist/brief/brief.js +345 -0
- package/dist/cli/commit.js +44 -0
- package/dist/cli/context.js +311 -0
- package/dist/cli/execute.js +154 -0
- package/dist/cli/fixes.js +78 -0
- package/dist/cli/format.js +53 -0
- package/dist/cli/help.js +59 -0
- package/dist/cli/main.js +152 -0
- package/dist/cli/menu.js +212 -0
- package/dist/cli/registry.js +96 -0
- package/dist/cli/ui.js +266 -0
- package/dist/cli/wizard.js +142 -0
- package/dist/cli.js +14 -0
- package/dist/commands/analysis.js +622 -0
- package/dist/commands/batch.js +181 -0
- package/dist/commands/checks.js +153 -0
- package/dist/commands/connectors.js +1377 -0
- package/dist/commands/guide.js +160 -0
- package/dist/commands/index.js +19 -0
- package/dist/commands/intake.js +234 -0
- package/dist/commands/media.js +406 -0
- package/dist/commands/meta.js +195 -0
- package/dist/commands/output.js +117 -0
- package/dist/commands/people.js +664 -0
- package/dist/commands/read.js +199 -0
- package/dist/commands/research.js +139 -0
- package/dist/commands/session.js +605 -0
- package/dist/commands/setup.js +465 -0
- package/dist/commands/sources.js +634 -0
- package/dist/commands/start.js +383 -0
- package/dist/commands/story.js +75 -0
- package/dist/commands/tasks.js +436 -0
- package/dist/commands/trees.js +128 -0
- package/dist/core/actions.js +852 -0
- package/dist/core/age.js +95 -0
- package/dist/core/apps.js +74 -0
- package/dist/core/assets.js +34 -0
- package/dist/core/awake.js +33 -0
- package/dist/core/browser.js +281 -0
- package/dist/core/calibration.js +48 -0
- package/dist/core/check.js +112 -0
- package/dist/core/chromium.js +88 -0
- package/dist/core/config.js +348 -0
- package/dist/core/connector.js +811 -0
- package/dist/core/deps.js +73 -0
- package/dist/core/dialog.js +61 -0
- package/dist/core/errors.js +89 -0
- package/dist/core/evidence.js +58 -0
- package/dist/core/frontier.js +219 -0
- package/dist/core/gdate.js +77 -0
- package/dist/core/git.js +300 -0
- package/dist/core/guard.js +124 -0
- package/dist/core/http2.js +76 -0
- package/dist/core/import.js +541 -0
- package/dist/core/install.js +28 -0
- package/dist/core/integrity.js +219 -0
- package/dist/core/json.js +87 -0
- package/dist/core/lang.js +70 -0
- package/dist/core/live.js +244 -0
- package/dist/core/lock.js +112 -0
- package/dist/core/logins.js +67 -0
- package/dist/core/media.js +223 -0
- package/dist/core/model.js +101 -0
- package/dist/core/net.js +366 -0
- package/dist/core/open.js +29 -0
- package/dist/core/paths.js +84 -0
- package/dist/core/people.js +283 -0
- package/dist/core/phrases.js +85 -0
- package/dist/core/queue.js +113 -0
- package/dist/core/reader.js +76 -0
- package/dist/core/records.js +105 -0
- package/dist/core/roles.js +30 -0
- package/dist/core/schema.js +261 -0
- package/dist/core/seal.js +77 -0
- package/dist/core/self.js +40 -0
- package/dist/core/session.js +155 -0
- package/dist/core/shortcut.js +90 -0
- package/dist/core/stories.js +61 -0
- package/dist/core/stromapp.js +138 -0
- package/dist/core/text.js +104 -0
- package/dist/core/tree.js +507 -0
- package/dist/core/uninstall.js +128 -0
- package/dist/core/update.js +193 -0
- package/dist/core/validate.js +260 -0
- package/dist/core/views.js +164 -0
- package/dist/core/which.js +51 -0
- package/dist/core/workers.js +42 -0
- package/dist/gedcom/export.js +454 -0
- package/dist/gedcom/labels.js +103 -0
- package/dist/gedcom/lines.js +91 -0
- package/dist/gedcom/parse.js +53 -0
- package/dist/gedcom/validate.js +183 -0
- package/dist/image/image.js +223 -0
- package/dist/image/index.js +114 -0
- package/dist/image/jpeg-decode.js +552 -0
- package/dist/image/jpeg-encode.js +254 -0
- package/dist/image/png.js +241 -0
- package/dist/runners/antigravity.js +70 -0
- package/dist/runners/claude.js +179 -0
- package/dist/runners/codex.js +45 -0
- package/dist/runners/index.js +13 -0
- package/dist/runners/jsonl.js +86 -0
- package/dist/runners/opencode.js +50 -0
- package/dist/runners/runner.js +63 -0
- package/dist/runners/script.js +58 -0
- package/package.json +44 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Cross-platform file lock: exclusive create ("wx") + owner info.
|
|
2
|
+
// A lock whose process is gone (same host) or which is older than `staleMs`
|
|
3
|
+
// is taken over. Works the same on Windows, macOS and Linux.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { LockedError } from "./errors.js";
|
|
8
|
+
function sleepSync(ms) {
|
|
9
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
10
|
+
}
|
|
11
|
+
function processAlive(pid) {
|
|
12
|
+
try {
|
|
13
|
+
process.kill(pid, 0);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
return err.code === "EPERM";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function readLock(file) {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function isStale(info, staleMs) {
|
|
29
|
+
if (!info)
|
|
30
|
+
return true; // unreadable: locks appear whole, so this one was damaged (a crash, a full disk)
|
|
31
|
+
// On this computer the process decides: a live holder keeps its lock however
|
|
32
|
+
// long it works (a big intake), a dead one loses it at once.
|
|
33
|
+
if (info.host === os.hostname())
|
|
34
|
+
return !processAlive(info.pid);
|
|
35
|
+
return Date.now() - Date.parse(info.at) > staleMs;
|
|
36
|
+
}
|
|
37
|
+
/** Who holds the lock now, if anyone alive does. */
|
|
38
|
+
export function liveHolder(file, staleMs = 24 * 3600_000) {
|
|
39
|
+
if (!fs.existsSync(file))
|
|
40
|
+
return undefined;
|
|
41
|
+
const info = readLock(file);
|
|
42
|
+
return isStale(info, staleMs) ? undefined : info;
|
|
43
|
+
}
|
|
44
|
+
/** Acquire the lock or throw LockedError. Returns the release function. */
|
|
45
|
+
export function acquireLock(file, opts) {
|
|
46
|
+
const waitMs = opts.waitMs ?? 5000;
|
|
47
|
+
const staleMs = opts.staleMs ?? 10 * 60 * 1000;
|
|
48
|
+
const deadline = Date.now() + waitMs;
|
|
49
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
50
|
+
const info = { pid: process.pid, host: os.hostname(), at: new Date().toISOString(), owner: opts.owner };
|
|
51
|
+
// The lock appears whole or not at all: written aside, then linked in under its name
|
|
52
|
+
// (a link fails if the name exists). A lock created empty and filled a moment later
|
|
53
|
+
// looked abandoned to a process that read it in that moment — and two held it.
|
|
54
|
+
const body = JSON.stringify(info);
|
|
55
|
+
const aside = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
56
|
+
fs.writeFileSync(aside, body);
|
|
57
|
+
try {
|
|
58
|
+
for (;;) {
|
|
59
|
+
try {
|
|
60
|
+
fs.linkSync(aside, file);
|
|
61
|
+
return () => {
|
|
62
|
+
const cur = readLock(file);
|
|
63
|
+
if (cur && cur.pid === info.pid && cur.at === info.at)
|
|
64
|
+
fs.rmSync(file, { force: true });
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
if (err.code !== "EEXIST")
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
const holder = readLock(file);
|
|
72
|
+
if (isStale(holder, staleMs)) {
|
|
73
|
+
// Taken out of the way in one step; of several waiters only one moves it, and only
|
|
74
|
+
// the lock it judged dead — another one is put back.
|
|
75
|
+
const dead = `${file}.dead.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
76
|
+
try {
|
|
77
|
+
fs.renameSync(file, dead);
|
|
78
|
+
const moved = readLock(dead);
|
|
79
|
+
if (holder && moved && (moved.pid !== holder.pid || moved.at !== holder.at)) {
|
|
80
|
+
try {
|
|
81
|
+
fs.linkSync(dead, file);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// someone took the name meanwhile: that one holds it now
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
fs.rmSync(dead, { force: true });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// someone else moved it first
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (Date.now() >= deadline) {
|
|
95
|
+
throw new LockedError(`locked by ${holder?.owner ?? "another process"} (pid ${holder?.pid ?? "?"} since ${holder?.at ?? "?"})`, "wait for it to finish, or check `strom status`");
|
|
96
|
+
}
|
|
97
|
+
sleepSync(50);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
fs.rmSync(aside, { force: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export function withLock(file, opts, fn) {
|
|
105
|
+
const release = acquireLock(file, opts);
|
|
106
|
+
try {
|
|
107
|
+
return fn();
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
release();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Logins of the user to archive portals: an account they have, perhaps one they
|
|
2
|
+
// paid for. The user types them in on a terminal (strom login <connector>),
|
|
3
|
+
// never an agent. They stay on this computer, in the user's config folder (which
|
|
4
|
+
// agents may not read) — never in a tree, the shared folder or git. strom puts a
|
|
5
|
+
// value into a request of the connector itself, only to the hosts it was saved
|
|
6
|
+
// for and only over https: the connector's code never sees it, nor does the
|
|
7
|
+
// agent that runs it, and it is taken out of every answer the connector reads.
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
import { configDir } from "./paths.js";
|
|
12
|
+
import { readJsonIfExists, stringifyCanonical } from "./json.js";
|
|
13
|
+
/** Fields typed in the open; every other one (a password, a key, a token) is hidden, and kept out of answers. */
|
|
14
|
+
export const VISIBLE_FIELDS = new Set(["user", "email"]);
|
|
15
|
+
export function loginsFile(env) {
|
|
16
|
+
return path.join(configDir(env), "logins.json");
|
|
17
|
+
}
|
|
18
|
+
export function loadLogins(env) {
|
|
19
|
+
return readJsonIfExists(loginsFile(env)) ?? {};
|
|
20
|
+
}
|
|
21
|
+
/** Written readable by the user alone, and never half. */
|
|
22
|
+
function write(env, all) {
|
|
23
|
+
const file = loginsFile(env);
|
|
24
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
25
|
+
const tmp = path.join(path.dirname(file), `.logins.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`);
|
|
26
|
+
fs.writeFileSync(tmp, stringifyCanonical(all), { encoding: "utf8", mode: 0o600 });
|
|
27
|
+
try {
|
|
28
|
+
fs.renameSync(tmp, file);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
fs.rmSync(tmp, { force: true });
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function saveLogin(env, name, login) {
|
|
36
|
+
write(env, { ...loadLogins(env), [name]: login });
|
|
37
|
+
}
|
|
38
|
+
export function removeLogin(env, name) {
|
|
39
|
+
const all = loadLogins(env);
|
|
40
|
+
if (!all[name])
|
|
41
|
+
return false;
|
|
42
|
+
delete all[name];
|
|
43
|
+
write(env, all);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
/** The login saved for this connector — for its folder, not for another one of the same name. */
|
|
47
|
+
export function loginOf(env, c) {
|
|
48
|
+
const l = loadLogins(env)[c.name];
|
|
49
|
+
return l && path.resolve(l.dir) === path.resolve(c.dir) ? l : undefined;
|
|
50
|
+
}
|
|
51
|
+
/** The forms a value takes in a page, a URL, a form or JSON: each one comes out of an answer. */
|
|
52
|
+
function spellings(v) {
|
|
53
|
+
const html = v.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
54
|
+
const form = new URLSearchParams({ v }).toString().slice(2);
|
|
55
|
+
return [...new Set([v, encodeURIComponent(v), form, html, JSON.stringify(v).slice(1, -1)])].sort((a, b) => b.length - a.length);
|
|
56
|
+
}
|
|
57
|
+
/** Takes the hidden values of a login out of a text: "[login]" stands in their place. */
|
|
58
|
+
export function redactor(login) {
|
|
59
|
+
const secrets = login
|
|
60
|
+
? Object.entries(login.values)
|
|
61
|
+
.filter(([k, v]) => !VISIBLE_FIELDS.has(k) && v.length >= 4)
|
|
62
|
+
.flatMap(([, v]) => spellings(v))
|
|
63
|
+
: [];
|
|
64
|
+
if (!secrets.length)
|
|
65
|
+
return (s) => s;
|
|
66
|
+
return (s) => secrets.reduce((t, v) => t.split(v).join("[login]"), s);
|
|
67
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Files that come into a research: stored unchanged, found again by their
|
|
2
|
+
// SHA-256. Small material from the family goes into the tree (and git) —
|
|
3
|
+
// it cannot be downloaded again; large files go to the shared media store.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import { Settings } from "./config.js";
|
|
8
|
+
import { UsageError } from "./errors.js";
|
|
9
|
+
import { safeFolderName } from "./text.js";
|
|
10
|
+
export const MAX_IN_TREE = 20 * 1024 * 1024;
|
|
11
|
+
export function fileSha256(file) {
|
|
12
|
+
const h = crypto.createHash("sha256");
|
|
13
|
+
const fd = fs.openSync(file, "r");
|
|
14
|
+
try {
|
|
15
|
+
const buf = Buffer.allocUnsafe(1 << 20);
|
|
16
|
+
let n;
|
|
17
|
+
while ((n = fs.readSync(fd, buf, 0, buf.length, null)) > 0)
|
|
18
|
+
h.update(buf.subarray(0, n));
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
fs.closeSync(fd);
|
|
22
|
+
}
|
|
23
|
+
return h.digest("hex");
|
|
24
|
+
}
|
|
25
|
+
/** Shared store path for a hash: media/ab/cd/abcd….ext */
|
|
26
|
+
export function sharedMediaPath(sharedDir, sha, ext) {
|
|
27
|
+
return path.join(sharedDir, "media", sha.slice(0, 2), sha.slice(2, 4), `${sha}${ext.toLowerCase()}`);
|
|
28
|
+
}
|
|
29
|
+
/** Copy into the shared store (no-op when the same content is already there). */
|
|
30
|
+
export function storeShared(sharedDir, file, sha) {
|
|
31
|
+
const dest = sharedMediaPath(sharedDir, sha, path.extname(file));
|
|
32
|
+
if (!fs.existsSync(dest)) {
|
|
33
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
34
|
+
// a clone where Node can make one (Linux on Btrfs or XFS: no space taken until one of them changes), else a copy —
|
|
35
|
+
// on macOS Node copies (it cannot clone), so an archive of scans takes its size again
|
|
36
|
+
fs.copyFileSync(file, dest, fs.constants.COPYFILE_FICLONE);
|
|
37
|
+
}
|
|
38
|
+
return dest;
|
|
39
|
+
}
|
|
40
|
+
const MIME = {
|
|
41
|
+
".jpg": "image/jpeg",
|
|
42
|
+
".jpeg": "image/jpeg",
|
|
43
|
+
".png": "image/png",
|
|
44
|
+
".tif": "image/tiff",
|
|
45
|
+
".tiff": "image/tiff",
|
|
46
|
+
".gif": "image/gif",
|
|
47
|
+
".webp": "image/webp",
|
|
48
|
+
".heic": "image/heic",
|
|
49
|
+
".pdf": "application/pdf",
|
|
50
|
+
".txt": "text/plain",
|
|
51
|
+
".md": "text/markdown",
|
|
52
|
+
".rtf": "application/rtf",
|
|
53
|
+
".doc": "application/msword",
|
|
54
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
55
|
+
".odt": "application/vnd.oasis.opendocument.text",
|
|
56
|
+
".ged": "text/x-gedcom",
|
|
57
|
+
".json": "application/json",
|
|
58
|
+
".html": "text/html",
|
|
59
|
+
".csv": "text/csv",
|
|
60
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
61
|
+
".mp3": "audio/mpeg",
|
|
62
|
+
".m4a": "audio/mp4",
|
|
63
|
+
};
|
|
64
|
+
export function mimeOf(file) {
|
|
65
|
+
return MIME[path.extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
66
|
+
}
|
|
67
|
+
/** Every file under the given paths (folders recursively), hidden files skipped. */
|
|
68
|
+
export function collectFiles(paths) {
|
|
69
|
+
const out = [];
|
|
70
|
+
const walk = (p) => {
|
|
71
|
+
const base = path.basename(p);
|
|
72
|
+
if (base.startsWith(".") || base === "Thumbs.db" || base === "desktop.ini")
|
|
73
|
+
return;
|
|
74
|
+
const st = fs.statSync(p);
|
|
75
|
+
if (st.isDirectory())
|
|
76
|
+
for (const e of fs.readdirSync(p).sort())
|
|
77
|
+
walk(path.join(p, e));
|
|
78
|
+
else if (st.isFile())
|
|
79
|
+
out.push(p);
|
|
80
|
+
};
|
|
81
|
+
for (const p of paths)
|
|
82
|
+
walk(p);
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
const digitGroups = (file) => path.basename(file, path.extname(file)).match(/\d+/g) ?? [];
|
|
86
|
+
/**
|
|
87
|
+
* The image number in one file name: the last zero-padded group of digits
|
|
88
|
+
* ("s0057.jpg" → 57, "…_00141_sign-4411.jpg" → 141), else the last group.
|
|
89
|
+
*/
|
|
90
|
+
export function numberInName(file) {
|
|
91
|
+
const groups = digitGroups(file);
|
|
92
|
+
const g = groups.filter((d) => d.length > 1 && d.startsWith("0")).at(-1) ?? groups.at(-1);
|
|
93
|
+
return g === undefined ? undefined : Number(g);
|
|
94
|
+
}
|
|
95
|
+
/** The folder of the shared inbox where the user saves images of a record set by hand: "B0003 <its title>". */
|
|
96
|
+
export function inboxFolderFor(b) {
|
|
97
|
+
const name = [...safeFolderName(`${b.id} ${b.title}`)];
|
|
98
|
+
return name.length <= 80 ? name.join("") : name.slice(0, 80).join("").replace(/[. ]+$/, "");
|
|
99
|
+
}
|
|
100
|
+
/** A list of image numbers as people write it: "9", "9-12", "9–12, 15" → [9, 10, 11, 12, 15]; undefined if it is not one. */
|
|
101
|
+
export function parseImageList(s, max = 2000) {
|
|
102
|
+
const parts = s.split(",").map((p) => p.trim());
|
|
103
|
+
const out = new Set();
|
|
104
|
+
for (const p of parts) {
|
|
105
|
+
const m = /^(\d+)(?:\s*[-–]\s*(\d+))?$/.exec(p);
|
|
106
|
+
if (!m)
|
|
107
|
+
return undefined;
|
|
108
|
+
const a = Number(m[1]);
|
|
109
|
+
const z = Number(m[2] ?? m[1]);
|
|
110
|
+
if (a < 1 || z < a || z - a >= max)
|
|
111
|
+
return undefined;
|
|
112
|
+
for (let n = a; n <= z && out.size <= max; n++)
|
|
113
|
+
out.add(n);
|
|
114
|
+
}
|
|
115
|
+
return out.size && out.size <= max ? [...out].sort((x, y) => x - y) : undefined;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Image numbers of the files of one download: the group of digits that changes
|
|
119
|
+
* from file to file (an archive's file names also carry a call number or a
|
|
120
|
+
* fonds code that stays the same).
|
|
121
|
+
*/
|
|
122
|
+
export function imageNumbers(files) {
|
|
123
|
+
const groups = files.map(digitGroups);
|
|
124
|
+
const n = groups[0]?.length ?? 0;
|
|
125
|
+
if (files.length > 1 && n > 0 && groups.every((g) => g.length === n)) {
|
|
126
|
+
const varying = [...Array(n).keys()].filter((i) => new Set(groups.map((g) => Number(g[i]))).size > 1);
|
|
127
|
+
const i = varying.at(-1);
|
|
128
|
+
if (i !== undefined)
|
|
129
|
+
return groups.map((g) => Number(g[i]));
|
|
130
|
+
}
|
|
131
|
+
return files.map(numberInName);
|
|
132
|
+
}
|
|
133
|
+
/** The shared inbox by folder ("" = files lying in the inbox itself): one download, one record set. */
|
|
134
|
+
export function inboxFolders(inbox) {
|
|
135
|
+
if (!fs.existsSync(inbox))
|
|
136
|
+
return [];
|
|
137
|
+
const by = new Map();
|
|
138
|
+
for (const f of collectFiles([inbox])) {
|
|
139
|
+
const rel = path.relative(inbox, f).split(path.sep);
|
|
140
|
+
const folder = rel.length > 1 ? rel[0] : "";
|
|
141
|
+
by.set(folder, [...(by.get(folder) ?? []), f]);
|
|
142
|
+
}
|
|
143
|
+
return [...by].map(([folder, files]) => ({ folder, files }));
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Image n of a record set: the whole image, before any part of it fetched sharper —
|
|
147
|
+
* of several copies of the whole image (a reduced download, then the full one), the sharpest.
|
|
148
|
+
*/
|
|
149
|
+
export function findImage(all, recordset, image) {
|
|
150
|
+
const of = all.filter((m) => m.recordset === recordset && m.image === image);
|
|
151
|
+
const pixels = (m) => (m.width ?? 0) * (m.height ?? 0);
|
|
152
|
+
return of.filter((m) => !m.part).sort((a, b) => pixels(b) - pixels(a))[0] ?? of[0];
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Image n as a reference names it ("B0001:57"): the whole image. When only parts of it are
|
|
156
|
+
* registered (its two halves), "B0001:57" says nothing of which: the parts are named, none is picked.
|
|
157
|
+
*/
|
|
158
|
+
export function imageOfRef(all, recordset, image) {
|
|
159
|
+
const m = findImage(all, recordset, image);
|
|
160
|
+
if (!m?.part)
|
|
161
|
+
return m;
|
|
162
|
+
const parts = all.filter((x) => x.recordset === recordset && x.image === image && x.part);
|
|
163
|
+
if (parts.length < 2)
|
|
164
|
+
return m;
|
|
165
|
+
throw new UsageError(`image ${image} of ${recordset} is registered only in parts: ${parts.map((p) => `${p.id} (part ${regionText(p.part)})`).join(", ")}`, {
|
|
166
|
+
hint: `name the part the record is on: ${parts.map((p) => p.id).join(" or ")}`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/** The other copies of the same whole image, sharpest first. */
|
|
170
|
+
export function otherCopies(all, m) {
|
|
171
|
+
if (m.part || m.recordset === undefined || m.image === undefined)
|
|
172
|
+
return [];
|
|
173
|
+
return all
|
|
174
|
+
.filter((x) => x.id !== m.id && !x.part && x.recordset === m.recordset && x.image === m.image)
|
|
175
|
+
.sort((a, b) => (b.width ?? 0) * (b.height ?? 0) - (a.width ?? 0) * (a.height ?? 0));
|
|
176
|
+
}
|
|
177
|
+
/** The same part of an image (to a thousandth of it). */
|
|
178
|
+
export function sameRegion(a, b) {
|
|
179
|
+
return Math.abs(a.x - b.x) < 0.001 && Math.abs(a.y - b.y) < 0.001 && Math.abs(a.w - b.w) < 0.001 && Math.abs(a.h - b.h) < 0.001;
|
|
180
|
+
}
|
|
181
|
+
/** "0.5,0,0.5,0.5" — a region as --crop takes it. */
|
|
182
|
+
export function regionText(r) {
|
|
183
|
+
return [r.x, r.y, r.w, r.h].map((n) => String(Math.round(n * 1000) / 1000)).join(",");
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* A registered part of a whole image that shows a region of it (fractions of the
|
|
187
|
+
* whole) with more detail than the whole image has: the sharpest one, and where
|
|
188
|
+
* the region is in it. Worth it from a fifth more pixels on. Another copy of the
|
|
189
|
+
* whole image with more pixels counts as a part that covers all of it.
|
|
190
|
+
*/
|
|
191
|
+
export function sharperPart(all, whole, r) {
|
|
192
|
+
if (!whole.width || whole.part || whole.recordset === undefined || whole.image === undefined)
|
|
193
|
+
return undefined;
|
|
194
|
+
const e = 0.002;
|
|
195
|
+
let best;
|
|
196
|
+
for (const m of all) {
|
|
197
|
+
const p = m.part ?? (m.id !== whole.id ? { x: 0, y: 0, w: 1, h: 1 } : undefined);
|
|
198
|
+
if (!p || !m.width || m.recordset !== whole.recordset || m.image !== whole.image)
|
|
199
|
+
continue;
|
|
200
|
+
if (r.x < p.x - e || r.y < p.y - e || r.x + r.w > p.x + p.w + e || r.y + r.h > p.y + p.h + e)
|
|
201
|
+
continue;
|
|
202
|
+
const gain = m.width / p.w / whole.width;
|
|
203
|
+
if (gain < 1.2 || (best && gain <= best.gain))
|
|
204
|
+
continue;
|
|
205
|
+
const f = (n) => Math.min(1, Math.max(0, n));
|
|
206
|
+
const x = f((r.x - p.x) / p.w);
|
|
207
|
+
const y = f((r.y - p.y) / p.h);
|
|
208
|
+
best = { part: m, gain, crop: { x, y, w: Math.min(1 - x, r.w / p.w), h: Math.min(1 - y, r.h / p.h) } };
|
|
209
|
+
}
|
|
210
|
+
return best;
|
|
211
|
+
}
|
|
212
|
+
/** Where an input's file is on disk (in the tree, or in the shared media store). */
|
|
213
|
+
export function inputPath(tree, input) {
|
|
214
|
+
if (!input.file)
|
|
215
|
+
return undefined;
|
|
216
|
+
if (input.file.startsWith("inputs/"))
|
|
217
|
+
return path.join(tree.root, input.file);
|
|
218
|
+
if (input.file.startsWith("media:")) {
|
|
219
|
+
const shared = new Settings(tree.env, {}).shared()?.value;
|
|
220
|
+
return shared ? path.join(shared, input.file.slice(6)) : undefined;
|
|
221
|
+
}
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Data model. One record = one JSON file under data/<collection>/<ID>.json.
|
|
2
|
+
// Facts are "conclusions" backed by citations; nothing is ever deleted,
|
|
3
|
+
// records and facts are retracted with a reason instead.
|
|
4
|
+
export const RECORD_TYPES = {
|
|
5
|
+
research: { prefix: "G", dir: "researches" },
|
|
6
|
+
person: { prefix: "P", dir: "persons" },
|
|
7
|
+
family: { prefix: "F", dir: "families" },
|
|
8
|
+
source: { prefix: "S", dir: "sources" },
|
|
9
|
+
repository: { prefix: "R", dir: "repositories" },
|
|
10
|
+
recordset: { prefix: "B", dir: "recordsets" },
|
|
11
|
+
place: { prefix: "L", dir: "places" },
|
|
12
|
+
search: { prefix: "Q", dir: "searches" },
|
|
13
|
+
task: { prefix: "T", dir: "tasks" },
|
|
14
|
+
conflict: { prefix: "X", dir: "conflicts" },
|
|
15
|
+
hypothesis: { prefix: "H", dir: "hypotheses" },
|
|
16
|
+
lesson: { prefix: "K", dir: "lessons" },
|
|
17
|
+
input: { prefix: "I", dir: "inputs" },
|
|
18
|
+
session: { prefix: "N", dir: "sessions" },
|
|
19
|
+
media: { prefix: "M", dir: "images" },
|
|
20
|
+
};
|
|
21
|
+
/** Prefixes of IDs that are not files of their own (embedded records). */
|
|
22
|
+
export const EMBEDDED_PREFIXES = {
|
|
23
|
+
event: "E",
|
|
24
|
+
};
|
|
25
|
+
export const ALL_PREFIXES = {
|
|
26
|
+
...Object.fromEntries(Object.entries(RECORD_TYPES).map(([t, v]) => [t, v.prefix])),
|
|
27
|
+
...EMBEDDED_PREFIXES,
|
|
28
|
+
};
|
|
29
|
+
/** Certainty of the conclusion (a fact). The quality of each piece of evidence is on its citation (GEDCOM QUAY). */
|
|
30
|
+
export const STATUSES = ["proven", "probable", "possible", "lead", "disproven", "retracted"];
|
|
31
|
+
export const PARTICIPANT_ROLES = ["godparent", "witness", "officiant", "informant", "midwife", "other"];
|
|
32
|
+
export const NAME_KINDS = ["birth", "married", "religious", "alias"];
|
|
33
|
+
export const CHILD_RELATIONS = ["birth", "adopted", "step", "foster", "unknown"];
|
|
34
|
+
export const DIRECTIONS = ["ancestors", "descendants", "person", "question"];
|
|
35
|
+
export const RESEARCH_STATES = ["active", "paused", "done"];
|
|
36
|
+
export const SOURCE_KINDS = [
|
|
37
|
+
"baptism", "birth", "marriage", "death", "burial", "census", "civil", "church", "land", "tax",
|
|
38
|
+
"military", "court", "notarial", "newspaper", "index", "letter", "photo", "family-tree",
|
|
39
|
+
"family-memory", "certificate", "book", "web", "other",
|
|
40
|
+
];
|
|
41
|
+
export const AUTOMATION = ["allowed", "manual", "forbidden", "unknown"];
|
|
42
|
+
export const ACCESS = ["online-free", "online-login", "onsite", "request", "lost", "unknown"];
|
|
43
|
+
export const JURISDICTIONS = ["parish", "civil", "diocese", "manor", "district", "county", "state", "other"];
|
|
44
|
+
export const SEARCH_METHODS = ["index", "page-by-page", "full-text", "catalog", "web", "request", "other"];
|
|
45
|
+
export const SEARCH_RESULTS = ["found", "negative", "partial", "inconclusive"];
|
|
46
|
+
/** How the task queue is ordered (core/queue.ts). */
|
|
47
|
+
export const STRATEGIES = ["balanced", "depth", "priority"];
|
|
48
|
+
export const TASK_LEVELS = ["intake", "locate", "link", "verify", "enrich", "request", "narrate"];
|
|
49
|
+
export const TASK_STATES = ["open", "doing", "done", "parked", "waiting", "dropped"];
|
|
50
|
+
export const LESSON_SCOPES = ["method", "repository", "recordset", "place", "project"];
|
|
51
|
+
export const LESSON_MAX = 200;
|
|
52
|
+
export const INPUT_KINDS = ["document", "photo", "tree", "text", "other"];
|
|
53
|
+
export const SCHEMA_VERSION = 1;
|
|
54
|
+
export const NOTE_MAX = 500;
|
|
55
|
+
/** Event tags accepted by `event add`, with friendly aliases. */
|
|
56
|
+
export const EVENT_KINDS = {
|
|
57
|
+
BIRT: "birth",
|
|
58
|
+
CHR: "christening",
|
|
59
|
+
BAPM: "baptism",
|
|
60
|
+
DEAT: "death",
|
|
61
|
+
BURI: "burial",
|
|
62
|
+
CREM: "cremation",
|
|
63
|
+
MARR: "marriage",
|
|
64
|
+
MARB: "marriage banns",
|
|
65
|
+
MARC: "marriage contract",
|
|
66
|
+
MARL: "marriage license",
|
|
67
|
+
MARS: "marriage settlement",
|
|
68
|
+
ANUL: "annulment",
|
|
69
|
+
DIVF: "divorce filed",
|
|
70
|
+
ADOP: "adoption",
|
|
71
|
+
GRAD: "graduation",
|
|
72
|
+
RETI: "retirement",
|
|
73
|
+
CHRA: "adult christening",
|
|
74
|
+
DIV: "divorce",
|
|
75
|
+
ENGA: "engagement",
|
|
76
|
+
OCCU: "occupation",
|
|
77
|
+
RESI: "residence",
|
|
78
|
+
EDUC: "education",
|
|
79
|
+
RELI: "religion",
|
|
80
|
+
EMIG: "emigration",
|
|
81
|
+
IMMI: "immigration",
|
|
82
|
+
NATU: "naturalization",
|
|
83
|
+
CENS: "census",
|
|
84
|
+
PROB: "probate",
|
|
85
|
+
WILL: "will",
|
|
86
|
+
CONF: "confirmation",
|
|
87
|
+
FCOM: "first communion",
|
|
88
|
+
ORDN: "ordination",
|
|
89
|
+
NATI: "nationality",
|
|
90
|
+
TITL: "title",
|
|
91
|
+
MILI: "military service",
|
|
92
|
+
EVEN: "other event",
|
|
93
|
+
};
|
|
94
|
+
export const FAMILY_EVENT_KINDS = new Set(["MARR", "MARB", "MARC", "MARL", "MARS", "DIV", "DIVF", "ANUL", "ENGA", "EVEN", "RESI", "CENS"]);
|
|
95
|
+
const ALIASES = Object.fromEntries(Object.entries(EVENT_KINDS).map(([tag, label]) => [label.replace(/ /g, "-"), tag]));
|
|
96
|
+
export function eventKind(input) {
|
|
97
|
+
const up = input.toUpperCase();
|
|
98
|
+
if (up in EVENT_KINDS)
|
|
99
|
+
return up;
|
|
100
|
+
return ALIASES[input.toLowerCase().replace(/ /g, "-")];
|
|
101
|
+
}
|