@heroiclands/package-build 23.6.1 → 23.7.1

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.
@@ -0,0 +1,993 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * The rows of the SQL relations a language note's register, sounds, history
16
+ * and names are read through, from index records whose Addresses are already
17
+ * strings.
18
+ *
19
+ * Every relation is keyed by `language`, the note that states the entry, and
20
+ * a list's entries by `position`, their order in it. **Every row has every
21
+ * column**, so a fence naming a key no entry writes still binds. A key that
22
+ * would share a name with `language` or `position` takes a prefix:
23
+ * a tongue's `language` is `tongue_language`, an element's `position` is
24
+ * `element_position`, and a cognate form's language is `form_language`.
25
+ *
26
+ * - `register` — one row per `data.register` entry, with `built_text` and
27
+ * `notes`, every note in the language's `data.scope` carrying the name — of
28
+ * the whole package, for a language stating no scope: as its full name,
29
+ * an alias or a part of its name, as a rank or an office title, or as the
30
+ * name of an anchor, which is cited with its anchor. A folder note carries
31
+ * no name;
32
+ * - `retired`, `tongues`, `letters`, `edges`, `read_past`, `sound_changes`,
33
+ * `elements` and `roots` — one row per entry, the list keys always lists;
34
+ * - `scope` — one row per value, with the `kind` of `data.scope` it is under;
35
+ * - `clusters` — one row per language stating `data.sounds.clusters`;
36
+ * - `cluster_groups` and `mutations` — one row per entry of
37
+ * `data.sounds.clusters.groups` and `data.sounds.mutations`;
38
+ * - `older` — one row per `data.older` entry;
39
+ * - `negation` — one row per language stating `data.negation`;
40
+ * - `name_shapes`, `name_edges`, `name_draws` and `held_from` — one row per
41
+ * entry of the lists of `data.naming`, and `near_names` one per worked
42
+ * near name, with the language's `min_syllables`;
43
+ * - `cognates` — one row per form of each set, with the set's `gloss`.
44
+ *
45
+ * @module
46
+ */
47
+
48
+ import { isLanguageRecord, noteAddressOf } from "./word-rows.mjs";
49
+ import {
50
+ ANY_NAME,
51
+ CALLING_CLOSING_KEYS,
52
+ CLUSTER_GROUP_KEYS,
53
+ EDGE_KEYS,
54
+ ELEMENT_KEYS,
55
+ FINAL_KEYS,
56
+ JOINING_KEYS,
57
+ JOIN_KEYS,
58
+ LETTER_KEYS,
59
+ MARK_KEYS,
60
+ MUTATION_KEYS,
61
+ OLDER_KEYS,
62
+ READ_PAST_KEYS,
63
+ REGISTER_KEYS,
64
+ RETIRED_KEYS,
65
+ ROOT_KEYS,
66
+ SCOPE_KINDS,
67
+ SKELETON_KEYS,
68
+ SOUND_CHANGE_STEP_KEYS,
69
+ TONGUE_KEYS,
70
+ } from "./language-terms.mjs";
71
+
72
+ /** The columns every list relation opens with. */
73
+ const IDENTITY = Object.freeze(["language", "position"]);
74
+
75
+ /** A key's column, prefixed where it would share an identity column's name. */
76
+ const column = (prefix, key) => (IDENTITY.includes(key) ? `${prefix}_${key}` : key);
77
+
78
+ /** Every column of the `register` relation, in order. */
79
+ export const REGISTER_COLUMNS = Object.freeze([
80
+ ...IDENTITY,
81
+ ...REGISTER_KEYS,
82
+ "built_text",
83
+ "notes",
84
+ ]);
85
+ /** Every column of the `retired` relation, in order. */
86
+ export const RETIRED_COLUMNS = Object.freeze([...IDENTITY, ...RETIRED_KEYS]);
87
+ /** Every column of the `tongues` relation, in order. */
88
+ export const TONGUE_COLUMNS = Object.freeze([
89
+ ...IDENTITY,
90
+ ...TONGUE_KEYS.map((key) => column("tongue", key)),
91
+ ]);
92
+ /** Every column of the `scope` relation, in order. */
93
+ export const SCOPE_COLUMNS = Object.freeze(["language", "kind", "value"]);
94
+ /** Every column of the `letters` relation, in order. */
95
+ export const LETTER_COLUMNS = Object.freeze([...IDENTITY, ...LETTER_KEYS]);
96
+ /** Every column of the `edges` relation, in order. */
97
+ export const EDGE_COLUMNS = Object.freeze([...IDENTITY, ...EDGE_KEYS]);
98
+ /** Every column of the `clusters` relation, in order. */
99
+ export const CLUSTER_COLUMNS = Object.freeze(["language", "max"]);
100
+ /** Every column of the `cluster_groups` relation, in order. */
101
+ export const CLUSTER_GROUP_COLUMNS = Object.freeze([...IDENTITY, ...CLUSTER_GROUP_KEYS]);
102
+ /** Every column of the `mutations` relation, in order. */
103
+ export const MUTATION_COLUMNS = Object.freeze([...IDENTITY, ...MUTATION_KEYS]);
104
+ /** Every column of the `older` relation, in order. */
105
+ export const OLDER_COLUMNS = Object.freeze([...IDENTITY, ...OLDER_KEYS]);
106
+ /** Every column of the `name_shapes` relation, in order. */
107
+ export const NAME_SHAPE_COLUMNS = Object.freeze([
108
+ ...IDENTITY,
109
+ "of",
110
+ "syllables_min",
111
+ "syllables_max",
112
+ "per_vowel_min",
113
+ "per_vowel_max",
114
+ ]);
115
+ /** Every column of the `name_edges` relation, in order. */
116
+ export const NAME_EDGE_COLUMNS = Object.freeze([...IDENTITY, "edge", "sounds", "vowel", "of"]);
117
+ /** Every column of the `name_draws` relation, in order. */
118
+ export const NAME_DRAW_COLUMNS = Object.freeze([...IDENTITY, "kind", "elements"]);
119
+ /** Every column of the `held_from` relation, in order. */
120
+ export const HELD_FROM_COLUMNS = Object.freeze([
121
+ ...IDENTITY,
122
+ "source",
123
+ "takes",
124
+ "clip",
125
+ "genitive",
126
+ "elements",
127
+ ]);
128
+ /** Every column of the `near_names` relation, in order. */
129
+ export const NEAR_NAME_COLUMNS = Object.freeze([...IDENTITY, "near", "given", "min_syllables"]);
130
+ /** Every column of the `negation` relation, in order: one row per language. */
131
+ export const NEGATION_COLUMNS = Object.freeze(["language", "particle", "mutation"]);
132
+ /** Every column of the `stress` relation, in order. */
133
+ export const STRESS_COLUMNS = Object.freeze(["language", "stress"]);
134
+ /** Every column of the `finals` relation, in order. */
135
+ export const FINAL_COLUMNS = Object.freeze([...IDENTITY, ...FINAL_KEYS]);
136
+ /** Every column of the `syllables` relation, in order: one row per language. */
137
+ export const SYLLABLE_COLUMNS = Object.freeze([
138
+ "language",
139
+ "onsets",
140
+ "codas",
141
+ "finals",
142
+ "doubled",
143
+ "vowelPairs",
144
+ "initialVowel",
145
+ "finalVowel",
146
+ "neverInitial",
147
+ "signature",
148
+ "harmony_back",
149
+ "harmony_front",
150
+ "harmony_neutral",
151
+ "harmony_neutralAs",
152
+ "harmony_partners",
153
+ ]);
154
+ /** Every column of the `joining` relation, in order. */
155
+ export const JOINING_COLUMNS = Object.freeze([...IDENTITY, ...JOINING_KEYS]);
156
+ /** Every column of the `joins` relation, in order. */
157
+ export const JOIN_COLUMNS = Object.freeze([...IDENTITY, ...JOIN_KEYS]);
158
+ /** Every column of the `marks` relation, in order. */
159
+ export const MARK_COLUMNS = Object.freeze([...IDENTITY, ...MARK_KEYS]);
160
+ /** Every column of the `skeleton` relation, in order. */
161
+ export const SKELETON_COLUMNS = Object.freeze([...IDENTITY, ...SKELETON_KEYS]);
162
+ /** Every column of the `frames` relation, in order. */
163
+ export const FRAME_COLUMNS = Object.freeze([
164
+ ...IDENTITY,
165
+ "frame",
166
+ "shape",
167
+ "class",
168
+ "makes",
169
+ "examples",
170
+ ]);
171
+ /** Every column of the `read_past` relation, in order. */
172
+ export const READ_PAST_COLUMNS = Object.freeze([...IDENTITY, ...READ_PAST_KEYS]);
173
+ /** Every column of the `sound_changes` relation, in order: one row per step. */
174
+ export const SOUND_CHANGE_COLUMNS = Object.freeze([
175
+ ...IDENTITY,
176
+ "name",
177
+ "from_language",
178
+ "to_language",
179
+ "takes",
180
+ "step_position",
181
+ ...SOUND_CHANGE_STEP_KEYS,
182
+ ]);
183
+ /** Every column of the `elements` relation, in order. */
184
+ export const ELEMENT_COLUMNS = Object.freeze([
185
+ ...IDENTITY,
186
+ ...ELEMENT_KEYS.map((key) => column("element", key)),
187
+ ]);
188
+ /** Every column of the `roots` relation, in order. */
189
+ export const ROOT_COLUMNS = Object.freeze([...IDENTITY, ...ROOT_KEYS]);
190
+ /** Every column of the `cognates` relation, in order. */
191
+ export const COGNATE_COLUMNS = Object.freeze([
192
+ ...IDENTITY,
193
+ "gloss",
194
+ "proto",
195
+ "form_language",
196
+ "form",
197
+ ]);
198
+ /** Every column of the `calling_names` relation, in order. */
199
+ export const CALLING_NAME_COLUMNS = Object.freeze([
200
+ ...IDENTITY,
201
+ ...CALLING_CLOSING_KEYS,
202
+ "min_syllables",
203
+ ]);
204
+
205
+ /** A list of strings, as a column type. */
206
+ const LIST = "VARCHAR[]";
207
+
208
+ /**
209
+ * The type of each column that holds no text, by relation, so a relation
210
+ * with no row still has the type a fence's list or number function binds to.
211
+ *
212
+ * @type {Readonly<Record<string, Readonly<Record<string, string>>>>}
213
+ */
214
+ export const LANGUAGE_COLUMN_TYPES = Object.freeze({
215
+ register: { position: "BIGINT", built: LIST, notes: LIST },
216
+ retired: { position: "BIGINT", instead: LIST },
217
+ tongues: { position: "BIGINT" },
218
+ scope: {},
219
+ letters: { position: "BIGINT", letters: LIST, tongue: LIST },
220
+ edges: { position: "BIGINT", sounds: LIST },
221
+ clusters: { max: "BIGINT" },
222
+ cluster_groups: { position: "BIGINT", clusters: LIST },
223
+ mutations: { position: "BIGINT" },
224
+ older: { position: "BIGINT" },
225
+ negation: {},
226
+ name_shapes: {
227
+ position: "BIGINT",
228
+ syllables_min: "BIGINT",
229
+ syllables_max: "BIGINT",
230
+ per_vowel_min: "DOUBLE",
231
+ per_vowel_max: "DOUBLE",
232
+ },
233
+ name_edges: { position: "BIGINT", sounds: LIST, vowel: "BOOLEAN" },
234
+ name_draws: { position: "BIGINT", elements: LIST },
235
+ held_from: { position: "BIGINT", genitive: LIST, elements: LIST },
236
+ near_names: { position: "BIGINT", min_syllables: "BIGINT" },
237
+ stress: {},
238
+ finals: { position: "BIGINT" },
239
+ syllables: {
240
+ codas: LIST,
241
+ finals: LIST,
242
+ doubled: LIST,
243
+ vowelPairs: LIST,
244
+ initialVowel: "BOOLEAN",
245
+ finalVowel: "BOOLEAN",
246
+ neverInitial: LIST,
247
+ signature: LIST,
248
+ harmony_back: LIST,
249
+ harmony_front: LIST,
250
+ harmony_neutral: LIST,
251
+ harmony_partners: "STRUCT(vowel VARCHAR, partner VARCHAR)[]",
252
+ },
253
+ joining: { position: "BIGINT", first: LIST, second: LIST },
254
+ joins: { position: "BIGINT", letters: LIST },
255
+ marks: { position: "BIGINT", letters: LIST },
256
+ skeleton: { position: "BIGINT", second: LIST },
257
+ frames: {
258
+ position: "BIGINT",
259
+ class: LIST,
260
+ examples: "STRUCT(root VARCHAR, form VARCHAR)[]",
261
+ },
262
+ read_past: { position: "BIGINT" },
263
+ sound_changes: { position: "BIGINT", step_position: "BIGINT" },
264
+ elements: { position: "BIGINT", variants: LIST, examples: LIST },
265
+ roots: { position: "BIGINT" },
266
+ cognates: { position: "BIGINT" },
267
+ calling_names: { position: "BIGINT", doubled: "BOOLEAN", min_syllables: "BIGINT" },
268
+ });
269
+
270
+ /** Whether a value is a plain map. */
271
+ const isMap = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
272
+
273
+ /** Code-point order, so a list is the same on every machine. */
274
+ const compare = (a, b) =>
275
+ a < b ? -1
276
+ : a > b ? 1
277
+ : 0;
278
+
279
+ /** A value that may be one string or a list of them, as a list; `null` for none. */
280
+ const asList = (value) => {
281
+ if (value === undefined || value === null) return null;
282
+ return (Array.isArray(value) ? value : [value]).map(String);
283
+ };
284
+
285
+ /**
286
+ * Each language record of a set, with its Address.
287
+ *
288
+ * @param {Iterable<Record<string, any>>} records - Index records.
289
+ * @returns {Array<[string, Record<string, any>]>}
290
+ */
291
+ function languagesOf(records) {
292
+ const out = [];
293
+ for (const record of records) {
294
+ if (!isLanguageRecord(record) || record.documents) continue;
295
+ const language = noteAddressOf(record);
296
+ if (language) out.push([language, record]);
297
+ }
298
+ return out;
299
+ }
300
+
301
+ /**
302
+ * The rows of one list a language note states: one per entry, every key a
303
+ * column.
304
+ *
305
+ * @param {Iterable<Record<string, any>>} records - Index records.
306
+ * @param {(data: object) => unknown} read - The list, from a record's `data`.
307
+ * @param {readonly string[]} keys - The keys an entry carries.
308
+ * @param {object} [options]
309
+ * @param {string} [options.prefix] - The prefix of a key sharing an identity
310
+ * column's name.
311
+ * @param {readonly string[]} [options.lists] - The keys always set as lists.
312
+ * @returns {object[]} One row per entry, in record then entry order.
313
+ */
314
+ function listRows(records, read, keys, { prefix = "", lists = [] } = {}) {
315
+ const rows = [];
316
+ for (const [language, record] of languagesOf(records)) {
317
+ const entries = read(isMap(record.data) ? record.data : {});
318
+ if (!Array.isArray(entries)) continue;
319
+ entries.forEach((entry, position) => {
320
+ if (!isMap(entry)) return;
321
+ const row = { language, position };
322
+ for (const key of keys)
323
+ row[column(prefix, key)] =
324
+ lists.includes(key) ? asList(entry[key]) : (entry[key] ?? null);
325
+ rows.push(row);
326
+ });
327
+ }
328
+ return rows;
329
+ }
330
+
331
+ /** A language's `data.sounds`, or an empty map. */
332
+ const soundsOf = (data) => (isMap(data.sounds) ? data.sounds : {});
333
+
334
+ /**
335
+ * Every name a note of the package carries, with the Addresses carrying it:
336
+ * each string of a note's `name`, its aliases, an affiliation's rank and
337
+ * office titles, and the name of each anchor, cited with the anchor.
338
+ *
339
+ * @param {Iterable<Record<string, any>>} records - Index records.
340
+ * @returns {Map<string, Set<string>>} By name as written, NFC.
341
+ */
342
+ function carriedNames(records) {
343
+ const names = new Map();
344
+ const carry = (name, address) => {
345
+ if (typeof name !== "string" || !name.trim()) return;
346
+ const key = name.normalize("NFC");
347
+ if (!names.has(key)) names.set(key, new Set());
348
+ names.get(key).add(address);
349
+ };
350
+ for (const record of records) {
351
+ const address = noteAddressOf(record);
352
+ if (!address || record.documents || record.type === "folder") continue;
353
+ if (isMap(record.name))
354
+ for (const [key, value] of Object.entries(record.name)) {
355
+ if (key === "aliases" && Array.isArray(value))
356
+ for (const alias of value) carry(alias, address);
357
+ else carry(value, address);
358
+ }
359
+ const governance = isMap(record.data?.governance) ? record.data.governance : {};
360
+ for (const rank of Array.isArray(governance.ranks) ? governance.ranks : [])
361
+ if (isMap(rank)) carry(rank.title, address);
362
+ if (isMap(governance.offices))
363
+ for (const title of Object.keys(governance.offices)) carry(title, address);
364
+ for (const anchor of Array.isArray(record.anchors) ? record.anchors : [])
365
+ if (isMap(anchor) && typeof anchor.slug === "string")
366
+ carry(anchor.name, `${address}#${anchor.slug}`);
367
+ }
368
+ return names;
369
+ }
370
+
371
+ /** A list of strings from a record value, or an empty one. */
372
+ const strings = (value) =>
373
+ (Array.isArray(value) ? value : [value]).filter((one) => typeof one === "string" && one);
374
+
375
+ /**
376
+ * Whether a note falls in a language's `data.scope`: one of its kinds catches
377
+ * the note, `types` (where stated) admits its type, and `skip` does not name
378
+ * it. A language that states no scope covers every note.
379
+ *
380
+ * @param {Record<string, any>} record - A note's index record.
381
+ * @param {string} address - The note's Address.
382
+ * @param {object|undefined} scope - The language's `data.scope`.
383
+ * @returns {boolean}
384
+ */
385
+ export function inScope(record, address, scope) {
386
+ if (!isMap(scope)) return true;
387
+ const values = (kind) => new Set(strings(scope[kind]));
388
+ if (values("skip").has(address)) return false;
389
+ const types = values("types");
390
+ if (types.size && !types.has(record.type)) return false;
391
+ const data = isMap(record.data) ? record.data : {};
392
+ const any = (kind, list) => {
393
+ const wanted = values(kind);
394
+ return strings(list).some((one) => wanted.has(one));
395
+ };
396
+ const file = typeof record.file?.path === "string" ? record.file.path : "";
397
+ return (
398
+ values("addresses").has(address) ||
399
+ any("folders", data.packFolder) ||
400
+ any("cultures", data.culture) ||
401
+ any("lore", data.lore) ||
402
+ any("parents", data.parents) ||
403
+ strings(scope.paths).some((prefix) => file.startsWith(prefix))
404
+ );
405
+ }
406
+
407
+ /**
408
+ * The `register` rows of a set of index records.
409
+ *
410
+ * @param {Iterable<Record<string, any>>} records - Index records, every
411
+ * Address a string, already narrowed to the audience being published to.
412
+ * @param {Iterable<Record<string, any>>} [all] - Every record of the package;
413
+ * a note carrying a name is cited only where the audience sees it.
414
+ * @returns {object[]} One row per register entry, in record then entry order.
415
+ */
416
+ export function registerRows(records, all = records) {
417
+ const visible = new Set();
418
+ for (const record of records) {
419
+ const key = noteAddressOf(record);
420
+ if (key) visible.add(key);
421
+ }
422
+ const every = [...all];
423
+ // The language note's own record, never the journal sharing its Address.
424
+ const languages = new Map(languagesOf(every));
425
+ const byLanguage = new Map();
426
+ const namesOf = (language) => {
427
+ if (!byLanguage.has(language)) {
428
+ const scope = languages.get(language)?.data?.scope;
429
+ byLanguage.set(
430
+ language,
431
+ carriedNames(
432
+ every.filter((one) => {
433
+ const address = noteAddressOf(one);
434
+ return address !== null && inScope(one, address, scope);
435
+ }),
436
+ ),
437
+ );
438
+ }
439
+ return byLanguage.get(language);
440
+ };
441
+ return listRows(records, (data) => data.register, REGISTER_KEYS, { lists: ["built"] }).map(
442
+ (row) => {
443
+ const built = row.built ?? [];
444
+ const names = namesOf(row.language);
445
+ const notes = [...(names.get(String(row.name ?? "").normalize("NFC")) ?? [])]
446
+ .filter((address) => visible.has(address.split("#")[0]))
447
+ .sort(compare);
448
+ return {
449
+ ...row,
450
+ built_text: built.length ? built.join(" + ") : null,
451
+ notes: notes.length ? notes : null,
452
+ };
453
+ },
454
+ );
455
+ }
456
+
457
+ /**
458
+ * The `retired` rows: one per `data.retired` entry, `instead` a list.
459
+ *
460
+ * @param {Iterable<Record<string, any>>} records - Index records.
461
+ * @returns {object[]}
462
+ */
463
+ export function retiredRows(records) {
464
+ return listRows(records, (data) => data.retired, RETIRED_KEYS, { lists: ["instead"] });
465
+ }
466
+
467
+ /**
468
+ * The `tongues` rows: one per `data.tongues` entry.
469
+ *
470
+ * @param {Iterable<Record<string, any>>} records - Index records.
471
+ * @returns {object[]}
472
+ */
473
+ export function tongueRows(records) {
474
+ return listRows(records, (data) => data.tongues, TONGUE_KEYS, { prefix: "tongue" });
475
+ }
476
+
477
+ /**
478
+ * The `scope` rows: one per value of each kind of `data.scope`, in the order
479
+ * the kinds are declared.
480
+ *
481
+ * @param {Iterable<Record<string, any>>} records - Index records.
482
+ * @returns {object[]}
483
+ */
484
+ export function scopeRows(records) {
485
+ const rows = [];
486
+ for (const [language, record] of languagesOf(records)) {
487
+ const scope = isMap(record.data?.scope) ? record.data.scope : {};
488
+ for (const kind of SCOPE_KINDS)
489
+ for (const value of Array.isArray(scope[kind]) ? scope[kind] : [])
490
+ if (value !== null && value !== undefined)
491
+ rows.push({ language, kind, value: String(value) });
492
+ }
493
+ return rows;
494
+ }
495
+
496
+ /**
497
+ * The `letters` rows: one per `data.sounds.letters` entry, `letters` and
498
+ * `tongue` lists.
499
+ *
500
+ * @param {Iterable<Record<string, any>>} records - Index records.
501
+ * @returns {object[]}
502
+ */
503
+ export function letterRows(records) {
504
+ return listRows(records, (data) => soundsOf(data).letters, LETTER_KEYS, {
505
+ lists: ["letters", "tongue"],
506
+ });
507
+ }
508
+
509
+ /**
510
+ * The `edges` rows: one per `data.sounds.edges` entry, `sounds` a list.
511
+ *
512
+ * @param {Iterable<Record<string, any>>} records - Index records.
513
+ * @returns {object[]}
514
+ */
515
+ export function edgeRows(records) {
516
+ return listRows(records, (data) => soundsOf(data).edges, EDGE_KEYS, { lists: ["sounds"] });
517
+ }
518
+
519
+ /**
520
+ * The `clusters` rows: one per language stating `data.sounds.clusters`.
521
+ *
522
+ * @param {Iterable<Record<string, any>>} records - Index records.
523
+ * @returns {object[]}
524
+ */
525
+ export function clusterRows(records) {
526
+ const rows = [];
527
+ for (const [language, record] of languagesOf(records)) {
528
+ const clusters = soundsOf(isMap(record.data) ? record.data : {}).clusters;
529
+ if (isMap(clusters)) rows.push({ language, max: clusters.max ?? null });
530
+ }
531
+ return rows;
532
+ }
533
+
534
+ /**
535
+ * The `cluster_groups` rows: one per `data.sounds.clusters.groups` entry,
536
+ * `clusters` a list.
537
+ *
538
+ * @param {Iterable<Record<string, any>>} records - Index records.
539
+ * @returns {object[]}
540
+ */
541
+ export function clusterGroupRows(records) {
542
+ return listRows(records, (data) => soundsOf(data).clusters?.groups, CLUSTER_GROUP_KEYS, {
543
+ lists: ["clusters"],
544
+ });
545
+ }
546
+
547
+ /**
548
+ * The `mutations` rows: one per `data.sounds.mutations` entry.
549
+ *
550
+ * @param {Iterable<Record<string, any>>} records - Index records.
551
+ * @returns {object[]}
552
+ */
553
+ export function mutationRows(records) {
554
+ return listRows(records, (data) => soundsOf(data).mutations, MUTATION_KEYS);
555
+ }
556
+
557
+ /**
558
+ * The `older` rows: one per `data.older` entry.
559
+ *
560
+ * @param {Iterable<Record<string, any>>} records - Index records.
561
+ * @returns {object[]}
562
+ */
563
+ export function olderRows(records) {
564
+ return listRows(records, (data) => data.older, OLDER_KEYS);
565
+ }
566
+
567
+ /**
568
+ * The `negation` rows: one per language stating `data.negation`.
569
+ *
570
+ * @param {Iterable<Record<string, any>>} records - Index records.
571
+ * @returns {object[]}
572
+ */
573
+ export function negationRows(records) {
574
+ const rows = [];
575
+ for (const [language, record] of languagesOf(records)) {
576
+ const negation = record.data?.negation;
577
+ if (isMap(negation))
578
+ rows.push({
579
+ language,
580
+ particle: negation.particle ?? null,
581
+ mutation: negation.mutation ?? null,
582
+ });
583
+ }
584
+ return rows;
585
+ }
586
+
587
+ /** A language's `data.naming`, or an empty map. */
588
+ const namingOf = (data) => (isMap(data.naming) ? data.naming : {});
589
+
590
+ /** A range's bound, or `null`. */
591
+ const bound = (range, key) => (isMap(range) && typeof range[key] === "number" ? range[key] : null);
592
+
593
+ /**
594
+ * The `name_shapes` rows: one per `data.naming.shapes` entry, each range as
595
+ * its two bounds.
596
+ *
597
+ * @param {Iterable<Record<string, any>>} records - Index records.
598
+ * @returns {object[]}
599
+ */
600
+ export function nameShapeRows(records) {
601
+ const rows = [];
602
+ for (const [language, record] of languagesOf(records)) {
603
+ const shapes = namingOf(isMap(record.data) ? record.data : {}).shapes;
604
+ if (!Array.isArray(shapes)) continue;
605
+ shapes.forEach((shape, position) => {
606
+ if (!isMap(shape)) return;
607
+ rows.push({
608
+ language,
609
+ position,
610
+ of: shape.of ?? null,
611
+ syllables_min: bound(shape.syllables, "min"),
612
+ syllables_max: bound(shape.syllables, "max"),
613
+ per_vowel_min: bound(shape.perVowel, "min"),
614
+ per_vowel_max: bound(shape.perVowel, "max"),
615
+ });
616
+ });
617
+ }
618
+ return rows;
619
+ }
620
+
621
+ /**
622
+ * The `name_edges` rows: one per `data.naming.edges` entry, `sounds` a list,
623
+ * `vowel` `false` and `of` `name` where unstated.
624
+ *
625
+ * @param {Iterable<Record<string, any>>} records - Index records.
626
+ * @returns {object[]}
627
+ */
628
+ export function nameEdgeRows(records) {
629
+ return listRows(records, (data) => namingOf(data).edges, ["edge", "sounds", "vowel", "of"], {
630
+ lists: ["sounds"],
631
+ }).map((row) => ({ ...row, vowel: row.vowel === true, of: row.of ?? ANY_NAME }));
632
+ }
633
+
634
+ /**
635
+ * The `name_draws` rows: one per `data.naming.draws` entry.
636
+ *
637
+ * @param {Iterable<Record<string, any>>} records - Index records.
638
+ * @returns {object[]}
639
+ */
640
+ export function nameDrawRows(records) {
641
+ return listRows(records, (data) => namingOf(data).draws, ["kind", "elements"], {
642
+ lists: ["elements"],
643
+ });
644
+ }
645
+
646
+ /**
647
+ * The `held_from` rows: one per `data.naming.heldFrom` entry.
648
+ *
649
+ * @param {Iterable<Record<string, any>>} records - Index records.
650
+ * @returns {object[]}
651
+ */
652
+ export function heldFromRows(records) {
653
+ return listRows(
654
+ records,
655
+ (data) => namingOf(data).heldFrom,
656
+ ["source", "takes", "clip", "genitive", "elements"],
657
+ { lists: ["genitive", "elements"] },
658
+ );
659
+ }
660
+
661
+ /**
662
+ * The `near_names` rows: one per worked example of `data.naming.nearName`,
663
+ * each with the language's `min_syllables`.
664
+ *
665
+ * @param {Iterable<Record<string, any>>} records - Index records.
666
+ * @returns {object[]}
667
+ */
668
+ export function nearNameRows(records) {
669
+ const rows = [];
670
+ for (const [language, record] of languagesOf(records)) {
671
+ const near = namingOf(isMap(record.data) ? record.data : {}).nearName;
672
+ if (!isMap(near) || !Array.isArray(near.examples)) continue;
673
+ near.examples.forEach((example, position) => {
674
+ if (!isMap(example)) return;
675
+ rows.push({
676
+ language,
677
+ position,
678
+ near: example.near ?? null,
679
+ given: example.given ?? null,
680
+ min_syllables: near.minSyllables ?? null,
681
+ });
682
+ });
683
+ }
684
+ return rows;
685
+ }
686
+
687
+ /**
688
+ * The `read_past` rows: one per `data.sounds.readPast` entry.
689
+ *
690
+ * @param {Iterable<Record<string, any>>} records - Index records.
691
+ * @returns {object[]}
692
+ */
693
+ export function readPastRows(records) {
694
+ return listRows(records, (data) => soundsOf(data).readPast, READ_PAST_KEYS);
695
+ }
696
+
697
+ /**
698
+ * The `sound_changes` rows: one per step of each `data.soundChanges` set, with
699
+ * the set's `position`, `name`, `from_language`, `to_language` and `takes`,
700
+ * and the step's `step_position` within it.
701
+ *
702
+ * @param {Iterable<Record<string, any>>} records - Index records.
703
+ * @returns {object[]}
704
+ */
705
+ export function soundChangeRows(records) {
706
+ const rows = [];
707
+ for (const [language, record] of languagesOf(records)) {
708
+ const sets = record.data?.soundChanges;
709
+ if (!Array.isArray(sets)) continue;
710
+ sets.forEach((set, position) => {
711
+ if (!isMap(set) || !Array.isArray(set.steps)) return;
712
+ set.steps.forEach((step, stepPosition) => {
713
+ if (!isMap(step)) return;
714
+ const row = {
715
+ language,
716
+ position,
717
+ name: set.name ?? null,
718
+ from_language: set.from ?? null,
719
+ to_language: set.to ?? null,
720
+ takes: set.takes ?? null,
721
+ step_position: stepPosition,
722
+ };
723
+ for (const key of SOUND_CHANGE_STEP_KEYS) row[key] = step[key] ?? null;
724
+ rows.push(row);
725
+ });
726
+ });
727
+ }
728
+ return rows;
729
+ }
730
+
731
+ /**
732
+ * The `elements` rows: one per `data.elements` entry, `examples` a list.
733
+ *
734
+ * @param {Iterable<Record<string, any>>} records - Index records.
735
+ * @returns {object[]}
736
+ */
737
+ export function elementRows(records) {
738
+ return listRows(records, (data) => data.elements, ELEMENT_KEYS, {
739
+ prefix: "element",
740
+ lists: ["variants", "examples"],
741
+ });
742
+ }
743
+
744
+ /**
745
+ * The `roots` rows: one per `data.roots` entry.
746
+ *
747
+ * @param {Iterable<Record<string, any>>} records - Index records.
748
+ * @returns {object[]}
749
+ */
750
+ export function rootRows(records) {
751
+ return listRows(records, (data) => data.roots, ROOT_KEYS);
752
+ }
753
+
754
+ /**
755
+ * The `cognates` rows: one per form of each `data.cognates` set, in the order
756
+ * the set writes them, with the set's `position`, `gloss` and `proto`.
757
+ *
758
+ * @param {Iterable<Record<string, any>>} records - Index records.
759
+ * @returns {object[]}
760
+ */
761
+ export function cognateRows(records) {
762
+ const rows = [];
763
+ for (const [language, record] of languagesOf(records)) {
764
+ const sets = record.data?.cognates;
765
+ if (!Array.isArray(sets)) continue;
766
+ sets.forEach((set, position) => {
767
+ if (!isMap(set) || !isMap(set.forms)) return;
768
+ for (const [formLanguage, form] of Object.entries(set.forms))
769
+ rows.push({
770
+ language,
771
+ position,
772
+ gloss: set.gloss ?? null,
773
+ proto: set.proto ?? null,
774
+ form_language: formLanguage,
775
+ form: form ?? null,
776
+ });
777
+ });
778
+ }
779
+ return rows;
780
+ }
781
+
782
+ /**
783
+ * The `stress` rows: one per language stating `data.sounds.stress`.
784
+ *
785
+ * @param {Iterable<Record<string, any>>} records - Index records.
786
+ * @returns {object[]}
787
+ */
788
+ export function stressRows(records) {
789
+ const rows = [];
790
+ for (const [language, record] of languagesOf(records)) {
791
+ const stress = soundsOf(isMap(record.data) ? record.data : {}).stress;
792
+ if (typeof stress === "string") rows.push({ language, stress });
793
+ }
794
+ return rows;
795
+ }
796
+
797
+ /**
798
+ * The `finals` rows: one per `data.sounds.finals` entry.
799
+ *
800
+ * @param {Iterable<Record<string, any>>} records - Index records.
801
+ * @returns {object[]}
802
+ */
803
+ export function finalRows(records) {
804
+ return listRows(records, (data) => soundsOf(data).finals, FINAL_KEYS);
805
+ }
806
+
807
+ /**
808
+ * The `syllables` rows: one per language stating `data.sounds.syllables`, a
809
+ * list rule always a list (`any` a list of one), the harmony's sets each a
810
+ * column of its own, its partners a list of `{ vowel, partner }` in the order
811
+ * stated.
812
+ *
813
+ * @param {Iterable<Record<string, any>>} records - Index records.
814
+ * @returns {object[]}
815
+ */
816
+ export function syllableRows(records) {
817
+ const rows = [];
818
+ for (const [language, record] of languagesOf(records)) {
819
+ const rules = soundsOf(isMap(record.data) ? record.data : {}).syllables;
820
+ if (!isMap(rules)) continue;
821
+ const harmony = isMap(rules.harmony) ? rules.harmony : {};
822
+ rows.push({
823
+ language,
824
+ onsets: rules.onsets ?? null,
825
+ codas: asList(rules.codas),
826
+ finals: asList(rules.finals),
827
+ doubled: asList(rules.doubled),
828
+ vowelPairs: asList(rules.vowelPairs),
829
+ initialVowel: typeof rules.initialVowel === "boolean" ? rules.initialVowel : null,
830
+ finalVowel: typeof rules.finalVowel === "boolean" ? rules.finalVowel : true,
831
+ neverInitial: asList(rules.neverInitial),
832
+ signature: asList(rules.signature),
833
+ harmony_back: asList(harmony.back),
834
+ harmony_front: asList(harmony.front),
835
+ harmony_neutral: asList(harmony.neutral),
836
+ harmony_neutralAs: harmony.neutralAs ?? null,
837
+ harmony_partners:
838
+ isMap(harmony.partners) && Object.keys(harmony.partners).length ?
839
+ Object.entries(harmony.partners).map(([vowel, partner]) => ({
840
+ vowel,
841
+ partner: partner == null ? null : String(partner),
842
+ }))
843
+ : null,
844
+ });
845
+ }
846
+ return rows;
847
+ }
848
+
849
+ /**
850
+ * The `joining` rows: one per `data.sounds.joining` entry.
851
+ *
852
+ * @param {Iterable<Record<string, any>>} records - Index records.
853
+ * @returns {object[]}
854
+ */
855
+ export function joiningRows(records) {
856
+ return listRows(records, (data) => soundsOf(data).joining, JOINING_KEYS, {
857
+ lists: ["first", "second"],
858
+ });
859
+ }
860
+
861
+ /**
862
+ * The `joins` rows: one per `data.sounds.joins` entry.
863
+ *
864
+ * @param {Iterable<Record<string, any>>} records - Index records.
865
+ * @returns {object[]}
866
+ */
867
+ export function joinRows(records) {
868
+ return listRows(records, (data) => soundsOf(data).joins, JOIN_KEYS, { lists: ["letters"] });
869
+ }
870
+
871
+ /**
872
+ * The `marks` rows: one per `data.sounds.marks` entry.
873
+ *
874
+ * @param {Iterable<Record<string, any>>} records - Index records.
875
+ * @returns {object[]}
876
+ */
877
+ export function markRows(records) {
878
+ return listRows(records, (data) => soundsOf(data).marks, MARK_KEYS, { lists: ["letters"] });
879
+ }
880
+
881
+ /**
882
+ * The `skeleton` rows: one per `data.sounds.skeleton` entry, `second` a list.
883
+ *
884
+ * @param {Iterable<Record<string, any>>} records - Index records.
885
+ * @returns {object[]}
886
+ */
887
+ export function skeletonRows(records) {
888
+ return listRows(records, (data) => soundsOf(data).skeleton, SKELETON_KEYS, {
889
+ lists: ["second"],
890
+ });
891
+ }
892
+
893
+ /**
894
+ * The `frames` rows: one per `data.sounds.frames` entry, `examples` a list of
895
+ * `{ root, form }` in the order the frame states them.
896
+ *
897
+ * @param {Iterable<Record<string, any>>} records - Index records.
898
+ * @returns {object[]}
899
+ */
900
+ export function frameRows(records) {
901
+ const rows = [];
902
+ for (const [language, record] of languagesOf(records)) {
903
+ const frames = soundsOf(isMap(record.data) ? record.data : {}).frames;
904
+ if (!Array.isArray(frames)) continue;
905
+ frames.forEach((frame, position) => {
906
+ if (!isMap(frame)) return;
907
+ const examples =
908
+ isMap(frame.examples) ?
909
+ Object.entries(frame.examples).map(([root, form]) => ({
910
+ root,
911
+ form: form == null ? null : String(form),
912
+ }))
913
+ : [];
914
+ rows.push({
915
+ language,
916
+ position,
917
+ frame: frame.frame ?? null,
918
+ shape: frame.shape ?? null,
919
+ class: asList(frame.class),
920
+ makes: frame.makes ?? null,
921
+ examples: examples.length ? examples : null,
922
+ });
923
+ });
924
+ }
925
+ return rows;
926
+ }
927
+
928
+ /**
929
+ * The `calling_names` rows: one per `data.callingNames.closings` entry, each
930
+ * with the language's `min_syllables`.
931
+ *
932
+ * @param {Iterable<Record<string, any>>} records - Index records.
933
+ * @returns {object[]}
934
+ */
935
+ export function callingNameRows(records) {
936
+ const rows = [];
937
+ for (const [language, record] of languagesOf(records)) {
938
+ const calling = record.data?.callingNames;
939
+ if (!isMap(calling) || !Array.isArray(calling.closings)) continue;
940
+ calling.closings.forEach((closing, position) => {
941
+ if (!isMap(closing)) return;
942
+ rows.push({
943
+ language,
944
+ position,
945
+ form: closing.form ?? null,
946
+ doubled: closing.doubled === true,
947
+ usual: closing.usual ?? null,
948
+ min_syllables: calling.minSyllables ?? null,
949
+ });
950
+ });
951
+ }
952
+ return rows;
953
+ }
954
+
955
+ /**
956
+ * Every relation of a language's register, sounds, history and names: its
957
+ * name, its rows from the records the audience sees and every record of the
958
+ * package, and its columns. {@link LANGUAGE_COLUMN_TYPES} types its columns.
959
+ *
960
+ * @type {ReadonlyArray<[string, (visible: object[], all: object[]) => object[], readonly string[]]>}
961
+ */
962
+ export const LANGUAGE_RELATIONS = Object.freeze([
963
+ ["register", registerRows, REGISTER_COLUMNS],
964
+ ["retired", retiredRows, RETIRED_COLUMNS],
965
+ ["tongues", tongueRows, TONGUE_COLUMNS],
966
+ ["scope", scopeRows, SCOPE_COLUMNS],
967
+ ["letters", letterRows, LETTER_COLUMNS],
968
+ ["edges", edgeRows, EDGE_COLUMNS],
969
+ ["clusters", clusterRows, CLUSTER_COLUMNS],
970
+ ["cluster_groups", clusterGroupRows, CLUSTER_GROUP_COLUMNS],
971
+ ["mutations", mutationRows, MUTATION_COLUMNS],
972
+ ["stress", stressRows, STRESS_COLUMNS],
973
+ ["finals", finalRows, FINAL_COLUMNS],
974
+ ["syllables", syllableRows, SYLLABLE_COLUMNS],
975
+ ["joining", joiningRows, JOINING_COLUMNS],
976
+ ["joins", joinRows, JOIN_COLUMNS],
977
+ ["marks", markRows, MARK_COLUMNS],
978
+ ["skeleton", skeletonRows, SKELETON_COLUMNS],
979
+ ["frames", frameRows, FRAME_COLUMNS],
980
+ ["read_past", readPastRows, READ_PAST_COLUMNS],
981
+ ["sound_changes", soundChangeRows, SOUND_CHANGE_COLUMNS],
982
+ ["elements", elementRows, ELEMENT_COLUMNS],
983
+ ["roots", rootRows, ROOT_COLUMNS],
984
+ ["cognates", cognateRows, COGNATE_COLUMNS],
985
+ ["calling_names", callingNameRows, CALLING_NAME_COLUMNS],
986
+ ["older", olderRows, OLDER_COLUMNS],
987
+ ["negation", negationRows, NEGATION_COLUMNS],
988
+ ["name_shapes", nameShapeRows, NAME_SHAPE_COLUMNS],
989
+ ["name_edges", nameEdgeRows, NAME_EDGE_COLUMNS],
990
+ ["name_draws", nameDrawRows, NAME_DRAW_COLUMNS],
991
+ ["held_from", heldFromRows, HELD_FROM_COLUMNS],
992
+ ["near_names", nearNameRows, NEAR_NAME_COLUMNS],
993
+ ]);