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
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ok": true,
|
|
3
|
+
"searchcompletion": [
|
|
4
|
+
"has:submission",
|
|
5
|
+
"has:abstract",
|
|
6
|
+
"abstract:{text}",
|
|
7
|
+
"has:response",
|
|
8
|
+
"has:draftresponse",
|
|
9
|
+
"topic:\"DS/ML/AI Technique\"",
|
|
10
|
+
"topic:\"DS/ML/AI Technique: Classical machine learning (e.g., regression, SVMs, nearest neighbour, decision trees, PCA, naive Bayes classifier, and k-means clustering)\"",
|
|
11
|
+
"topic:\"DS/ML/AI Technique: Convolutional neural networks\"",
|
|
12
|
+
"topic:\"DS/ML/AI Technique: LLM or other foundation models (e.g., large vision model)\"",
|
|
13
|
+
"topic:\"DS/ML/AI Technique: Reinforcement learning\"",
|
|
14
|
+
"topic:\"DS/ML/AI Technique: Transformers\"",
|
|
15
|
+
"topic:\"Performance analysis and testing\"",
|
|
16
|
+
"topic:\"Research Method\"",
|
|
17
|
+
"topic:\"Research Method: Controlled experiments with human subjects (e.g., A/B tests, lab studies)\"",
|
|
18
|
+
"topic:\"Research Method: Qualitative analysis (e.g., thematic analysis, grounded theory)\"",
|
|
19
|
+
"topic:\"Research Method: Statistical analysis and modelling\"",
|
|
20
|
+
"topic:\"Research Method: Survey design and analysis\"",
|
|
21
|
+
"topic:\"SE Topic\"",
|
|
22
|
+
"topic:\"SE Topic: AI for software engineering\"",
|
|
23
|
+
"topic:\"SE Topic: API design and evolution\"",
|
|
24
|
+
"topic:\"SE Topic: App store analysis\"",
|
|
25
|
+
"topic:\"SE Topic: Cloud-based and distributed systems\"",
|
|
26
|
+
"topic:\"SE Topic: Debugging, fault localization, and fault prediction\"",
|
|
27
|
+
"topic:\"SE Topic: DevOps and release engineering\"",
|
|
28
|
+
"topic:\"SE Topic: Distributed and collaborative software engineering\"",
|
|
29
|
+
"topic:\"SE Topic: Energy profiling\"",
|
|
30
|
+
"topic:\"SE Topic: Ethical concerns (bias, fairness, explainability, etc.)\"",
|
|
31
|
+
"topic:\"SE Topic: Human aspects\"",
|
|
32
|
+
"topic:\"SE Topic: Legal aspects (licenses, copyright, etc.)\"",
|
|
33
|
+
"topic:\"SE Topic: Privacy and security\"",
|
|
34
|
+
"topic:\"SE Topic: Program comprehension\"",
|
|
35
|
+
"topic:\"SE Topic: Recommender systems\"",
|
|
36
|
+
"topic:\"SE Topic: Software development processes\"",
|
|
37
|
+
"topic:\"SE Topic: Software ecosystems\"",
|
|
38
|
+
"topic:\"SE Topic: Software engineering for AI\"",
|
|
39
|
+
"topic:\"SE Topic: Software maintenance and evolution\"",
|
|
40
|
+
"topic:\"SE Topic: Software measurement and analytics\"",
|
|
41
|
+
"topic:\"SE Topic: Software quality assurance\"",
|
|
42
|
+
"topic:\"SE Topic: Source / test code\"",
|
|
43
|
+
"topic:\"SE Topic: Version control history logs\"",
|
|
44
|
+
"topic:\"Software repository\"",
|
|
45
|
+
"topic:\"Software repository: App stores\"",
|
|
46
|
+
"topic:\"Software repository: Archived communications (mailing lists, Gitter/Slack/Discord channels, etc.)\"",
|
|
47
|
+
"topic:\"Software repository: Build / CI logs\"",
|
|
48
|
+
"topic:\"Software repository: Code reviews\"",
|
|
49
|
+
"topic:\"Software repository: Execution traces and logs\"",
|
|
50
|
+
"topic:\"Software repository: Human interaction data\"",
|
|
51
|
+
"topic:\"Software repository: Issue trackers / defect repositories\"",
|
|
52
|
+
"topic:\"Software repository: Package registries / software releases\"",
|
|
53
|
+
"topic:\"Software repository: Q&A websites\"",
|
|
54
|
+
"show:({formula})",
|
|
55
|
+
"hide:({formula})",
|
|
56
|
+
"show:abstract",
|
|
57
|
+
"hide:abstract",
|
|
58
|
+
"show:ACMCorAut",
|
|
59
|
+
"hide:ACMCorAut",
|
|
60
|
+
"show:acmpagecount",
|
|
61
|
+
"hide:acmpagecount",
|
|
62
|
+
"show:authors",
|
|
63
|
+
"hide:authors",
|
|
64
|
+
"show:commenters",
|
|
65
|
+
"hide:commenters",
|
|
66
|
+
"show:conflict",
|
|
67
|
+
"hide:conflict",
|
|
68
|
+
"show:conflict:{user}",
|
|
69
|
+
"hide:conflict:{user}",
|
|
70
|
+
"show:decision",
|
|
71
|
+
"hide:decision",
|
|
72
|
+
"show:graph({formula})",
|
|
73
|
+
"hide:graph({formula})",
|
|
74
|
+
"show:id",
|
|
75
|
+
"hide:id",
|
|
76
|
+
"show:options",
|
|
77
|
+
"hide:options",
|
|
78
|
+
"show:pagecount",
|
|
79
|
+
"hide:pagecount",
|
|
80
|
+
"show:pcconf",
|
|
81
|
+
"hide:pcconf",
|
|
82
|
+
"show:revtype",
|
|
83
|
+
"hide:revtype",
|
|
84
|
+
"show:shuffle",
|
|
85
|
+
"hide:shuffle",
|
|
86
|
+
"show:status",
|
|
87
|
+
"hide:status",
|
|
88
|
+
"show:timestamp",
|
|
89
|
+
"hide:timestamp",
|
|
90
|
+
"show:title",
|
|
91
|
+
"hide:title",
|
|
92
|
+
"show:topics",
|
|
93
|
+
"hide:topics",
|
|
94
|
+
"show:wordcount",
|
|
95
|
+
"hide:wordcount",
|
|
96
|
+
"show:facets",
|
|
97
|
+
"show:statistics",
|
|
98
|
+
"show:rownumbers"
|
|
99
|
+
]
|
|
100
|
+
}
|
|
@@ -12,7 +12,15 @@
|
|
|
12
12
|
* Create: the same `POST`, with `p=new` and `"pid": "new"` in the object β Β«Setting `p=new` will
|
|
13
13
|
* create a new submission; the response will contain the chosen submission IDΒ»
|
|
14
14
|
* (`devel/apidoc/submissions.md`, `post /paper`, in kohler/hotcrp). The object carries the title, the
|
|
15
|
-
* abstract, the authors and the PDF
|
|
15
|
+
* abstract, the authors and the PDF, and the venue's own fields the paper answers.
|
|
16
|
+
*
|
|
17
|
+
* Fields: a change and a creation carry the paper's `fields` as submission-object keys, as checked
|
|
18
|
+
* against the venue preset's `submission_fields` (`topics`: a list of `"Group: Topic"`; measured
|
|
19
|
+
* 2026-10-20, a bare `"Topic"` is dropped without a word).
|
|
20
|
+
*
|
|
21
|
+
* Options: `GET /api/searchcompletion` β the search box's completions, which an author's token may
|
|
22
|
+
* read; they name every topic as `topic:"<name>"`. (`/api/settings`, which describes the form,
|
|
23
|
+
* answers "Permission error" to an author.)
|
|
16
24
|
*
|
|
17
25
|
* π΄ THE TOKEN goes in the `Authorization` header and nowhere else: not into a URL, a message, or an
|
|
18
26
|
* error. A failure is described by HTTP status, HotCRP's own messages, or the network error's text.
|
|
@@ -26,7 +34,12 @@ import type {
|
|
|
26
34
|
SubmissionChange,
|
|
27
35
|
} from "../../domain/submission.ts";
|
|
28
36
|
import type { SubmissionPortal } from "../../ports/submission-portal.ts";
|
|
29
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
parseCreate,
|
|
39
|
+
parseOptions,
|
|
40
|
+
parseShow,
|
|
41
|
+
parseUpdate,
|
|
42
|
+
} from "./response.ts";
|
|
30
43
|
|
|
31
44
|
/**
|
|
32
45
|
* The form field the PDF travels in, and the name the `json` object's `content_file` refers to it
|
|
@@ -91,6 +104,7 @@ export function changeObject(
|
|
|
91
104
|
...(c.pdf === null ? {} : { submission: { content_file: CONTENT_FIELD } }),
|
|
92
105
|
...(c.abstract === null ? {} : { abstract: c.abstract }),
|
|
93
106
|
...(c.submit ? { status: "submitted" } : {}),
|
|
107
|
+
...c.fields,
|
|
94
108
|
};
|
|
95
109
|
}
|
|
96
110
|
|
|
@@ -112,6 +126,7 @@ export function createObject(
|
|
|
112
126
|
abstract: p.abstract,
|
|
113
127
|
authors: p.authors.map(authorObject),
|
|
114
128
|
submission: { content_file: CONTENT_FIELD },
|
|
129
|
+
...p.fields,
|
|
115
130
|
};
|
|
116
131
|
}
|
|
117
132
|
|
|
@@ -170,5 +185,10 @@ export function hotcrpPortal(o: HotcrpOptions): SubmissionPortal {
|
|
|
170
185
|
const a = await post(o, { p: "new", object, pdf: paper.pdf, save });
|
|
171
186
|
return a.ok ? parseCreate(a.value.status, a.value.body) : a;
|
|
172
187
|
},
|
|
188
|
+
async options() {
|
|
189
|
+
const url = `${o.url}/api/searchcompletion`;
|
|
190
|
+
const a = await call(url, { method: "GET" }, o.token, READ_TIMEOUT_MS);
|
|
191
|
+
return a.ok ? parseOptions(a.value.status, a.value.body) : a;
|
|
192
|
+
},
|
|
173
193
|
};
|
|
174
194
|
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
createObject,
|
|
25
25
|
hotcrpPortal,
|
|
26
26
|
parseCreate,
|
|
27
|
+
parseOptions,
|
|
27
28
|
parseShow,
|
|
28
29
|
parseUpdate,
|
|
29
30
|
sha256OfHash,
|
|
@@ -79,7 +80,12 @@ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
|
79
80
|
*/
|
|
80
81
|
/** A recorded HotCRP answer (`cassette/README.md`), as the text it was sent as. */
|
|
81
82
|
function cassette(
|
|
82
|
-
name:
|
|
83
|
+
name:
|
|
84
|
+
| "get-paper"
|
|
85
|
+
| "get-noauth"
|
|
86
|
+
| "post-dot"
|
|
87
|
+
| "post-ok"
|
|
88
|
+
| "get-searchcompletion",
|
|
83
89
|
): string {
|
|
84
90
|
return readFileSync(
|
|
85
91
|
new URL(`./cassette/${name}.json`, import.meta.url),
|
|
@@ -165,6 +171,17 @@ function partsOf(s: Seen): ReadonlyMap<string, Part> {
|
|
|
165
171
|
);
|
|
166
172
|
}
|
|
167
173
|
|
|
174
|
+
test("options: GET /api/searchcompletion with the bearer token β the topics", async () => {
|
|
175
|
+
seen.length = 0;
|
|
176
|
+
reply = { status: 200, body: cassette("get-searchcompletion") };
|
|
177
|
+
const r = await portal().options();
|
|
178
|
+
const s = last();
|
|
179
|
+
assert.equal(s.method, "GET");
|
|
180
|
+
assert.equal(s.url.pathname, "/api/searchcompletion");
|
|
181
|
+
assert.equal(s.headers.authorization, `bearer ${TOKEN}`);
|
|
182
|
+
assert.ok(r.ok && r.value.topics.length === 41);
|
|
183
|
+
});
|
|
184
|
+
|
|
168
185
|
test("show: GET /api/paper?p=<id>&word_limit=hard with the bearer token, parsed whole", async () => {
|
|
169
186
|
seen.length = 0;
|
|
170
187
|
reply = { status: 200, body: cassette("get-paper") };
|
|
@@ -263,6 +280,7 @@ const CHANGE: SubmissionChange = {
|
|
|
263
280
|
pdf: { name: "paper.pdf", bytes: PDF },
|
|
264
281
|
abstract: "new abstract",
|
|
265
282
|
submit: true,
|
|
283
|
+
fields: {},
|
|
266
284
|
};
|
|
267
285
|
|
|
268
286
|
test("update: a dry run by default β dry_run=1, the json field, the PDF under its content_file name", async () => {
|
|
@@ -359,6 +377,7 @@ const NEW: NewSubmission = {
|
|
|
359
377
|
{ name: "Bob", email: "bob@example.org", affiliation: null },
|
|
360
378
|
],
|
|
361
379
|
pdf: { name: "paper.pdf", bytes: PDF },
|
|
380
|
+
fields: {},
|
|
362
381
|
};
|
|
363
382
|
|
|
364
383
|
test("create: a dry run by default β p=new, dry_run=1, title/abstract/authors and the PDF; no id", async () => {
|
|
@@ -545,6 +564,7 @@ test("a portal that does not answer is unreachable, with the network's words", a
|
|
|
545
564
|
await down.show(7),
|
|
546
565
|
await down.update(7, CHANGE, { save: false }),
|
|
547
566
|
await down.create(NEW, { save: false }),
|
|
567
|
+
await down.options(),
|
|
548
568
|
]) {
|
|
549
569
|
assert.equal(!r.ok && r.error.kind, "unreachable");
|
|
550
570
|
assert.ok(!r.ok && r.error.kind === "unreachable" && r.error.detail !== "");
|
|
@@ -553,11 +573,92 @@ test("a portal that does not answer is unreachable, with the network's words", a
|
|
|
553
573
|
|
|
554
574
|
test("changeObject: only what changes is sent", () => {
|
|
555
575
|
assert.deepEqual(
|
|
556
|
-
changeObject(7, { pdf: null, abstract: null, submit: false }),
|
|
576
|
+
changeObject(7, { pdf: null, abstract: null, submit: false, fields: {} }),
|
|
557
577
|
{ object: "paper", pid: 7 },
|
|
558
578
|
);
|
|
559
579
|
});
|
|
560
580
|
|
|
581
|
+
test("changeObject and createObject: the venue's fields go in as submission-object keys", () => {
|
|
582
|
+
const fields = {
|
|
583
|
+
topics: ["SE Topic: AI for software engineering"],
|
|
584
|
+
pc_conflicts: { "pc@example.org": "collaborator" },
|
|
585
|
+
ACMCorAut: "ada@example.org",
|
|
586
|
+
};
|
|
587
|
+
assert.deepEqual(
|
|
588
|
+
changeObject(7, { pdf: null, abstract: null, submit: false, fields }),
|
|
589
|
+
{ object: "paper", pid: 7, ...fields },
|
|
590
|
+
);
|
|
591
|
+
const created = createObject({ ...NEW, fields });
|
|
592
|
+
assert.deepEqual(
|
|
593
|
+
[created["topics"], created["pc_conflicts"], created["ACMCorAut"]],
|
|
594
|
+
[fields.topics, fields.pc_conflicts, fields.ACMCorAut],
|
|
595
|
+
);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("π΄ parseOptions: the recorded msr2027 completions β its 41 topics, the 4 group names left out", () => {
|
|
599
|
+
const r = parseOptions(200, parsed("get-searchcompletion"));
|
|
600
|
+
assert.ok(r.ok);
|
|
601
|
+
assert.equal(r.value.topics.length, 41);
|
|
602
|
+
assert.ok(r.value.topics.includes("SE Topic: AI for software engineering"));
|
|
603
|
+
assert.ok(r.value.topics.includes("Performance analysis and testing"));
|
|
604
|
+
assert.ok(!r.value.topics.includes("SE Topic"));
|
|
605
|
+
// A completion that is no string is no topic.
|
|
606
|
+
assert.deepEqual(
|
|
607
|
+
parseOptions(200, {
|
|
608
|
+
ok: true,
|
|
609
|
+
searchcompletion: [{ q: "x" }, 'topic:"Only"'],
|
|
610
|
+
}),
|
|
611
|
+
{ ok: true, value: { topics: ["Only"] } },
|
|
612
|
+
);
|
|
613
|
+
// A quote or backslash inside the quoted name comes escaped; the topic is the decoded name.
|
|
614
|
+
assert.deepEqual(
|
|
615
|
+
parseOptions(200, {
|
|
616
|
+
ok: true,
|
|
617
|
+
searchcompletion: ['topic:"C++ \\"modern\\" a\\\\b"'],
|
|
618
|
+
}),
|
|
619
|
+
{ ok: true, value: { topics: ['C++ "modern" a\\b'] } },
|
|
620
|
+
);
|
|
621
|
+
assert.deepEqual(parseOptions(401, parsed("get-noauth")), {
|
|
622
|
+
ok: false,
|
|
623
|
+
error: {
|
|
624
|
+
kind: "refused",
|
|
625
|
+
httpStatus: 401,
|
|
626
|
+
messages: [{ message: "Missing credentials", field: null, status: 2 }],
|
|
627
|
+
},
|
|
628
|
+
});
|
|
629
|
+
assert.deepEqual(parseOptions(200, { ok: true }), {
|
|
630
|
+
ok: false,
|
|
631
|
+
error: {
|
|
632
|
+
kind: "malformed",
|
|
633
|
+
httpStatus: 200,
|
|
634
|
+
detail: "the answer has no `searchcompletion`",
|
|
635
|
+
},
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test("π΄ an HTML message (<5>) is printed as its text, the tags dropped; a plain one (<0>) as it is", () => {
|
|
640
|
+
const r = parseUpdate(200, {
|
|
641
|
+
valid: true,
|
|
642
|
+
message_list: [
|
|
643
|
+
{
|
|
644
|
+
status: 1,
|
|
645
|
+
field: "pc_conflicts",
|
|
646
|
+
message:
|
|
647
|
+
'<5>You may have missed conflicts of interest with <a href="#pcconf:1">A B</a>.',
|
|
648
|
+
},
|
|
649
|
+
{ status: 2, field: "topics", message: "<0>Use <b> sparingly" },
|
|
650
|
+
],
|
|
651
|
+
});
|
|
652
|
+
assert.ok(r.ok);
|
|
653
|
+
assert.deepEqual(
|
|
654
|
+
r.value.messages.map((m) => m.message),
|
|
655
|
+
[
|
|
656
|
+
"You may have missed conflicts of interest with A B.",
|
|
657
|
+
"Use <b> sparingly",
|
|
658
|
+
],
|
|
659
|
+
);
|
|
660
|
+
});
|
|
661
|
+
|
|
561
662
|
test("sha256OfHash: sha2-<hex> is a sha256; any other hash is not claimed to be one", () => {
|
|
562
663
|
assert.equal(sha256OfHash(`sha2-${HEX}`), HEX);
|
|
563
664
|
assert.equal(sha256OfHash("da39a3ee5e6b4b0d3255bfef95601890afd80709"), null);
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Only the fields the command uses are read; every other key HotCRP sends is dropped here. The field
|
|
5
5
|
* names are HotCRP's (`devel/openapi.json` and `devel/apidoc/submissions.md` in kohler/hotcrp):
|
|
6
6
|
* `GET /api/paper` answers `{ ok, paper, message_list? }`, `POST /api/paper` answers
|
|
7
|
-
* `{ ok, valid, change_list, message_list, dry_run?, pid }
|
|
7
|
+
* `{ ok, valid, change_list, message_list, dry_run?, pid }`, `GET /api/searchcompletion` answers
|
|
8
|
+
* `{ ok, searchcompletion: [<completion>, β¦] }`. A document's `hash` is
|
|
8
9
|
* `sha2-<64 hex>` when HotCRP stored a sha256; timestamps are Unix seconds.
|
|
9
10
|
*/
|
|
10
11
|
import { z } from "zod";
|
|
@@ -15,6 +16,7 @@ import type {
|
|
|
15
16
|
PortalDocument,
|
|
16
17
|
PortalFailure,
|
|
17
18
|
PortalMessage,
|
|
19
|
+
PortalOptions,
|
|
18
20
|
SubmissionView,
|
|
19
21
|
UpdateOutcome,
|
|
20
22
|
} from "../../domain/submission.ts";
|
|
@@ -70,9 +72,15 @@ export function sha256OfHash(hash: string): Sha256 | null {
|
|
|
70
72
|
return hex === undefined ? null : parseSha256(hex);
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
75
|
+
/**
|
|
76
|
+
* HotCRP prefixes a message with its format (`<0>` plain text, `<5>` HTML); the text is the rest,
|
|
77
|
+
* an HTML one's tags dropped (Β«You may have missed conflicts of interest with <a href=β¦>β¦</a>Β»).
|
|
78
|
+
*/
|
|
79
|
+
function textOf(message: string | undefined): string {
|
|
80
|
+
const m = /^<(\d+)>/.exec(message ?? "");
|
|
81
|
+
const rest = (message ?? "").slice(m?.[0].length ?? 0);
|
|
82
|
+
return (m?.[1] === "5" ? rest.replace(/<[^>]*>/g, "") : rest).trim();
|
|
83
|
+
}
|
|
76
84
|
|
|
77
85
|
/** The messages that say something: HotCRP also sends entries with no text, which print as blanks. */
|
|
78
86
|
const messagesOf = (
|
|
@@ -200,3 +208,36 @@ export function parseCreate(
|
|
|
200
208
|
id: saved && typeof pid === "number" ? pid : null,
|
|
201
209
|
});
|
|
202
210
|
}
|
|
211
|
+
|
|
212
|
+
const OptionsBody = z.object({
|
|
213
|
+
ok: z.literal(true),
|
|
214
|
+
searchcompletion: z.array(z.unknown()),
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const TOPIC = /^topic:"(.+)"$/s;
|
|
218
|
+
|
|
219
|
+
/** A quoted search operand with its backslash escapes undone: the name as the form spells it. */
|
|
220
|
+
const unquoted = (quoted: string): string => quoted.replace(/\\(.)/gs, "$1");
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* `GET /api/searchcompletion` β the topics an author can choose. A completion `topic:"<name>"` names
|
|
224
|
+
* a topic; a name that only groups others (`SE Topic`, beside `SE Topic: Human aspects`) is not one
|
|
225
|
+
* a submission can carry β measured 2026-10-20, HotCRP drops it β so it is left out.
|
|
226
|
+
*/
|
|
227
|
+
export function parseOptions(
|
|
228
|
+
httpStatus: number,
|
|
229
|
+
body: unknown,
|
|
230
|
+
): Result<PortalOptions, PortalFailure> {
|
|
231
|
+
const status = statusOf(httpStatus, body);
|
|
232
|
+
const b = OptionsBody.safeParse(body);
|
|
233
|
+
if (!b.success || !is2xx(status))
|
|
234
|
+
return err(refusal(status, body, "the answer has no `searchcompletion`"));
|
|
235
|
+
const names = b.data.searchcompletion
|
|
236
|
+
.map((c) => (typeof c === "string" ? TOPIC.exec(c)?.[1] : undefined))
|
|
237
|
+
.filter((n) => n !== undefined)
|
|
238
|
+
.map(unquoted);
|
|
239
|
+
const groups = new Set(
|
|
240
|
+
names.filter((n) => names.some((x) => x.startsWith(`${n}: `))),
|
|
241
|
+
);
|
|
242
|
+
return ok({ topics: names.filter((n) => !groups.has(n)) });
|
|
243
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A HotCRP site before any token, as the `PortalSite` port: the variable its token is read from,
|
|
3
3
|
* where on the site an author makes one and with which scopes, what `submission new` leaves to the
|
|
4
|
-
* site, a submission's page β and the API, once there is a token. Every word here is HotCRP's.
|
|
4
|
+
* site (what the venue preset records no field for), a submission's page β and the API, once there is a token. Every word here is HotCRP's.
|
|
5
5
|
*/
|
|
6
6
|
import type { PortalSite } from "../../ports/submission-portal.ts";
|
|
7
7
|
import { hotcrpPortal } from "./portal.io.ts";
|
|
@@ -25,8 +25,7 @@ export const hotcrpSite = (url: string): PortalSite => ({
|
|
|
25
25
|
"submeta:read and document:read (show), submeta:write and document:write (update, new) β tick all four to use every subcommand",
|
|
26
26
|
},
|
|
27
27
|
siteOnly: [
|
|
28
|
-
|
|
29
|
-
"topics, and every field the venue adds (paper type, artifact, consent boxes β¦)",
|
|
28
|
+
'every field of the form the venue preset does not record in "submission_fields" (with none recorded: the topics, the PC conflicts, a paper type, consent boxes β¦)',
|
|
30
29
|
],
|
|
31
30
|
submissionPage: (id) =>
|
|
32
31
|
`${url}/paper/${id === null ? "<its number>" : String(id)}`,
|
package/src/cli.ts
CHANGED
|
@@ -247,6 +247,9 @@ const USAGE = `paperlint β machine-checkable gates for a paper kept in git
|
|
|
247
247
|
send the PDF (default: the paper's own), an abstract, or
|
|
248
248
|
"submitted". A DRY RUN unless --save: the portal checks the
|
|
249
249
|
change and keeps nothing
|
|
250
|
+
npx paperlint submission topics [paper]
|
|
251
|
+
the topics the venue's portal offers, against the ones its
|
|
252
|
+
preset records; exit 1 when they differ
|
|
250
253
|
npx paperlint studies new <paper-dir> "<title>" [--kind <kind>]
|
|
251
254
|
register a study: the next id in repro/studies.json, the folder
|
|
252
255
|
repro/<id>-<slug>/ with its README.md and plan.json (a template),
|
|
@@ -291,7 +294,9 @@ studies:
|
|
|
291
294
|
submission:
|
|
292
295
|
The venue preset declares the portal β "portal": { "kind": "hotcrp", "url": "<site>" } β and the
|
|
293
296
|
paper's paperlint.json its submission there β "submission": { "id": <number> }, written by new
|
|
294
|
-
β and, for new, its "authors": [{ "name", "email", "affiliation"? }].
|
|
297
|
+
β and, for new, its "authors": [{ "name", "email", "affiliation"? }]. Its "fields" answer the
|
|
298
|
+
form's own fields the preset records in "submission_fields" ({ "topics": [...], "pc_conflicts":
|
|
299
|
+
{ "<pc email>": "<type>" }, β¦ }); new and update send them, checked first. The token comes only from
|
|
295
300
|
the environment, one variable per site: HOTCRP_TOKEN_<subdomain> on *.hotcrp.com, else
|
|
296
301
|
HOTCRP_TOKEN_<host and path> β e.g. HOTCRP_TOKEN_MSR2027 for msr2027.hotcrp.com,
|
|
297
302
|
HOTCRP_TOKEN_AIDC_SUBMIT_ACSAC_ORG for aidc.submit.acsac.org (HotCRP: Account settings β
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/** A paper's answers to its venue's own form fields: checked against the preset's kinds before anything is sent. */
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { expect, test } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
droppedFields,
|
|
6
|
+
parsePaperFields,
|
|
7
|
+
type VenueFields,
|
|
8
|
+
} from "./submission-fields.ts";
|
|
9
|
+
|
|
10
|
+
/** The fields of an edition's form, as a preset records them (the topics of msr2027, two of them). */
|
|
11
|
+
const VENUE: VenueFields = {
|
|
12
|
+
source: "portal",
|
|
13
|
+
url: "https://example.org/api/searchcompletion",
|
|
14
|
+
read: "2026-10-20",
|
|
15
|
+
fields: {
|
|
16
|
+
topics: {
|
|
17
|
+
kind: "checkboxes",
|
|
18
|
+
required: true,
|
|
19
|
+
label: null,
|
|
20
|
+
options: [
|
|
21
|
+
"SE Topic: AI for software engineering",
|
|
22
|
+
"Performance analysis and testing",
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
pc_conflicts: {
|
|
26
|
+
kind: "pc_conflicts",
|
|
27
|
+
required: false,
|
|
28
|
+
label: null,
|
|
29
|
+
options: [],
|
|
30
|
+
},
|
|
31
|
+
ACMCorAut: {
|
|
32
|
+
kind: "email",
|
|
33
|
+
required: false,
|
|
34
|
+
label: "ACM corresponding author",
|
|
35
|
+
options: [],
|
|
36
|
+
},
|
|
37
|
+
note: { kind: "text", required: false, label: null, options: [] },
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
test("parsePaperFields: absent on a venue that records no fields β nothing to send; every kind's value passes as given", () => {
|
|
42
|
+
assert.deepEqual(parsePaperFields(null, null, "msr.jsonc"), {
|
|
43
|
+
ok: true,
|
|
44
|
+
value: {},
|
|
45
|
+
});
|
|
46
|
+
const fields = {
|
|
47
|
+
topics: ["SE Topic: AI for software engineering"],
|
|
48
|
+
pc_conflicts: {
|
|
49
|
+
"pc@example.org": "collaborator",
|
|
50
|
+
"chair@example.org": true,
|
|
51
|
+
},
|
|
52
|
+
ACMCorAut: "author@example.org",
|
|
53
|
+
note: "free text",
|
|
54
|
+
};
|
|
55
|
+
assert.deepEqual(parsePaperFields(fields, VENUE, "msr.jsonc"), {
|
|
56
|
+
ok: true,
|
|
57
|
+
value: fields,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("π΄ parsePaperFields: a topic the form does not offer is refused, naming what it offers β before any request", () => {
|
|
62
|
+
assert.deepEqual(
|
|
63
|
+
parsePaperFields(
|
|
64
|
+
{ topics: ["AI for software engineering"] },
|
|
65
|
+
VENUE,
|
|
66
|
+
"msr.jsonc",
|
|
67
|
+
),
|
|
68
|
+
{
|
|
69
|
+
ok: false,
|
|
70
|
+
error:
|
|
71
|
+
'"fields".topics[0]: "AI for software engineering" is not one of the form\'s options β the form offers: "SE Topic: AI for software engineering", "Performance analysis and testing"',
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("π΄ parsePaperFields: each other wrong answer is refused by key", () => {
|
|
77
|
+
const refused = (v: unknown): string => {
|
|
78
|
+
const r = parsePaperFields(v, VENUE, "msr.jsonc");
|
|
79
|
+
return r.ok ? "accepted" : r.error;
|
|
80
|
+
};
|
|
81
|
+
assert.equal(
|
|
82
|
+
refused({ topics: [] }),
|
|
83
|
+
'"fields".topics: the form requires at least one β the form offers: "SE Topic: AI for software engineering", "Performance analysis and testing"',
|
|
84
|
+
);
|
|
85
|
+
assert.equal(
|
|
86
|
+
refused({
|
|
87
|
+
topics: [
|
|
88
|
+
"Performance analysis and testing",
|
|
89
|
+
"Performance analysis and testing",
|
|
90
|
+
],
|
|
91
|
+
}),
|
|
92
|
+
'"fields".topics: an option is given twice β the form offers: "SE Topic: AI for software engineering", "Performance analysis and testing"',
|
|
93
|
+
);
|
|
94
|
+
assert.equal(
|
|
95
|
+
refused({ topics: ["Performance analysis and testing"], paper_type: "x" }),
|
|
96
|
+
'"fields" has "paper_type", which the venue\'s form does not ask (it asks: topics, pc_conflicts, ACMCorAut, note)',
|
|
97
|
+
);
|
|
98
|
+
assert.match(
|
|
99
|
+
refused({
|
|
100
|
+
topics: ["Performance analysis and testing"],
|
|
101
|
+
ACMCorAut: "Erdni Zergetaev",
|
|
102
|
+
}),
|
|
103
|
+
/^"fields"\.ACMCorAut: /,
|
|
104
|
+
);
|
|
105
|
+
assert.match(
|
|
106
|
+
refused({
|
|
107
|
+
topics: ["Performance analysis and testing"],
|
|
108
|
+
pc_conflicts: { "pc@example.org": "" },
|
|
109
|
+
}),
|
|
110
|
+
/^"fields"\.pc_conflicts\.pc@example\.org: /,
|
|
111
|
+
);
|
|
112
|
+
assert.match(refused({}), /^"fields"\.topics: /);
|
|
113
|
+
assert.match(refused([]), /^"fields": /);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("π΄ parsePaperFields: no fields at all, on a venue whose form requires one β refused, not sent empty", () => {
|
|
117
|
+
const r = parsePaperFields(null, VENUE, "msr.jsonc");
|
|
118
|
+
expect(r.ok ? "accepted" : r.error).toMatch(/^"fields"\.topics: /);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("parsePaperFields: an optional checkbox list may be empty; a required text may not", () => {
|
|
122
|
+
const venue: VenueFields = {
|
|
123
|
+
...VENUE,
|
|
124
|
+
fields: {
|
|
125
|
+
tags: {
|
|
126
|
+
kind: "checkboxes",
|
|
127
|
+
required: false,
|
|
128
|
+
label: null,
|
|
129
|
+
options: ["x"],
|
|
130
|
+
},
|
|
131
|
+
title2: { kind: "text", required: true, label: null, options: [] },
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
expect(parsePaperFields({ tags: [], title2: "t" }, venue, "v")).toStrictEqual(
|
|
135
|
+
{ ok: true, value: { tags: [], title2: "t" } },
|
|
136
|
+
);
|
|
137
|
+
const r = parsePaperFields({ title2: " " }, venue, "v");
|
|
138
|
+
expect(r.ok ? "accepted" : r.error).toMatch(/^"fields"\.title2: /);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("π΄ parsePaperFields: fields on a venue whose preset records none β refused, naming where it looked", () => {
|
|
142
|
+
assert.deepEqual(parsePaperFields({ topics: ["x"] }, null, "msr.jsonc"), {
|
|
143
|
+
ok: false,
|
|
144
|
+
error:
|
|
145
|
+
'"fields" is set, but the venue preset records no "submission_fields" (read: msr.jsonc) β record the form\'s fields in the venue\'s preset first, or drop "fields"',
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("π΄ droppedFields: a checkbox list sent, missing from the changes and named by the portal β dropped; otherwise not", () => {
|
|
150
|
+
const sent = { topics: ["Performance analysis and testing"], note: "x" };
|
|
151
|
+
// Measured on HotCRP 2026-10-20: an unknown topic β `change_list` without `topics`, and
|
|
152
|
+
// Β«topics: At least one selection requiredΒ».
|
|
153
|
+
assert.deepEqual(
|
|
154
|
+
droppedFields(sent, VENUE, {
|
|
155
|
+
changes: [],
|
|
156
|
+
messageFields: ["topics", "pc_conflicts"],
|
|
157
|
+
}),
|
|
158
|
+
["topics"],
|
|
159
|
+
);
|
|
160
|
+
assert.deepEqual(
|
|
161
|
+
droppedFields(sent, VENUE, {
|
|
162
|
+
changes: ["topics"],
|
|
163
|
+
messageFields: ["topics"],
|
|
164
|
+
}),
|
|
165
|
+
[],
|
|
166
|
+
);
|
|
167
|
+
// The same topics as the portal already holds: no change, and nothing said about them.
|
|
168
|
+
assert.deepEqual(
|
|
169
|
+
droppedFields(sent, VENUE, { changes: [], messageFields: [null] }),
|
|
170
|
+
[],
|
|
171
|
+
);
|
|
172
|
+
assert.deepEqual(
|
|
173
|
+
droppedFields({ topics: [] }, VENUE, {
|
|
174
|
+
changes: [],
|
|
175
|
+
messageFields: ["topics"],
|
|
176
|
+
}),
|
|
177
|
+
[],
|
|
178
|
+
);
|
|
179
|
+
assert.deepEqual(
|
|
180
|
+
droppedFields(sent, null, { changes: [], messageFields: ["topics"] }),
|
|
181
|
+
[],
|
|
182
|
+
);
|
|
183
|
+
});
|