paperlint 11.0.0 → 11.1.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/dist/adapters/hotcrp/index.d.ts +1 -1
- package/dist/adapters/hotcrp/index.d.ts.map +1 -1
- package/dist/adapters/hotcrp/index.js +1 -1
- package/dist/adapters/hotcrp/index.js.map +1 -1
- package/dist/adapters/hotcrp/portal.io.d.ts.map +1 -1
- package/dist/adapters/hotcrp/portal.io.js +17 -2
- package/dist/adapters/hotcrp/portal.io.js.map +1 -1
- package/dist/adapters/hotcrp/response.d.ts +7 -1
- package/dist/adapters/hotcrp/response.d.ts.map +1 -1
- package/dist/adapters/hotcrp/response.js +35 -3
- package/dist/adapters/hotcrp/response.js.map +1 -1
- package/dist/adapters/hotcrp/site.d.ts +1 -1
- package/dist/adapters/hotcrp/site.d.ts.map +1 -1
- package/dist/adapters/hotcrp/site.js +1 -2
- package/dist/adapters/hotcrp/site.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -1
- package/dist/cli.js.map +1 -1
- package/dist/domain/submission-fields.d.ts +77 -0
- package/dist/domain/submission-fields.d.ts.map +1 -0
- package/dist/domain/submission-fields.js +127 -0
- package/dist/domain/submission-fields.js.map +1 -0
- package/dist/domain/submission.d.ts +10 -0
- package/dist/domain/submission.d.ts.map +1 -1
- package/dist/domain/submission.js.map +1 -1
- package/dist/lib/paper-config.d.ts.map +1 -1
- package/dist/lib/paper-config.js +2 -0
- package/dist/lib/paper-config.js.map +1 -1
- package/dist/paper-settings.d.ts +5 -0
- package/dist/paper-settings.d.ts.map +1 -1
- package/dist/paper-settings.js +5 -2
- package/dist/paper-settings.js.map +1 -1
- package/dist/ports/submission-portal.d.ts +3 -1
- package/dist/ports/submission-portal.d.ts.map +1 -1
- package/dist/presets.d.ts +3 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +3 -1
- package/dist/presets.js.map +1 -1
- package/dist/submission.d.ts +24 -5
- package/dist/submission.d.ts.map +1 -1
- package/dist/submission.js +140 -50
- package/dist/submission.js.map +1 -1
- package/dist/tex-requirements.d.ts +3 -0
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +30 -2
- package/dist/tex-requirements.js.map +1 -1
- package/docs/submission.md +29 -6
- package/lib/paper-config.ts +3 -0
- package/package.json +1 -1
- package/presets/msr.jsonc +66 -0
- package/presets/venue-profile.schema.json +41 -0
- package/skills/submission-portal/SKILL.md +51 -20
- package/skills/submission-portal/SKILL.md.spec.ts +50 -19
- package/src/adapters/hotcrp/cassette/README.md +7 -6
- package/src/adapters/hotcrp/cassette/get-searchcompletion.json +100 -0
- package/src/adapters/hotcrp/index.ts +1 -0
- package/src/adapters/hotcrp/portal.io.ts +22 -2
- package/src/adapters/hotcrp/portal.test.ts +103 -2
- package/src/adapters/hotcrp/response.ts +45 -4
- package/src/adapters/hotcrp/site.ts +2 -3
- package/src/cli.ts +6 -1
- package/src/domain/submission-fields.test.ts +183 -0
- package/src/domain/submission-fields.ts +190 -0
- package/src/domain/submission.ts +11 -0
- package/src/new-paper.test.ts +1 -0
- package/src/paper-settings.test.ts +6 -0
- package/src/paper-settings.ts +11 -3
- package/src/ports/submission-portal.ts +3 -0
- package/src/presets.ts +12 -2
- package/src/submission-fields-run.test.ts +247 -0
- package/src/submission-new.test.ts +4 -4
- package/src/submission-update.test.ts +7 -1
- package/src/submission.test.ts +1 -1
- package/src/submission.ts +207 -54
- package/src/tex-requirements.test.ts +21 -0
- package/src/tex-requirements.ts +67 -2
|
@@ -21,7 +21,12 @@ test("update: a dry run by default — the paper's PDF sent, nothing saved, exit
|
|
|
21
21
|
[
|
|
22
22
|
"update",
|
|
23
23
|
7,
|
|
24
|
-
{
|
|
24
|
+
{
|
|
25
|
+
pdf: { name: "paper.pdf", bytes: PDF },
|
|
26
|
+
abstract: null,
|
|
27
|
+
submit: false,
|
|
28
|
+
fields: {},
|
|
29
|
+
},
|
|
25
30
|
{ save: false },
|
|
26
31
|
],
|
|
27
32
|
]);
|
|
@@ -58,6 +63,7 @@ test("update --save --submit --abstract --pdf: all of it sent, and said to be sa
|
|
|
58
63
|
pdf: { name: "other.pdf", bytes: PDF },
|
|
59
64
|
abstract: "The abstract.",
|
|
60
65
|
submit: true,
|
|
66
|
+
fields: {},
|
|
61
67
|
},
|
|
62
68
|
{ save: true },
|
|
63
69
|
],
|
package/src/submission.test.ts
CHANGED
|
@@ -239,7 +239,7 @@ test("usage: refused before anything is read, exit 2", async () => {
|
|
|
239
239
|
|
|
240
240
|
test("usage: a subcommand is required, one paper folder, update-only flags refused on show", () => {
|
|
241
241
|
expect(usageProblem({ ...ARGS, sub: undefined }) ?? "").toMatch(
|
|
242
|
-
/needs a subcommand: new or show or update — got ``/,
|
|
242
|
+
/needs a subcommand: new or show or update or topics — got ``/,
|
|
243
243
|
);
|
|
244
244
|
expect(usageProblem({ ...ARGS, sub: "upload" }) ?? "").toMatch(
|
|
245
245
|
/got `upload`/,
|
package/src/submission.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `paperlint submission show|update [paper-dir]` — read and change a paper's
|
|
3
|
-
* venue's portal, through the portal's API.
|
|
2
|
+
* `paperlint submission new|show|update|topics [paper-dir]` — create, read and change a paper's
|
|
3
|
+
* submission on the venue's portal, through the portal's API.
|
|
4
4
|
*
|
|
5
5
|
* The command is named by the job, not by the portal: WHERE the submission lives is data. The venue
|
|
6
6
|
* preset declares the portal (`"portal": { "kind": "hotcrp", "url": … }`), the paper's
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* whether it is the local `paper.pdf`, by sha256
|
|
12
12
|
* update send the PDF (and, if asked, an abstract, or "submitted"). A DRY RUN unless `--save`:
|
|
13
13
|
* the portal checks the change and keeps nothing
|
|
14
|
+
* topics the topics the portal offers, against the ones the venue preset records
|
|
15
|
+
*
|
|
16
|
+
* `new` and `update` also send the paper's `fields` — its answers to the venue's own form fields
|
|
17
|
+
* (topics, PC conflicts, …), checked against the preset's `submission_fields` before anything is
|
|
18
|
+
* sent (`src/domain/submission-fields.ts`). A checkbox field the portal did not take is said, and
|
|
19
|
+
* fails the command: HotCRP keeps an option it does not know as nothing, without an error.
|
|
14
20
|
*
|
|
15
21
|
* 🔴 THE TOKEN IS AN INPUT, AND ONLY AN INPUT. It comes from the environment (the root reads it), is
|
|
16
22
|
* handed to the adapter, and never appears in anything printed here.
|
|
@@ -33,6 +39,12 @@ import {
|
|
|
33
39
|
type SupportedPortal,
|
|
34
40
|
type UpdateOutcome,
|
|
35
41
|
} from "./domain/submission.ts";
|
|
42
|
+
import {
|
|
43
|
+
droppedFields,
|
|
44
|
+
parsePaperFields,
|
|
45
|
+
type FieldValues,
|
|
46
|
+
type VenueFields,
|
|
47
|
+
} from "./domain/submission-fields.ts";
|
|
36
48
|
import type { Files } from "./ports/files.ts";
|
|
37
49
|
import type {
|
|
38
50
|
PortalSite,
|
|
@@ -111,7 +123,7 @@ export function tokenRefusedLines(
|
|
|
111
123
|
return [why, ...tokenSteps(portal, site, variable)];
|
|
112
124
|
}
|
|
113
125
|
|
|
114
|
-
export const SUBCOMMANDS = ["new", "show", "update"] as const;
|
|
126
|
+
export const SUBCOMMANDS = ["new", "show", "update", "topics"] as const;
|
|
115
127
|
export type Subcommand = (typeof SUBCOMMANDS)[number];
|
|
116
128
|
const isSubcommand = (s: string | undefined): s is Subcommand =>
|
|
117
129
|
SUBCOMMANDS.some((x) => x === s);
|
|
@@ -158,6 +170,15 @@ export interface SubmissionTarget {
|
|
|
158
170
|
readonly config: string;
|
|
159
171
|
/** The authors the paper declares for the portal; null when it declares none. */
|
|
160
172
|
readonly authors: readonly Author[] | null;
|
|
173
|
+
/**
|
|
174
|
+
* The paper's answers to the venue's own form fields, checked against `venueFields`: only `new`
|
|
175
|
+
* and `update` send them, so only they stop on a problem — `show` and `topics` read past it.
|
|
176
|
+
*/
|
|
177
|
+
readonly answers: Result<FieldValues, string>;
|
|
178
|
+
/** The answers to send: `answers`' value once a sending subcommand checked it; empty otherwise. */
|
|
179
|
+
readonly fields: FieldValues;
|
|
180
|
+
/** The venue's own form fields, as its preset records them; null when it records none. */
|
|
181
|
+
readonly venueFields: VenueFields | null;
|
|
161
182
|
/** The cycle attempt is accepted: a new submission belongs to a new cycle. */
|
|
162
183
|
readonly accepted: boolean;
|
|
163
184
|
/** The paper's built PDF, absolute. */
|
|
@@ -182,35 +203,55 @@ const noPresetLine = (
|
|
|
182
203
|
? `${configOf(paperDir)}: no "extends" — the venue preset it names declares the portal ("portal": { "kind": …, "url": … })`
|
|
183
204
|
: `${configOf(paperDir)}: the current cycle names no venue preset, and the preset is what declares the portal — set the venue in the current cycle ("venue": { "kind": "preset", "extends": "paperlint:…" }), or upload by hand`;
|
|
184
205
|
|
|
206
|
+
/** The paper's `fields`, checked against its venue preset's form, or the line naming what is wrong. */
|
|
207
|
+
function fieldsOf(
|
|
208
|
+
paperDir: string,
|
|
209
|
+
p: Extract<PaperPreset, { readonly kind: "resolved" }>,
|
|
210
|
+
): Result<FieldValues, string> {
|
|
211
|
+
const fields = parsePaperFields(
|
|
212
|
+
p.settings.fields,
|
|
213
|
+
p.preset.submissionFields,
|
|
214
|
+
p.preset.chain.join(", "),
|
|
215
|
+
);
|
|
216
|
+
return fields.ok ? fields : err(`${configOf(paperDir)}: ${fields.error}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The venue preset's portal, if it declares one that paperlint can talk to; else the line to act on. */
|
|
220
|
+
function portalOfPreset(
|
|
221
|
+
p: Extract<PaperPreset, { readonly kind: "resolved" }>,
|
|
222
|
+
): Result<SupportedPortal, string> {
|
|
223
|
+
const declared = p.preset.portal;
|
|
224
|
+
return declared === null
|
|
225
|
+
? err(
|
|
226
|
+
`the venue preset declares no "portal" (read: ${p.preset.chain.join(", ")}) — add "portal": { "kind": "hotcrp", "url": "<the site>" } to the venue's preset, or upload by hand`,
|
|
227
|
+
)
|
|
228
|
+
: supportedPortal(declared);
|
|
229
|
+
}
|
|
230
|
+
|
|
185
231
|
/** A resolved paper → its portal and submission, or the line naming the file and key to set. */
|
|
186
232
|
function targetOf(
|
|
187
233
|
paperDir: string,
|
|
188
234
|
p: Extract<PaperPreset, { readonly kind: "resolved" }>,
|
|
189
235
|
): Result<SubmissionTarget, string> {
|
|
190
|
-
const
|
|
191
|
-
if (declared === null)
|
|
192
|
-
return err(
|
|
193
|
-
`the venue preset declares no "portal" (read: ${p.preset.chain.join(", ")}) — add "portal": { "kind": "hotcrp", "url": "<the site>" } to the venue's preset, or upload by hand`,
|
|
194
|
-
);
|
|
195
|
-
const portal = supportedPortal(declared);
|
|
236
|
+
const portal = portalOfPreset(p);
|
|
196
237
|
if (!portal.ok) return portal;
|
|
197
|
-
const pdf = join(paperDir, p.settings.pdf ?? "paper.pdf");
|
|
198
238
|
const identity = p.preset.identity;
|
|
199
|
-
const call = identity.type === "venue" ? identity.url : null;
|
|
200
239
|
const own =
|
|
201
240
|
p.settings.cycles === null
|
|
202
241
|
? []
|
|
203
242
|
: currentDeadlines(p.settings.cycles.current);
|
|
204
|
-
const deadlines = deadlinesInForce(own, p.preset.deadlines);
|
|
205
243
|
return ok({
|
|
206
244
|
portal: portal.value,
|
|
207
245
|
id: p.settings.submission?.id ?? null,
|
|
208
246
|
config: configOf(paperDir),
|
|
209
247
|
authors: p.settings.authors,
|
|
248
|
+
answers: fieldsOf(paperDir, p),
|
|
249
|
+
fields: {},
|
|
250
|
+
venueFields: p.preset.submissionFields,
|
|
210
251
|
accepted: p.settings.cycles?.current.kind === "accepted",
|
|
211
|
-
pdf,
|
|
212
|
-
call,
|
|
213
|
-
deadlines,
|
|
252
|
+
pdf: join(paperDir, p.settings.pdf ?? "paper.pdf"),
|
|
253
|
+
call: identity.type === "venue" ? identity.url : null,
|
|
254
|
+
deadlines: deadlinesInForce(own, p.preset.deadlines),
|
|
214
255
|
});
|
|
215
256
|
}
|
|
216
257
|
|
|
@@ -312,6 +353,28 @@ export function showLines(
|
|
|
312
353
|
export const checkPdfLine = (shown: string, call: string | null): string =>
|
|
313
354
|
`before you rely on it, open ${shown} yourself and check it against ${call === null ? "the venue's call for papers" : `the call for papers, ${call}`}: the page count and what counts toward the limit, the template and its class options, anonymity, and that every figure, table and reference renders — paperlint passing is not the venue accepting the format`;
|
|
314
355
|
|
|
356
|
+
/** The fields the paper sent, one line each, as the portal will get them. Pure. */
|
|
357
|
+
export const fieldLines = (fields: FieldValues): readonly string[] =>
|
|
358
|
+
Object.entries(fields).map(
|
|
359
|
+
([key, value]) => `field ${key}: ${JSON.stringify(value)}`,
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
/** The checkbox fields the portal did not take, said; none when it took them all. Pure. */
|
|
363
|
+
export function droppedLines(
|
|
364
|
+
fields: FieldValues,
|
|
365
|
+
venue: VenueFields | null,
|
|
366
|
+
o: UpdateOutcome,
|
|
367
|
+
): readonly string[] {
|
|
368
|
+
const dropped = droppedFields(fields, venue, {
|
|
369
|
+
changes: o.changes,
|
|
370
|
+
messageFields: o.messages.map((m) => m.field),
|
|
371
|
+
});
|
|
372
|
+
return dropped.map(
|
|
373
|
+
(key) =>
|
|
374
|
+
`NOT TAKEN ${key}: the portal kept none of ${JSON.stringify(fields[key])} — an option it does not know is dropped without an error; \`paperlint submission topics\` lists what it offers`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
315
378
|
/** `update`'s report. Pure. */
|
|
316
379
|
export function updateLines(
|
|
317
380
|
o: UpdateOutcome,
|
|
@@ -346,6 +409,12 @@ type RunDeps = SubmissionDeps & {
|
|
|
346
409
|
readonly refused: (f: PortalFailure) => readonly string[];
|
|
347
410
|
};
|
|
348
411
|
|
|
412
|
+
const logAll = (lines: readonly string[], deps: RunDeps): void => {
|
|
413
|
+
lines.forEach((l) => {
|
|
414
|
+
deps.log(l);
|
|
415
|
+
});
|
|
416
|
+
};
|
|
417
|
+
|
|
349
418
|
/** A failure printed — the portal's words, then what to do about the token if it was the token — exit 1. */
|
|
350
419
|
function failed(f: PortalFailure, deps: RunDeps): number {
|
|
351
420
|
[failureText(f), ...deps.refused(f)].forEach((l) => {
|
|
@@ -395,15 +464,17 @@ function abstractOf(
|
|
|
395
464
|
: ok(new TextDecoder().decode(bytes).trim());
|
|
396
465
|
}
|
|
397
466
|
|
|
398
|
-
/** What `update` sends: the PDF's bytes, the abstract's text,
|
|
467
|
+
/** What `update` sends: the PDF's bytes, the abstract's text, whether to mark it submitted, the fields. */
|
|
399
468
|
const changeOf = (
|
|
400
469
|
a: SubmissionArgs & { readonly pdfPath: string },
|
|
401
470
|
bytes: Uint8Array,
|
|
402
471
|
abstract: string | null,
|
|
472
|
+
fields: FieldValues,
|
|
403
473
|
) => ({
|
|
404
474
|
pdf: { name: basename(a.pdfPath), bytes },
|
|
405
475
|
abstract,
|
|
406
476
|
submit: a.submit,
|
|
477
|
+
fields,
|
|
407
478
|
});
|
|
408
479
|
|
|
409
480
|
/** `update`: send the PDF (and the abstract, and "submitted" when asked); a dry run unless saving. */
|
|
@@ -422,18 +493,16 @@ async function runUpdate(
|
|
|
422
493
|
);
|
|
423
494
|
const abstract = abstractOf(a, deps);
|
|
424
495
|
if (!abstract.ok) return (deps.err(abstract.error), 2);
|
|
425
|
-
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
});
|
|
496
|
+
logAll([checkPdfLine(shown, t.call), ...fieldLines(t.fields)], deps);
|
|
497
|
+
const change = changeOf(a, bytes, abstract.value, t.fields);
|
|
498
|
+
const r = await portal.update(t.id, change, { save: a.save });
|
|
429
499
|
if (!r.ok) return failed(r.error, deps);
|
|
430
500
|
const local = { shown, sha256: sha256Hex(bytes) };
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
});
|
|
501
|
+
const dropped = droppedLines(t.fields, t.venueFields, r.value);
|
|
502
|
+
logAll([...updateLines(r.value, local, a.save), ...dropped], deps);
|
|
434
503
|
const fine =
|
|
435
504
|
r.value.valid && r.value.httpStatus >= 200 && r.value.httpStatus < 300;
|
|
436
|
-
return fine ? 0 : 1;
|
|
505
|
+
return fine && dropped.length === 0 ? 0 : 1;
|
|
437
506
|
}
|
|
438
507
|
|
|
439
508
|
// ── new ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -541,31 +610,37 @@ const noReadableTitle = (text: string): string =>
|
|
|
541
610
|
: `the \\title in ${MAIN} uses a command paperlint does not read («${text.replaceAll(OPAQUE, "…")}»), and the PDF names no Title — set \\hypersetup{pdftitle={<the title as plain text>}} and rebuild`;
|
|
542
611
|
|
|
543
612
|
/** What `new` will send, as the author reads it before saying yes. Pure. */
|
|
544
|
-
export function newLines(
|
|
545
|
-
title: string,
|
|
546
|
-
abstract: string,
|
|
547
|
-
authors: readonly Author[],
|
|
548
|
-
): readonly string[] {
|
|
613
|
+
export function newLines(p: NewSubmission): readonly string[] {
|
|
549
614
|
return [
|
|
550
|
-
`title ${title}`,
|
|
551
|
-
...authors.map(
|
|
615
|
+
`title ${p.title}`,
|
|
616
|
+
...p.authors.map(
|
|
552
617
|
(a, i) =>
|
|
553
618
|
`author ${String(i + 1).padEnd(6)} ${a.name} <${a.email}>${a.affiliation === null ? "" : `, ${a.affiliation}`}`,
|
|
554
619
|
),
|
|
555
|
-
`abstract ${String(wordCount(abstract))} words:`,
|
|
556
|
-
` ${abstract}`,
|
|
620
|
+
`abstract ${String(wordCount(p.abstract))} words:`,
|
|
621
|
+
` ${p.abstract}`,
|
|
622
|
+
...fieldLines(p.fields),
|
|
557
623
|
];
|
|
558
624
|
}
|
|
559
625
|
|
|
560
|
-
/** What
|
|
626
|
+
/** What is left after `new`: the venue's fields the paper did not answer, what only the site takes, the submit. Pure. */
|
|
561
627
|
export function onTheSiteLines(
|
|
562
628
|
site: PortalSite,
|
|
629
|
+
t: Pick<SubmissionTarget, "fields" | "venueFields">,
|
|
563
630
|
id: number | null,
|
|
564
631
|
paperArg: string,
|
|
565
632
|
): readonly string[] {
|
|
633
|
+
const sent = Object.keys(t.fields);
|
|
634
|
+
const unanswered = Object.keys(t.venueFields?.fields ?? {}).filter(
|
|
635
|
+
(k) => !sent.includes(k),
|
|
636
|
+
);
|
|
566
637
|
return [
|
|
567
|
-
`
|
|
568
|
-
...
|
|
638
|
+
`still to do for ${site.submissionPage(id)} — paperlint sent the title, the abstract, the authors and the PDF${sent.length ? `, and ${sent.join(", ")}` : ""}:`,
|
|
639
|
+
...unanswered.map(
|
|
640
|
+
(k) =>
|
|
641
|
+
` - answer ${k} in the paper's paperlint.json "fields" (the venue preset records it), then: npx paperlint submission update ${paperArg} (a dry run; --save after your yes)`,
|
|
642
|
+
),
|
|
643
|
+
...site.siteOnly.map((f) => ` - on the site: ${f}`),
|
|
569
644
|
` - then mark it submitted: npx paperlint submission update ${paperArg} --submit (a dry run; --save after your yes)`,
|
|
570
645
|
];
|
|
571
646
|
}
|
|
@@ -695,6 +770,7 @@ function newSubmissionOf(
|
|
|
695
770
|
...text.value,
|
|
696
771
|
authors: authors.value,
|
|
697
772
|
pdf: { name: basename(a.pdfPath), bytes: pdf },
|
|
773
|
+
fields: t.fields,
|
|
698
774
|
};
|
|
699
775
|
return ok({ paper, shown });
|
|
700
776
|
}
|
|
@@ -721,11 +797,18 @@ function recordCreated(
|
|
|
721
797
|
return 0;
|
|
722
798
|
}
|
|
723
799
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
}
|
|
800
|
+
/** After a creation the portal took: what is still to do, and the id recorded when it saved one. */
|
|
801
|
+
function afterCreated(
|
|
802
|
+
t: SubmissionTarget,
|
|
803
|
+
id: number | null,
|
|
804
|
+
a: SubmissionArgs & { readonly paperDir: string },
|
|
805
|
+
deps: RunDeps,
|
|
806
|
+
): number {
|
|
807
|
+
const paperArg =
|
|
808
|
+
a.paperDir === deps.cwd ? "." : shownPath(deps.cwd, a.paperDir);
|
|
809
|
+
logAll(onTheSiteLines(deps.portalSite, t, id, paperArg), deps);
|
|
810
|
+
return id === null ? 0 : recordCreated(t, id, paperArg, deps);
|
|
811
|
+
}
|
|
729
812
|
|
|
730
813
|
/**
|
|
731
814
|
* `new`: create the paper's submission — title and abstract from the build, the authors the paper
|
|
@@ -741,17 +824,85 @@ async function runNew(
|
|
|
741
824
|
if (!n.ok) return (deps.err(n.error), 2);
|
|
742
825
|
const { paper, shown } = n.value;
|
|
743
826
|
deps.log(checkPdfLine(shown, t.call));
|
|
744
|
-
logAll(newLines(paper
|
|
827
|
+
logAll(newLines(paper), deps);
|
|
745
828
|
const r = await portal.create(paper, { save: a.save });
|
|
746
829
|
if (!r.ok) return failed(r.error, deps);
|
|
747
|
-
|
|
830
|
+
const dropped = droppedLines(t.fields, t.venueFields, r.value);
|
|
831
|
+
logAll([...createLines(r.value, a.save), ...dropped], deps);
|
|
748
832
|
const fine =
|
|
749
833
|
r.value.valid && r.value.httpStatus >= 200 && r.value.httpStatus < 300;
|
|
750
834
|
if (!fine || (a.save && r.value.id === null)) return 1;
|
|
751
|
-
const
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
835
|
+
const recorded = afterCreated(t, r.value.id, a, deps);
|
|
836
|
+
return dropped.length === 0 ? recorded : 1;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** The subcommands that run before the paper has a submission id: `new` creates it, `topics` needs none. */
|
|
840
|
+
const NEEDS_NO_ID: readonly string[] = ["new", "topics"];
|
|
841
|
+
/** The subcommands that send the paper's answers to the venue's fields. */
|
|
842
|
+
const SENDS_FIELDS: readonly string[] = ["new", "update"];
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* What the subcommand needs before a token is looked up: the submission id (`show`, `update`) and
|
|
846
|
+
* the paper's checked answers (`new`, `update`) — or the line naming what to fix.
|
|
847
|
+
*/
|
|
848
|
+
function readyFor(
|
|
849
|
+
sub: string | undefined,
|
|
850
|
+
t: SubmissionTarget,
|
|
851
|
+
): Result<
|
|
852
|
+
{ readonly id: number | null; readonly fields: FieldValues },
|
|
853
|
+
string
|
|
854
|
+
> {
|
|
855
|
+
const id = NEEDS_NO_ID.some((s) => s === sub) ? ok(null) : idOf(t);
|
|
856
|
+
if (!id.ok) return id;
|
|
857
|
+
const fields = SENDS_FIELDS.some((s) => s === sub) ? t.answers : ok({});
|
|
858
|
+
return fields.ok ? ok({ id: id.value, fields: fields.value }) : fields;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** The checkbox field the preset records the portal's topics under: the one named `topics`. */
|
|
862
|
+
const TOPICS = "topics";
|
|
863
|
+
|
|
864
|
+
/** `topics`' report: each topic the portal offers against the preset's options, and whether they agree. Pure. */
|
|
865
|
+
export function topicsReport(
|
|
866
|
+
t: Pick<SubmissionTarget, "portal" | "venueFields">,
|
|
867
|
+
offered: readonly string[],
|
|
868
|
+
): { readonly lines: readonly string[]; readonly agrees: boolean } {
|
|
869
|
+
const declared = t.venueFields?.fields[TOPICS];
|
|
870
|
+
const head = `topics on ${t.portal.url}: ${String(offered.length)}`;
|
|
871
|
+
if (t.venueFields === null || declared?.kind !== "checkboxes")
|
|
872
|
+
return {
|
|
873
|
+
lines: [
|
|
874
|
+
head,
|
|
875
|
+
...offered.map((n) => ` ${n}`),
|
|
876
|
+
`the venue preset records no "submission_fields" with "topics" — record these, spelled as listed, before a paper can send any`,
|
|
877
|
+
],
|
|
878
|
+
agrees: false,
|
|
879
|
+
};
|
|
880
|
+
const recorded = declared.options;
|
|
881
|
+
const extra = offered.filter((n) => !recorded.includes(n));
|
|
882
|
+
const missing = recorded.filter((n) => !offered.includes(n));
|
|
883
|
+
return {
|
|
884
|
+
lines: [
|
|
885
|
+
`${head}; the preset records ${String(recorded.length)} (read ${t.venueFields.read})`,
|
|
886
|
+
...offered.map(
|
|
887
|
+
(n) => ` ${recorded.includes(n) ? "✓" : "+ not in the preset:"} ${n}`,
|
|
888
|
+
),
|
|
889
|
+
...missing.map((n) => ` − in the preset, not offered: ${n}`),
|
|
890
|
+
],
|
|
891
|
+
agrees: extra.length === 0 && missing.length === 0,
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** `topics`: what the portal offers, and whether the preset records the same — exit 1 when not. */
|
|
896
|
+
async function runTopics(
|
|
897
|
+
t: SubmissionTarget,
|
|
898
|
+
portal: SubmissionPortal,
|
|
899
|
+
deps: RunDeps,
|
|
900
|
+
): Promise<number> {
|
|
901
|
+
const r = await portal.options();
|
|
902
|
+
if (!r.ok) return failed(r.error, deps);
|
|
903
|
+
const report = topicsReport(t, r.value.topics);
|
|
904
|
+
logAll(report.lines, deps);
|
|
905
|
+
return report.agrees ? 0 : 1;
|
|
755
906
|
}
|
|
756
907
|
|
|
757
908
|
//** The flags that change something, as given. */
|
|
@@ -769,8 +920,8 @@ export function usageProblem(a: SubmissionArgs): string | null {
|
|
|
769
920
|
if (a.extra.length > 0)
|
|
770
921
|
return `\`submission ${a.sub}\` takes one paper folder — got also \`${a.extra.join(" ")}\``;
|
|
771
922
|
const writing = writingFlags(a);
|
|
772
|
-
if (a.sub === "show" && writing.length > 0)
|
|
773
|
-
return `${writing.join(", ")}: only \`submission new\` and \`submission update\` change anything; \`
|
|
923
|
+
if ((a.sub === "show" || a.sub === "topics") && writing.length > 0)
|
|
924
|
+
return `${writing.join(", ")}: only \`submission new\` and \`submission update\` change anything; \`${a.sub}\` reads`;
|
|
774
925
|
return a.sub === "new" && a.submit
|
|
775
926
|
? "--submit: `submission new` creates the submission; mark it submitted with `submission update --submit` once `show` says the portal holds this build"
|
|
776
927
|
: null;
|
|
@@ -805,8 +956,8 @@ export async function runSubmission(
|
|
|
805
956
|
const paperDir = resolve(deps.cwd, a.paperDir);
|
|
806
957
|
const t = submissionTarget(paperDir, deps.presets);
|
|
807
958
|
if (!t.ok) return (deps.err(t.error), 2);
|
|
808
|
-
const
|
|
809
|
-
if (!
|
|
959
|
+
const ready = readyFor(a.sub, t.value);
|
|
960
|
+
if (!ready.ok) return (deps.err(ready.error), 2);
|
|
810
961
|
const site = deps.site(t.value.portal);
|
|
811
962
|
const found = lookUpToken(tokenNames(site), deps.env);
|
|
812
963
|
if (!found.ok) return tokenMissing(t.value.portal, site, found.error, deps);
|
|
@@ -817,9 +968,11 @@ export async function runSubmission(
|
|
|
817
968
|
portalSite: site,
|
|
818
969
|
refused: (f) => tokenRefusedLines(t.value.portal, site, f),
|
|
819
970
|
};
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
971
|
+
const sending = { ...t.value, fields: ready.value.fields };
|
|
972
|
+
if (a.sub === "topics") return runTopics(sending, portal, withToken);
|
|
973
|
+
if (ready.value.id === null)
|
|
974
|
+
return runNew(sending, portal, { ...a, pdfPath, paperDir }, withToken);
|
|
975
|
+
const target = { ...sending, id: ready.value.id };
|
|
823
976
|
return a.sub === "show"
|
|
824
977
|
? runShow(target, portal, pdfPath, withToken)
|
|
825
978
|
: runUpdate(target, portal, { ...a, pdfPath }, withToken);
|
|
@@ -54,6 +54,27 @@ test("a venue without the link to its call for papers is refused at load, saying
|
|
|
54
54
|
);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
test("🔴 submission_fields naming a key the submission object carries itself is refused, naming it", () => {
|
|
58
|
+
// Guards: a field `submission` spread over the PDF's `content_file` would drop the upload.
|
|
59
|
+
const preset = (key: string) => ({
|
|
60
|
+
type: "venue",
|
|
61
|
+
name: "X",
|
|
62
|
+
url: "https://example.org/cfp",
|
|
63
|
+
extends: "paperlint:acm-sigconf",
|
|
64
|
+
submission_fields: {
|
|
65
|
+
source: "form",
|
|
66
|
+
url: "https://example.org/paper/new",
|
|
67
|
+
read: "2026-10-20",
|
|
68
|
+
fields: { [key]: { kind: "text" } },
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
assert.equal(
|
|
72
|
+
refusalOf("v.jsonc", preset("submission")),
|
|
73
|
+
'v.jsonc: "submission_fields" names "submission", which paperlint sends itself (object, pid, title, abstract, authors, submission, status) — a field of that name would overwrite it',
|
|
74
|
+
);
|
|
75
|
+
assert.equal(refusalOf("v.jsonc", preset("topics")), "");
|
|
76
|
+
});
|
|
77
|
+
|
|
57
78
|
test("a template family with a venue's name and url is refused, naming each key", () => {
|
|
58
79
|
assert.equal(
|
|
59
80
|
refusalOf("f.jsonc", {
|
package/src/tex-requirements.ts
CHANGED
|
@@ -36,6 +36,12 @@ import {
|
|
|
36
36
|
} from "./domain/talk.ts";
|
|
37
37
|
import type { VenuePortal } from "./domain/submission.ts";
|
|
38
38
|
import type { Reading } from "./domain/deadline.ts";
|
|
39
|
+
import {
|
|
40
|
+
RESERVED_FIELD_KEYS,
|
|
41
|
+
reservedKey,
|
|
42
|
+
type FieldKind,
|
|
43
|
+
type VenueFields,
|
|
44
|
+
} from "./domain/submission-fields.ts";
|
|
39
45
|
|
|
40
46
|
/** CTAN package name → the names that prove it is installed. */
|
|
41
47
|
export type PackageProofs = Readonly<Record<string, readonly string[]>>;
|
|
@@ -222,6 +228,8 @@ export interface PresetFile {
|
|
|
222
228
|
readonly portal: VenuePortal | null;
|
|
223
229
|
/** The edition's deadlines as they were read; null when the file names none (a child's list replaces its parent's). */
|
|
224
230
|
readonly deadlines: readonly Reading[] | null;
|
|
231
|
+
/** The fields the edition's submission form asks, as read; null when the file records none. */
|
|
232
|
+
readonly submissionFields: VenueFields | null;
|
|
225
233
|
}
|
|
226
234
|
|
|
227
235
|
type KindsJson = Readonly<
|
|
@@ -280,6 +288,54 @@ interface PresetBodyJson {
|
|
|
280
288
|
readonly talk?: VenueTalkJson;
|
|
281
289
|
readonly portal?: VenuePortal;
|
|
282
290
|
readonly deadlines?: readonly Reading[];
|
|
291
|
+
readonly submission_fields?: SubmissionFieldsJson;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** A preset's `submission_fields` after the schema accepted it. */
|
|
295
|
+
interface SubmissionFieldsJson {
|
|
296
|
+
readonly source: VenueFields["source"];
|
|
297
|
+
readonly url: string;
|
|
298
|
+
readonly read: string;
|
|
299
|
+
readonly fields: Readonly<
|
|
300
|
+
Record<
|
|
301
|
+
string,
|
|
302
|
+
{
|
|
303
|
+
readonly kind: FieldKind;
|
|
304
|
+
readonly required?: boolean;
|
|
305
|
+
readonly label?: string;
|
|
306
|
+
readonly options?: readonly string[];
|
|
307
|
+
}
|
|
308
|
+
>
|
|
309
|
+
>;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** A preset's `submission_fields`, or null when it records none; a reserved field name is refused. */
|
|
313
|
+
function submissionFieldsOf(
|
|
314
|
+
j: SubmissionFieldsJson | undefined,
|
|
315
|
+
file: string,
|
|
316
|
+
): VenueFields | null {
|
|
317
|
+
if (j === undefined) return null;
|
|
318
|
+
const reserved = reservedKey(Object.keys(j.fields));
|
|
319
|
+
if (reserved !== null)
|
|
320
|
+
throw new Error(
|
|
321
|
+
`${file}: "submission_fields" names "${reserved}", which paperlint sends itself (${RESERVED_FIELD_KEYS.join(", ")}) — a field of that name would overwrite it`,
|
|
322
|
+
);
|
|
323
|
+
return {
|
|
324
|
+
source: j.source,
|
|
325
|
+
url: j.url,
|
|
326
|
+
read: j.read,
|
|
327
|
+
fields: Object.fromEntries(
|
|
328
|
+
Object.entries(j.fields).map(([key, f]) => [
|
|
329
|
+
key,
|
|
330
|
+
{
|
|
331
|
+
kind: f.kind,
|
|
332
|
+
required: f.required ?? false,
|
|
333
|
+
label: f.label ?? null,
|
|
334
|
+
options: f.options ?? [],
|
|
335
|
+
},
|
|
336
|
+
]),
|
|
337
|
+
),
|
|
338
|
+
};
|
|
283
339
|
}
|
|
284
340
|
|
|
285
341
|
/** An optional field as the typed preset holds it: absent is null. */
|
|
@@ -424,6 +480,16 @@ function deadlinesOf(
|
|
|
424
480
|
}));
|
|
425
481
|
}
|
|
426
482
|
|
|
483
|
+
/** A preset's blocks about its portal: where, the deadlines read there, the form's own fields. */
|
|
484
|
+
const portalBlocks = (
|
|
485
|
+
j: PresetJson,
|
|
486
|
+
file: string,
|
|
487
|
+
): Pick<PresetFile, "portal" | "deadlines" | "submissionFields"> => ({
|
|
488
|
+
portal: portalOf(j.portal),
|
|
489
|
+
deadlines: deadlinesOf(j.deadlines, file),
|
|
490
|
+
submissionFields: submissionFieldsOf(j.submission_fields, file),
|
|
491
|
+
});
|
|
492
|
+
|
|
427
493
|
/**
|
|
428
494
|
* The text of one preset file → the typed file, or an Error naming every problem. The ONE parser
|
|
429
495
|
* of a preset: the toolchain reads its `tex`, the venue rules its `format`, the config its `rules`.
|
|
@@ -458,8 +524,7 @@ export function parsePreset(
|
|
|
458
524
|
rules: j.rules ?? {},
|
|
459
525
|
registerAnchors: anchorsOf(j.register),
|
|
460
526
|
talk: j.talk === undefined ? null : venueTalkOf(j.talk),
|
|
461
|
-
|
|
462
|
-
deadlines: deadlinesOf(j.deadlines, file),
|
|
527
|
+
...portalBlocks(j, file),
|
|
463
528
|
};
|
|
464
529
|
}
|
|
465
530
|
|