@finbheara/archive-client 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +358 -2
- package/dist/chunks/index-hghdy980.js +75 -0
- package/dist/chunks/index-hne1wadw.js +57 -0
- package/dist/chunks/index-zr8tfjk0.js +2357 -0
- package/dist/cli.js +699 -0
- package/dist/config.js +21 -0
- package/dist/index.js +99 -0
- package/dist/tag.js +19 -0
- package/dist/types/assets.d.ts +40 -0
- package/dist/types/bytes.d.ts +3 -0
- package/dist/types/cli.d.ts +24 -0
- package/dist/types/comps.d.ts +127 -0
- package/dist/types/config.d.ts +16 -0
- package/dist/types/dancers-names.d.ts +5 -0
- package/dist/types/dancers.d.ts +138 -0
- package/dist/types/feises.d.ts +84 -0
- package/dist/types/index.d.ts +17 -0
- package/dist/types/indexes.d.ts +31 -0
- package/dist/types/manager.d.ts +94 -0
- package/dist/types/people.d.ts +65 -0
- package/dist/types/pin.d.ts +8 -0
- package/dist/types/registrations.d.ts +60 -0
- package/dist/types/release.d.ts +70 -0
- package/dist/types/remote.d.ts +16 -0
- package/dist/types/results.d.ts +82 -0
- package/dist/types/schools.d.ts +42 -0
- package/dist/types/tag.d.ts +24 -0
- package/package.json +52 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Finbheara Systems
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,359 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @finbheara/archive-client
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Open a versioned release of an Irish dance results dataset and work with it locally: download,
|
|
4
|
+
verify, decompress and cache it by tag, keep a user-local cache of releases, and resolve feises,
|
|
5
|
+
schools, competitions, registration lists, dancers, results, majors, teachers and
|
|
6
|
+
adjudicators against one. A command line, `idq`, is a thin wrapper over the same calls.
|
|
7
|
+
|
|
8
|
+
Bun 1.4.2 or later (the databases open through `bun:sqlite`). MIT.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
bun add @finbheara/archive-client
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Bun only: the databases open through `bun:sqlite`. The package ships built JavaScript and its
|
|
17
|
+
type declarations (`.d.ts`), so a TypeScript project needs Bun's types (`"types": ["bun"]` or
|
|
18
|
+
`["bun-types"]`) and nothing else from the client: no `allowImportingTsExtensions`, no
|
|
19
|
+
`preserveSymlinks`. The `idq` command line comes with it: in a project that installed it,
|
|
20
|
+
`bunx idq <verb>`.
|
|
21
|
+
|
|
22
|
+
The version is 0.x while the API settles: a minor bump may break, so pin a minor
|
|
23
|
+
(`"@finbheara/archive-client": "0.1.x"`).
|
|
24
|
+
|
|
25
|
+
## Configuration
|
|
26
|
+
|
|
27
|
+
The package carries no data and names no releases repository: it reads releases from a
|
|
28
|
+
repository you name, your own.
|
|
29
|
+
|
|
30
|
+
| what | option | environment | user-local |
|
|
31
|
+
| --- | --- | --- | --- |
|
|
32
|
+
| the releases repository (`owner/name`) | `repo` | `ARCHIVE_CLIENT_REPO` | `idq config repo <owner/name>` |
|
|
33
|
+
| the cache root | `cache` | `ARCHIVE_CLIENT_CACHE` | |
|
|
34
|
+
|
|
35
|
+
The option wins over the environment, the environment over the user-local config. With no
|
|
36
|
+
repository, a call that needs the network refuses by name. With no cache set, the cache is
|
|
37
|
+
`archive-client` in your home cache directory.
|
|
38
|
+
|
|
39
|
+
A private releases repository needs credentials: `gh` logged in to an account that can read it,
|
|
40
|
+
or `GITHUB_TOKEN` (or `GH_TOKEN`) holding a token that can.
|
|
41
|
+
|
|
42
|
+
## Where the releases come from
|
|
43
|
+
|
|
44
|
+
A release is a GitHub release of some repository: a `MANIFEST.json`, the databases
|
|
45
|
+
(`results.sqlite.zst`, `majors.sqlite.zst`) and the side files (jsonl, parquet). The package names
|
|
46
|
+
no repository. Give one, in order of precedence:
|
|
47
|
+
|
|
48
|
+
- the `repo` option (`owner/name`)
|
|
49
|
+
- the `ARCHIVE_CLIENT_REPO` environment variable
|
|
50
|
+
- the user-local config: `idq config repo <owner/name>`
|
|
51
|
+
|
|
52
|
+
`gh` is used first (it reads your own login), and the public API over HTTPS where `gh` is missing
|
|
53
|
+
or fails (`GITHUB_TOKEN` or `GH_TOKEN` as a bearer token, needed where the repository is not public).
|
|
54
|
+
|
|
55
|
+
## Open a release
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { openRelease } from "@finbheara/archive-client";
|
|
59
|
+
|
|
60
|
+
const rel = await openRelease("latest", { assets: ["results", "majors"] });
|
|
61
|
+
rel.tag; // the release tag
|
|
62
|
+
rel.cut; // the cut number the tag carries
|
|
63
|
+
rel.manifest; // its MANIFEST.json
|
|
64
|
+
rel.db("results").query("SELECT count(*) AS n FROM results").get(); // read-only handle
|
|
65
|
+
await rel.fetch("school-registry.jsonl"); // any other asset, on demand
|
|
66
|
+
rel.path("school-registry.jsonl"); // its cached file
|
|
67
|
+
rel.close();
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
A spec is `pinned` (the default: the user-local pin, else the latest), `latest`, a cut number
|
|
71
|
+
(`42`, or `42.1` for a fraction) or a tag (`archive-YYYY.MM.DD-N`).
|
|
72
|
+
|
|
73
|
+
EVERY FILE IS CHECKED before it lands: the downloaded bytes against the host's own sha256 digest,
|
|
74
|
+
and the content against the manifest (a database's decompressed bytes against
|
|
75
|
+
`databases.<name>.sha256`, a parquet file against `parquet.files[<name>].sha256`). A mismatch
|
|
76
|
+
refuses and leaves nothing in place. A file nothing vouches for is kept and its receipt says so
|
|
77
|
+
(`strict: true` refuses it). A database is decompressed in process, streamed, never through a
|
|
78
|
+
`zstd` binary.
|
|
79
|
+
|
|
80
|
+
## The cache
|
|
81
|
+
|
|
82
|
+
One directory per tag under the cache root: the `cache` option, else `ARCHIVE_CLIENT_CACHE`,
|
|
83
|
+
else `archive-client` in your home cache directory.
|
|
84
|
+
|
|
85
|
+
<root>/<tag>/MANIFEST.json
|
|
86
|
+
<root>/<tag>/results.sqlite decompressed, verified
|
|
87
|
+
<root>/<tag>/<asset> any other file, as published
|
|
88
|
+
<root>/<tag>/.client.json the receipt: every file's sha256 and what verified it
|
|
89
|
+
<root>/<tag>/index/<name>.sqlite the per-tag indexes
|
|
90
|
+
<root>/PINNED.json the user-local pin
|
|
91
|
+
<root>/config.json the user-local config
|
|
92
|
+
|
|
93
|
+
A file already in place with no receipt (laid out by hand) is adopted where the manifest's hash
|
|
94
|
+
vouches for it, and fetched again where it does not. A published release never changes, so a
|
|
95
|
+
verified file is never fetched twice.
|
|
96
|
+
|
|
97
|
+
## The release manager
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import { releases } from "@finbheara/archive-client";
|
|
101
|
+
|
|
102
|
+
const m = releases();
|
|
103
|
+
await m.list({ remote: true }); // cached releases, sizes, files, indexes, the pin
|
|
104
|
+
await m.fetch("latest", { assets: ["results"] });
|
|
105
|
+
await m.pin("42"); // a bare openRelease() now opens it
|
|
106
|
+
m.unpin();
|
|
107
|
+
await m.add("<dir>", "archive-YYYY.MM.DD-N"); // a release built locally, under its own tag
|
|
108
|
+
await m.index("pinned"); // build every index
|
|
109
|
+
m.prune({ keep: 2, dryRun: true }); // the plan: what would go, and its bytes
|
|
110
|
+
m.remove("<tag>"); // refuses the pinned release
|
|
111
|
+
m.dropIndexes(); // the index files only
|
|
112
|
+
await m.verify("<tag>"); // re-hash every cached file against its receipt
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Every removal answers its plan first (`dryRun`), keeps the pinned release, and touches nothing
|
|
116
|
+
outside the cache root.
|
|
117
|
+
|
|
118
|
+
## The indexes
|
|
119
|
+
|
|
120
|
+
The release databases carry a primary key per table and nothing more, so lookups build side
|
|
121
|
+
indexes, one SQLite file per index per tag, each keyed on its builder's version and the sha256 of
|
|
122
|
+
every input it read. A file whose key is stale is rebuilt, never served. Four ship here, and a
|
|
123
|
+
caller may hand its own (`builders` option, an `IndexBuilder`):
|
|
124
|
+
|
|
125
|
+
- `schools`: every published school print with the ids its rows carry, filed into a
|
|
126
|
+
`@finbheara/domain` `schoolEncoder` noun index, with the registry and the retired ids
|
|
127
|
+
- `feises`: every feis by id, platform, host code, series and name, with its competition and
|
|
128
|
+
result counts and the regions its competitions read
|
|
129
|
+
- `dancers` (keyed on the names and domain package versions too): every published dancer print at the names package's keys (one row per print and
|
|
130
|
+
reading, a `@finbheara/domain` `personEncoder` noun index), the dancers each print was
|
|
131
|
+
published under, each dancer's schools and years
|
|
132
|
+
- `staff` (keyed the same way): teachers and adjudicators by name (a `roleEncoder` noun index) and TD number: the
|
|
133
|
+
schools' staff lists and TD links, the adjudicator register, and the seats each sat
|
|
134
|
+
- `comps`: every competition by its feis and the feis's own code, with its series and year
|
|
135
|
+
|
|
136
|
+
## Feises
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
await rel.feises.find({ platform: "feisweb", code: "ABC24" }); // a source or its id token
|
|
140
|
+
await rel.feises.find({ id: "<feis, series instance or brand id>" }); // a retired id followed
|
|
141
|
+
await rel.feises.find({ name: "*Spring*", year: 2024 });
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
One answer per feis id: dates, location, series ids, regions, and how many competitions, rows
|
|
145
|
+
and captures it has.
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
const s = await rel.feises.series("Ardvarna Spring"); // a name, a host code ("fweb:ABC", "fweb:ABC26"), any feis id
|
|
149
|
+
s.arm; // "unique" | "ambiguous" | "none"
|
|
150
|
+
s.series_id; // the series, with s.name, s.host ("feisweb"), s.years, s.instances
|
|
151
|
+
s.next; // the next scheduled instance where the release knows one, else null with s.next_why
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
A series is asked by any id of it (a feis, instance or brand id, a retired one followed), by a
|
|
155
|
+
host code with or without its year (held codes `<stem><yy>` whose two digits are their own year),
|
|
156
|
+
or by a name (a glob, or a plain word read as `*word*`). Two series answer `ambiguous` with both.
|
|
157
|
+
A release holds a feis-year once its results are captured and carries no calendar, so `next` is
|
|
158
|
+
`null` today, with the reason.
|
|
159
|
+
|
|
160
|
+
## Schools
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
const a = await rel.schools.resolve("Ardvarna Academy of Irish Dance (Calgary, AB)", { region: "WCRO" });
|
|
164
|
+
a.arm; // "unique" | "ambiguous" | "none"
|
|
165
|
+
a.candidates; // [{ school_id, display, rows, by, prints, regions, places }]
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The rungs, the first that answers wins: a school id, a retired id followed to the live one, the
|
|
169
|
+
print exactly as the release published it, then the print read by `@finbheara/domain`'s school
|
|
170
|
+
reader at its filing key (`sn3`) and at its real words. `ambiguous` and `none` are answers: a
|
|
171
|
+
best guess is never promoted to `unique`. `region` drops only a candidate the registry places in
|
|
172
|
+
other regions, and keeps one it places nowhere. A print the reader refuses (a placeholder)
|
|
173
|
+
answers `none` with the reason.
|
|
174
|
+
|
|
175
|
+
Every answer carries the release tag it came from.
|
|
176
|
+
|
|
177
|
+
## Dancers
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
const r = await rel.dancers.resolve({
|
|
181
|
+
name: "Quellan, A", order: "surname-first", // initials and partial given names are first-class
|
|
182
|
+
school: "Ardvarna Academy", // a print or an id, through rel.schools
|
|
183
|
+
age: { band: "U10", year: 2026 }, // or { yob: 2016 }, or { comp: "<competition>" }
|
|
184
|
+
asOf: "2026-10-17", // the school as of then
|
|
185
|
+
});
|
|
186
|
+
r.arm; // "unique" | "ambiguous" | "none"
|
|
187
|
+
r.candidates; // [{ dancer_id, display, scope, score, evidence: { nameLevel, age, school }, prints, ... }]
|
|
188
|
+
r.excluded; // the candidates an age contradiction set aside (why: "age")
|
|
189
|
+
r.preferred; // NON-BINDING: { dancer_id, why } where the school singles one out; never moves arm
|
|
190
|
+
r.unlinked; // rows printing a matched name that the release linked to no dancer
|
|
191
|
+
|
|
192
|
+
await rel.dancers.search({ surname: "Quellan" }); // or { like: "a* quellan" }, or { name }
|
|
193
|
+
await rel.dancers.get(1001); // every print and school of one dancer
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
NO NEW MATCHER. A print is read in the dancer's role by `@finbheara/domain` over
|
|
197
|
+
`@finbheara/names`, and a candidate's `nameLevel` is the finest of: `nn0` to `nn4` (equal at that
|
|
198
|
+
level of the names package: the print, the key, the folded key, the tokens in any order),
|
|
199
|
+
`initial` (one surname, one given name the other's made poorer: `givenAbbreviates`), `middle`
|
|
200
|
+
(a middle name one print omits: `nameRelation`). `age` is how the dancer's published birth window
|
|
201
|
+
meets the asked age (`Age.fitsIn`, `overlaps`): `fits`, `overlaps`, `contradicts`, `unknown`.
|
|
202
|
+
`school` is how the dancer's schools meet the asked one: `same` (the latest school as of `asOf`),
|
|
203
|
+
`moved` (an earlier one), `none`, `unresolved` (the print reached no school).
|
|
204
|
+
|
|
205
|
+
NO SILENT GUESS. Only a contradiction sets a candidate aside: a birth window that shares no birth
|
|
206
|
+
year with the asked age (listed in `excluded`). The school is evidence and order, never a reason
|
|
207
|
+
to drop anyone: candidates whose latest school is the asked one come first, then those that ever
|
|
208
|
+
danced for it, then the rest. A school row meets the asked school by its id, else by its print
|
|
209
|
+
(one `rel.schools` resolves to the asked ids), so a latest print the release
|
|
210
|
+
filed under no id is still `same`. `unique` is one candidate left after the age, never a best
|
|
211
|
+
guess promoted. Where the school singles out one candidate, `preferred` names it with the reason,
|
|
212
|
+
for a caller to weigh, and `arm` stays as counted. The `score` orders candidates and is no
|
|
213
|
+
probability.
|
|
214
|
+
|
|
215
|
+
A FEIS-LOCAL ID (`FEIS_LOCAL_ID_FLOOR`, 2^34, and up) is valid in its release only: a candidate and a result row
|
|
216
|
+
carrying one say `scope: "per-release"` (`dancer_scope` on a row), so a caller never stores one
|
|
217
|
+
across releases. Every answer carries the release tag.
|
|
218
|
+
|
|
219
|
+
## Results and the majors
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
await rel.results.query({ dancer_id: 1001 }); // a dancer's rows, oldest first
|
|
223
|
+
await rel.results.query({ feis: "<feis id>", year: 2025 });
|
|
224
|
+
await rel.results.query({ competition: "<competition>" });
|
|
225
|
+
await rel.results.query({ school_id: "<school id>", year: 2025, limit: 100 });
|
|
226
|
+
await rel.majors.events(); // every major and its years
|
|
227
|
+
await rel.majors.competitions({ event: "<event>", year: 2023 });
|
|
228
|
+
await rel.majors.results({ dancer_id: 1001 }); // a dancer's majors
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
A row carries the prints and the ids that stand for them: the address, the competition's title
|
|
232
|
+
and age, the place, the name and school as printed with the dancer and school ids. The majors are
|
|
233
|
+
read from `majors.sqlite`, fetched on first use.
|
|
234
|
+
|
|
235
|
+
## Teachers and adjudicators
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
await rel.staff.teacher("<name>"); // or a TD number
|
|
239
|
+
await rel.staff.adjudicator("<name>");
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
One person per TD number (else per judge id, else per name): the schools taught at (role,
|
|
243
|
+
credentials, years), the schools that list the TD number, the adjudicator register by year, and
|
|
244
|
+
the seats sat, per event and year, at majors and at feiseanna.
|
|
245
|
+
## Competitions
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
const c = await rel.comps.resolve({ feis: "fweb:ABC26", code: "610" });
|
|
249
|
+
c.arm; // "unique" | "ambiguous" | "none"
|
|
250
|
+
c.source; // "syllabus" | "prior-year" | "title"
|
|
251
|
+
c.kind; // "championship" | "grade" | "unrated"
|
|
252
|
+
c.level; // "OC" (FF, BG, AB, NV, PW, PC, OC), c.feis_level 700
|
|
253
|
+
c.age; // "U10", c.population "mixed", c.canonical "710OC", c.title, c.competition
|
|
254
|
+
c.from; // { feis_ids: ["fweb:ABC25"], year: 2025 } where an earlier year answered
|
|
255
|
+
c.check; // "unchecked", or what the caller's `title` says about the earlier comp
|
|
256
|
+
c.syllabus_source; // "results": what the syllabus the answer read is read from
|
|
257
|
+
await rel.comps.syllabus("fweb:ABC25"); // one feis-year's competitions, in printed order
|
|
258
|
+
const syl = await rel.comps.syllabusFor("Ardvarna Spring", 2026); // or a `feises.series` answer
|
|
259
|
+
syl.year; // the asked year where the release holds it, else its newest (syl.held, syl.why)
|
|
260
|
+
syl.source; // "results" (a syllabus document is a source of its own name, shipped by no release yet)
|
|
261
|
+
syl.entries; // the comps, syl.championships how many are championships, syl.note where none
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
EVERY SYLLABUS SAYS WHAT IT IS READ FROM. Today that is the comps the release's result captures
|
|
265
|
+
list (`results`): a comp the feis held whose results the platform did not tabulate is not in it.
|
|
266
|
+
A feis whose platform tabulated its grades only has a syllabus with no championship, and
|
|
267
|
+
`syllabusFor` says so (`note`), as does a `none` answer of `resolve` on such a series.
|
|
268
|
+
|
|
269
|
+
A code is matched trimmed and case folded (`412a` finds `412A`). A code is the feis's own: FeisWeb, FeisWorx and a local QuickFeis number their competitions as
|
|
270
|
+
the host likes, so a code is answered from that feis's competitions in the release, never from
|
|
271
|
+
another platform's numbering. The sources, in order:
|
|
272
|
+
|
|
273
|
+
- `syllabus`: the feis-year is in the release, and one of its competitions prints the code
|
|
274
|
+
- `prior-year`: the feis-year is not (next year's, before its results): the newest earlier year
|
|
275
|
+
of the same series printing the code. Give the comp's `title` and `matchTitle` checks it
|
|
276
|
+
against the earlier comp (`check`), and with no title the answer says `unchecked`
|
|
277
|
+
- `title`: the code answers nothing and a `title` was given: the competitions of that syllabus
|
|
278
|
+
the title names, by band and population
|
|
279
|
+
|
|
280
|
+
A feis the release holds names itself, its year and its series. A series instance or brand id
|
|
281
|
+
names the series. A feis id the release does not hold is placed in a series by its host code
|
|
282
|
+
(`<token>:<stem><yy>` where the held codes of that stem carry their own year), and a stem two
|
|
283
|
+
series share answers `ambiguous`. A held feis-year never falls back to an earlier year, and two
|
|
284
|
+
competitions printing one code are never folded into one.
|
|
285
|
+
|
|
286
|
+
`kind` and `level` are read off the release's published rung (`feis_level`: 100 first feis to
|
|
287
|
+
700 open championship): `championship` from 600, `grade` below, `unrated` where the release
|
|
288
|
+
publishes none (specials, sets, adult and team events). No level word is read from a title, so
|
|
289
|
+
a title alone names every comp of its band and population. `canonical` is a rendering of a
|
|
290
|
+
championship in the QuickFeis shape, never a key: an `Under N` band prints `N` (`609PC`,
|
|
291
|
+
`710OC`), and an `N & over` band prints `N + 1`, the slot after the oldest `Under` band, as
|
|
292
|
+
QuickFeis numbers its own and-over championships (`15 & over` prelim `616PC`, `18 & over` open
|
|
293
|
+
`719OC`). An and-over code is then the same string as an `Under N + 1` one, and `age` (`O18`)
|
|
294
|
+
tells them apart. An adult band, a range and a grade render none.
|
|
295
|
+
|
|
296
|
+
## Registrations
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
const rows = await rel.registrations.resolve([
|
|
300
|
+
{ name: "Doe, J", school: "Ardvarna Academy", codes: ["610", "611"], feis: "fweb:ABC26" },
|
|
301
|
+
]);
|
|
302
|
+
rows[0].comps; // one competition answer per code
|
|
303
|
+
rows[0].age; // { band: "U10", year: 2026, code: "610", from: "comp" }: the band the dancer is asked at
|
|
304
|
+
rows[0].dancer; // the dancer resolver's answer
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Each code is resolved as above. The dancer is asked at the YOUNGEST band the row's competitions
|
|
308
|
+
read (the lowest ceiling, then the highest floor): a dancer may dance up into an older band and
|
|
309
|
+
never down, so the youngest bounds the dancer. Every band a row read is listed (`bands`) where
|
|
310
|
+
there are several. Where no comp answers with a band (a syllabus read from results that lacks the
|
|
311
|
+
feis's championships), the band is read from the row's own titles (`titles`, by code) by the
|
|
312
|
+
domain package's title reader, the youngest again, and `age.from` says `title`. Nothing else
|
|
313
|
+
stands in for a band. The dancer resolver is the release's own (`rel.dancers`, above), or the
|
|
314
|
+
`dancerResolver` option. With `dancerResolver: null`, each row's dancer answers `unavailable`
|
|
315
|
+
and the competitions still resolve.
|
|
316
|
+
|
|
317
|
+
## The command line
|
|
318
|
+
|
|
319
|
+
idq config repo <owner/name>
|
|
320
|
+
idq list [--remote]
|
|
321
|
+
idq fetch [--release <spec>] [--assets results,majors,<file>...]
|
|
322
|
+
idq pin <spec> | unpin | current
|
|
323
|
+
idq add <dir> --tag <tag>
|
|
324
|
+
idq index [--release <spec>] [<name>...] [--force]
|
|
325
|
+
idq verify [--release <spec>]
|
|
326
|
+
idq prune [--keep N] [--dry-run]
|
|
327
|
+
idq rm <tag> [--dry-run]
|
|
328
|
+
idq drop-indexes [<tag>] [--dry-run]
|
|
329
|
+
idq feis [--release <spec>] [--platform p] [--code c] [--id i] [--name glob] [--year y]
|
|
330
|
+
idq series [--release <spec>] <name|host code|feis id>
|
|
331
|
+
idq school [--release <spec>] <print|school_id> [--region <code>]
|
|
332
|
+
idq dancer [--release <spec>] <name> | --surname <s> | --like <glob> | --id <dancer_id>
|
|
333
|
+
[--order surname-first|given-first] [--school <print|id>]
|
|
334
|
+
[--yob <y> | --band <band> [--year <y>] | --comp <competition>] [--as-of <date>] [--rows]
|
|
335
|
+
idq results [--release <spec>] [--dancer <id>] [--feis <id>] [--comp <c>] [--school <s>] [--year <y>] [--limit <n>]
|
|
336
|
+
idq majors [--release <spec>] [--event <e>] [--year <y>] [--comp <c>] [--dancer <id>] [--rows]
|
|
337
|
+
idq teacher [--release <spec>] <name|TD number>
|
|
338
|
+
idq adjudicator [--release <spec>] <name|TD number>
|
|
339
|
+
idq comp [--release <spec>] <feis> <code> [--year y] [--title t]
|
|
340
|
+
idq syllabus [--release <spec>] <feis> [year]
|
|
341
|
+
idq syllabus [--release <spec>] --for <series> [year]
|
|
342
|
+
idq resolve comp [<feis> <code>] [--year y] [--title t]
|
|
343
|
+
idq resolve dancer [<name>] [--school s] [--comp <feis>/<code> | --yob y | --band b --year y]
|
|
344
|
+
idq resolve registrations
|
|
345
|
+
|
|
346
|
+
`dancer` prints the results of the one dancer where the answer is unique (every candidate's with
|
|
347
|
+
`--rows`). `--json` prints the call's answer as it is. `--cache` and `--repo` override the defaults.
|
|
348
|
+
|
|
349
|
+
`resolve` speaks JSON lines: one answer per line out, and with no positional question the
|
|
350
|
+
questions are JSON lines on stdin, one per line (`{feis, code, year?, title?}`,
|
|
351
|
+
`{name, school?, age?}`, `{name, school?, codes, feis, year?}`). A line that is no JSON object,
|
|
352
|
+
or no question of its kind, answers `{tag, line, error}` and the other lines still answer. The
|
|
353
|
+
exit is non-zero where any line erred. A dancer's age may be given as a comp
|
|
354
|
+
(`{"comp": "<feis>/<code>"}`, or `--comp` on the command line): `rel.registrations.dancer` reads
|
|
355
|
+
it through the comp's syllabus and asks the dancer resolver at that comp's band.
|
|
356
|
+
|
|
357
|
+
## Licence
|
|
358
|
+
|
|
359
|
+
MIT
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
var ARCHIVE_TAG2 = /^archive-(\d{4}\.\d{2}\.\d{2})(?:-(\d+)(?:\.([1-9]\d*))?)?$/;
|
|
3
|
+
function parseArchiveTag2(tag) {
|
|
4
|
+
const m = ARCHIVE_TAG2.exec(tag);
|
|
5
|
+
if (m === null)
|
|
6
|
+
return null;
|
|
7
|
+
const out = { date: m[1] };
|
|
8
|
+
if (m[2] !== undefined)
|
|
9
|
+
out.cut = Number(m[2]);
|
|
10
|
+
if (m[3] !== undefined)
|
|
11
|
+
out.fraction = Number(m[3]);
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
function renderArchiveTag2(t) {
|
|
15
|
+
if (t.cut === undefined) {
|
|
16
|
+
if ((t.fraction ?? 0) > 0)
|
|
17
|
+
throw new Error("archive tag: a fraction needs a cut number");
|
|
18
|
+
return `archive-${t.date}`;
|
|
19
|
+
}
|
|
20
|
+
return `archive-${t.date}-${t.cut}${(t.fraction ?? 0) > 0 ? `.${t.fraction}` : ""}`;
|
|
21
|
+
}
|
|
22
|
+
function cutOf2(inputRelease) {
|
|
23
|
+
const m = inputRelease === undefined ? null : /-(\d+)$/.exec(inputRelease);
|
|
24
|
+
return m === null ? undefined : Number(m[1]);
|
|
25
|
+
}
|
|
26
|
+
function compareTagsNewestFirst2(a, b) {
|
|
27
|
+
const pa = parseArchiveTag2(a);
|
|
28
|
+
const pb = parseArchiveTag2(b);
|
|
29
|
+
if (pa === null || pb === null)
|
|
30
|
+
return pa === null ? pb === null ? a < b ? 1 : -1 : 1 : -1;
|
|
31
|
+
if (pa.date !== pb.date)
|
|
32
|
+
return pa.date < pb.date ? 1 : -1;
|
|
33
|
+
const ca = pa.cut ?? -1;
|
|
34
|
+
const cb = pb.cut ?? -1;
|
|
35
|
+
if (ca !== cb)
|
|
36
|
+
return cb - ca;
|
|
37
|
+
const fa = pa.fraction ?? 0;
|
|
38
|
+
const fb = pb.fraction ?? 0;
|
|
39
|
+
if (fa !== fb)
|
|
40
|
+
return fb - fa;
|
|
41
|
+
return a < b ? 1 : a > b ? -1 : 0;
|
|
42
|
+
}
|
|
43
|
+
function parseReleaseSpec2(spec) {
|
|
44
|
+
if (typeof spec === "number")
|
|
45
|
+
return Number.isInteger(spec) && spec >= 0 ? { kind: "cut", cut: spec, fraction: 0 } : `release: ${spec} is not a cut number`;
|
|
46
|
+
const s = spec.trim();
|
|
47
|
+
if (s === "latest")
|
|
48
|
+
return { kind: "latest" };
|
|
49
|
+
if (s === "pinned")
|
|
50
|
+
return { kind: "pinned" };
|
|
51
|
+
const m = /^(\d+)(?:\.(\d+))?$/.exec(s);
|
|
52
|
+
if (m !== null)
|
|
53
|
+
return { kind: "cut", cut: Number(m[1]), fraction: Number(m[2] ?? 0) };
|
|
54
|
+
if (parseArchiveTag2(s) !== null)
|
|
55
|
+
return { kind: "tag", tag: s };
|
|
56
|
+
return `release: "${spec}" is not latest, pinned, a cut number (42, 42.1) or a release tag (archive-YYYY.MM.DD-N)`;
|
|
57
|
+
}
|
|
58
|
+
function pickTag2(tags, spec) {
|
|
59
|
+
const sorted = tags.filter((t) => parseArchiveTag2(t) !== null).sort(compareTagsNewestFirst2);
|
|
60
|
+
switch (spec.kind) {
|
|
61
|
+
case "latest":
|
|
62
|
+
return sorted.find((t) => parseArchiveTag2(t)?.cut !== undefined) ?? sorted[0] ?? null;
|
|
63
|
+
case "tag":
|
|
64
|
+
return tags.includes(spec.tag) ? spec.tag : null;
|
|
65
|
+
case "cut":
|
|
66
|
+
return sorted.find((t) => {
|
|
67
|
+
const p = parseArchiveTag2(t);
|
|
68
|
+
return p?.cut === spec.cut && (p.fraction ?? 0) === spec.fraction;
|
|
69
|
+
}) ?? null;
|
|
70
|
+
case "pinned":
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { ARCHIVE_TAG2, parseArchiveTag2, renderArchiveTag2, cutOf2, compareTagsNewestFirst2, parseReleaseSpec2, pickTag2 };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { dirname, join } from "path";
|
|
5
|
+
var set = (v) => v !== undefined && v.trim() !== "";
|
|
6
|
+
function expandHome2(p) {
|
|
7
|
+
return p === "~" || p.startsWith("~/") ? join(homedir(), p.slice(1)) : p;
|
|
8
|
+
}
|
|
9
|
+
function cacheRoot2(opts = {}) {
|
|
10
|
+
const env = opts.env ?? process.env;
|
|
11
|
+
if (set(opts.cache))
|
|
12
|
+
return expandHome2(opts.cache);
|
|
13
|
+
if (set(env.ARCHIVE_CLIENT_CACHE))
|
|
14
|
+
return expandHome2(env.ARCHIVE_CLIENT_CACHE);
|
|
15
|
+
return join(homedir(), ".cache", "archive-client");
|
|
16
|
+
}
|
|
17
|
+
var configPath2 = (root) => join(root, "config.json");
|
|
18
|
+
function readConfig2(root) {
|
|
19
|
+
const p = configPath2(root);
|
|
20
|
+
if (!existsSync(p))
|
|
21
|
+
return {};
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
24
|
+
} catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function writeJsonAtomic2(path, value) {
|
|
29
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
30
|
+
const part = `${path}.${process.pid}.part`;
|
|
31
|
+
writeFileSync(part, `${JSON.stringify(value, null, "\t")}
|
|
32
|
+
`);
|
|
33
|
+
renameSync(part, path);
|
|
34
|
+
}
|
|
35
|
+
function writeConfig2(root, patch) {
|
|
36
|
+
const next = { ...readConfig2(root), ...patch };
|
|
37
|
+
writeJsonAtomic2(configPath2(root), next);
|
|
38
|
+
return next;
|
|
39
|
+
}
|
|
40
|
+
var REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
41
|
+
function repoOf2(opts = {}) {
|
|
42
|
+
const env = opts.env ?? process.env;
|
|
43
|
+
const r = [opts.repo, env.ARCHIVE_CLIENT_REPO, readConfig2(cacheRoot2(opts)).repo].find(set)?.trim();
|
|
44
|
+
if (r === undefined)
|
|
45
|
+
return null;
|
|
46
|
+
if (!REPO.test(r))
|
|
47
|
+
throw new Error(`archive-client: repo "${r}" is not owner/name`);
|
|
48
|
+
return r;
|
|
49
|
+
}
|
|
50
|
+
function requireRepo2(opts = {}) {
|
|
51
|
+
const r = repoOf2(opts);
|
|
52
|
+
if (r === null)
|
|
53
|
+
throw new Error("archive-client: no repository: pass `repo`, set ARCHIVE_CLIENT_REPO, or run `config repo <owner/name>`");
|
|
54
|
+
return r;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export { expandHome2, cacheRoot2, configPath2, readConfig2, writeJsonAtomic2, writeConfig2, repoOf2, requireRepo2 };
|