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.
Files changed (232) hide show
  1. package/.github/workflows/deadlines.yml +27 -7
  2. package/README.md +16 -15
  3. package/dist/CLAUDE.md.spec.d.ts.map +1 -1
  4. package/dist/CLAUDE.md.spec.js +6 -3
  5. package/dist/CLAUDE.md.spec.js.map +1 -1
  6. package/dist/adapters/hotcrp/index.d.ts +6 -4
  7. package/dist/adapters/hotcrp/index.d.ts.map +1 -1
  8. package/dist/adapters/hotcrp/index.js +6 -4
  9. package/dist/adapters/hotcrp/index.js.map +1 -1
  10. package/dist/adapters/hotcrp/portal.io.d.ts +3 -1
  11. package/dist/adapters/hotcrp/portal.io.d.ts.map +1 -1
  12. package/dist/adapters/hotcrp/portal.io.js +36 -11
  13. package/dist/adapters/hotcrp/portal.io.js.map +1 -1
  14. package/dist/adapters/hotcrp/response.d.ts +6 -1
  15. package/dist/adapters/hotcrp/response.d.ts.map +1 -1
  16. package/dist/adapters/hotcrp/response.js +17 -0
  17. package/dist/adapters/hotcrp/response.js.map +1 -1
  18. package/dist/adapters/hotcrp/site.d.ts +9 -0
  19. package/dist/adapters/hotcrp/site.d.ts.map +1 -0
  20. package/dist/adapters/hotcrp/site.js +22 -0
  21. package/dist/adapters/hotcrp/site.js.map +1 -0
  22. package/dist/adapters/hotcrp/token.d.ts +13 -0
  23. package/dist/adapters/hotcrp/token.d.ts.map +1 -0
  24. package/dist/adapters/hotcrp/token.js +48 -0
  25. package/dist/adapters/hotcrp/token.js.map +1 -0
  26. package/dist/adapters/latex/abstract.d.ts +22 -0
  27. package/dist/adapters/latex/abstract.d.ts.map +1 -0
  28. package/dist/adapters/latex/abstract.js +187 -0
  29. package/dist/adapters/latex/abstract.js.map +1 -0
  30. package/dist/adapters/latex/captions.d.ts +9 -0
  31. package/dist/adapters/latex/captions.d.ts.map +1 -0
  32. package/dist/adapters/latex/captions.js +40 -0
  33. package/dist/adapters/latex/captions.js.map +1 -0
  34. package/dist/adapters/latex/headings.d.ts +6 -0
  35. package/dist/adapters/latex/headings.d.ts.map +1 -1
  36. package/dist/adapters/latex/headings.js +5 -4
  37. package/dist/adapters/latex/headings.js.map +1 -1
  38. package/dist/adapters/latex/index.d.ts +1 -0
  39. package/dist/adapters/latex/index.d.ts.map +1 -1
  40. package/dist/adapters/latex/index.js +3 -0
  41. package/dist/adapters/latex/index.js.map +1 -1
  42. package/dist/adapters/latex/parse.d.ts +15 -0
  43. package/dist/adapters/latex/parse.d.ts.map +1 -1
  44. package/dist/adapters/latex/parse.js +64 -1
  45. package/dist/adapters/latex/parse.js.map +1 -1
  46. package/dist/adapters/node/list-entries.io.d.ts.map +1 -1
  47. package/dist/adapters/node/list-entries.io.js +3 -1
  48. package/dist/adapters/node/list-entries.io.js.map +1 -1
  49. package/dist/cli.d.ts.map +1 -1
  50. package/dist/cli.js +20 -8
  51. package/dist/cli.js.map +1 -1
  52. package/dist/cycle-rules.d.ts.map +1 -1
  53. package/dist/cycle-rules.js +3 -4
  54. package/dist/cycle-rules.js.map +1 -1
  55. package/dist/domain/deadline.d.ts +8 -1
  56. package/dist/domain/deadline.d.ts.map +1 -1
  57. package/dist/domain/deadline.js +26 -12
  58. package/dist/domain/deadline.js.map +1 -1
  59. package/dist/domain/decide-first.d.ts +11 -1
  60. package/dist/domain/decide-first.d.ts.map +1 -1
  61. package/dist/domain/decide-first.js +6 -0
  62. package/dist/domain/decide-first.js.map +1 -1
  63. package/dist/domain/index-columns.d.ts.map +1 -1
  64. package/dist/domain/index-columns.js +2 -0
  65. package/dist/domain/index-columns.js.map +1 -1
  66. package/dist/domain/portal-token.d.ts +44 -0
  67. package/dist/domain/portal-token.d.ts.map +1 -0
  68. package/dist/domain/portal-token.js +32 -0
  69. package/dist/domain/portal-token.js.map +1 -0
  70. package/dist/domain/study-registry.d.ts +65 -8
  71. package/dist/domain/study-registry.d.ts.map +1 -1
  72. package/dist/domain/study-registry.js +63 -19
  73. package/dist/domain/study-registry.js.map +1 -1
  74. package/dist/domain/submission.d.ts +30 -0
  75. package/dist/domain/submission.d.ts.map +1 -1
  76. package/dist/domain/submission.js +68 -0
  77. package/dist/domain/submission.js.map +1 -1
  78. package/dist/lib/paper-config.d.ts +1 -1
  79. package/dist/lib/paper-config.d.ts.map +1 -1
  80. package/dist/lib/paper-config.js +6 -4
  81. package/dist/lib/paper-config.js.map +1 -1
  82. package/dist/outputs-before-decision-rule.d.ts +1 -1
  83. package/dist/outputs-before-decision-rule.d.ts.map +1 -1
  84. package/dist/outputs-before-decision-rule.js +21 -17
  85. package/dist/outputs-before-decision-rule.js.map +1 -1
  86. package/dist/paper-settings.d.ts +3 -1
  87. package/dist/paper-settings.d.ts.map +1 -1
  88. package/dist/paper-settings.js +11 -5
  89. package/dist/paper-settings.js.map +1 -1
  90. package/dist/ports/latex.d.ts +23 -0
  91. package/dist/ports/latex.d.ts.map +1 -1
  92. package/dist/ports/list-entries.d.ts +7 -4
  93. package/dist/ports/list-entries.d.ts.map +1 -1
  94. package/dist/ports/study-deps.d.ts +14 -0
  95. package/dist/ports/study-deps.d.ts.map +1 -0
  96. package/dist/ports/study-deps.js +2 -0
  97. package/dist/ports/study-deps.js.map +1 -0
  98. package/dist/ports/study-folder.d.ts +43 -0
  99. package/dist/ports/study-folder.d.ts.map +1 -0
  100. package/dist/ports/study-folder.js +2 -0
  101. package/dist/ports/study-folder.js.map +1 -0
  102. package/dist/ports/submission-portal.d.ts +39 -2
  103. package/dist/ports/submission-portal.d.ts.map +1 -1
  104. package/dist/ports/venue-deadlines.d.ts +1 -1
  105. package/dist/presets.d.ts +1 -2
  106. package/dist/presets.d.ts.map +1 -1
  107. package/dist/presets.js +1 -2
  108. package/dist/presets.js.map +1 -1
  109. package/dist/studies.d.ts.map +1 -1
  110. package/dist/studies.js +10 -7
  111. package/dist/studies.js.map +1 -1
  112. package/dist/study-folder.d.ts +14 -0
  113. package/dist/study-folder.d.ts.map +1 -0
  114. package/dist/study-folder.js +123 -0
  115. package/dist/study-folder.js.map +1 -0
  116. package/dist/study-gate.d.ts +1 -1
  117. package/dist/study-gate.d.ts.map +1 -1
  118. package/dist/study-gate.js +65 -28
  119. package/dist/study-gate.js.map +1 -1
  120. package/dist/study-index-input.d.ts +1 -1
  121. package/dist/study-index-input.d.ts.map +1 -1
  122. package/dist/study-index-input.js +8 -3
  123. package/dist/study-index-input.js.map +1 -1
  124. package/dist/study-modes.d.ts +38 -27
  125. package/dist/study-modes.d.ts.map +1 -1
  126. package/dist/study-modes.js +42 -53
  127. package/dist/study-modes.js.map +1 -1
  128. package/dist/study-registry-rule.d.ts +2 -6
  129. package/dist/study-registry-rule.d.ts.map +1 -1
  130. package/dist/study-registry-rule.js +32 -41
  131. package/dist/study-registry-rule.js.map +1 -1
  132. package/dist/submission.d.ts +49 -11
  133. package/dist/submission.d.ts.map +1 -1
  134. package/dist/submission.js +332 -44
  135. package/dist/submission.js.map +1 -1
  136. package/docs/configuration.md +2 -1
  137. package/docs/design/study-modes.md +46 -28
  138. package/docs/rules/cycle/record.md +9 -6
  139. package/docs/rules/repro/outputs-before-decision.md +3 -0
  140. package/docs/rules/repro/study-registered.md +3 -2
  141. package/docs/rules/tex/heading-case.md +1 -1
  142. package/docs/submission.md +47 -48
  143. package/eslint.config.mjs +39 -24
  144. package/lib/paper-config.ts +7 -4
  145. package/package.json +6 -1
  146. package/presets/msr.jsonc +9 -7
  147. package/presets/msr.md +14 -10
  148. package/scripts/check.ts +1 -1
  149. package/scripts/refresh-deadlines/compare.test.ts +75 -0
  150. package/scripts/refresh-deadlines/compare.ts +101 -0
  151. package/scripts/refresh-deadlines/main.test.ts +140 -0
  152. package/scripts/refresh-deadlines/main.ts +56 -0
  153. package/scripts/refresh-deadlines/refresh.test.ts +287 -0
  154. package/scripts/refresh-deadlines/refresh.ts +183 -0
  155. package/skills/grade-paper-writing/SKILL.md +4 -2
  156. package/skills/grade-paper-writing/SKILL.md.spec.ts +3 -1
  157. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.ts.snap +16 -3
  158. package/skills/grade-paper-writing/prose-lint.mjs +127 -95
  159. package/skills/grade-paper-writing/prose-lint.test.ts +119 -10
  160. package/skills/submission-portal/SKILL.md +60 -44
  161. package/skills/submission-portal/SKILL.md.spec.ts +62 -43
  162. package/skills/submission-portal/submission-portal.test.ts +4 -73
  163. package/src/CLAUDE.md +7 -4
  164. package/src/CLAUDE.md.spec.ts +6 -3
  165. package/src/adapters/hotcrp/cassette/README.md +4 -4
  166. package/src/adapters/hotcrp/cassette/deadlines-msr2027.html +9 -9
  167. package/src/adapters/hotcrp/deadlines.io.test.ts +2 -2
  168. package/src/adapters/hotcrp/deadlines.test.ts +3 -3
  169. package/src/adapters/hotcrp/index.ts +15 -3
  170. package/src/adapters/hotcrp/portal.io.ts +61 -17
  171. package/src/adapters/hotcrp/portal.test.ts +105 -1
  172. package/src/adapters/hotcrp/response.ts +21 -0
  173. package/src/adapters/hotcrp/site.ts +34 -0
  174. package/src/adapters/hotcrp/token.test.ts +69 -0
  175. package/src/adapters/hotcrp/token.ts +51 -0
  176. package/src/adapters/latex/abstract.test.ts +280 -0
  177. package/src/adapters/latex/abstract.ts +272 -0
  178. package/src/adapters/latex/captions.test.ts +57 -0
  179. package/src/adapters/latex/captions.ts +56 -0
  180. package/src/adapters/latex/headings.ts +5 -4
  181. package/src/adapters/latex/index.test.ts +31 -1
  182. package/src/adapters/latex/index.ts +3 -0
  183. package/src/adapters/latex/parse.ts +71 -3
  184. package/src/adapters/node/list-entries.io.ts +2 -1
  185. package/src/adapters/node/list-entries.test.ts +18 -0
  186. package/src/cli.test.ts +20 -8
  187. package/src/cli.ts +22 -13
  188. package/src/cycle-rules.test.ts +6 -6
  189. package/src/cycle-rules.ts +7 -4
  190. package/src/domain/deadline.test.ts +31 -2
  191. package/src/domain/deadline.ts +42 -16
  192. package/src/domain/decide-first.test.ts +33 -0
  193. package/src/domain/decide-first.ts +16 -1
  194. package/src/domain/index-columns.test.ts +49 -4
  195. package/src/domain/index-columns.ts +2 -0
  196. package/src/domain/portal-token.test.ts +55 -0
  197. package/src/domain/portal-token.ts +63 -0
  198. package/src/domain/study-measurements.test.ts +7 -3
  199. package/src/domain/study-registry.test.ts +34 -6
  200. package/src/domain/study-registry.ts +123 -31
  201. package/src/domain/submission.test.ts +88 -0
  202. package/src/domain/submission.ts +99 -0
  203. package/src/new-paper.test.ts +1 -0
  204. package/src/outputs-before-decision-rule.test.ts +47 -2
  205. package/src/outputs-before-decision-rule.ts +31 -22
  206. package/src/paper-settings.test.ts +20 -0
  207. package/src/paper-settings.ts +17 -5
  208. package/src/ports/latex.ts +25 -0
  209. package/src/ports/list-entries.ts +7 -4
  210. package/src/ports/study-deps.ts +14 -0
  211. package/src/ports/study-folder.ts +38 -0
  212. package/src/ports/submission-portal.ts +43 -1
  213. package/src/ports/venue-deadlines.ts +1 -1
  214. package/src/presets.test.ts +8 -5
  215. package/src/presets.ts +1 -2
  216. package/src/studies-index-modes.test.ts +2 -2
  217. package/src/studies.ts +19 -10
  218. package/src/study-folder.test.ts +237 -0
  219. package/src/study-folder.ts +176 -0
  220. package/src/study-gate.test.ts +158 -7
  221. package/src/study-gate.ts +127 -54
  222. package/src/study-index-input.ts +12 -4
  223. package/src/study-modes.ts +67 -80
  224. package/src/study-registry-rule.test.ts +87 -0
  225. package/src/study-registry-rule.ts +58 -66
  226. package/src/submission-new.test.ts +569 -0
  227. package/src/submission-show.test.ts +210 -0
  228. package/src/submission-update.test.ts +160 -0
  229. package/src/submission.test.ts +140 -518
  230. package/src/submission.ts +494 -61
  231. package/scripts/refresh-deadlines.test.ts +0 -431
  232. package/scripts/refresh-deadlines.ts +0 -387
package/src/submission.ts CHANGED
@@ -17,11 +17,16 @@
17
17
  */
18
18
  import { basename, join, resolve } from "node:path";
19
19
  import { absolutePath } from "./domain/paths.ts";
20
+ import { messageOf } from "./domain/text.ts";
20
21
  import { err, ok, type Result } from "./domain/result.ts";
21
22
  import { sha256Hex, type Sha256 } from "./domain/sha256.ts";
22
23
  import {
23
24
  supportedPortal,
25
+ withSubmissionId,
24
26
  wordCount,
27
+ type Author,
28
+ type CreateOutcome,
29
+ type NewSubmission,
25
30
  type PortalFailure,
26
31
  type PortalMessage,
27
32
  type SubmissionView,
@@ -29,7 +34,10 @@ import {
29
34
  type UpdateOutcome,
30
35
  } from "./domain/submission.ts";
31
36
  import type { Files } from "./ports/files.ts";
32
- import type { SubmissionPortal } from "./ports/submission-portal.ts";
37
+ import type {
38
+ PortalSite,
39
+ SubmissionPortal,
40
+ } from "./ports/submission-portal.ts";
33
41
  import {
34
42
  paperPreset,
35
43
  unusablePresetLine,
@@ -40,23 +48,70 @@ import type { PaperSettings } from "./paper-settings.ts";
40
48
  import { currentDeadlines } from "./domain/cycle.ts";
41
49
  import {
42
50
  deadlinesInForce,
51
+ readingText,
43
52
  type InForce,
44
- type Observation,
45
53
  } from "./domain/deadline.ts";
46
54
  import { CONFIG_FILE } from "#lib/paper-config";
55
+ import {
56
+ hostOf,
57
+ lookUpToken,
58
+ type TokenProblem,
59
+ } from "./domain/portal-token.ts";
60
+ import { LINE_BREAK, OPAQUE, runText } from "./domain/tex-document.ts";
61
+ import type { LatexReader } from "./ports/latex.ts";
62
+ import { factsPath, parseFactsText } from "./facts-file.ts";
63
+ import { lineColumn, readPaper } from "./tex-paper.ts";
64
+ import { originOf, type PaperSource } from "./domain/paper-source.ts";
65
+ import type { SourcesCodec } from "./ports/sources-codec.ts";
66
+ import { MAIN } from "./build.ts";
47
67
 
48
- /** The environment variable each portal kind's token is read from. */
49
- export const TOKEN_ENV: Readonly<Record<SupportedPortal["kind"], string>> = {
50
- hotcrp: "HOTCRP_TOKEN",
51
- };
68
+ /** How to make and set the site's token: the page, the scopes, the variable. Pure. */
69
+ export function tokenSteps(
70
+ portal: SupportedPortal,
71
+ site: PortalSite,
72
+ variable: string,
73
+ ): readonly string[] {
74
+ const c = site.credentials;
75
+ return [
76
+ ` 1. create a token on this site: ${c.page} (${c.where}), with the scopes ${c.scopes}`,
77
+ ` 2. set it in your environment — never paste it into a chat: export ${variable}=<the token>`,
78
+ ` tokens are per ${portal.kind} site: ${variable} is read for ${hostOf(portal.url)} alone, so two venues take two variables`,
79
+ ];
80
+ }
52
81
 
53
- /** Where each portal kind lets an author create a token. */
54
- const TOKEN_HELP: Readonly<Record<SupportedPortal["kind"], string>> = {
55
- hotcrp:
56
- "create one on the HotCRP site under Account settings → Developer, with the scopes submeta:read and document:read (show) and submeta:write and document:write (update)",
57
- };
82
+ /** Why there is no token, as the complete instruction the author follows. Pure. */
83
+ export function tokenProblemLines(
84
+ portal: SupportedPortal,
85
+ site: PortalSite,
86
+ p: TokenProblem,
87
+ ): readonly string[] {
88
+ const head =
89
+ p.kind === "missing"
90
+ ? `no ${portal.kind} token for ${portal.url}: ${p.variable} is not set`
91
+ : `${p.siteLess} is no longer read: tokens are per ${portal.kind} site. If it was made on ${hostOf(portal.url)}, rename it to ${p.variable}; if it was made on another site, rename it to that site's variable (${site.variablePattern}) and create a token here:`;
92
+ return [head, ...tokenSteps(portal, site, p.variable)];
93
+ }
94
+
95
+ /**
96
+ * What to do when the portal turned the token away: 401 is "this site does not know the token", 403
97
+ * a token without the scope — both answered with the site's token page. Empty for anything else. Pure.
98
+ */
99
+ export function tokenRefusedLines(
100
+ portal: SupportedPortal,
101
+ site: PortalSite,
102
+ f: PortalFailure,
103
+ ): readonly string[] {
104
+ if (f.kind !== "refused" || (f.httpStatus !== 401 && f.httpStatus !== 403))
105
+ return [];
106
+ const variable = site.tokenVariable;
107
+ const why =
108
+ f.httpStatus === 401
109
+ ? `${variable} was used; it may belong to another ${portal.kind} site (tokens are per site), or be revoked or expired`
110
+ : `${variable} was used; the site turned the request away — the token may lack a scope this subcommand needs`;
111
+ return [why, ...tokenSteps(portal, site, variable)];
112
+ }
58
113
 
59
- export const SUBCOMMANDS = ["show", "update"] as const;
114
+ export const SUBCOMMANDS = ["new", "show", "update"] as const;
60
115
  export type Subcommand = (typeof SUBCOMMANDS)[number];
61
116
  const isSubcommand = (s: string | undefined): s is Subcommand =>
62
117
  SUBCOMMANDS.some((x) => x === s);
@@ -69,7 +124,10 @@ export interface SubmissionArgs {
69
124
  readonly extra: readonly string[];
70
125
  /** `--pdf <file>`, relative to `cwd`; null: the paper's own PDF. */
71
126
  readonly pdf: string | null;
72
- /** `--abstract <file>`, relative to `cwd`; null: leave the abstract as it is. */
127
+ /**
128
+ * `--abstract <file>`, relative to `cwd`; null: `update` leaves the abstract as it is, `new` reads
129
+ * it from the build.
130
+ */
73
131
  readonly abstract: string | null;
74
132
  readonly submit: boolean;
75
133
  readonly save: boolean;
@@ -81,7 +139,12 @@ export interface SubmissionDeps {
81
139
  readonly cwd: string;
82
140
  /** The value of an environment variable; the root reads the environment. */
83
141
  readonly env: (name: string) => string | undefined;
84
- readonly portalFor: (p: SupportedPortal, token: string) => SubmissionPortal;
142
+ /** The portal site of a supported kind: its token variable and help, and its API once there is a token. */
143
+ readonly site: (p: SupportedPortal) => PortalSite;
144
+ /** Reads the paper's `\title` and its abstract, its includes spliced in (`new`). */
145
+ readonly latex: LatexReader;
146
+ /** Reads the build's record of the files TeX read, for the includes (`new`). */
147
+ readonly codec: SourcesCodec;
85
148
  readonly log: (line: string) => void;
86
149
  readonly err: (line: string) => void;
87
150
  }
@@ -89,7 +152,14 @@ export interface SubmissionDeps {
89
152
  /** A paper's portal, its submission there, and the PDF the paper builds. */
90
153
  export interface SubmissionTarget {
91
154
  readonly portal: SupportedPortal;
92
- readonly id: number;
155
+ /** The paper's submission on the portal; null before `new` created one. */
156
+ readonly id: number | null;
157
+ /** The paper's `paperlint.json`, absolute. */
158
+ readonly config: string;
159
+ /** The authors the paper declares for the portal; null when it declares none. */
160
+ readonly authors: readonly Author[] | null;
161
+ /** The cycle attempt is accepted: a new submission belongs to a new cycle. */
162
+ readonly accepted: boolean;
93
163
  /** The paper's built PDF, absolute. */
94
164
  readonly pdf: string;
95
165
  /** The venue's call for papers (the preset's `url`); null when the preset names no venue. */
@@ -122,11 +192,6 @@ function targetOf(
122
192
  return err(
123
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`,
124
194
  );
125
- const sub = p.settings.submission;
126
- if (sub === null)
127
- return err(
128
- `${configOf(paperDir)}: no "submission" — add "submission": { "id": <the submission number on ${declared.url}> }`,
129
- );
130
195
  const portal = supportedPortal(declared);
131
196
  if (!portal.ok) return portal;
132
197
  const pdf = join(paperDir, p.settings.pdf ?? "paper.pdf");
@@ -137,7 +202,16 @@ function targetOf(
137
202
  ? []
138
203
  : currentDeadlines(p.settings.cycles.current);
139
204
  const deadlines = deadlinesInForce(own, p.preset.deadlines);
140
- return ok({ portal: portal.value, id: sub.id, pdf, call, deadlines });
205
+ return ok({
206
+ portal: portal.value,
207
+ id: p.settings.submission?.id ?? null,
208
+ config: configOf(paperDir),
209
+ authors: p.settings.authors,
210
+ accepted: p.settings.cycles?.current.kind === "accepted",
211
+ pdf,
212
+ call,
213
+ deadlines,
214
+ });
141
215
  }
142
216
 
143
217
  /** The paper directory → its portal and submission, or the line naming the file and key to set. */
@@ -157,20 +231,6 @@ export function submissionTarget(
157
231
  }
158
232
  }
159
233
 
160
- /** The token from the environment, or the line naming the variable and where to get one. */
161
- export function tokenFor(
162
- portal: SupportedPortal,
163
- env: (name: string) => string | undefined,
164
- ): Result<string, string> {
165
- const name = TOKEN_ENV[portal.kind];
166
- const v = env(name)?.trim();
167
- return v
168
- ? ok(v)
169
- : err(
170
- `${name} is not set — the ${portal.kind} API needs a token: ${TOKEN_HELP[portal.kind]}. Then: export ${name}=…`,
171
- );
172
- }
173
-
174
234
  const iso = (seconds: number | null): string =>
175
235
  seconds === null ? "—" : new Date(seconds * 1000).toISOString();
176
236
 
@@ -207,23 +267,20 @@ const pdfLine = (view: SubmissionView): string =>
207
267
  ? "portal PDF none"
208
268
  : `portal PDF ${view.pdf.hash}, ${view.pdf.size === null ? "?" : String(view.pdf.size)} bytes, uploaded ${iso(view.pdf.uploadedAt)}`;
209
269
 
210
- const readingText = (o: Observation): string =>
211
- `${o.source} ${o.at}, read ${o.read}`;
212
-
213
270
  /** One deadline in force, what set it, and the other readings of it. Pure. */
214
271
  export function deadlineLine(d: InForce): string {
215
272
  const by = d.by;
216
- const why =
273
+ const set =
217
274
  by.kind === "override"
218
- ? `override: ${by.override.reason} (${by.override.evidence})`
219
- : `${by.reading.source}, read ${by.reading.read}`;
275
+ ? `${d.at} — override: ${by.override.reason} (${by.override.evidence})`
276
+ : readingText(by.reading);
220
277
  const others = d.readings.filter(
221
278
  (r) => by.kind === "override" || r !== by.reading,
222
279
  );
223
280
  const also = others.length
224
281
  ? `; also ${others.map(readingText).join("; ")}`
225
282
  : "";
226
- return `deadline ${d.what} ${d.at} — ${why}${also}`;
283
+ return `deadline ${d.what} ${set}${also}`;
227
284
  }
228
285
 
229
286
  /** `show`'s report. Pure. */
@@ -283,15 +340,37 @@ const shownPath = (cwd: string, p: string): string =>
283
340
  const bytesAt = (files: Files, p: string): Uint8Array | null =>
284
341
  files.readBytes(absolutePath(p));
285
342
 
343
+ /** The deps a subcommand runs with: the command's, and what to say when the portal turns the token away. */
344
+ type RunDeps = SubmissionDeps & {
345
+ readonly portalSite: PortalSite;
346
+ readonly refused: (f: PortalFailure) => readonly string[];
347
+ };
348
+
349
+ /** A failure printed — the portal's words, then what to do about the token if it was the token — exit 1. */
350
+ function failed(f: PortalFailure, deps: RunDeps): number {
351
+ [failureText(f), ...deps.refused(f)].forEach((l) => {
352
+ deps.err(l);
353
+ });
354
+ return 1;
355
+ }
356
+
357
+ /** The submission's id, or the line naming the key to set — `show` and `update` need one. */
358
+ const idOf = (t: SubmissionTarget): Result<number, string> =>
359
+ t.id === null
360
+ ? err(
361
+ `${t.config}: no "submission" — add "submission": { "id": <the submission number on ${t.portal.url}> }, or create the submission with \`paperlint submission new\``,
362
+ )
363
+ : ok(t.id);
364
+
286
365
  /** `show`: read the submission and compare its PDF with the local one. */
287
366
  async function runShow(
288
- t: SubmissionTarget,
367
+ t: SubmissionTarget & { readonly id: number },
289
368
  portal: SubmissionPortal,
290
369
  pdfPath: string,
291
- deps: SubmissionDeps,
370
+ deps: RunDeps,
292
371
  ): Promise<number> {
293
372
  const view = await portal.show(t.id);
294
- if (!view.ok) return (deps.err(failureText(view.error)), 1);
373
+ if (!view.ok) return failed(view.error, deps);
295
374
  const bytes = bytesAt(deps.files, pdfPath);
296
375
  const local = {
297
376
  shown: shownPath(deps.cwd, pdfPath),
@@ -329,10 +408,10 @@ const changeOf = (
329
408
 
330
409
  /** `update`: send the PDF (and the abstract, and "submitted" when asked); a dry run unless saving. */
331
410
  async function runUpdate(
332
- t: SubmissionTarget,
411
+ t: SubmissionTarget & { readonly id: number },
333
412
  portal: SubmissionPortal,
334
413
  a: SubmissionArgs & { readonly pdfPath: string },
335
- deps: SubmissionDeps,
414
+ deps: RunDeps,
336
415
  ): Promise<number> {
337
416
  const bytes = bytesAt(deps.files, a.pdfPath);
338
417
  const shown = shownPath(deps.cwd, a.pdfPath);
@@ -347,7 +426,7 @@ async function runUpdate(
347
426
  const r = await portal.update(t.id, changeOf(a, bytes, abstract.value), {
348
427
  save: a.save,
349
428
  });
350
- if (!r.ok) return (deps.err(failureText(r.error)), 1);
429
+ if (!r.ok) return failed(r.error, deps);
351
430
  const local = { shown, sha256: sha256Hex(bytes) };
352
431
  updateLines(r.value, local, a.save).forEach((l) => {
353
432
  deps.log(l);
@@ -357,23 +436,366 @@ async function runUpdate(
357
436
  return fine ? 0 : 1;
358
437
  }
359
438
 
439
+ // ── new ──────────────────────────────────────────────────────────────────────────────
440
+
441
+ /**
442
+ * The metadata of the PDF that is sent, from the build's facts — the title's fallback; null when
443
+ * there are no facts, or they describe another PDF.
444
+ */
445
+ function pdfMetadata(
446
+ paperDir: string,
447
+ pdf: Uint8Array,
448
+ deps: SubmissionDeps,
449
+ ): Readonly<Record<string, string>> | null {
450
+ const bytes = bytesAt(deps.files, factsPath(paperDir));
451
+ const facts =
452
+ bytes === null ? null : parseFactsText(new TextDecoder().decode(bytes));
453
+ return facts?.ok === true && facts.value.sha === sha256Hex(pdf)
454
+ ? facts.value.text.metadata
455
+ : null;
456
+ }
457
+
458
+ /** Where an offset of the assembled paper stands in its own file: `file:line:column`. */
459
+ function placeIn(paper: PaperSource, offset: number): string {
460
+ const o = originOf(paper, { start: offset, end: offset });
461
+ return o === null ? MAIN : `${o.file}:${lineColumn(o.source, o.span.start)}`;
462
+ }
463
+
464
+ /**
465
+ * The abstract from the paper's source: its `abstract` environment, with the files it includes
466
+ * spliced in as the build recorded them, as plain text — or why it cannot be read without guessing.
467
+ */
468
+ function sourceAbstract(
469
+ paper: PaperSource | null,
470
+ deps: SubmissionDeps,
471
+ ): Result<string, string> {
472
+ const instead = "give the abstract with --abstract <file>, a plain-text file";
473
+ if (paper === null)
474
+ return err(`no ${MAIN} to read the abstract from — ${instead}`);
475
+ const r = deps.latex.abstractText(paper.text);
476
+ if (r.ok) return r;
477
+ const guess = "paperlint sends no text it would have to guess";
478
+ switch (r.error.kind) {
479
+ case "none":
480
+ return err(
481
+ `${MAIN} has no \\begin{abstract}…\\end{abstract} — ${instead}`,
482
+ );
483
+ case "unreadable":
484
+ return err(
485
+ `the abstract, ${placeIn(paper, r.error.at)}: ${r.error.what} — ${guess}; ${instead}`,
486
+ );
487
+ case "redefined":
488
+ return err(
489
+ `the abstract uses \\${r.error.name}, which the paper defines ${String(r.error.at.length)} times (${r.error.at.map((o) => placeIn(paper, o)).join(", ")}) — which one TeX uses there is not guessed; ${instead}`,
490
+ );
491
+ }
492
+ }
493
+
494
+ /**
495
+ * The title as the paper sets it: its `\title`, as text — or, when the title holds a command
496
+ * paperlint cannot read (a macro of the author's own), the PDF's own Title (`pdftitle`). Pure.
497
+ */
498
+ export function titleOf(
499
+ source: {
500
+ readonly text: string;
501
+ /** Where an offset of `text` stands, as a message names it (file:line:column). */
502
+ readonly where: (offset: number) => string;
503
+ } | null,
504
+ latex: Pick<LatexReader, "headings">,
505
+ metadata: Readonly<Record<string, string>> | null,
506
+ ): Result<string, string> {
507
+ const titles = (source === null ? [] : latex.headings(source.text)).filter(
508
+ (h) => h.command.kind === "title" && h.argument === "title",
509
+ );
510
+ // One \title is read; several are not chosen between — the PDF's own Title stands in, or the fix.
511
+ const [heading] = titles.length === 1 ? titles : [];
512
+ const text =
513
+ heading === undefined
514
+ ? ""
515
+ : runText(heading.title)
516
+ .replaceAll(LINE_BREAK, " ")
517
+ .replace(/\s+/g, " ")
518
+ .trim();
519
+ if (text !== "" && !text.includes(OPAQUE)) return ok(text);
520
+ const pdfTitle = pdfTitleOf(metadata);
521
+ if (pdfTitle !== "") return ok(pdfTitle);
522
+ return err(
523
+ titles.length > 1
524
+ ? `the paper sets \\title ${String(titles.length)} times (${titles.map((h) => source?.where(h.title.segments[0].at)).join(", ")}), and the PDF names no Title — which one to send is not guessed: keep one \\title, or set \\hypersetup{pdftitle={<the title as plain text>}} and rebuild`
525
+ : noReadableTitle(text),
526
+ );
527
+ }
528
+
529
+ /** The Info dictionary's Title, else the XMP packet's dc:title (pdf-content.ts keeps both); "" for none. */
530
+ const pdfTitleOf = (
531
+ metadata: Readonly<Record<string, string>> | null,
532
+ ): string =>
533
+ [metadata?.["Title"], metadata?.["dc:title"]]
534
+ .map((t) => t?.trim() ?? "")
535
+ .find((t) => t !== "") ?? "";
536
+
537
+ /** The fix when the one \\title is missing (`text` "") or holds a command paperlint does not read. */
538
+ const noReadableTitle = (text: string): string =>
539
+ text === ""
540
+ ? `no \\title in ${MAIN}, and the PDF names no Title — add \\title{…}, or \\hypersetup{pdftitle={…}}, and rebuild`
541
+ : `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
+
543
+ /** 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[] {
549
+ return [
550
+ `title ${title}`,
551
+ ...authors.map(
552
+ (a, i) =>
553
+ `author ${String(i + 1).padEnd(6)} ${a.name} <${a.email}>${a.affiliation === null ? "" : `, ${a.affiliation}`}`,
554
+ ),
555
+ `abstract ${String(wordCount(abstract))} words:`,
556
+ ` ${abstract}`,
557
+ ];
558
+ }
559
+
560
+ /** What the author does on the site after `new`: what paperlint does not send. Pure. */
561
+ export function onTheSiteLines(
562
+ site: PortalSite,
563
+ id: number | null,
564
+ paperArg: string,
565
+ ): readonly string[] {
566
+ return [
567
+ `do on the site, ${site.submissionPage(id)} — paperlint sends only the title, the abstract, the authors and the PDF:`,
568
+ ...site.siteOnly.map((f) => ` - ${f}`),
569
+ ` - then mark it submitted: npx paperlint submission update ${paperArg} --submit (a dry run; --save after your yes)`,
570
+ ];
571
+ }
572
+
573
+ /** `new`'s report of the portal's answer. Pure. */
574
+ export function createLines(
575
+ o: CreateOutcome,
576
+ save: boolean,
577
+ ): readonly string[] {
578
+ const mode = save
579
+ ? o.id === null
580
+ ? "NOTHING CREATED — the portal did not save it"
581
+ : `created submission ${String(o.id)}`
582
+ : "dry run — the portal checked the new submission and kept nothing; --save creates it for real";
583
+ return [
584
+ mode,
585
+ `HTTP ${String(o.httpStatus)}`,
586
+ `valid ${o.valid ? "yes" : "NO"}`,
587
+ `changes ${o.changes.length ? o.changes.join(", ") : "—"}`,
588
+ ...o.messages.map(messageLine),
589
+ ];
590
+ }
591
+
592
+ /** A file's JSON, or why there is none: gone, or not JSON. */
593
+ function jsonAt(files: Files, path: string): Result<unknown, string> {
594
+ const bytes = bytesAt(files, path);
595
+ if (bytes === null) return err("the file is gone");
596
+ try {
597
+ return ok(JSON.parse(new TextDecoder().decode(bytes)));
598
+ } catch (e) {
599
+ return err(`not JSON: ${messageOf(e)}`);
600
+ }
601
+ }
602
+
603
+ /** `paperlint.json` with the new id recorded in it, written; or why it was not. */
604
+ function recordId(
605
+ config: string,
606
+ id: number,
607
+ deps: SubmissionDeps,
608
+ ): Result<null, string> {
609
+ const json = jsonAt(deps.files, config);
610
+ if (!json.ok) return json;
611
+ const next = withSubmissionId(json.value, id);
612
+ if (!next.ok) return next;
613
+ // A write that fails (read-only, disk full) must not lose the id the portal just gave.
614
+ try {
615
+ deps.files.writeAtomic(
616
+ absolutePath(config),
617
+ new TextEncoder().encode(`${JSON.stringify(next.value, null, 2)}\n`),
618
+ );
619
+ return ok(null);
620
+ } catch (e) {
621
+ return err(messageOf(e));
622
+ }
623
+ }
624
+
625
+ //** Why `new` cannot run for this paper, or the authors it sends: checked before anything is read. */
626
+ function readyForNew(t: SubmissionTarget): Result<readonly Author[], string> {
627
+ if (t.id !== null)
628
+ return err(
629
+ `${t.config}: the paper already has submission ${String(t.id)} on ${t.portal.url} — \`paperlint submission show\` reads it, \`paperlint submission update\` changes it`,
630
+ );
631
+ if (t.accepted)
632
+ return err(
633
+ `${t.config}: the current attempt is accepted — a new submission belongs to a new cycle`,
634
+ );
635
+ return t.authors === null
636
+ ? err(
637
+ `${t.config}: no "authors" — add "authors": [{ "name": "…", "email": "…", "affiliation": "…" }], every author in the paper's order, as ${hostOf(t.portal.url)} should record them; paperlint never guesses them`,
638
+ )
639
+ : ok(t.authors);
640
+ }
641
+
642
+ /** The paper as the build read it: paper.tex with the files it includes spliced in; null without one. */
643
+ function assembledPaper(
644
+ paperDir: string,
645
+ deps: SubmissionDeps,
646
+ ): PaperSource | null {
647
+ const bytes = bytesAt(deps.files, join(paperDir, MAIN));
648
+ return bytes === null
649
+ ? null
650
+ : readPaper(join(paperDir, MAIN), new TextDecoder().decode(bytes), deps);
651
+ }
652
+
653
+ /** The title and the abstract: the paper's `\title`, and `--abstract`'s file or the source's abstract. */
654
+ function titleAndAbstract(
655
+ a: SubmissionArgs & { readonly paperDir: string },
656
+ pdf: Uint8Array,
657
+ deps: SubmissionDeps,
658
+ ): Result<{ readonly title: string; readonly abstract: string }, string> {
659
+ const own = abstractOf(a, deps);
660
+ if (!own.ok) return own;
661
+ const paper = assembledPaper(a.paperDir, deps);
662
+ const metadata = pdfMetadata(a.paperDir, pdf, deps);
663
+ const title = titleOf(
664
+ paper === null
665
+ ? null
666
+ : { text: paper.text, where: (o: number) => placeIn(paper, o) },
667
+ deps.latex,
668
+ metadata,
669
+ );
670
+ if (!title.ok) return title;
671
+ const abstract =
672
+ own.value === null
673
+ ? sourceAbstract(paper, deps)
674
+ : ok(own.value.replace(/\s+/g, " "));
675
+ return abstract.ok
676
+ ? ok({ title: title.value, abstract: abstract.value })
677
+ : abstract;
678
+ }
679
+
680
+ /** What `new` sends, or the first line saying what is missing. */
681
+ function newSubmissionOf(
682
+ t: SubmissionTarget,
683
+ a: SubmissionArgs & { readonly pdfPath: string; readonly paperDir: string },
684
+ deps: SubmissionDeps,
685
+ ): Result<{ readonly paper: NewSubmission; readonly shown: string }, string> {
686
+ const authors = readyForNew(t);
687
+ if (!authors.ok) return authors;
688
+ const shown = shownPath(deps.cwd, a.pdfPath);
689
+ const pdf = bytesAt(deps.files, a.pdfPath);
690
+ if (pdf === null)
691
+ return err(`no PDF at ${shown} — build the paper, or name one with --pdf`);
692
+ const text = titleAndAbstract(a, pdf, deps);
693
+ if (!text.ok) return text;
694
+ const paper = {
695
+ ...text.value,
696
+ authors: authors.value,
697
+ pdf: { name: basename(a.pdfPath), bytes: pdf },
698
+ };
699
+ return ok({ paper, shown });
700
+ }
701
+
702
+ //** After a save that created one: the id written into `paperlint.json`, and the next command. */
703
+ function recordCreated(
704
+ t: SubmissionTarget,
705
+ id: number,
706
+ paperArg: string,
707
+ deps: RunDeps,
708
+ ): number {
709
+ const config = shownPath(deps.cwd, t.config);
710
+ const recorded = recordId(t.config, id, deps);
711
+ if (!recorded.ok) {
712
+ deps.err(
713
+ `submission ${String(id)} was created (${deps.portalSite.submissionPage(id)}), but ${config} was not changed (${recorded.error}) — add "submission": { "id": ${String(id)} } to the current attempt by hand`,
714
+ );
715
+ return 1;
716
+ }
717
+ deps.log(`recorded "submission": { "id": ${String(id)} } in ${config}`);
718
+ deps.log(
719
+ `next npx paperlint submission show ${paperArg} — it should say "match yes"`,
720
+ );
721
+ return 0;
722
+ }
723
+
724
+ const logAll = (lines: readonly string[], deps: RunDeps): void => {
725
+ lines.forEach((l) => {
726
+ deps.log(l);
727
+ });
728
+ };
729
+
730
+ /**
731
+ * `new`: create the paper's submission — title and abstract from the build, the authors the paper
732
+ * declares, the PDF. A dry run unless `--save`; after a save, the id goes into `paperlint.json`.
733
+ */
734
+ async function runNew(
735
+ t: SubmissionTarget,
736
+ portal: SubmissionPortal,
737
+ a: SubmissionArgs & { readonly pdfPath: string; readonly paperDir: string },
738
+ deps: RunDeps,
739
+ ): Promise<number> {
740
+ const n = newSubmissionOf(t, a, deps);
741
+ if (!n.ok) return (deps.err(n.error), 2);
742
+ const { paper, shown } = n.value;
743
+ deps.log(checkPdfLine(shown, t.call));
744
+ logAll(newLines(paper.title, paper.abstract, paper.authors), deps);
745
+ const r = await portal.create(paper, { save: a.save });
746
+ if (!r.ok) return failed(r.error, deps);
747
+ logAll(createLines(r.value, a.save), deps);
748
+ const fine =
749
+ r.value.valid && r.value.httpStatus >= 200 && r.value.httpStatus < 300;
750
+ if (!fine || (a.save && r.value.id === null)) return 1;
751
+ const paperArg =
752
+ a.paperDir === deps.cwd ? "." : shownPath(deps.cwd, a.paperDir);
753
+ logAll(onTheSiteLines(deps.portalSite, r.value.id, paperArg), deps);
754
+ return r.value.id === null ? 0 : recordCreated(t, r.value.id, paperArg, deps);
755
+ }
756
+
757
+ //** The flags that change something, as given. */
758
+ const writingFlags = (a: SubmissionArgs): readonly string[] =>
759
+ [
760
+ a.abstract === null ? null : "--abstract",
761
+ a.submit ? "--submit" : null,
762
+ a.save ? "--save" : null,
763
+ ].filter((f) => f !== null);
764
+
360
765
  /** Why the command line cannot be run as asked; null when it can. Pure. */
361
766
  export function usageProblem(a: SubmissionArgs): string | null {
362
767
  if (!isSubcommand(a.sub))
363
768
  return `\`submission\` needs a subcommand: ${SUBCOMMANDS.join(" or ")} — got \`${a.sub ?? ""}\``;
364
769
  if (a.extra.length > 0)
365
770
  return `\`submission ${a.sub}\` takes one paper folder — got also \`${a.extra.join(" ")}\``;
366
- const updateOnly = [
367
- a.abstract === null ? null : "--abstract",
368
- a.submit ? "--submit" : null,
369
- a.save ? "--save" : null,
370
- ].filter((f) => f !== null);
371
- return a.sub === "show" && updateOnly.length > 0
372
- ? `${updateOnly.join(", ")}: only \`submission update\` changes anything; \`show\` reads`
771
+ const writing = writingFlags(a);
772
+ if (a.sub === "show" && writing.length > 0)
773
+ return `${writing.join(", ")}: only \`submission new\` and \`submission update\` change anything; \`show\` reads`;
774
+ return a.sub === "new" && a.submit
775
+ ? "--submit: `submission new` creates the submission; mark it submitted with `submission update --submit` once `show` says the portal holds this build"
373
776
  : null;
374
777
  }
375
778
 
376
- /** `paperlint submission <show|update> [paper-dir]` — exit 0, 1 (the portal said no), or 2 (usage). */
779
+ /** The variables a site's token lookup reads. */
780
+ const tokenNames = (site: PortalSite) => ({
781
+ variable: site.tokenVariable,
782
+ siteLess: site.siteLessVariable,
783
+ });
784
+
785
+ /** No token to use: the complete instruction, exit 2 — nothing is sent. */
786
+ function tokenMissing(
787
+ portal: SupportedPortal,
788
+ site: PortalSite,
789
+ p: TokenProblem,
790
+ deps: SubmissionDeps,
791
+ ): number {
792
+ tokenProblemLines(portal, site, p).forEach((l) => {
793
+ deps.err(l);
794
+ });
795
+ return 2;
796
+ }
797
+
798
+ /** `paperlint submission <new|show|update> [paper-dir]` — exit 0, 1 (the portal said no), or 2 (usage). */
377
799
  export async function runSubmission(
378
800
  a: SubmissionArgs,
379
801
  deps: SubmissionDeps,
@@ -383,11 +805,22 @@ export async function runSubmission(
383
805
  const paperDir = resolve(deps.cwd, a.paperDir);
384
806
  const t = submissionTarget(paperDir, deps.presets);
385
807
  if (!t.ok) return (deps.err(t.error), 2);
386
- const token = tokenFor(t.value.portal, deps.env);
387
- if (!token.ok) return (deps.err(token.error), 2);
388
- const portal = deps.portalFor(t.value.portal, token.value);
808
+ const id = a.sub === "new" ? ok(null) : idOf(t.value);
809
+ if (!id.ok) return (deps.err(id.error), 2);
810
+ const site = deps.site(t.value.portal);
811
+ const found = lookUpToken(tokenNames(site), deps.env);
812
+ if (!found.ok) return tokenMissing(t.value.portal, site, found.error, deps);
813
+ const portal = site.connect(found.value.token);
389
814
  const pdfPath = a.pdf === null ? t.value.pdf : resolve(deps.cwd, a.pdf);
815
+ const withToken: RunDeps = {
816
+ ...deps,
817
+ portalSite: site,
818
+ refused: (f) => tokenRefusedLines(t.value.portal, site, f),
819
+ };
820
+ if (id.value === null)
821
+ return runNew(t.value, portal, { ...a, pdfPath, paperDir }, withToken);
822
+ const target = { ...t.value, id: id.value };
390
823
  return a.sub === "show"
391
- ? runShow(t.value, portal, pdfPath, deps)
392
- : runUpdate(t.value, portal, { ...a, pdfPath }, deps);
824
+ ? runShow(target, portal, pdfPath, withToken)
825
+ : runUpdate(target, portal, { ...a, pdfPath }, withToken);
393
826
  }