paperlint 10.4.1 → 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.
Files changed (131) hide show
  1. package/.github/workflows/deadlines.yml +27 -7
  2. package/README.md +16 -15
  3. package/dist/adapters/hotcrp/index.d.ts +6 -4
  4. package/dist/adapters/hotcrp/index.d.ts.map +1 -1
  5. package/dist/adapters/hotcrp/index.js +6 -4
  6. package/dist/adapters/hotcrp/index.js.map +1 -1
  7. package/dist/adapters/hotcrp/portal.io.d.ts +3 -1
  8. package/dist/adapters/hotcrp/portal.io.d.ts.map +1 -1
  9. package/dist/adapters/hotcrp/portal.io.js +36 -11
  10. package/dist/adapters/hotcrp/portal.io.js.map +1 -1
  11. package/dist/adapters/hotcrp/response.d.ts +6 -1
  12. package/dist/adapters/hotcrp/response.d.ts.map +1 -1
  13. package/dist/adapters/hotcrp/response.js +17 -0
  14. package/dist/adapters/hotcrp/response.js.map +1 -1
  15. package/dist/adapters/hotcrp/site.d.ts +9 -0
  16. package/dist/adapters/hotcrp/site.d.ts.map +1 -0
  17. package/dist/adapters/hotcrp/site.js +22 -0
  18. package/dist/adapters/hotcrp/site.js.map +1 -0
  19. package/dist/adapters/hotcrp/token.d.ts +13 -0
  20. package/dist/adapters/hotcrp/token.d.ts.map +1 -0
  21. package/dist/adapters/hotcrp/token.js +48 -0
  22. package/dist/adapters/hotcrp/token.js.map +1 -0
  23. package/dist/adapters/latex/abstract.d.ts +22 -0
  24. package/dist/adapters/latex/abstract.d.ts.map +1 -0
  25. package/dist/adapters/latex/abstract.js +187 -0
  26. package/dist/adapters/latex/abstract.js.map +1 -0
  27. package/dist/adapters/latex/headings.d.ts +6 -0
  28. package/dist/adapters/latex/headings.d.ts.map +1 -1
  29. package/dist/adapters/latex/headings.js +5 -4
  30. package/dist/adapters/latex/headings.js.map +1 -1
  31. package/dist/adapters/latex/index.d.ts.map +1 -1
  32. package/dist/adapters/latex/index.js +2 -0
  33. package/dist/adapters/latex/index.js.map +1 -1
  34. package/dist/adapters/latex/parse.d.ts +15 -0
  35. package/dist/adapters/latex/parse.d.ts.map +1 -1
  36. package/dist/adapters/latex/parse.js +58 -1
  37. package/dist/adapters/latex/parse.js.map +1 -1
  38. package/dist/cli.d.ts.map +1 -1
  39. package/dist/cli.js +20 -8
  40. package/dist/cli.js.map +1 -1
  41. package/dist/cycle-rules.d.ts.map +1 -1
  42. package/dist/cycle-rules.js +3 -4
  43. package/dist/cycle-rules.js.map +1 -1
  44. package/dist/domain/deadline.d.ts +8 -1
  45. package/dist/domain/deadline.d.ts.map +1 -1
  46. package/dist/domain/deadline.js +26 -12
  47. package/dist/domain/deadline.js.map +1 -1
  48. package/dist/domain/portal-token.d.ts +44 -0
  49. package/dist/domain/portal-token.d.ts.map +1 -0
  50. package/dist/domain/portal-token.js +32 -0
  51. package/dist/domain/portal-token.js.map +1 -0
  52. package/dist/domain/submission.d.ts +30 -0
  53. package/dist/domain/submission.d.ts.map +1 -1
  54. package/dist/domain/submission.js +68 -0
  55. package/dist/domain/submission.js.map +1 -1
  56. package/dist/lib/paper-config.d.ts +1 -1
  57. package/dist/lib/paper-config.d.ts.map +1 -1
  58. package/dist/lib/paper-config.js +6 -4
  59. package/dist/lib/paper-config.js.map +1 -1
  60. package/dist/paper-settings.d.ts +3 -1
  61. package/dist/paper-settings.d.ts.map +1 -1
  62. package/dist/paper-settings.js +11 -5
  63. package/dist/paper-settings.js.map +1 -1
  64. package/dist/ports/latex.d.ts +23 -0
  65. package/dist/ports/latex.d.ts.map +1 -1
  66. package/dist/ports/submission-portal.d.ts +39 -2
  67. package/dist/ports/submission-portal.d.ts.map +1 -1
  68. package/dist/ports/venue-deadlines.d.ts +1 -1
  69. package/dist/submission.d.ts +49 -11
  70. package/dist/submission.d.ts.map +1 -1
  71. package/dist/submission.js +332 -44
  72. package/dist/submission.js.map +1 -1
  73. package/docs/configuration.md +2 -1
  74. package/docs/rules/cycle/record.md +9 -6
  75. package/docs/submission.md +47 -48
  76. package/eslint.config.mjs +3 -0
  77. package/lib/paper-config.ts +7 -4
  78. package/package.json +6 -1
  79. package/presets/msr.jsonc +9 -7
  80. package/presets/msr.md +14 -10
  81. package/scripts/check.ts +1 -1
  82. package/scripts/refresh-deadlines/compare.test.ts +75 -0
  83. package/scripts/refresh-deadlines/compare.ts +101 -0
  84. package/scripts/refresh-deadlines/main.test.ts +140 -0
  85. package/scripts/refresh-deadlines/main.ts +56 -0
  86. package/scripts/refresh-deadlines/refresh.test.ts +287 -0
  87. package/scripts/refresh-deadlines/refresh.ts +183 -0
  88. package/skills/submission-portal/SKILL.md +60 -44
  89. package/skills/submission-portal/SKILL.md.spec.ts +62 -43
  90. package/skills/submission-portal/submission-portal.test.ts +4 -73
  91. package/src/adapters/hotcrp/cassette/README.md +4 -4
  92. package/src/adapters/hotcrp/cassette/deadlines-msr2027.html +9 -9
  93. package/src/adapters/hotcrp/deadlines.io.test.ts +2 -2
  94. package/src/adapters/hotcrp/deadlines.test.ts +3 -3
  95. package/src/adapters/hotcrp/index.ts +15 -3
  96. package/src/adapters/hotcrp/portal.io.ts +61 -17
  97. package/src/adapters/hotcrp/portal.test.ts +105 -1
  98. package/src/adapters/hotcrp/response.ts +21 -0
  99. package/src/adapters/hotcrp/site.ts +34 -0
  100. package/src/adapters/hotcrp/token.test.ts +69 -0
  101. package/src/adapters/hotcrp/token.ts +51 -0
  102. package/src/adapters/latex/abstract.test.ts +280 -0
  103. package/src/adapters/latex/abstract.ts +272 -0
  104. package/src/adapters/latex/headings.ts +5 -4
  105. package/src/adapters/latex/index.test.ts +31 -1
  106. package/src/adapters/latex/index.ts +2 -0
  107. package/src/adapters/latex/parse.ts +64 -3
  108. package/src/cli.test.ts +20 -8
  109. package/src/cli.ts +22 -13
  110. package/src/cycle-rules.test.ts +6 -6
  111. package/src/cycle-rules.ts +7 -4
  112. package/src/domain/deadline.test.ts +31 -2
  113. package/src/domain/deadline.ts +42 -16
  114. package/src/domain/portal-token.test.ts +55 -0
  115. package/src/domain/portal-token.ts +63 -0
  116. package/src/domain/submission.test.ts +88 -0
  117. package/src/domain/submission.ts +99 -0
  118. package/src/new-paper.test.ts +1 -0
  119. package/src/paper-settings.test.ts +20 -0
  120. package/src/paper-settings.ts +17 -5
  121. package/src/ports/latex.ts +25 -0
  122. package/src/ports/submission-portal.ts +43 -1
  123. package/src/ports/venue-deadlines.ts +1 -1
  124. package/src/presets.test.ts +8 -5
  125. package/src/submission-new.test.ts +569 -0
  126. package/src/submission-show.test.ts +210 -0
  127. package/src/submission-update.test.ts +160 -0
  128. package/src/submission.test.ts +140 -518
  129. package/src/submission.ts +494 -61
  130. package/scripts/refresh-deadlines.test.ts +0 -431
  131. package/scripts/refresh-deadlines.ts +0 -387
@@ -4,68 +4,87 @@ import { experimental_skill } from "vigiles/spec";
4
4
  export default experimental_skill({
5
5
  name: "submission-portal",
6
6
  description:
7
- "Read or change a paper's submission on the venue's HotCRP portal via `paperlint submission show|update`: replace the PDF or abstract, mark it submitted, check it is really \"Submitted\", and verify by sha256 that the portal holds the latest build. Use when the PDF or abstract on HotCRP needs replacing, when asked whether the paper is truly submitted or which PDF reviewers see, or before a deadline. Composes with submit-paper (what to submit); this gets it onto the portal and proves it landed.",
7
+ "Create, read or change a paper's submission on the venue's HotCRP portal via `paperlint submission new|show|update`: register the paper, replace the PDF or abstract, mark it submitted, and verify by sha256 that the portal holds the latest build. Use when the paper is not yet on HotCRP, when its PDF or abstract there needs replacing, when asked whether it is truly submitted, or before a deadline. Composes with submit-paper (what to submit).",
8
8
  tools: ["Read", "Grep", "Glob", "Bash"],
9
+ // The `npx paperlint submission …` commands in the body are not verified at compile time until
10
+ // zernie/vigiles#395 (cmd() for the package's own CLI): keep them in step with src/cli.ts by hand.
11
+ // Do not re-add a test that re-parses the compiled SKILL.md — that check belongs in vigiles.
9
12
  body: `
10
- # submission-portal — read and update the submission on the portal, then prove it landed
13
+ # submission-portal — create, read and update the submission on the portal, then prove it landed
11
14
 
12
- \`paperlint submission\` talks to the venue's submission portal through its API. Only HotCRP is
13
- supported; for any other portal the command says so and the upload is done by hand.
15
+ \`paperlint submission\` creates, reads and updates the paper's submission on the venue's portal.
16
+ Only HotCRP is supported; for any other portal the command says so and the upload is done by hand.
17
+
18
+ ## What the command does, and what stays on the site
19
+
20
+ \`submission new\` creates the submission with the title, the abstract, the authors and the PDF.
21
+ Everything else — conflicts, topics, the venue's own fields — is done on the site by the author: the
22
+ command prints that list, with the submission's page, after every run.
23
+
24
+ ## Token
25
+
26
+ One variable per site: \`HOTCRP_TOKEN_MSR2027\` for \`msr2027.hotcrp.com\`; for a site not on
27
+ hotcrp.com, its whole host (with a port other than the default) and path. A plain \`HOTCRP_TOKEN\` is not read. When the command reports
28
+ a missing, misnamed or refused token, **relay its message to the author verbatim and stop**: it
29
+ already says where on the site to make one, which permissions to tick, and which variable to set.
30
+ 🔴 Never ask for the token in chat, never print it, never write it to a file — the author sets it in
31
+ their own environment.
14
32
 
15
33
  ## What must be in place
16
34
 
17
35
  - **The venue preset declares the portal**: \`"portal": { "kind": "hotcrp", "url": "<the site>" }\`
18
- (\`paperlint:aidc\` and \`paperlint:msr\` have it). For your own venue preset, add it there.
19
- - **The paper's \`paperlint.json\` names its submission**: \`"submission": { "id": <number> }\` — the
20
- number HotCRP shows for the paper.
21
- - **A token in \`HOTCRP_TOKEN\`**, created by the author on the HotCRP site under Account settings →
22
- Developer (scopes \`submeta:read\` and \`document:read\` to read; \`submeta:write\` and
23
- \`document:write\` to update). Never ask for it in chat, never print it, never write it to a file:
24
- the command reads it from the environment and sends it only as the \`Authorization\` header.
36
+ (\`paperlint:aidc\` and \`paperlint:msr\` have it).
37
+ - **For \`new\`: \`"authors"\` in the paper's \`paperlint.json\`** — \`[{ "name", "email",
38
+ "affiliation" }]\`, every author in the paper's order. The author writes this list; never fill it
39
+ in from the paper, a profile or memory.
40
+ - **For \`show\` and \`update\`: \`"submission": { "id": <number> }\`** — \`new --save\` writes it.
25
41
 
26
- A missing key or token stops the command with exit 2 and a line naming the file and key, or the
27
- variable — fix exactly that and run again.
42
+ A missing key or token stops the command with exit 2 and a line naming what to set — fix exactly that.
28
43
 
29
44
  ## The procedure — always in this order
30
45
 
31
- 1. **Read it.** \`npx paperlint submission show papers/<paper>\` — status, title, type, topics,
32
- abstract length, the PDF the portal holds, and \`match\`: whether that PDF's sha256 equals the
33
- local \`paper.pdf\`. Rebuild first (\`npx paperlint build papers/<paper>\`) if the local PDF is stale.
34
- 2. **Dry run.** \`npx paperlint submission update papers/<paper>\` — sends the PDF (add
35
- \`--abstract <file>\` for a plain-text abstract, \`--submit\` to mark it submitted) with HotCRP's
36
- \`dry_run=1\`: the portal checks the change and **keeps nothing**. Read \`valid\`, \`changes\` and every
37
- \`portal says:\` line. Exit 1 means the portal would refuse it — report why, do not go on.
38
- 3. **Ask the author.** Show the dry run's output and ask for an explicit yes to change the real
39
- submission.
40
- 4. **Save — only after that yes.** \`npx paperlint submission update papers/<paper> --save\` (same
41
- flags as the dry run). 🔴 \`--save\` CHANGES A REAL SUBMISSION that reviewers will see. Never run it
42
- without the author's explicit yes in this conversation, never "to test", and never because a
43
- dry run was valid.
44
- 5. **Prove it landed.** \`npx paperlint submission show papers/<paper>\` again: \`match\` must read
45
- \`yes — the portal holds this build\`, and the status must be what was intended (\`submitted\` if
46
- \`--submit\` was sent). Anything else is not done — say so.
47
-
48
- The same five steps as commands, in order (\`<paper>\` is the paper's folder):
46
+ \`papers/my-paper\` stands for the paper's folder.
47
+
48
+ 0. **Create it, once — only while the paper has no \`submission\` id.**
49
+ \`npx paperlint submission new papers/my-paper\` is a dry run: the portal checks it and keeps
50
+ nothing. It prints the title, the authors and the whole abstract (read from the source's
51
+ \`\\begin{abstract}\`). Read them with the author; an abstract the command cannot read, or one
52
+ that reads wrong, is given as a plain-text file with \`--abstract <file>\`.
53
+ 1. **Ask the author** for an explicit yes, then \`npx paperlint submission new papers/my-paper --save\`,
54
+ and do the printed "on the site" list with the author.
55
+ 2. **Read it.** \`npx paperlint submission show papers/my-paper\` — status, the PDF the portal holds,
56
+ and \`match\`: whether it is the local build. Rebuild first if the local PDF is stale.
57
+ 3. **Dry run the change.** \`npx paperlint submission update papers/my-paper\` (\`--abstract <file>\`,
58
+ \`--submit\` to mark it submitted). Exit 1 means the portal would refuse it — report why, stop.
59
+ 4. **Ask the author** for an explicit yes, then the same command with \`--save\`.
60
+ 5. **Prove it landed.** \`show\` again: \`match\` must read \`yes — the portal holds this build\`, and
61
+ the status must be what was intended. Anything else is not done — say so.
62
+
63
+ 🔴 \`--save\` creates or changes a real submission that reviewers will see: never without the
64
+ author's yes in this conversation, never "to test", never because a dry run passed.
49
65
 
50
66
  \`\`\`bash
51
- npx paperlint submission show papers/<paper>
52
- npx paperlint submission update papers/<paper>
67
+ npx paperlint build papers/my-paper
68
+ # only while the paper has no submission id:
69
+ npx paperlint submission new papers/my-paper
70
+ # … the author reads the dry run and says yes …
71
+ npx paperlint submission new papers/my-paper --save
72
+ npx paperlint submission show papers/my-paper
73
+ npx paperlint submission update papers/my-paper --submit
53
74
  # … the author reads the dry run and says yes …
54
- npx paperlint submission update papers/<paper> --save
55
- npx paperlint submission show papers/<paper>
75
+ npx paperlint submission update papers/my-paper --submit --save
76
+ npx paperlint submission show papers/my-paper
56
77
  \`\`\`
57
78
 
58
79
  ## Reading the answers
59
80
 
60
- - \`match yes\` is the only proof the reviewers get the local build; a status of \`submitted\` alone
61
- says nothing about WHICH PDF.
62
- - \`the portal refused (HTTP 401): …\` — the token is missing, wrong or expired; \`403\` — the token
63
- lacks the scope, or the deadline has passed for this change. The text after the colon is
64
- HotCRP's own; quote it.
65
- - \`SAVE WITHHELD by the portal\` — HotCRP did not save; nothing changed.
81
+ - \`match yes\` is the only proof the reviewers get the local build; \`submitted\` alone says nothing
82
+ about WHICH PDF.
83
+ - \`portal says: …\` lines are the portal's own words; quote them.
84
+ - \`SAVE WITHHELD\` or \`NOTHING CREATED\` — the portal saved nothing.
66
85
 
67
86
  ## What this skill does not do
68
87
 
69
- It does not decide whether the paper is ready (that is \`harden-paper\`), anonymize it, or fill the
70
- submission form's other fields (authors, conflicts) — see \`submit-paper\`.`,
88
+ It does not decide whether the paper is ready (\`harden-paper\`), anonymize it, or set the portal's
89
+ other fields — see \`submit-paper\`.`,
71
90
  });
@@ -1,80 +1,11 @@
1
1
  /**
2
- * submission-portal — the free tier: the skill's commands are commands the CLI accepts, in the
3
- * order the skill promises, and the skill passes the shared per-skill checks. No model, no network,
4
- * no portal.
5
- *
6
- * 🔴 WHY THE COMMANDS ARE PARSED, NOT GREPPED: a skill that tells the agent to run a flag the CLI
7
- * refuses fails at the worst moment — before a deadline, on a real submission. So every
8
- * `paperlint submission …` the skill writes goes through the CLI's own argument parser and its
9
- * usage check, and must come out runnable.
2
+ * submission-portal — the shared per-skill checks (frontmatter, tool contract, pipeline wiring, own
3
+ * identity). Not checked here: that the skill's `npx paperlint submission …` commands are ones the
4
+ * CLI runs — that belongs in vigiles (zernie/vigiles#395) — nor that the skill fires
5
+ * (`submission-portal.eval.mjs`).
10
6
  */
11
- import assert from "node:assert/strict";
12
- import { readFileSync } from "node:fs";
13
- import { dirname, join } from "node:path";
14
- import { fileURLToPath } from "node:url";
15
7
  import { test } from "vitest";
16
- import { commandsIn } from "vigiles";
17
8
  import { checkSkill } from "../../lib/skill-checks.mjs";
18
- import { parseArgs } from "../../src/cli.ts";
19
- import { usageProblem } from "../../src/submission.ts";
20
-
21
- const SKILL = readFileSync(
22
- join(dirname(fileURLToPath(import.meta.url)), "SKILL.md"),
23
- "utf8",
24
- );
25
- const commands = commandsIn(
26
- SKILL,
27
- /(^|[|;&]\s*)npx paperlint submission\b/,
28
- ).map((c) => c.text);
29
-
30
- /** A command as the CLI receives it: the words after `npx paperlint`. */
31
- const argvOf = (command: string): string[] =>
32
- command
33
- .replace(/^.*?npx paperlint /, "")
34
- .split(/\s+/)
35
- .filter(Boolean);
36
-
37
- test("the skill names show, a dry run and a save — and nothing else", () => {
38
- const kinds = commands.map((c) => {
39
- const a = parseArgs(argvOf(c));
40
- return `${String(a.paths[0])}${a.save ? " --save" : ""}`;
41
- });
42
- assert.deepEqual([...new Set(kinds)], ["show", "update", "update --save"]);
43
- });
44
-
45
- test("every `paperlint submission` command in the skill is one the CLI runs", () => {
46
- assert.ok(
47
- commands.length > 0,
48
- "the skill names no `paperlint submission` command",
49
- );
50
- for (const c of commands) {
51
- const a = parseArgs(argvOf(c));
52
- assert.equal(a.cmd, "submission", c);
53
- assert.equal(a.unknownFlag, undefined, c);
54
- assert.equal(a.missingValue, undefined, c);
55
- const problem = usageProblem({
56
- sub: a.paths[0],
57
- paperDir: a.paths[1] ?? ".",
58
- extra: a.paths.slice(2),
59
- pdf: a.pdf,
60
- abstract: a.abstract,
61
- submit: a.submit,
62
- save: a.save,
63
- });
64
- assert.equal(problem, null, c);
65
- }
66
- });
67
-
68
- test("🔴 the procedure reads, dry-runs, then saves, then reads again — never saves first", () => {
69
- const order = commands.map((c) => {
70
- const a = parseArgs(argvOf(c));
71
- return a.paths[0] === "show" ? "show" : a.save ? "save" : "dry";
72
- });
73
- const firstSave = order.indexOf("save");
74
- assert.ok(firstSave > order.indexOf("dry"), order.join(" → "));
75
- assert.ok(order.indexOf("dry") > order.indexOf("show"), order.join(" → "));
76
- assert.ok(order.lastIndexOf("show") > firstSave, order.join(" → "));
77
- });
78
9
 
79
10
  test("the shared per-skill checks pass", async () => {
80
11
  await checkSkill("submission-portal");
@@ -19,19 +19,19 @@ verbatim.
19
19
  | `post-ok.json` | the same with the part and `content_file` named `paperlint_pdf` | 200 |
20
20
 
21
21
  To re-record: run the same four requests with `curl` against a HotCRP site where you have a
22
- submission and a token (`Authorization: bearer $HOTCRP_TOKEN`), always with `dry_run=1` on a `POST`,
22
+ submission and a token (`Authorization: bearer $HOTCRP_TOKEN_<SITE>`), always with `dry_run=1` on a `POST`,
23
23
  save each body, then replace every identifying value with a fake of the same type before committing.
24
24
  These files ship in the npm package with the rest of `src/`, like the tests beside them.
25
25
 
26
26
  ## The deadlines pages
27
27
 
28
28
  `deadlines-<site>.html` are five live sites' `GET /deadlines` pages and one site that does not exist,
29
- recorded whole on 2026-10-05 (`curl`, no account): the page is public and carries no personal data,
29
+ recorded whole on 2026-10-05 (`curl`, no account; `deadlines-msr2027.html` re-recorded 2026-10-19 18:27 UTC, when the portal had moved both its deadlines — #235): the page is public and carries no personal data,
30
30
  so nothing is replaced. `deadlines.test.ts` parses each through the adapter's real parser; the
31
31
  expected values were read off the pages' text by hand first. `/api/deadlines` on the same sites
32
32
  answers 401 «Missing credentials», so the HTML page is the source.
33
- `scripts/refresh-deadlines.test.ts` replays the MSR and AIDC pages through the scheduled refresh of
34
- the presets' `deadlines` (`.github/workflows/deadlines.yml`). Re-read live through the adapter at
33
+ `scripts/refresh-deadlines/main.test.ts` replays the MSR page through `npm run refresh-deadlines`, the
34
+ scheduled refresh of the presets' `deadlines` (`.github/workflows/deadlines.yml`). Re-read live through the adapter at
35
35
  14:38 UTC the same day, both pages differed from these files only in the script nonces and `now`.
36
36
 
37
37
  | file | site | HTTP | what it shows |
@@ -5,7 +5,7 @@
5
5
  <meta name="google" content="notranslate">
6
6
  <meta name="theme-color" content="#ffb7ca">
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1">
8
- <link href="https://static.hotcrp.com/@1790985506/stylesheets/style.css" rel="stylesheet">
8
+ <link href="https://static.hotcrp.com/@1792005925/stylesheets/style.css" rel="stylesheet">
9
9
  <link href="https://static.hotcrp.com/@1786633883/stylesheets/mobile.css" media="screen and (max-width: 1100px)" rel="stylesheet">
10
10
  <link href="https://static.hotcrp.com/@1788721683/stylesheets/plugin.css" rel="stylesheet">
11
11
  <link rel="icon" type="image/svg+xml" href="https://static.hotcrp.com/images/review48.svg">
@@ -14,12 +14,12 @@
14
14
  <body id="m-deadlines" data-upload-limit="104857600" data-document-max-size="600000000"><div id="p-page" class="need-banner-offset"><header id="p-header"><hr class="c"><div id="h-site" class="header-site-page"><a class="q" href="/"><span class="header-site-name">MSR 2027</span></a></div><div id="h-right"></div><div id="h-page"><h1 id="h-title">Deadlines</h1></div><nav id="p-quicklinks" aria-label="Submission search"></nav><hr class="c">
15
15
  <div id="h-messages" class="want-mx-auto"></div></header>
16
16
  <main id="p-body">
17
- <h3>Upcoming</h3><dl class="bsp"><dt><strong>Submission deadline</strong>: <span class="need-usertime" data-ts="1792468800">Tuesday Oct 20, 2026, 12 AM EDT</span></dt><dd>Submissions must be completed by this deadline to be reviewed.</dd>
18
- <dt><strong>Resubmission deadline</strong>: <span class="need-usertime" data-ts="1792728000">Friday Oct 23, 2026, 12 AM EDT</span></dt><dd>Completed submissions may be updated until this deadline.</dd></dl>
19
- <hr class="c"></main></div><footer id="p-footer" class="need-banner-offset banner-bottom"><a class="noq" href="https://hotcrp.com/">HotCRP</a><!-- Version 3.4 --></footer><!--[if lt IE 9]><script nonce="8XgRxNPYP5XebPvVSL6MyA==" src="https://static.hotcrp.com/@1749780400/scripts/jquery-1.12.4.min.js" crossorigin="anonymous" integrity="sha256-ZosEbRLbNQzLpnKIkEdrPv7lOy9C27hHQ+Xp8a4MxAQ="></script><![endif]-->
20
- <![if !IE|gte IE 9]><script nonce="8XgRxNPYP5XebPvVSL6MyA==" src="https://static.hotcrp.com/@1749780400/scripts/jquery-3.7.1.min.js" crossorigin="anonymous" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo="></script><![endif]>
21
- <script nonce="8XgRxNPYP5XebPvVSL6MyA==">window.siteinfo={"site_relative":"","base":"\/","suffix":"","assets":"https:\/\/static.hotcrp.com\/","icons_url":"@1787797517\/images\/icons.svg","cookie_params":"; Domain=.hotcrp.com; Secure; SameSite=Lax","postvalue":".empty","download_prefix":"msr2027-","snouns":["submission","submissions","Submission","Submissions"],"user":[]}</script><script nonce="8XgRxNPYP5XebPvVSL6MyA==" src="https://static.hotcrp.com/@1790985505/scripts/script.js" crossorigin="anonymous"></script>
22
- <script nonce="8XgRxNPYP5XebPvVSL6MyA==" src="https://static.hotcrp.com/@1777496734/scripts/markdown-it.min.js" crossorigin="anonymous"></script>
23
- <script nonce="8XgRxNPYP5XebPvVSL6MyA==" src="https://static.hotcrp.com/@1787955704/scripts/plugin.js" crossorigin="anonymous"></script>
24
- <script nonce="8XgRxNPYP5XebPvVSL6MyA==">hotcrp.onload.time(240,0);hotcrp.init_deadlines({"now":1791194488.329003,"email":null,"sub":{"open":true,"sub":1792468800,"blind":true}})</script></body>
17
+ <h3>Upcoming</h3><dl class="bsp"><dt><strong>Submission deadline</strong>: <span class="need-usertime" data-ts="1792583999">Wednesday Oct 21, 2026, 7:59:59 AM EDT</span></dt><dd>Submissions must be completed by this deadline to be reviewed.</dd>
18
+ <dt><strong>Resubmission deadline</strong>: <span class="need-usertime" data-ts="1792843199">Saturday Oct 24, 2026, 7:59:59 AM EDT</span></dt><dd>Completed submissions may be updated until this deadline.</dd></dl>
19
+ <hr class="c"></main></div><footer id="p-footer" class="need-banner-offset banner-bottom"><a class="noq" href="https://hotcrp.com/">HotCRP</a><!-- Version 3.4 --></footer><!--[if lt IE 9]><script nonce="ecy+ctcGvxeRVeuZdgbMbA==" src="https://static.hotcrp.com/@1749780400/scripts/jquery-1.12.4.min.js" crossorigin="anonymous" integrity="sha256-ZosEbRLbNQzLpnKIkEdrPv7lOy9C27hHQ+Xp8a4MxAQ="></script><![endif]-->
20
+ <![if !IE|gte IE 9]><script nonce="ecy+ctcGvxeRVeuZdgbMbA==" src="https://static.hotcrp.com/@1749780400/scripts/jquery-3.7.1.min.js" crossorigin="anonymous" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo="></script><![endif]>
21
+ <script nonce="ecy+ctcGvxeRVeuZdgbMbA==">window.siteinfo={"site_relative":"","base":"\/","suffix":"","assets":"https:\/\/static.hotcrp.com\/","icons_url":"@1787797517\/images\/icons.svg","cookie_params":"; Domain=.hotcrp.com; Secure; SameSite=Lax","postvalue":".empty","download_prefix":"msr2027-","snouns":["submission","submissions","Submission","Submissions"],"user":[]}</script><script nonce="ecy+ctcGvxeRVeuZdgbMbA==" src="https://static.hotcrp.com/@1792005925/scripts/script.js" crossorigin="anonymous"></script>
22
+ <script nonce="ecy+ctcGvxeRVeuZdgbMbA==" src="https://static.hotcrp.com/@1777496734/scripts/markdown-it.min.js" crossorigin="anonymous"></script>
23
+ <script nonce="ecy+ctcGvxeRVeuZdgbMbA==" src="https://static.hotcrp.com/@1787955704/scripts/plugin.js" crossorigin="anonymous"></script>
24
+ <script nonce="ecy+ctcGvxeRVeuZdgbMbA==">hotcrp.onload.time(240,0);hotcrp.init_deadlines({"now":1792434476.311902,"email":null,"sub":{"open":true,"sub":1792583999,"blind":true}})</script></body>
25
25
  </html>
@@ -61,8 +61,8 @@ test("GET <site>/deadlines, no credential; the page parses to the venue's deadli
61
61
  assert.deepEqual(
62
62
  r.value.deadlines.map((d) => [d.what, d.at]),
63
63
  [
64
- ["submission", "2026-10-20T04:00:00Z"],
65
- ["resubmission", "2026-10-23T04:00:00Z"],
64
+ ["submission", "2026-10-21T11:59:59Z"],
65
+ ["resubmission", "2026-10-24T11:59:59Z"],
66
66
  ],
67
67
  );
68
68
  });
@@ -23,19 +23,19 @@ const read = (site: Site, status = 200) => {
23
23
  describe("parseDeadlinesPage", () => {
24
24
  it("a conference with a resubmission deadline: the list carries it, the status object does not", () => {
25
25
  expect(read("msr2027")).toEqual({
26
- now: 1791194488.329003,
26
+ now: 1792434476.311902,
27
27
  open: true,
28
28
  blind: true,
29
29
  deadlines: [
30
30
  {
31
31
  what: "submission",
32
32
  label: "Submission deadline",
33
- at: "2026-10-20T04:00:00Z",
33
+ at: "2026-10-21T11:59:59Z",
34
34
  },
35
35
  {
36
36
  what: "resubmission",
37
37
  label: "Resubmission deadline",
38
- at: "2026-10-23T04:00:00Z",
38
+ at: "2026-10-24T11:59:59Z",
39
39
  },
40
40
  ],
41
41
  });
@@ -1,13 +1,25 @@
1
1
  /**
2
- * HotCRP, as the `SubmissionPortal` port: its REST API read and written over `fetch`, its answers
3
- * parsed here. Everything that changes if HotCRP's API does lives in this folder.
2
+ * HotCRP, as the `PortalSite` and `SubmissionPortal` ports: its token variables and token page, its
3
+ * REST API read and written over `fetch`, its answers parsed here. Everything that changes if HotCRP's API does lives in this folder.
4
4
  */
5
5
  export {
6
6
  CONTENT_FIELD,
7
7
  changeObject,
8
+ createObject,
8
9
  hotcrpPortal,
9
10
  type HotcrpOptions,
10
11
  } from "./portal.io.ts";
11
- export { parseShow, parseUpdate, sha256OfHash } from "./response.ts";
12
+ export {
13
+ parseCreate,
14
+ parseShow,
15
+ parseUpdate,
16
+ sha256OfHash,
17
+ } from "./response.ts";
12
18
  export { hotcrpDeadlines } from "./deadlines.io.ts";
19
+ export { hotcrpSite } from "./site.ts";
20
+ export {
21
+ SITE_LESS_VARIABLE,
22
+ VARIABLE_PATTERN,
23
+ tokenVariable,
24
+ } from "./token.ts";
13
25
  export { parseDeadlinesPage, whatOf } from "./deadlines.ts";
@@ -9,17 +9,24 @@
9
9
  * (the documented form: `curl -F "json=<data.json" -F paper.pdf=@paper.pdf SITEURL/api/paper`).
10
10
  * Without `save` the request carries `dry_run=1`: HotCRP checks the change and keeps nothing.
11
11
  *
12
+ * Create: the same `POST`, with `p=new` and `"pid": "new"` in the object — «Setting `p=new` will
13
+ * create a new submission; the response will contain the chosen submission ID»
14
+ * (`devel/apidoc/submissions.md`, `post /paper`, in kohler/hotcrp). The object carries the title, the
15
+ * abstract, the authors and the PDF; nothing else is sent.
16
+ *
12
17
  * 🔴 THE TOKEN goes in the `Authorization` header and nowhere else: not into a URL, a message, or an
13
18
  * error. A failure is described by HTTP status, HotCRP's own messages, or the network error's text.
14
19
  */
15
20
  import { messageOf } from "../../domain/text.ts";
16
21
  import { err, type Result } from "../../domain/result.ts";
17
22
  import type {
23
+ Author,
24
+ NewSubmission,
18
25
  PortalFailure,
19
26
  SubmissionChange,
20
27
  } from "../../domain/submission.ts";
21
28
  import type { SubmissionPortal } from "../../ports/submission-portal.ts";
22
- import { parseShow, parseUpdate } from "./response.ts";
29
+ import { parseCreate, parseShow, parseUpdate } from "./response.ts";
23
30
 
24
31
  /**
25
32
  * The form field the PDF travels in, and the name the `json` object's `content_file` refers to it
@@ -87,14 +94,38 @@ export function changeObject(
87
94
  };
88
95
  }
89
96
 
90
- function formOf(id: number, c: SubmissionChange): Readonly<FormData> {
97
+ /** An author as HotCRP's submission object spells one (`Author::make_keyed` reads `name`). */
98
+ const authorObject = (a: Author): Readonly<Record<string, string>> => ({
99
+ name: a.name,
100
+ email: a.email,
101
+ ...(a.affiliation === null ? {} : { affiliation: a.affiliation }),
102
+ });
103
+
104
+ /** The submission object a creation sends: title, abstract, authors and the PDF's field. */
105
+ export function createObject(
106
+ p: NewSubmission,
107
+ ): Readonly<Record<string, unknown>> {
108
+ return {
109
+ object: "paper",
110
+ pid: "new",
111
+ title: p.title,
112
+ abstract: p.abstract,
113
+ authors: p.authors.map(authorObject),
114
+ submission: { content_file: CONTENT_FIELD },
115
+ };
116
+ }
117
+
118
+ function formOf(
119
+ object: Readonly<Record<string, unknown>>,
120
+ pdf: { readonly name: string; readonly bytes: Uint8Array } | null,
121
+ ): Readonly<FormData> {
91
122
  const form = new FormData();
92
- form.append("json", JSON.stringify(changeObject(id, c)));
93
- if (c.pdf !== null)
123
+ form.append("json", JSON.stringify(object));
124
+ if (pdf !== null)
94
125
  form.append(
95
126
  CONTENT_FIELD,
96
- new Blob([new Uint8Array(c.pdf.bytes)], { type: "application/pdf" }),
97
- c.pdf.name,
127
+ new Blob([new Uint8Array(pdf.bytes)], { type: "application/pdf" }),
128
+ pdf.name,
98
129
  );
99
130
  return form;
100
131
  }
@@ -104,6 +135,23 @@ const endpoint = (
104
135
  params: Readonly<Record<string, string>>,
105
136
  ): string => `${base}/api/paper?${new URLSearchParams(params).toString()}`;
106
137
 
138
+ /** What one `POST /api/paper` sends: which submission, its object, the PDF, and whether to save. */
139
+ interface Post {
140
+ readonly p: string;
141
+ readonly object: Readonly<Record<string, unknown>>;
142
+ readonly pdf: { readonly name: string; readonly bytes: Uint8Array } | null;
143
+ readonly save: boolean;
144
+ }
145
+
146
+ /** A `POST /api/paper` carrying the object and the PDF, a dry run unless saving. */
147
+ const post = (o: HotcrpOptions, w: Post): Promise<Answer> =>
148
+ call(
149
+ endpoint(o.url, { p: w.p, ...(w.save ? {} : { dry_run: "1" }) }),
150
+ { method: "POST", body: formOf(w.object, w.pdf) },
151
+ o.token,
152
+ WRITE_TIMEOUT_MS,
153
+ );
154
+
107
155
  /** HotCRP at `o.url`, read and changed with `o.token`. */
108
156
  export function hotcrpPortal(o: HotcrpOptions): SubmissionPortal {
109
157
  return {
@@ -113,18 +161,14 @@ export function hotcrpPortal(o: HotcrpOptions): SubmissionPortal {
113
161
  return a.ok ? parseShow(a.value.status, a.value.body) : a;
114
162
  },
115
163
  async update(id, change, { save }) {
116
- const url = endpoint(o.url, {
117
- p: String(id),
118
- ...(save ? {} : { dry_run: "1" }),
119
- });
120
- const body = formOf(id, change);
121
- const a = await call(
122
- url,
123
- { method: "POST", body },
124
- o.token,
125
- WRITE_TIMEOUT_MS,
126
- );
164
+ const object = changeObject(id, change);
165
+ const a = await post(o, { p: String(id), object, pdf: change.pdf, save });
127
166
  return a.ok ? parseUpdate(a.value.status, a.value.body) : a;
128
167
  },
168
+ async create(paper, { save }) {
169
+ const object = createObject(paper);
170
+ const a = await post(o, { p: "new", object, pdf: paper.pdf, save });
171
+ return a.ok ? parseCreate(a.value.status, a.value.body) : a;
172
+ },
129
173
  };
130
174
  }
@@ -14,11 +14,16 @@ import {
14
14
  import { afterAll, beforeAll, test } from "vitest";
15
15
  import { fieldOf } from "../../domain/record.ts";
16
16
  import { sha256Hex } from "../../domain/sha256.ts";
17
- import type { SubmissionChange } from "../../domain/submission.ts";
17
+ import type {
18
+ NewSubmission,
19
+ SubmissionChange,
20
+ } from "../../domain/submission.ts";
18
21
  import {
19
22
  CONTENT_FIELD,
20
23
  changeObject,
24
+ createObject,
21
25
  hotcrpPortal,
26
+ parseCreate,
22
27
  parseShow,
23
28
  parseUpdate,
24
29
  sha256OfHash,
@@ -342,6 +347,104 @@ test("update with save: no dry_run parameter at all", async () => {
342
347
  });
343
348
  });
344
349
 
350
+ const NEW: NewSubmission = {
351
+ title: "Agents That Lie",
352
+ abstract: "One paragraph.",
353
+ authors: [
354
+ {
355
+ name: "Ada Lovelace",
356
+ email: "ada@example.org",
357
+ affiliation: "Analytical",
358
+ },
359
+ { name: "Bob", email: "bob@example.org", affiliation: null },
360
+ ],
361
+ pdf: { name: "paper.pdf", bytes: PDF },
362
+ };
363
+
364
+ test("create: a dry run by default — p=new, dry_run=1, title/abstract/authors and the PDF; no id", async () => {
365
+ // HotCRP's dry-run answer: `Paper_API_Status` sends `"pid": "new"` until a save chose one
366
+ // (src/api/api_paper.php, kohler/hotcrp 9a01097).
367
+ answer(200, {
368
+ ok: true,
369
+ valid: true,
370
+ change_list: ["new", "title", "abstract", "authors", "submission"],
371
+ pid: "new",
372
+ dry_run: true,
373
+ message_list: [],
374
+ });
375
+ const r = await portal().create(NEW, { save: false });
376
+ const s = last();
377
+ assert.equal(s.method, "POST");
378
+ assert.equal(s.url.pathname, "/api/paper");
379
+ assert.deepEqual(Object.fromEntries(s.url.searchParams), {
380
+ p: "new",
381
+ dry_run: "1",
382
+ });
383
+ assert.equal(s.headers.authorization, `bearer ${TOKEN}`);
384
+ const parts = partsOf(s);
385
+ assert.deepEqual([...parts.keys()], ["json", CONTENT_FIELD]);
386
+ assert.deepEqual(JSON.parse(parts.get("json")?.body.toString("utf8") ?? ""), {
387
+ object: "paper",
388
+ pid: "new",
389
+ title: "Agents That Lie",
390
+ abstract: "One paragraph.",
391
+ authors: [
392
+ {
393
+ name: "Ada Lovelace",
394
+ email: "ada@example.org",
395
+ affiliation: "Analytical",
396
+ },
397
+ { name: "Bob", email: "bob@example.org" },
398
+ ],
399
+ submission: { content_file: CONTENT_FIELD },
400
+ });
401
+ assert.deepEqual(new Uint8Array(parts.get(CONTENT_FIELD)?.body ?? []), PDF);
402
+ assert.deepEqual(r, {
403
+ ok: true,
404
+ value: {
405
+ httpStatus: 200,
406
+ valid: true,
407
+ changes: ["new", "title", "abstract", "authors", "submission"],
408
+ messages: [],
409
+ dryRun: true,
410
+ id: null,
411
+ },
412
+ });
413
+ });
414
+
415
+ test("create with save: no dry_run, and the id HotCRP chose", async () => {
416
+ answer(200, { ok: true, valid: true, change_list: ["new"], pid: 42 });
417
+ const r = await portal().create(NEW, { save: true });
418
+ assert.deepEqual(Object.fromEntries(last().url.searchParams), { p: "new" });
419
+ assert.deepEqual(r.ok ? r.value.id : r, 42);
420
+ });
421
+
422
+ test("🔴 parseCreate: an id only for a valid, saved creation", () => {
423
+ assert.deepEqual(
424
+ [
425
+ parseCreate(200, { ok: false, valid: false, pid: 42 }),
426
+ parseCreate(200, { ok: true, valid: true, pid: "new", dry_run: true }),
427
+ parseCreate(200, { ok: true, valid: true, pid: "new" }),
428
+ ].map((r) => (r.ok ? r.value.id : r)),
429
+ [null, null, null],
430
+ );
431
+ assert.deepEqual(parseCreate(401, { ok: false, status_code: 401 }), {
432
+ ok: false,
433
+ error: { kind: "refused", httpStatus: 401, messages: [] },
434
+ });
435
+ });
436
+
437
+ test("createObject: an author without an affiliation sends none", () => {
438
+ assert.deepEqual(fieldOf(createObject(NEW), "authors"), [
439
+ {
440
+ name: "Ada Lovelace",
441
+ email: "ada@example.org",
442
+ affiliation: "Analytical",
443
+ },
444
+ { name: "Bob", email: "bob@example.org" },
445
+ ]);
446
+ });
447
+
345
448
  test("update: valid:false is an outcome with HotCRP's messages, at any HTTP status", async () => {
346
449
  answer(400, {
347
450
  ok: false,
@@ -441,6 +544,7 @@ test("a portal that does not answer is unreachable, with the network's words", a
441
544
  for (const r of [
442
545
  await down.show(7),
443
546
  await down.update(7, CHANGE, { save: false }),
547
+ await down.create(NEW, { save: false }),
444
548
  ]) {
445
549
  assert.equal(!r.ok && r.error.kind, "unreachable");
446
550
  assert.ok(!r.ok && r.error.kind === "unreachable" && r.error.detail !== "");
@@ -11,6 +11,7 @@ import { z } from "zod";
11
11
  import { err, ok, type Result } from "../../domain/result.ts";
12
12
  import { parseSha256, type Sha256 } from "../../domain/sha256.ts";
13
13
  import type {
14
+ CreateOutcome,
14
15
  PortalDocument,
15
16
  PortalFailure,
16
17
  PortalMessage,
@@ -55,6 +56,8 @@ const UpdateBody = z.object({
55
56
  change_list: z.array(z.string()).optional(),
56
57
  message_list: Messages,
57
58
  dry_run: z.boolean().optional(),
59
+ // The submission's id: a number once saved, `"new"` for a creation that was not.
60
+ pid: z.union([z.number(), z.string()]).optional(),
58
61
  });
59
62
 
60
63
  const Envelope = z.object({ message_list: Messages });
@@ -179,3 +182,21 @@ export function parseUpdate(
179
182
  dryRun: b.data.dry_run ?? false,
180
183
  });
181
184
  }
185
+
186
+ /**
187
+ * `POST /api/paper?p=new` → the outcome, with the id HotCRP chose: its `pid`, a number only when the
188
+ * submission was saved (a dry run answers `"pid": "new"`).
189
+ */
190
+ export function parseCreate(
191
+ httpStatus: number,
192
+ body: unknown,
193
+ ): Result<CreateOutcome, PortalFailure> {
194
+ const u = parseUpdate(httpStatus, body);
195
+ if (!u.ok) return u;
196
+ const pid = UpdateBody.safeParse(body).data?.pid;
197
+ const saved = u.value.valid && !u.value.dryRun;
198
+ return ok({
199
+ ...u.value,
200
+ id: saved && typeof pid === "number" ? pid : null,
201
+ });
202
+ }