paperlint 10.4.0 → 11.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/.github/workflows/deadlines.yml +27 -7
- package/README.md +16 -15
- package/dist/CLAUDE.md.spec.d.ts.map +1 -1
- package/dist/CLAUDE.md.spec.js +6 -3
- package/dist/CLAUDE.md.spec.js.map +1 -1
- package/dist/adapters/hotcrp/index.d.ts +6 -4
- package/dist/adapters/hotcrp/index.d.ts.map +1 -1
- package/dist/adapters/hotcrp/index.js +6 -4
- package/dist/adapters/hotcrp/index.js.map +1 -1
- package/dist/adapters/hotcrp/portal.io.d.ts +3 -1
- package/dist/adapters/hotcrp/portal.io.d.ts.map +1 -1
- package/dist/adapters/hotcrp/portal.io.js +36 -11
- package/dist/adapters/hotcrp/portal.io.js.map +1 -1
- package/dist/adapters/hotcrp/response.d.ts +6 -1
- package/dist/adapters/hotcrp/response.d.ts.map +1 -1
- package/dist/adapters/hotcrp/response.js +17 -0
- package/dist/adapters/hotcrp/response.js.map +1 -1
- package/dist/adapters/hotcrp/site.d.ts +9 -0
- package/dist/adapters/hotcrp/site.d.ts.map +1 -0
- package/dist/adapters/hotcrp/site.js +22 -0
- package/dist/adapters/hotcrp/site.js.map +1 -0
- package/dist/adapters/hotcrp/token.d.ts +13 -0
- package/dist/adapters/hotcrp/token.d.ts.map +1 -0
- package/dist/adapters/hotcrp/token.js +48 -0
- package/dist/adapters/hotcrp/token.js.map +1 -0
- package/dist/adapters/latex/abstract.d.ts +22 -0
- package/dist/adapters/latex/abstract.d.ts.map +1 -0
- package/dist/adapters/latex/abstract.js +187 -0
- package/dist/adapters/latex/abstract.js.map +1 -0
- package/dist/adapters/latex/captions.d.ts +9 -0
- package/dist/adapters/latex/captions.d.ts.map +1 -0
- package/dist/adapters/latex/captions.js +40 -0
- package/dist/adapters/latex/captions.js.map +1 -0
- package/dist/adapters/latex/headings.d.ts +6 -0
- package/dist/adapters/latex/headings.d.ts.map +1 -1
- package/dist/adapters/latex/headings.js +5 -4
- package/dist/adapters/latex/headings.js.map +1 -1
- package/dist/adapters/latex/index.d.ts +1 -0
- package/dist/adapters/latex/index.d.ts.map +1 -1
- package/dist/adapters/latex/index.js +3 -0
- package/dist/adapters/latex/index.js.map +1 -1
- package/dist/adapters/latex/parse.d.ts +15 -0
- package/dist/adapters/latex/parse.d.ts.map +1 -1
- package/dist/adapters/latex/parse.js +64 -1
- package/dist/adapters/latex/parse.js.map +1 -1
- package/dist/adapters/node/list-entries.io.d.ts.map +1 -1
- package/dist/adapters/node/list-entries.io.js +3 -1
- package/dist/adapters/node/list-entries.io.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +20 -8
- package/dist/cli.js.map +1 -1
- package/dist/cycle-rules.d.ts.map +1 -1
- package/dist/cycle-rules.js +3 -4
- package/dist/cycle-rules.js.map +1 -1
- package/dist/domain/deadline.d.ts +8 -1
- package/dist/domain/deadline.d.ts.map +1 -1
- package/dist/domain/deadline.js +26 -12
- package/dist/domain/deadline.js.map +1 -1
- package/dist/domain/decide-first.d.ts +11 -1
- package/dist/domain/decide-first.d.ts.map +1 -1
- package/dist/domain/decide-first.js +6 -0
- package/dist/domain/decide-first.js.map +1 -1
- package/dist/domain/index-columns.d.ts.map +1 -1
- package/dist/domain/index-columns.js +2 -0
- package/dist/domain/index-columns.js.map +1 -1
- package/dist/domain/portal-token.d.ts +44 -0
- package/dist/domain/portal-token.d.ts.map +1 -0
- package/dist/domain/portal-token.js +32 -0
- package/dist/domain/portal-token.js.map +1 -0
- package/dist/domain/study-registry.d.ts +65 -8
- package/dist/domain/study-registry.d.ts.map +1 -1
- package/dist/domain/study-registry.js +63 -19
- package/dist/domain/study-registry.js.map +1 -1
- package/dist/domain/submission.d.ts +30 -0
- package/dist/domain/submission.d.ts.map +1 -1
- package/dist/domain/submission.js +68 -0
- package/dist/domain/submission.js.map +1 -1
- package/dist/lib/paper-config.d.ts +1 -1
- package/dist/lib/paper-config.d.ts.map +1 -1
- package/dist/lib/paper-config.js +6 -4
- package/dist/lib/paper-config.js.map +1 -1
- package/dist/outputs-before-decision-rule.d.ts +1 -1
- package/dist/outputs-before-decision-rule.d.ts.map +1 -1
- package/dist/outputs-before-decision-rule.js +21 -17
- package/dist/outputs-before-decision-rule.js.map +1 -1
- package/dist/paper-settings.d.ts +3 -1
- package/dist/paper-settings.d.ts.map +1 -1
- package/dist/paper-settings.js +11 -5
- package/dist/paper-settings.js.map +1 -1
- package/dist/ports/latex.d.ts +23 -0
- package/dist/ports/latex.d.ts.map +1 -1
- package/dist/ports/list-entries.d.ts +7 -4
- package/dist/ports/list-entries.d.ts.map +1 -1
- package/dist/ports/study-deps.d.ts +14 -0
- package/dist/ports/study-deps.d.ts.map +1 -0
- package/dist/ports/study-deps.js +2 -0
- package/dist/ports/study-deps.js.map +1 -0
- package/dist/ports/study-folder.d.ts +43 -0
- package/dist/ports/study-folder.d.ts.map +1 -0
- package/dist/ports/study-folder.js +2 -0
- package/dist/ports/study-folder.js.map +1 -0
- package/dist/ports/submission-portal.d.ts +39 -2
- package/dist/ports/submission-portal.d.ts.map +1 -1
- package/dist/ports/venue-deadlines.d.ts +1 -1
- package/dist/presets.d.ts +1 -2
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +1 -2
- package/dist/presets.js.map +1 -1
- package/dist/studies.d.ts.map +1 -1
- package/dist/studies.js +10 -7
- package/dist/studies.js.map +1 -1
- package/dist/study-folder.d.ts +14 -0
- package/dist/study-folder.d.ts.map +1 -0
- package/dist/study-folder.js +123 -0
- package/dist/study-folder.js.map +1 -0
- package/dist/study-gate.d.ts +1 -1
- package/dist/study-gate.d.ts.map +1 -1
- package/dist/study-gate.js +65 -28
- package/dist/study-gate.js.map +1 -1
- package/dist/study-index-input.d.ts +1 -1
- package/dist/study-index-input.d.ts.map +1 -1
- package/dist/study-index-input.js +8 -3
- package/dist/study-index-input.js.map +1 -1
- package/dist/study-modes.d.ts +38 -27
- package/dist/study-modes.d.ts.map +1 -1
- package/dist/study-modes.js +42 -53
- package/dist/study-modes.js.map +1 -1
- package/dist/study-registry-rule.d.ts +2 -6
- package/dist/study-registry-rule.d.ts.map +1 -1
- package/dist/study-registry-rule.js +32 -41
- package/dist/study-registry-rule.js.map +1 -1
- package/dist/submission.d.ts +49 -11
- package/dist/submission.d.ts.map +1 -1
- package/dist/submission.js +332 -44
- package/dist/submission.js.map +1 -1
- package/docs/configuration.md +2 -1
- package/docs/design/study-modes.md +46 -28
- package/docs/rules/cycle/record.md +9 -6
- package/docs/rules/repro/outputs-before-decision.md +3 -0
- package/docs/rules/repro/study-registered.md +3 -2
- package/docs/rules/tex/heading-case.md +1 -1
- package/docs/submission.md +47 -48
- package/eslint.config.mjs +39 -24
- package/lib/paper-config.ts +7 -4
- package/package.json +6 -1
- package/presets/msr.jsonc +9 -7
- package/presets/msr.md +14 -10
- package/scripts/check.ts +1 -1
- package/scripts/refresh-deadlines/compare.test.ts +75 -0
- package/scripts/refresh-deadlines/compare.ts +101 -0
- package/scripts/refresh-deadlines/main.test.ts +140 -0
- package/scripts/refresh-deadlines/main.ts +56 -0
- package/scripts/refresh-deadlines/refresh.test.ts +287 -0
- package/scripts/refresh-deadlines/refresh.ts +183 -0
- package/skills/grade-paper-writing/SKILL.md +4 -2
- package/skills/grade-paper-writing/SKILL.md.spec.ts +3 -1
- package/skills/grade-paper-writing/__snapshots__/prose-lint.test.ts.snap +16 -3
- package/skills/grade-paper-writing/prose-lint.mjs +127 -95
- package/skills/grade-paper-writing/prose-lint.test.ts +119 -10
- package/skills/submission-portal/SKILL.md +60 -44
- package/skills/submission-portal/SKILL.md.spec.ts +62 -43
- package/skills/submission-portal/submission-portal.test.ts +4 -73
- package/src/CLAUDE.md +7 -4
- package/src/CLAUDE.md.spec.ts +6 -3
- package/src/adapters/hotcrp/cassette/README.md +4 -4
- package/src/adapters/hotcrp/cassette/deadlines-msr2027.html +9 -9
- package/src/adapters/hotcrp/deadlines.io.test.ts +2 -2
- package/src/adapters/hotcrp/deadlines.test.ts +3 -3
- package/src/adapters/hotcrp/index.ts +15 -3
- package/src/adapters/hotcrp/portal.io.ts +61 -17
- package/src/adapters/hotcrp/portal.test.ts +105 -1
- package/src/adapters/hotcrp/response.ts +21 -0
- package/src/adapters/hotcrp/site.ts +34 -0
- package/src/adapters/hotcrp/token.test.ts +69 -0
- package/src/adapters/hotcrp/token.ts +51 -0
- package/src/adapters/latex/abstract.test.ts +280 -0
- package/src/adapters/latex/abstract.ts +272 -0
- package/src/adapters/latex/captions.test.ts +57 -0
- package/src/adapters/latex/captions.ts +56 -0
- package/src/adapters/latex/headings.ts +5 -4
- package/src/adapters/latex/index.test.ts +31 -1
- package/src/adapters/latex/index.ts +3 -0
- package/src/adapters/latex/parse.ts +71 -3
- package/src/adapters/node/list-entries.io.ts +2 -1
- package/src/adapters/node/list-entries.test.ts +18 -0
- package/src/cli.test.ts +20 -8
- package/src/cli.ts +22 -13
- package/src/cycle-rules.test.ts +6 -6
- package/src/cycle-rules.ts +7 -4
- package/src/domain/deadline.test.ts +31 -2
- package/src/domain/deadline.ts +42 -16
- package/src/domain/decide-first.test.ts +33 -0
- package/src/domain/decide-first.ts +16 -1
- package/src/domain/index-columns.test.ts +49 -4
- package/src/domain/index-columns.ts +2 -0
- package/src/domain/portal-token.test.ts +55 -0
- package/src/domain/portal-token.ts +63 -0
- package/src/domain/study-measurements.test.ts +7 -3
- package/src/domain/study-registry.test.ts +34 -6
- package/src/domain/study-registry.ts +123 -31
- package/src/domain/submission.test.ts +88 -0
- package/src/domain/submission.ts +99 -0
- package/src/new-paper.test.ts +1 -0
- package/src/outputs-before-decision-rule.test.ts +47 -2
- package/src/outputs-before-decision-rule.ts +31 -22
- package/src/paper-settings.test.ts +20 -0
- package/src/paper-settings.ts +17 -5
- package/src/ports/latex.ts +25 -0
- package/src/ports/list-entries.ts +7 -4
- package/src/ports/study-deps.ts +14 -0
- package/src/ports/study-folder.ts +38 -0
- package/src/ports/submission-portal.ts +43 -1
- package/src/ports/venue-deadlines.ts +1 -1
- package/src/presets.test.ts +8 -5
- package/src/presets.ts +1 -2
- package/src/studies-index-modes.test.ts +2 -2
- package/src/studies.ts +19 -10
- package/src/study-folder.test.ts +237 -0
- package/src/study-folder.ts +176 -0
- package/src/study-gate.test.ts +158 -7
- package/src/study-gate.ts +127 -54
- package/src/study-index-input.ts +12 -4
- package/src/study-modes.ts +67 -80
- package/src/study-registry-rule.test.ts +87 -0
- package/src/study-registry-rule.ts +58 -66
- package/src/submission-new.test.ts +569 -0
- package/src/submission-show.test.ts +210 -0
- package/src/submission-update.test.ts +160 -0
- package/src/submission.test.ts +140 -518
- package/src/submission.ts +494 -61
- package/scripts/refresh-deadlines.test.ts +0 -431
- package/scripts/refresh-deadlines.ts +0 -387
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH TOKEN A PORTAL COMMAND USES — the policy, in one place. Pure.
|
|
3
|
+
*
|
|
4
|
+
* The site's own variable, and nothing else: there is NO fallback to a site-less variable. With one,
|
|
5
|
+
* which token a command used is not visible, and a token from another site reads as "the portal
|
|
6
|
+
* refused". A site-less variable that is set while the site's own is not is refused by name. The
|
|
7
|
+
* names themselves are the portal's (`src/adapters/hotcrp/token.ts`).
|
|
8
|
+
*/
|
|
9
|
+
import { err, ok, type Result } from "./result.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The site's host as written in its URL, with its port when it is not the scheme's default
|
|
13
|
+
* (`review.example.org:8443`): two sites on one host differ by port. The URL itself when it does
|
|
14
|
+
* not parse.
|
|
15
|
+
*/
|
|
16
|
+
export const hostOf = (url: string): string =>
|
|
17
|
+
URL.canParse(url) ? new URL(url).host : url;
|
|
18
|
+
|
|
19
|
+
/** The variables a lookup reads: the site's own, and the site-less one it refuses. */
|
|
20
|
+
export interface TokenNames {
|
|
21
|
+
readonly variable: string;
|
|
22
|
+
readonly siteLess: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A token found, and the variable it came from. */
|
|
26
|
+
export interface FoundToken {
|
|
27
|
+
readonly variable: string;
|
|
28
|
+
readonly token: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Why there is no token to use. */
|
|
32
|
+
export type TokenProblem =
|
|
33
|
+
/** The site's variable is not set (or blank), and neither is the site-less one. */
|
|
34
|
+
| { readonly kind: "missing"; readonly variable: string }
|
|
35
|
+
/** The site's variable is not set, but the site-less one, no longer read, is. */
|
|
36
|
+
| {
|
|
37
|
+
readonly kind: "site-less";
|
|
38
|
+
readonly variable: string;
|
|
39
|
+
readonly siteLess: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const value = (
|
|
43
|
+
read: (name: string) => string | undefined,
|
|
44
|
+
name: string,
|
|
45
|
+
): string | null => {
|
|
46
|
+
const v = read(name)?.trim();
|
|
47
|
+
return v === undefined || v === "" ? null : v;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The token for a portal, read through `read` (the root reads the environment): the site's variable
|
|
52
|
+
* only. A set site-less variable never stands in for it — it is named as the problem instead.
|
|
53
|
+
*/
|
|
54
|
+
export function lookUpToken(
|
|
55
|
+
{ variable, siteLess }: TokenNames,
|
|
56
|
+
read: (name: string) => string | undefined,
|
|
57
|
+
): Result<FoundToken, TokenProblem> {
|
|
58
|
+
const token = value(read, variable);
|
|
59
|
+
if (token !== null) return ok({ variable, token });
|
|
60
|
+
return value(read, siteLess) === null
|
|
61
|
+
? err({ kind: "missing", variable })
|
|
62
|
+
: err({ kind: "site-less", variable, siteLess });
|
|
63
|
+
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type StudyPlan,
|
|
23
23
|
type StudyRegistry,
|
|
24
24
|
type IndexInput,
|
|
25
|
+
folderKey,
|
|
25
26
|
} from "./study-registry.ts";
|
|
26
27
|
|
|
27
28
|
/** No plan and no history: the index with every mode column `—`. */
|
|
@@ -110,10 +111,10 @@ const input = (
|
|
|
110
111
|
folders: r.studies.map((s) => s.folder),
|
|
111
112
|
registry: { kind: "read", registry: r },
|
|
112
113
|
readme: withIndex("# Reproduction index\n", r, NO_FACTS),
|
|
113
|
-
|
|
114
|
+
results: new Map(),
|
|
114
115
|
plans: new Map(
|
|
115
116
|
r.studies.flatMap((s) =>
|
|
116
|
-
s.plan === undefined ? [] : [[
|
|
117
|
+
s.plan === undefined ? [] : [[folderKey(s.folder, s.plan), read(PLAN)]],
|
|
117
118
|
),
|
|
118
119
|
),
|
|
119
120
|
...over,
|
|
@@ -385,7 +386,10 @@ describe("plans — the parser decides ready or draft", () => {
|
|
|
385
386
|
});
|
|
386
387
|
const plans = new Map([[`P1-S01-census/${PLAN_FILE}`, read(PLAN)]]);
|
|
387
388
|
expect(
|
|
388
|
-
findings(r, {
|
|
389
|
+
findings(r, {
|
|
390
|
+
plans,
|
|
391
|
+
results: new Map([["P1-S01-census/r.tsv", { kind: "file" as const }]]),
|
|
392
|
+
}),
|
|
389
393
|
).toEqual([]);
|
|
390
394
|
});
|
|
391
395
|
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
studyIndex,
|
|
20
20
|
studyMessage,
|
|
21
21
|
studyReadme,
|
|
22
|
+
studyById,
|
|
22
23
|
studySlug,
|
|
23
24
|
withIndex,
|
|
24
25
|
type RegistryState,
|
|
@@ -61,7 +62,7 @@ const input = (
|
|
|
61
62
|
folders: r.studies.map((s) => s.folder),
|
|
62
63
|
registry: read(r),
|
|
63
64
|
readme: withIndex("# Reproduction index\n", r, NO_FACTS),
|
|
64
|
-
|
|
65
|
+
results: new Map(),
|
|
65
66
|
plans: new Map(),
|
|
66
67
|
...over,
|
|
67
68
|
});
|
|
@@ -88,7 +89,9 @@ describe("judgeStudies — silent", () => {
|
|
|
88
89
|
expect(
|
|
89
90
|
judgeStudies(
|
|
90
91
|
input(r, {
|
|
91
|
-
|
|
92
|
+
results: new Map([
|
|
93
|
+
["P1-S02-ablation/out/results.tsv", { kind: "file" as const }],
|
|
94
|
+
]),
|
|
92
95
|
}),
|
|
93
96
|
),
|
|
94
97
|
).toEqual([]);
|
|
@@ -100,7 +103,7 @@ describe("judgeStudies — silent", () => {
|
|
|
100
103
|
folders: [],
|
|
101
104
|
registry: { kind: "absent" },
|
|
102
105
|
readme: null,
|
|
103
|
-
|
|
106
|
+
results: new Map(),
|
|
104
107
|
plans: new Map(),
|
|
105
108
|
}),
|
|
106
109
|
).toEqual([]);
|
|
@@ -129,7 +132,7 @@ describe("judgeStudies — the registry itself", () => {
|
|
|
129
132
|
folders: ["a-2026-10-01", "b-2026-10-02"],
|
|
130
133
|
registry: { kind: "absent" },
|
|
131
134
|
readme: null,
|
|
132
|
-
|
|
135
|
+
results: new Map(),
|
|
133
136
|
plans: new Map(),
|
|
134
137
|
}),
|
|
135
138
|
).toEqual([
|
|
@@ -143,7 +146,7 @@ describe("judgeStudies — the registry itself", () => {
|
|
|
143
146
|
folders: ["a-2026-10-01"],
|
|
144
147
|
registry: { kind: "unreadable", why: "prefix: required" },
|
|
145
148
|
readme: null,
|
|
146
|
-
|
|
149
|
+
results: new Map(),
|
|
147
150
|
plans: new Map(),
|
|
148
151
|
}),
|
|
149
152
|
).toEqual([{ problem: "unreadable", why: "prefix: required" }]);
|
|
@@ -259,7 +262,11 @@ describe("judgeStudies — status", () => {
|
|
|
259
262
|
const r = reg(study({ status: "running", result_file: "r.tsv" }));
|
|
260
263
|
expect(
|
|
261
264
|
judgeStudies(
|
|
262
|
-
input(r, {
|
|
265
|
+
input(r, {
|
|
266
|
+
results: new Map([
|
|
267
|
+
["P1-S01-baseline/r.tsv", { kind: "file" as const }],
|
|
268
|
+
]),
|
|
269
|
+
}),
|
|
263
270
|
),
|
|
264
271
|
).toEqual([]);
|
|
265
272
|
});
|
|
@@ -671,3 +678,24 @@ describe("studyReadme", () => {
|
|
|
671
678
|
);
|
|
672
679
|
});
|
|
673
680
|
});
|
|
681
|
+
|
|
682
|
+
describe("studyById — the one way to address a study by its id", () => {
|
|
683
|
+
const a = study({ id: "P1-S01", folder: "P1-S01-a" });
|
|
684
|
+
const b = study({ id: "P1-S01", folder: "P1-S01-b" });
|
|
685
|
+
const c = study({ id: "P1-S02", folder: "P1-S02-c" });
|
|
686
|
+
|
|
687
|
+
it("exactly one row: that row; no row: none; two or more: all of them, in registry order", () => {
|
|
688
|
+
// Guards: a caller can take a row only from `one`, never "the first" of an ambiguous id.
|
|
689
|
+
expect([
|
|
690
|
+
studyById([a, c], "P1-S01"),
|
|
691
|
+
studyById([a, c], "P1-S09"),
|
|
692
|
+
studyById([], "P1-S01"),
|
|
693
|
+
studyById([a, c, b], "P1-S01"),
|
|
694
|
+
]).toEqual([
|
|
695
|
+
{ kind: "one", row: a },
|
|
696
|
+
{ kind: "none" },
|
|
697
|
+
{ kind: "none" },
|
|
698
|
+
{ kind: "ambiguous", rows: [a, b] },
|
|
699
|
+
]);
|
|
700
|
+
});
|
|
701
|
+
});
|
|
@@ -248,8 +248,22 @@ export type ParsedPlan =
|
|
|
248
248
|
}
|
|
249
249
|
| { readonly kind: "unreadable"; readonly why: string };
|
|
250
250
|
|
|
251
|
+
/**
|
|
252
|
+
* What a path inside a study's folder is, found without reading it or following a link: a regular
|
|
253
|
+
* file, nothing, or a path that passes through a symbolic link (`link`: the first one, relative to
|
|
254
|
+
* the folder).
|
|
255
|
+
*/
|
|
256
|
+
export type Located =
|
|
257
|
+
| { readonly kind: "file" }
|
|
258
|
+
| { readonly kind: "absent" }
|
|
259
|
+
| { readonly kind: "linked"; readonly link: string };
|
|
260
|
+
|
|
251
261
|
/** What a study's folder holds of the plan its row names: absent, or what its text parsed to. */
|
|
252
|
-
export type PlanState =
|
|
262
|
+
export type PlanState =
|
|
263
|
+
| { readonly kind: "absent" }
|
|
264
|
+
/** The plan's path passes through a symbolic link, `link` (relative to the folder); never read. */
|
|
265
|
+
| { readonly kind: "linked"; readonly link: string }
|
|
266
|
+
| ParsedPlan;
|
|
253
267
|
|
|
254
268
|
/** The decision file `paperlint studies new` names for a study in `folder`, relative to the paper. */
|
|
255
269
|
export const decisionPath = (folder: string): string =>
|
|
@@ -303,8 +317,8 @@ export interface StudyInput {
|
|
|
303
317
|
readonly registry: RegistryState;
|
|
304
318
|
/** `repro/README.md`'s text; null when there is none. */
|
|
305
319
|
readonly readme: string | null;
|
|
306
|
-
/**
|
|
307
|
-
readonly
|
|
320
|
+
/** What each `<folder>/<result_file>` of the registry is, as `locate` found it; one not listed is absent. */
|
|
321
|
+
readonly results: ReadonlyMap<string, Located>;
|
|
308
322
|
/** The plan each row names, by `<folder>/<plan>`; one not listed is absent. */
|
|
309
323
|
readonly plans: ReadonlyMap<string, PlanState>;
|
|
310
324
|
}
|
|
@@ -351,6 +365,13 @@ export type StudyFinding =
|
|
|
351
365
|
readonly path: string;
|
|
352
366
|
readonly why: "missing" | "outside";
|
|
353
367
|
}
|
|
368
|
+
| {
|
|
369
|
+
readonly problem: "result-file";
|
|
370
|
+
readonly id: string;
|
|
371
|
+
readonly path: string;
|
|
372
|
+
readonly why: "linked";
|
|
373
|
+
readonly link: string;
|
|
374
|
+
}
|
|
354
375
|
| {
|
|
355
376
|
readonly problem: "no-successor";
|
|
356
377
|
readonly id: string;
|
|
@@ -390,6 +411,13 @@ export type StudyFinding =
|
|
|
390
411
|
readonly path: string;
|
|
391
412
|
readonly why: "missing" | "outside";
|
|
392
413
|
}
|
|
414
|
+
| {
|
|
415
|
+
readonly problem: "bad-plan";
|
|
416
|
+
readonly id: string;
|
|
417
|
+
readonly path: string;
|
|
418
|
+
readonly why: "linked";
|
|
419
|
+
readonly link: string;
|
|
420
|
+
}
|
|
393
421
|
| {
|
|
394
422
|
readonly problem: "bad-plan";
|
|
395
423
|
readonly id: string;
|
|
@@ -479,24 +507,66 @@ export const isInside = (path: string): boolean => {
|
|
|
479
507
|
export const isFolderName = (folder: string): boolean =>
|
|
480
508
|
folder !== "." && folder !== ".." && /^[^/\\\0]+$/u.test(folder);
|
|
481
509
|
|
|
510
|
+
/**
|
|
511
|
+
* The rows of a registry that carry an id: exactly one, none, or several. The one way to address a
|
|
512
|
+
* study by its id — a caller that wants "the study P1-S01" gets a row only when there is exactly one,
|
|
513
|
+
* so no command can act on the first of two rows by accident. A registry may still hold two rows of
|
|
514
|
+
* one id (`repro/study-registered` reports it); this says so instead of choosing.
|
|
515
|
+
*/
|
|
516
|
+
export type StudyLookup =
|
|
517
|
+
| { readonly kind: "one"; readonly row: Study }
|
|
518
|
+
| { readonly kind: "none" }
|
|
519
|
+
| { readonly kind: "ambiguous"; readonly rows: readonly Study[] };
|
|
520
|
+
|
|
521
|
+
/** The rows of a registry that was read; none when there is no registry, or it does not parse. */
|
|
522
|
+
export const studiesOf = (state: RegistryState): readonly Study[] =>
|
|
523
|
+
state.kind === "read" ? state.registry.studies : [];
|
|
524
|
+
|
|
525
|
+
/** The rows of `studies` whose id is `id`, as a `StudyLookup`. */
|
|
526
|
+
export const studyById = (
|
|
527
|
+
studies: readonly Study[],
|
|
528
|
+
id: string,
|
|
529
|
+
): StudyLookup => {
|
|
530
|
+
const [first, ...rest] = studies.filter((s) => s.id === id);
|
|
531
|
+
if (first === undefined) return { kind: "none" };
|
|
532
|
+
return rest.length === 0
|
|
533
|
+
? { kind: "one", row: first }
|
|
534
|
+
: { kind: "ambiguous", rows: [first, ...rest] };
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* The key a path inside a study's folder is held under: `<folder>/<path>`. A plan (`StudyInput.plans`,
|
|
539
|
+
* `IndexInput.plans`), the history of its study (`IndexInput.history`) and a result file
|
|
540
|
+
* (`StudyInput.results`) are all found by it.
|
|
541
|
+
*/
|
|
542
|
+
export const folderKey = (folder: string, path: string): string =>
|
|
543
|
+
`${folder}/${path}`;
|
|
544
|
+
|
|
482
545
|
/**
|
|
483
546
|
* The `<folder>/<plan>` of each row whose plan lies inside its folder: the files the judge needs read
|
|
484
547
|
* (`StudyInput.plans`). A plan outside its folder is reported without being read.
|
|
485
548
|
*/
|
|
486
549
|
export const planKeys = (r: StudyRegistry): readonly string[] =>
|
|
487
|
-
planRefs(r).map((p) => p.
|
|
550
|
+
planRefs(r).map((p) => folderKey(p.folder, p.plan));
|
|
488
551
|
|
|
489
|
-
/**
|
|
490
|
-
export
|
|
491
|
-
r: StudyRegistry,
|
|
492
|
-
): readonly {
|
|
493
|
-
readonly key: string;
|
|
552
|
+
/** A plan a row names: where it is read from, and the kind of the study — what its parser needs. */
|
|
553
|
+
export interface PlanRef {
|
|
494
554
|
readonly folder: string;
|
|
555
|
+
readonly plan: string;
|
|
495
556
|
readonly kind: StudyKind;
|
|
496
|
-
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Each plan `planKeys` names. */
|
|
560
|
+
export const planRefs = (r: StudyRegistry): readonly PlanRef[] =>
|
|
497
561
|
r.studies.flatMap((s) =>
|
|
498
562
|
s.plan !== undefined && isInside(s.plan) && isFolderName(s.folder)
|
|
499
|
-
? [
|
|
563
|
+
? [
|
|
564
|
+
{
|
|
565
|
+
folder: s.folder,
|
|
566
|
+
plan: s.plan,
|
|
567
|
+
kind: s.kind,
|
|
568
|
+
},
|
|
569
|
+
]
|
|
500
570
|
: [],
|
|
501
571
|
);
|
|
502
572
|
|
|
@@ -522,7 +592,7 @@ const badId: RowCheck = ({ r, s }) =>
|
|
|
522
592
|
|
|
523
593
|
/** Reported once, on the first row that holds the id. */
|
|
524
594
|
const duplicateId: RowCheck = (row) =>
|
|
525
|
-
row.r.studies
|
|
595
|
+
studyById(row.r.studies, row.s.id).kind === "ambiguous" &&
|
|
526
596
|
isFirst(row, (o) => o.id)
|
|
527
597
|
? { problem: "duplicate-id", id: row.s.id }
|
|
528
598
|
: null;
|
|
@@ -572,9 +642,23 @@ const resultFile: RowCheck = ({ s, input }) => {
|
|
|
572
642
|
if (path === undefined) return null;
|
|
573
643
|
if (!isInside(path))
|
|
574
644
|
return { problem: "result-file", id: s.id, path, why: "outside" };
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
645
|
+
const found = input.results.get(folderKey(s.folder, path)) ?? {
|
|
646
|
+
kind: "absent",
|
|
647
|
+
};
|
|
648
|
+
switch (found.kind) {
|
|
649
|
+
case "file":
|
|
650
|
+
return null;
|
|
651
|
+
case "linked":
|
|
652
|
+
return {
|
|
653
|
+
problem: "result-file",
|
|
654
|
+
id: s.id,
|
|
655
|
+
path,
|
|
656
|
+
why: "linked",
|
|
657
|
+
link: found.link,
|
|
658
|
+
};
|
|
659
|
+
case "absent":
|
|
660
|
+
return { problem: "result-file", id: s.id, path, why: "missing" };
|
|
661
|
+
}
|
|
578
662
|
};
|
|
579
663
|
|
|
580
664
|
const noSuccessor: RowCheck = ({ r, s }) => {
|
|
@@ -593,13 +677,15 @@ const unknownMeasurement: RowCheck = ({ r, s }) =>
|
|
|
593
677
|
? null
|
|
594
678
|
: { problem: "unknown-measurement", id: s.id, measurement: s.measurement };
|
|
595
679
|
|
|
596
|
-
/** The state of the plan the row
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
? (input.plans.get(`${s.folder}/${s.plan}`) ?? { kind: "absent" })
|
|
680
|
+
/** The state of the plan `plan` of the row `s` (absent when outside its folder): the one place a plan is looked up. */
|
|
681
|
+
const planStateOf = (input: StudyInput, s: Study, plan: string): PlanState =>
|
|
682
|
+
isInside(plan)
|
|
683
|
+
? (input.plans.get(folderKey(s.folder, plan)) ?? { kind: "absent" })
|
|
601
684
|
: { kind: "absent" };
|
|
602
|
-
|
|
685
|
+
|
|
686
|
+
/** The state of the plan the row names; null when it names none. */
|
|
687
|
+
const planOf = ({ s, input }: Row): PlanState | null =>
|
|
688
|
+
s.plan === undefined ? null : planStateOf(input, s, s.plan);
|
|
603
689
|
|
|
604
690
|
const hasRun = (s: Study): boolean => RAN_STATUSES.includes(s.status);
|
|
605
691
|
|
|
@@ -613,6 +699,8 @@ const planFinding = (
|
|
|
613
699
|
switch (state.kind) {
|
|
614
700
|
case "absent":
|
|
615
701
|
return { ...at, why: "missing" };
|
|
702
|
+
case "linked":
|
|
703
|
+
return { ...at, why: "linked", link: state.link };
|
|
616
704
|
case "unreadable":
|
|
617
705
|
return { ...at, why: "unreadable", reasons: state.why };
|
|
618
706
|
case "draft":
|
|
@@ -630,11 +718,7 @@ const badPlan: RowCheck = ({ s, input }) => {
|
|
|
630
718
|
if (path === undefined) return null;
|
|
631
719
|
if (!isInside(path))
|
|
632
720
|
return { problem: "bad-plan", id: s.id, path, why: "outside" };
|
|
633
|
-
return planFinding(
|
|
634
|
-
s,
|
|
635
|
-
path,
|
|
636
|
-
input.plans.get(`${s.folder}/${path}`) ?? { kind: "absent" },
|
|
637
|
-
);
|
|
721
|
+
return planFinding(s, path, planStateOf(input, s, path));
|
|
638
722
|
};
|
|
639
723
|
|
|
640
724
|
/** A plan under its old name: read, and named for renaming. */
|
|
@@ -801,8 +885,8 @@ const cell = (s: string): string =>
|
|
|
801
885
|
|
|
802
886
|
/**
|
|
803
887
|
* What the index is generated from beyond the registry: the plans by `<folder>/<plan>` (as
|
|
804
|
-
* `StudyInput.plans`), and the history of each decide-first study by
|
|
805
|
-
* listed shows `not read` — the lint, which never compares those columns, asks none.
|
|
888
|
+
* `StudyInput.plans`), and the history of each decide-first study by the same key. A study with no
|
|
889
|
+
* history listed shows `not read` — the lint, which never compares those columns, asks none.
|
|
806
890
|
*/
|
|
807
891
|
export interface IndexInput {
|
|
808
892
|
readonly plans: ReadonlyMap<string, PlanState>;
|
|
@@ -816,10 +900,11 @@ const declaredMode = (
|
|
|
816
900
|
): StudyMode | null => {
|
|
817
901
|
const state =
|
|
818
902
|
s.plan !== undefined && isInside(s.plan)
|
|
819
|
-
? plans.get(
|
|
903
|
+
? plans.get(folderKey(s.folder, s.plan))
|
|
820
904
|
: undefined;
|
|
821
905
|
return state === undefined ||
|
|
822
906
|
state.kind === "absent" ||
|
|
907
|
+
state.kind === "linked" ||
|
|
823
908
|
state.kind === "unreadable"
|
|
824
909
|
? null
|
|
825
910
|
: (state.plan.mode ?? null);
|
|
@@ -828,7 +913,10 @@ const declaredMode = (
|
|
|
828
913
|
/** What the index shows of `s` beyond its row. */
|
|
829
914
|
const factsOf = (s: Study, at: IndexInput): IndexFacts => ({
|
|
830
915
|
mode: declaredMode(s, at.plans),
|
|
831
|
-
history:
|
|
916
|
+
history:
|
|
917
|
+
(s.plan === undefined
|
|
918
|
+
? undefined
|
|
919
|
+
: at.history.get(folderKey(s.folder, s.plan))) ?? null,
|
|
832
920
|
});
|
|
833
921
|
|
|
834
922
|
/**
|
|
@@ -1039,7 +1127,9 @@ const HEADS: { readonly [P in Problem]: (f: FindingOf[P]) => string } = {
|
|
|
1039
1127
|
"no-result": (f) =>
|
|
1040
1128
|
`${REGISTRY}: ${tick(f.id)} is \`done\` without ${f.missing.map(tick).join(" and ")}. What to do: write the one-line result and the file it is read from (\`result\`, \`result_file\`), or set the status it really has.`,
|
|
1041
1129
|
"result-file": (f) =>
|
|
1042
|
-
|
|
1130
|
+
f.why === "linked"
|
|
1131
|
+
? `${REGISTRY}: the \`result_file\` of ${tick(f.id)}, ${tick(f.path)}, passes through the symbolic link ${tick(f.link)} in its folder; nothing is read through a link. What to do: name a regular file inside the study's folder, or replace the link with the file itself.`
|
|
1132
|
+
: `${REGISTRY}: the \`result_file\` of ${tick(f.id)}, ${tick(f.path)}, ${f.why === "missing" ? "is not a file in its folder" : "is outside its folder"}. What to do: name a file that exists, relative to the study's folder.`,
|
|
1043
1133
|
"no-successor": (f) =>
|
|
1044
1134
|
`${REGISTRY}: ${tick(f.id)} is \`superseded\` ${f.superseded_by === null ? "without `superseded_by`" : `by ${tick(f.superseded_by)}, which is no study in the registry`}. What to do: name the id of the study that replaced it.`,
|
|
1045
1135
|
"stale-index": (f) => `${STALE_HEAD[f.why]} ${RUN_INDEX}`,
|
|
@@ -1059,6 +1149,8 @@ const HEADS: { readonly [P in Problem]: (f: FindingOf[P]) => string } = {
|
|
|
1059
1149
|
switch (f.why) {
|
|
1060
1150
|
case "missing":
|
|
1061
1151
|
return `${REGISTRY}: ${of} is not a file in its folder. What to do: write the plan there (its fields are on the rule's page), or correct the row's \`plan\`.`;
|
|
1152
|
+
case "linked":
|
|
1153
|
+
return `${REGISTRY}: ${of} passes through the symbolic link ${tick(f.link)} in its folder; nothing is read through a link. What to do: replace the link with the plan file itself.`;
|
|
1062
1154
|
case "outside":
|
|
1063
1155
|
return `${REGISTRY}: ${of} is outside its folder. What to do: keep the plan in the study's folder, as \`${PLAN_FILE}\`, and name it relative to the folder.`;
|
|
1064
1156
|
case "unreadable":
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { test } from "vitest";
|
|
4
4
|
import {
|
|
5
|
+
parsePaperAuthors,
|
|
5
6
|
parsePaperSubmission,
|
|
7
|
+
withSubmissionId,
|
|
6
8
|
supportedPortal,
|
|
7
9
|
wordCount,
|
|
8
10
|
} from "./submission.ts";
|
|
@@ -43,3 +45,89 @@ test("wordCount: runs of non-space, any whitespace between", () => {
|
|
|
43
45
|
assert.equal(wordCount(""), 0);
|
|
44
46
|
assert.equal(wordCount(" one\ttwo\n\nthree "), 3);
|
|
45
47
|
});
|
|
48
|
+
|
|
49
|
+
test("parsePaperAuthors: absent is null; each author trimmed, in order, affiliation optional", () => {
|
|
50
|
+
assert.deepEqual(parsePaperAuthors(undefined), { ok: true, value: null });
|
|
51
|
+
assert.deepEqual(
|
|
52
|
+
parsePaperAuthors([
|
|
53
|
+
{ name: " Ada Lovelace ", email: "ada@example.org", affiliation: "AE" },
|
|
54
|
+
{ name: "Bob", email: " bob@example.org " },
|
|
55
|
+
]),
|
|
56
|
+
{
|
|
57
|
+
ok: true,
|
|
58
|
+
value: [
|
|
59
|
+
{ name: "Ada Lovelace", email: "ada@example.org", affiliation: "AE" },
|
|
60
|
+
{ name: "Bob", email: "bob@example.org", affiliation: null },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("🔴 parsePaperAuthors: every malformed list refused with what to fix — never a partial list", () => {
|
|
67
|
+
const shape = /^"authors" must be a non-empty list of \{ "name"/;
|
|
68
|
+
const ada = { name: "Ada", email: "ada@example.org" };
|
|
69
|
+
const cases: readonly (readonly [unknown, RegExp])[] = [
|
|
70
|
+
[[], shape],
|
|
71
|
+
["Ada", shape],
|
|
72
|
+
[["Ada"], shape],
|
|
73
|
+
[
|
|
74
|
+
[ada, { ...ada, orcid: "x" }],
|
|
75
|
+
/^"authors"\[1\] has an unknown key "orcid" — known: name, email, affiliation$/,
|
|
76
|
+
],
|
|
77
|
+
[
|
|
78
|
+
[{ ...ada, name: " " }],
|
|
79
|
+
/^"authors"\[0\]: "name" must be a non-empty string$/,
|
|
80
|
+
],
|
|
81
|
+
[
|
|
82
|
+
[{ ...ada, email: "ada" }],
|
|
83
|
+
/^"authors"\[0\]: "email" must be an email address, got "ada"$/,
|
|
84
|
+
],
|
|
85
|
+
[
|
|
86
|
+
[{ name: "Ada" }, ada],
|
|
87
|
+
/^"authors"\[0\]: "email" must be an email address, got undefined$/,
|
|
88
|
+
],
|
|
89
|
+
[
|
|
90
|
+
[{ ...ada, affiliation: "" }],
|
|
91
|
+
/^"authors"\[0\]: "affiliation", when given, must be a non-empty string$/,
|
|
92
|
+
],
|
|
93
|
+
[
|
|
94
|
+
[ada, { name: "Ada 2", email: "ADA@example.org" }],
|
|
95
|
+
/^"authors": the email ada@example\.org is given twice$/,
|
|
96
|
+
],
|
|
97
|
+
];
|
|
98
|
+
for (const [v, why] of cases) {
|
|
99
|
+
const r = parsePaperAuthors(v);
|
|
100
|
+
assert.ok(!r.ok, JSON.stringify(v));
|
|
101
|
+
assert.match(r.error, why);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("withSubmissionId: the flat form at the top level; the cycles form in the last cycle, the others untouched", () => {
|
|
106
|
+
assert.deepEqual(withSubmissionId({ extends: "x" }, 4), {
|
|
107
|
+
ok: true,
|
|
108
|
+
value: { extends: "x", submission: { id: 4 } },
|
|
109
|
+
});
|
|
110
|
+
assert.deepEqual(
|
|
111
|
+
withSubmissionId({ cycles: [{ id: "a" }, { id: "b" }] }, 4),
|
|
112
|
+
{
|
|
113
|
+
ok: true,
|
|
114
|
+
value: { cycles: [{ id: "a" }, { id: "b", submission: { id: 4 } }] },
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("🔴 withSubmissionId: nowhere to record it — refused, never guessed", () => {
|
|
120
|
+
assert.deepEqual(withSubmissionId([], 4), {
|
|
121
|
+
ok: false,
|
|
122
|
+
error: "must be a JSON object",
|
|
123
|
+
});
|
|
124
|
+
for (const cycles of [[], "x"])
|
|
125
|
+
assert.deepEqual(withSubmissionId({ cycles }, 4), {
|
|
126
|
+
ok: false,
|
|
127
|
+
error: `"cycles" has no attempt to record the submission in`,
|
|
128
|
+
});
|
|
129
|
+
assert.deepEqual(withSubmissionId({ cycles: [{ id: "a" }, 3] }, 4), {
|
|
130
|
+
ok: false,
|
|
131
|
+
error: `the last entry of "cycles" is not an object`,
|
|
132
|
+
});
|
|
133
|
+
});
|
package/src/domain/submission.ts
CHANGED
|
@@ -139,3 +139,102 @@ export type PortalFailure =
|
|
|
139
139
|
/** Words in a text, as a reader counts them: runs of non-space. Pure. */
|
|
140
140
|
export const wordCount = (s: string): number =>
|
|
141
141
|
s.split(/\s+/).filter((w) => w !== "").length;
|
|
142
|
+
|
|
143
|
+
// ── a new submission ────────────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
/** One author as the portal records it. Never guessed: the paper's `authors` key declares it. */
|
|
146
|
+
export interface Author {
|
|
147
|
+
readonly name: string;
|
|
148
|
+
readonly email: string;
|
|
149
|
+
/** Null when the paper does not declare one. */
|
|
150
|
+
readonly affiliation: string | null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const AUTHOR_KEYS = ["name", "email", "affiliation"] as const;
|
|
154
|
+
const AUTHORS_SHAPE = `"authors" must be a non-empty list of { "name": "<as the portal should show it>", "email": "<the address the portal knows the author by>", "affiliation"?: "<…>" }, in the paper's order`;
|
|
155
|
+
|
|
156
|
+
const nonEmpty = (v: unknown): v is string =>
|
|
157
|
+
typeof v === "string" && v.trim() !== "";
|
|
158
|
+
|
|
159
|
+
/** One entry of `authors` → the author, or what is wrong with it. */
|
|
160
|
+
function authorOf(v: unknown, i: number): Result<Author, string> {
|
|
161
|
+
const at = `"authors"[${String(i)}]`;
|
|
162
|
+
if (!isRecord(v)) return err(AUTHORS_SHAPE);
|
|
163
|
+
const unknown = Object.keys(v).find((k) => !AUTHOR_KEYS.some((a) => a === k));
|
|
164
|
+
if (unknown !== undefined)
|
|
165
|
+
return err(
|
|
166
|
+
`${at} has an unknown key "${unknown}" — known: ${AUTHOR_KEYS.join(", ")}`,
|
|
167
|
+
);
|
|
168
|
+
const { name, email, affiliation } = v;
|
|
169
|
+
if (!nonEmpty(name)) return err(`${at}: "name" must be a non-empty string`);
|
|
170
|
+
if (!nonEmpty(email) || !/^[^\s@]+@[^\s@]+$/.test(email.trim()))
|
|
171
|
+
return err(
|
|
172
|
+
`${at}: "email" must be an email address, got ${JSON.stringify(email)}`,
|
|
173
|
+
);
|
|
174
|
+
if (affiliation !== undefined && !nonEmpty(affiliation))
|
|
175
|
+
return err(`${at}: "affiliation", when given, must be a non-empty string`);
|
|
176
|
+
return ok({
|
|
177
|
+
name: name.trim(),
|
|
178
|
+
email: email.trim(),
|
|
179
|
+
affiliation: affiliation === undefined ? null : affiliation.trim(),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A paper's `authors` key → the list, null when absent, or one line saying what is wrong. Pure. */
|
|
184
|
+
export function parsePaperAuthors(
|
|
185
|
+
v: unknown,
|
|
186
|
+
): Result<readonly Author[] | null, string> {
|
|
187
|
+
if (v === undefined) return ok(null);
|
|
188
|
+
if (!Array.isArray(v) || v.length === 0) return err(AUTHORS_SHAPE);
|
|
189
|
+
const list: readonly unknown[] = v;
|
|
190
|
+
const parsed = list.reduce<Result<readonly Author[], string>>((acc, x, i) => {
|
|
191
|
+
if (!acc.ok) return acc;
|
|
192
|
+
const a = authorOf(x, i);
|
|
193
|
+
return a.ok ? ok([...acc.value, a.value]) : a;
|
|
194
|
+
}, ok([]));
|
|
195
|
+
if (!parsed.ok) return parsed;
|
|
196
|
+
const authors = parsed.value;
|
|
197
|
+
const emails = authors.map((a) => a.email.toLowerCase());
|
|
198
|
+
const twice = emails.find((e, i) => emails.indexOf(e) !== i);
|
|
199
|
+
return twice === undefined
|
|
200
|
+
? ok(authors)
|
|
201
|
+
: err(`"authors": the email ${twice} is given twice`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** What creating a submission sends. */
|
|
205
|
+
export interface NewSubmission {
|
|
206
|
+
readonly title: string;
|
|
207
|
+
readonly abstract: string;
|
|
208
|
+
readonly authors: readonly Author[];
|
|
209
|
+
readonly pdf: { readonly name: string; readonly bytes: Uint8Array };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** What the portal answered to a creation: an update's answer, and the id it chose when it saved. */
|
|
213
|
+
export interface CreateOutcome extends UpdateOutcome {
|
|
214
|
+
/** The new submission's id; null on a dry run, or when nothing was created. */
|
|
215
|
+
readonly id: number | null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The parsed `paperlint.json` with the new submission's id recorded where the paper's submission is
|
|
220
|
+
* read from: the last cycle (the open attempt) in the cycles form, the top level in the flat form.
|
|
221
|
+
* Pure; the caller writes the result and reads it back.
|
|
222
|
+
*/
|
|
223
|
+
export function withSubmissionId(
|
|
224
|
+
json: unknown,
|
|
225
|
+
id: number,
|
|
226
|
+
): Result<Readonly<Record<string, unknown>>, string> {
|
|
227
|
+
if (!isRecord(json)) return err("must be a JSON object");
|
|
228
|
+
const cycles = json["cycles"];
|
|
229
|
+
if (cycles === undefined) return ok({ ...json, submission: { id } });
|
|
230
|
+
if (!Array.isArray(cycles) || cycles.length === 0)
|
|
231
|
+
return err(`"cycles" has no attempt to record the submission in`);
|
|
232
|
+
const list: readonly unknown[] = cycles;
|
|
233
|
+
const last = list.at(-1);
|
|
234
|
+
return isRecord(last)
|
|
235
|
+
? ok({
|
|
236
|
+
...json,
|
|
237
|
+
cycles: [...list.slice(0, -1), { ...last, submission: { id } }],
|
|
238
|
+
})
|
|
239
|
+
: err(`the last entry of "cycles" is not an object`);
|
|
240
|
+
}
|