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,61 @@
|
|
|
1
|
+
// Stories of the ancestors (the setting `stories`, on by default): once records
|
|
2
|
+
// tell enough of a person's life, strom proposes the task of writing it for the
|
|
3
|
+
// family — and of adding to it when records brought more after it was written.
|
|
4
|
+
// The story goes into the family book of the Strom app (GEDCOM _STORY).
|
|
5
|
+
import { ancestorGenerations, displayName, familiesAsPartner, lifespan } from "./people.js";
|
|
6
|
+
import { phrase } from "./phrases.js";
|
|
7
|
+
const FROM_RECORDS = new Set(["proven", "probable"]);
|
|
8
|
+
const BIRTHS = new Set(["BIRT", "CHR", "BAPM"]);
|
|
9
|
+
const OPEN = new Set(["open", "doing", "parked", "waiting"]);
|
|
10
|
+
const FINISHED = new Set(["done", "dropped"]);
|
|
11
|
+
/** Enough to tell a life: a birth or baptism from a record, and more facts from records. */
|
|
12
|
+
export const STORY_MIN_FACTS = 3;
|
|
13
|
+
/** New facts from records that make a written story worth adding to. */
|
|
14
|
+
export const STORY_NEW_FACTS = 2;
|
|
15
|
+
/** The facts of a person's life that records support: their own, and their marriages. */
|
|
16
|
+
export function factsFromRecords(tree, p) {
|
|
17
|
+
const own = p.events.filter((e) => !e.retracted && FROM_RECORDS.has(e.status));
|
|
18
|
+
const marriages = familiesAsPartner(tree, p.id).flatMap((f) => f.events.filter((e) => !e.retracted && FROM_RECORDS.has(e.status)));
|
|
19
|
+
return [...own, ...marriages];
|
|
20
|
+
}
|
|
21
|
+
/** The stories strom proposes to write (or add to) for the ancestors of a research. */
|
|
22
|
+
export function storyProposals(tree, research) {
|
|
23
|
+
if (research.direction !== "ancestors")
|
|
24
|
+
return [];
|
|
25
|
+
const lang = tree.lang;
|
|
26
|
+
const narrate = tree.list("task").filter((t) => t.level === "narrate");
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const [id, generation] of ancestorGenerations(tree, research.focus, research.limits?.generations ?? 50)) {
|
|
29
|
+
const p = tree.get(id);
|
|
30
|
+
if (!p || p.retracted)
|
|
31
|
+
continue;
|
|
32
|
+
const facts = factsFromRecords(tree, p);
|
|
33
|
+
if (facts.length < STORY_MIN_FACTS || !facts.some((e) => BIRTHS.has(e.kind)))
|
|
34
|
+
continue;
|
|
35
|
+
const mine = narrate.filter((t) => t.subject.includes(id));
|
|
36
|
+
if (mine.some((t) => OPEN.has(t.state)))
|
|
37
|
+
continue;
|
|
38
|
+
// Dropped since the story was written (or with none): that was the answer — not again until the story changes.
|
|
39
|
+
const since = p.story?.at ?? "";
|
|
40
|
+
if (mine.some((t) => t.state === "dropped" && t.updated >= since))
|
|
41
|
+
continue;
|
|
42
|
+
// Done, yet no story: somebody decided so; the user can add the task by hand.
|
|
43
|
+
if (!p.story && mine.some((t) => FINISHED.has(t.state)))
|
|
44
|
+
continue;
|
|
45
|
+
const name = `${displayName(p)}${lifespan(p) ? ` (${lifespan(p)})` : ""}`;
|
|
46
|
+
const fresh = p.story ? facts.filter((e) => !p.story.facts.includes(e.id)) : facts;
|
|
47
|
+
if (p.story && fresh.length < STORY_NEW_FACTS)
|
|
48
|
+
continue;
|
|
49
|
+
out.push({
|
|
50
|
+
level: "narrate",
|
|
51
|
+
priority: 1, // the research first; a story when there is a moment for it
|
|
52
|
+
what: phrase(lang, p.story ? "story.more.what" : "story.what", { name, count: fresh.length }),
|
|
53
|
+
where: [phrase(lang, "story.where", { id })],
|
|
54
|
+
why: phrase(lang, "story.why", { count: facts.length, generation, research: research.name }),
|
|
55
|
+
doneWhen: phrase(lang, "story.done", { id }),
|
|
56
|
+
subject: [id],
|
|
57
|
+
research: research.id,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The Strom app (https://stromapp.info) — a web app, often installed from the
|
|
2
|
+
// browser as an app of its own. strom notices it quietly and never asks: the
|
|
3
|
+
// app says so when it starts strom (STROM_APP), or its shortcut is found among
|
|
4
|
+
// the apps the browser installed (only names are looked at — never the
|
|
5
|
+
// browser's history). What strom does with it: say which file to import into
|
|
6
|
+
// the app. The user can say yes or no for good (strom config set strom.app).
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { userHome } from "./paths.js";
|
|
10
|
+
import { foldText } from "./text.js";
|
|
11
|
+
export const STROM_APP_URL = "https://stromapp.info/run/";
|
|
12
|
+
/** The Strom app's address: STROM_APP_URL points strom at another copy of it (its development, http://127.0.0.1:8080/). */
|
|
13
|
+
export function stromAppUrl(env) {
|
|
14
|
+
const url = env.STROM_APP_URL?.trim();
|
|
15
|
+
return url && /^https?:\/\//.test(url) ? url : STROM_APP_URL;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The Strom app opens a research by itself (from its version 3.0.0; its import
|
|
19
|
+
* contract, docs/GEDCOM-IMPORT.md in the Strom repository): ?import-url=<the
|
|
20
|
+
* tree's GEDCOM on this computer> opens that tree — created the first time,
|
|
21
|
+
* updated after — and ?live=<the bridge> follows it while it goes on (see
|
|
22
|
+
* core/live.ts). On since stromapp.info runs 3.0.0; a copy of the app named by
|
|
23
|
+
* STROM_APP_URL (a beta, its development) is taken as current too.
|
|
24
|
+
*/
|
|
25
|
+
export const APP_OPENS_RESEARCH = true;
|
|
26
|
+
export function appOpensResearch(env) {
|
|
27
|
+
return APP_OPENS_RESEARCH || Boolean(env.STROM_APP_URL?.trim());
|
|
28
|
+
}
|
|
29
|
+
/** The address that opens the Strom app following a research through its bridge. */
|
|
30
|
+
export function liveAppUrl(bridge, env) {
|
|
31
|
+
return `${stromAppUrl(env)}?live=${encodeURIComponent(bridge)}`;
|
|
32
|
+
}
|
|
33
|
+
/** The address that opens a research's GEDCOM (served on this computer) in the Strom app. */
|
|
34
|
+
export function importAppUrl(file, env) {
|
|
35
|
+
return `${stromAppUrl(env)}?import-url=${encodeURIComponent(file)}`;
|
|
36
|
+
}
|
|
37
|
+
/** The page for people starting research with an AI agent: what an agent is, which one, how to install it. */
|
|
38
|
+
export const RESEARCH_URL = "https://stromapp.info/research/";
|
|
39
|
+
/** That page in the user's language (it speaks en, cs, de), at a section: "agents". */
|
|
40
|
+
export function researchUrl(lang, section) {
|
|
41
|
+
return `${RESEARCH_URL}${["cs", "de"].includes(lang) ? `?lang=${lang}` : ""}${section ? `#${section}` : ""}`;
|
|
42
|
+
}
|
|
43
|
+
/** "Strom", "Strom - Family Tree" — not this tool ("Strom Research"). */
|
|
44
|
+
export function isStromName(name) {
|
|
45
|
+
const n = foldText(name).trim();
|
|
46
|
+
return /^strom(\s*[-–:|]\s*.*)?$/u.test(n) && !/research|vyzkum/u.test(n);
|
|
47
|
+
}
|
|
48
|
+
function entries(dir) {
|
|
49
|
+
try {
|
|
50
|
+
return fs.readdirSync(dir);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** The Strom app installed from a browser, if there is one. */
|
|
57
|
+
export function installedStromApp(env, platform = process.platform) {
|
|
58
|
+
const home = userHome(env);
|
|
59
|
+
if (platform === "darwin") {
|
|
60
|
+
const apps = path.join(home, "Applications");
|
|
61
|
+
const browsers = [
|
|
62
|
+
["Chrome Apps.localized", "Chrome app"],
|
|
63
|
+
["Chrome Apps", "Chrome app"],
|
|
64
|
+
["Edge Apps.localized", "Edge app"],
|
|
65
|
+
["Brave Browser Apps.localized", "Brave app"],
|
|
66
|
+
["Chromium Apps.localized", "Chromium app"],
|
|
67
|
+
["Vivaldi Apps.localized", "Vivaldi app"],
|
|
68
|
+
];
|
|
69
|
+
for (const [dir, kind] of browsers)
|
|
70
|
+
for (const f of entries(path.join(apps, dir)))
|
|
71
|
+
if (f.endsWith(".app") && isStromName(f.slice(0, -4)))
|
|
72
|
+
return { path: path.join(apps, dir, f), kind };
|
|
73
|
+
// Safari: File → Add to Dock puts the web app straight into ~/Applications.
|
|
74
|
+
for (const f of entries(apps))
|
|
75
|
+
if (f.endsWith(".app") && isStromName(f.slice(0, -4)))
|
|
76
|
+
return { path: path.join(apps, f), kind: "Safari web app" };
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
if (platform === "win32") {
|
|
80
|
+
const programs = path.join(env.APPDATA ?? path.join(home, "AppData", "Roaming"), "Microsoft", "Windows", "Start Menu", "Programs");
|
|
81
|
+
for (const dir of ["Chrome Apps", "Edge Apps", ""]) {
|
|
82
|
+
const d = path.join(programs, dir);
|
|
83
|
+
for (const f of entries(d))
|
|
84
|
+
if (f.toLowerCase().endsWith(".lnk") && isStromName(f.slice(0, -4)))
|
|
85
|
+
return { path: path.join(d, f), kind: dir ? dir.replace(/s$/, "") : "app" };
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
const dir = path.join(env.XDG_DATA_HOME ?? path.join(home, ".local", "share"), "applications");
|
|
90
|
+
for (const f of entries(dir)) {
|
|
91
|
+
if (!f.endsWith(".desktop"))
|
|
92
|
+
continue;
|
|
93
|
+
let text = "";
|
|
94
|
+
try {
|
|
95
|
+
text = fs.readFileSync(path.join(dir, f), "utf8");
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const name = /^Name=(.*)$/m.exec(text)?.[1];
|
|
101
|
+
if (name && isStromName(name) && /--app-id=|--app=/.test(text))
|
|
102
|
+
return { path: path.join(dir, f), kind: "browser app" };
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
/** What strom knows of the Strom app: the user's word first, then what it noticed. */
|
|
107
|
+
export function stromAppState(settings) {
|
|
108
|
+
const said = settings.config.stromApp;
|
|
109
|
+
if (said === "yes" || said === "no")
|
|
110
|
+
return said;
|
|
111
|
+
return settings.config.stromAppSeen ? "seen" : "unknown";
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Notice the Strom app and remember it (quietly, in the user config). `look`
|
|
115
|
+
* also checks the apps installed from browsers — a few folder listings, done
|
|
116
|
+
* where it is cheap to do (setup, the menu), not on every command.
|
|
117
|
+
*/
|
|
118
|
+
export function noticeStromApp(settings, env, opts = {}) {
|
|
119
|
+
if (settings.config.stromApp === "no")
|
|
120
|
+
return undefined;
|
|
121
|
+
const at = new Date().toISOString();
|
|
122
|
+
if (env.STROM_APP) {
|
|
123
|
+
const version = env.STROM_APP_VERSION;
|
|
124
|
+
const seen = settings.config.stromAppSeen;
|
|
125
|
+
if (!seen || seen.via !== "started strom" || seen.version !== version) {
|
|
126
|
+
settings.config.stromAppSeen = { via: "started strom", at, ...(version ? { version } : {}) };
|
|
127
|
+
settings.save();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!opts.look)
|
|
131
|
+
return undefined;
|
|
132
|
+
const app = installedStromApp(env);
|
|
133
|
+
if (app && !settings.config.stromAppSeen) {
|
|
134
|
+
settings.config.stromAppSeen = { via: app.kind, at };
|
|
135
|
+
settings.save();
|
|
136
|
+
}
|
|
137
|
+
return app;
|
|
138
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Text helpers for matching names across spellings and diacritics.
|
|
2
|
+
/** Lowercase, strip diacritics, collapse whitespace: "Víšek Antonín" -> "visek antonin". */
|
|
3
|
+
export function foldText(s) {
|
|
4
|
+
return s
|
|
5
|
+
.normalize("NFD")
|
|
6
|
+
.replace(/[̀-ͯ]/g, "")
|
|
7
|
+
.replace(/ß/g, "ss")
|
|
8
|
+
.replace(/[łŁ]/g, "l")
|
|
9
|
+
.replace(/[đĐ]/g, "d")
|
|
10
|
+
.replace(/[øØ]/g, "o")
|
|
11
|
+
.replace(/[æÆ]/g, "ae")
|
|
12
|
+
.replace(/[œŒ]/g, "oe")
|
|
13
|
+
.replace(/[þÞ]/g, "th")
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.replace(/\s+/g, " ")
|
|
16
|
+
.trim();
|
|
17
|
+
}
|
|
18
|
+
/** Words of a folded text — in any script: "Шевчук Іван" has words too. */
|
|
19
|
+
export function tokens(s) {
|
|
20
|
+
return foldText(s)
|
|
21
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
// Named character references of web pages. A Latin letter with a mark is composed from its
|
|
25
|
+
// name (á š ů ő …); the rest are listed.
|
|
26
|
+
const MARKS = {
|
|
27
|
+
acute: "\u0301", grave: "\u0300", circ: "\u0302", tilde: "\u0303", uml: "\u0308", ring: "\u030a", cedil: "\u0327",
|
|
28
|
+
caron: "\u030c", ogon: "\u0328", dblac: "\u030b", breve: "\u0306", macr: "\u0304", dot: "\u0307",
|
|
29
|
+
};
|
|
30
|
+
const GREEK = "Alpha Beta Gamma Delta Epsilon Zeta Eta Theta Iota Kappa Lambda Mu Nu Xi Omicron Pi Rho Sigma Tau Upsilon Phi Chi Psi Omega".split(" ");
|
|
31
|
+
const NAMED = {
|
|
32
|
+
amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\u00a0", shy: "\u00ad", ensp: "\u2002", emsp: "\u2003", thinsp: "\u2009",
|
|
33
|
+
zwnj: "\u200c", zwj: "\u200d", lrm: "\u200e", rlm: "\u200f", ndash: "–", mdash: "—", hellip: "…", bull: "•", middot: "·",
|
|
34
|
+
laquo: "«", raquo: "»", lsaquo: "‹", rsaquo: "›", ldquo: "“", rdquo: "”", bdquo: "„", lsquo: "‘", rsquo: "’", sbquo: "‚",
|
|
35
|
+
prime: "′", Prime: "″", copy: "©", reg: "®", trade: "™", deg: "°", plusmn: "±", times: "×", divide: "÷", sect: "§", para: "¶",
|
|
36
|
+
dagger: "†", Dagger: "‡", permil: "‰", euro: "€", pound: "£", cent: "¢", yen: "¥", curren: "¤", brvbar: "¦", not: "¬",
|
|
37
|
+
micro: "µ", ordf: "ª", ordm: "º", sup1: "¹", sup2: "²", sup3: "³", frac14: "¼", frac12: "½", frac34: "¾", iexcl: "¡",
|
|
38
|
+
iquest: "¿", acute: "´", uml: "¨", cedil: "¸", macr: "¯", larr: "←", rarr: "→", uarr: "↑", darr: "↓", harr: "↔",
|
|
39
|
+
szlig: "ß", aelig: "æ", AElig: "Æ", oelig: "œ", OElig: "Œ", oslash: "ø", Oslash: "Ø", eth: "ð", ETH: "Ð", thorn: "þ",
|
|
40
|
+
THORN: "Þ", lstrok: "ł", Lstrok: "Ł", dstrok: "đ", Dstrok: "Đ", hstrok: "ħ", Hstrok: "Ħ", tstrok: "ŧ", Tstrok: "Ŧ",
|
|
41
|
+
eng: "ŋ", ENG: "Ŋ", imath: "ı", inodot: "ı", sigmaf: "ς", sdot: "⋅",
|
|
42
|
+
...Object.fromEntries(GREEK.flatMap((g, i) => [
|
|
43
|
+
[g, String.fromCodePoint(0x391 + i + (i > 16 ? 1 : 0))],
|
|
44
|
+
[g.toLowerCase(), String.fromCodePoint(0x3b1 + i + (i > 16 ? 1 : 0))],
|
|
45
|
+
])),
|
|
46
|
+
};
|
|
47
|
+
function reference(name) {
|
|
48
|
+
if (name[0] === "#") {
|
|
49
|
+
const cp = name[1] === "x" || name[1] === "X" ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
|
|
50
|
+
return cp > 0 && cp <= 0x10ffff && (cp < 0xd800 || cp > 0xdfff) ? String.fromCodePoint(cp) : undefined;
|
|
51
|
+
}
|
|
52
|
+
if (Object.hasOwn(NAMED, name))
|
|
53
|
+
return NAMED[name];
|
|
54
|
+
const m = /^([A-Za-z])([a-z]+)$/.exec(name);
|
|
55
|
+
// a dot above only on the letters that have one (⊙ is ⊙, not ȯ)
|
|
56
|
+
const mark = m && Object.hasOwn(MARKS, m[2]) && (m[2] !== "dot" || "cegzCEGIZ".includes(m[1])) ? MARKS[m[2]] : undefined;
|
|
57
|
+
const ch = mark && (m[1] + mark).normalize("NFC");
|
|
58
|
+
return ch && ch.length === 1 ? ch : undefined;
|
|
59
|
+
}
|
|
60
|
+
const ESCAPED = { "/": "/", "\\": "\\", '"': '"', "'": "'", n: "\n", r: "\r", t: "\t" };
|
|
61
|
+
/**
|
|
62
|
+
* A page's text as a person reads it, with where each of its characters is in the page:
|
|
63
|
+
* character references (á í á ) and the escapes of scripts and JSON
|
|
64
|
+
* (\u00e1 \/ \") written out. `at[i]` is the page's index of the text's character i, and
|
|
65
|
+
* `at[text.length]` the page's length. Undefined when there is nothing to write out.
|
|
66
|
+
*/
|
|
67
|
+
export function readable(page) {
|
|
68
|
+
const at = new Int32Array(page.length + 1); // written out, a text is never longer than its page
|
|
69
|
+
const parts = [];
|
|
70
|
+
let n = 0;
|
|
71
|
+
let last = 0;
|
|
72
|
+
const keep = (to) => {
|
|
73
|
+
for (let i = last; i < to; i++)
|
|
74
|
+
at[n++] = i;
|
|
75
|
+
parts.push(page.slice(last, to));
|
|
76
|
+
};
|
|
77
|
+
for (const m of page.matchAll(/&(#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|[A-Za-z][A-Za-z0-9]{1,31});|\\(u[0-9a-fA-F]{4}|[/\\"'nrt])/g)) {
|
|
78
|
+
const ch = m[1] !== undefined ? reference(m[1]) : m[2][0] === "u" ? String.fromCharCode(parseInt(m[2].slice(1), 16)) : ESCAPED[m[2]];
|
|
79
|
+
if (ch === undefined)
|
|
80
|
+
continue;
|
|
81
|
+
keep(m.index);
|
|
82
|
+
for (let k = 0; k < ch.length; k++)
|
|
83
|
+
at[n++] = m.index;
|
|
84
|
+
parts.push(ch);
|
|
85
|
+
last = m.index + m[0].length;
|
|
86
|
+
}
|
|
87
|
+
if (!parts.length)
|
|
88
|
+
return undefined;
|
|
89
|
+
keep(page.length);
|
|
90
|
+
at[n] = page.length;
|
|
91
|
+
return { text: parts.join(""), at: at.subarray(0, n + 1) };
|
|
92
|
+
}
|
|
93
|
+
/** Folder-safe name keeping diacritics (works on Windows, macOS, Linux). */
|
|
94
|
+
export function safeFolderName(name) {
|
|
95
|
+
const cleaned = name
|
|
96
|
+
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, " ")
|
|
97
|
+
.replace(/\s+/g, " ")
|
|
98
|
+
.trim()
|
|
99
|
+
.replace(/[. ]+$/, "");
|
|
100
|
+
const reserved = /^(con|prn|aux|nul|com\d|lpt\d)$/i;
|
|
101
|
+
if (cleaned === "" || reserved.test(cleaned) || cleaned.toLowerCase() === "shared")
|
|
102
|
+
return `${cleaned || "tree"}-1`;
|
|
103
|
+
return cleaned;
|
|
104
|
+
}
|