@forwardimpact/outpost 3.12.1 → 3.13.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/package.json +3 -2
- package/src/kb-manager.js +58 -5
- package/src/kb-validator.js +762 -0
- package/src/outpost.js +73 -8
- package/templates/.claude/agents/chief-of-staff.md +14 -6
- package/templates/.claude/agents/concierge.md +9 -2
- package/templates/.claude/agents/head-hunter.md +9 -2
- package/templates/.claude/agents/librarian.md +11 -4
- package/templates/.claude/agents/postman.md +9 -2
- package/templates/.claude/agents/recruiter.md +9 -2
- package/templates/.claude/skills/anarlog-follow/SKILL.md +53 -29
- package/templates/.claude/skills/anarlog-follow/references/sessions.md +52 -0
- package/templates/.claude/skills/anarlog-process/SKILL.md +88 -45
- package/templates/.claude/skills/anarlog-process/references/extraction.md +19 -11
- package/templates/.claude/skills/anarlog-process/references/sessions.md +92 -58
- package/templates/.claude/skills/anarlog-process/scripts/scan.mjs +404 -163
- package/templates/.claude/skills/candidate-report/SKILL.md +12 -9
- package/templates/.claude/skills/changelog/SKILL.md +106 -56
- package/templates/.claude/skills/deck-create/SKILL.md +60 -2
- package/templates/.claude/skills/deck-review/SKILL.md +4 -1
- package/templates/.claude/skills/deck-summarize/SKILL.md +6 -2
- package/templates/.claude/skills/deck-summarize/references/brief-template.md +1 -1
- package/templates/.claude/skills/doc-collab/SKILL.md +16 -11
- package/templates/.claude/skills/doc-create/SKILL.md +6 -3
- package/templates/.claude/skills/draft-emails/SKILL.md +31 -25
- package/templates/.claude/skills/draft-emails/references/template.md +1 -1
- package/templates/.claude/skills/draft-emails/scripts/scan-emails.mjs +14 -8
- package/templates/.claude/skills/draft-emails/scripts/send-email.mjs +9 -3
- package/templates/.claude/skills/extract-entities/SKILL.md +17 -16
- package/templates/.claude/skills/extract-entities/references/TEMPLATES.md +2 -1
- package/templates/.claude/skills/extract-entities/references/conditions.md +6 -5
- package/templates/.claude/skills/extract-entities/references/links.md +34 -10
- package/templates/.claude/skills/extract-entities/references/recruitment.md +15 -10
- package/templates/.claude/skills/extract-entities/references/resolution.md +1 -1
- package/templates/.claude/skills/extract-entities/references/sources.md +1 -1
- package/templates/.claude/skills/extract-entities/references/templates-conditions.md +10 -4
- package/templates/.claude/skills/extract-entities/references/templates-people-orgs.md +29 -10
- package/templates/.claude/skills/extract-entities/references/templates-priorities.md +8 -2
- package/templates/.claude/skills/extract-entities/references/templates-projects-topics.md +19 -7
- package/templates/.claude/skills/meeting-prep/SKILL.md +23 -18
- package/templates/.claude/skills/organize-files/SKILL.md +3 -0
- package/templates/.claude/skills/person-identify/SKILL.md +58 -10
- package/templates/.claude/skills/person-identify/scripts/identify.sh +110 -11
- package/templates/.claude/skills/person-lookup/SKILL.md +8 -3
- package/templates/.claude/skills/person-lookup/scripts/lookup.sh +27 -1
- package/templates/.claude/skills/req-assess/SKILL.md +19 -11
- package/templates/.claude/skills/req-assess/references/interview-template.md +6 -1
- package/templates/.claude/skills/req-assess/references/panel-template.md +5 -1
- package/templates/.claude/skills/req-bundle/SKILL.md +164 -0
- package/templates/.claude/skills/req-bundle/references/matching.md +70 -0
- package/templates/.claude/skills/req-bundle/references/pdf-structure.md +74 -0
- package/templates/.claude/skills/req-bundle/scripts/split-bundle.mjs +377 -0
- package/templates/.claude/skills/req-decide/SKILL.md +26 -18
- package/templates/.claude/skills/req-decide/references/template.md +2 -2
- package/templates/.claude/skills/req-forget/SKILL.md +39 -22
- package/templates/.claude/skills/req-forget/references/classify.md +12 -10
- package/templates/.claude/skills/req-forget/references/locations.md +27 -22
- package/templates/.claude/skills/req-forget/references/report-template.md +23 -15
- package/templates/.claude/skills/req-scan/SKILL.md +6 -3
- package/templates/.claude/skills/req-scan/references/template.md +11 -1
- package/templates/.claude/skills/req-screen/SKILL.md +78 -34
- package/templates/.claude/skills/req-screen/references/rubric.md +38 -42
- package/templates/.claude/skills/req-screen/references/scoring.md +79 -0
- package/templates/.claude/skills/req-screen/references/template.md +26 -14
- package/templates/.claude/skills/req-track/SKILL.md +44 -44
- package/templates/.claude/skills/req-track/references/fields.md +18 -15
- package/templates/.claude/skills/req-track/references/overlays.md +42 -0
- package/templates/.claude/skills/req-track/references/signals.md +2 -2
- package/templates/.claude/skills/req-track/references/templates.md +28 -27
- package/templates/.claude/skills/req-workday/SKILL.md +49 -20
- package/templates/.claude/skills/req-workday/references/brief.md +81 -0
- package/templates/.claude/skills/req-workday/references/templates.md +6 -73
- package/templates/.claude/skills/req-workday/references/xlsx-format.md +19 -0
- package/templates/.claude/skills/req-workday/scripts/parse-workday.mjs +96 -20
- package/templates/.claude/skills/send-chat/SKILL.md +11 -11
- package/templates/.claude/skills/sync-apple-calendar/SKILL.md +3 -0
- package/templates/.claude/skills/sync-apple-mail/SKILL.md +3 -0
- package/templates/.claude/skills/sync-teams/SKILL.md +3 -0
- package/templates/.claude/skills/upstream-instructions/SKILL.md +8 -5
- package/templates/.claude/skills/upstream-instructions/references/examples.md +3 -3
- package/templates/CLAUDE.md +96 -87
- package/templates/MIGRATION.md +359 -0
- package/templates/registry.yaml +25 -0
- package/templates/.claude/skills/anarlog-follow/scripts/follow.mjs +0 -243
- package/templates/.claude/skills/anarlog-trim/SKILL.md +0 -186
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: req-bundle
|
|
3
|
+
description: >
|
|
4
|
+
Split a Workday CV-bundle PDF into per-candidate CV.pdf files under
|
|
5
|
+
2-Confidential/Candidates/. Reads the PDF outline (one bookmark per candidate) to
|
|
6
|
+
find each candidate's page range, matches it to the requisition roster so
|
|
7
|
+
folder names agree with req-workday, and extracts each CV losslessly. Use
|
|
8
|
+
when the user provides a Workday CV bundle (a large combined resume PDF, often
|
|
9
|
+
split into `_N_of_M` parts) and asks to split, unbundle, or extract individual
|
|
10
|
+
CVs — run this before req-workday imports the roster.
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Workday CV Bundle Split
|
|
14
|
+
|
|
15
|
+
Write tier: `2-Confidential`
|
|
16
|
+
Frontmatter: none (writes CV assets into candidate folders)
|
|
17
|
+
|
|
18
|
+
Split a Workday CV-**bundle** PDF — one combined file holding every applicant's
|
|
19
|
+
attachments — into per-candidate
|
|
20
|
+
`2-Confidential/Candidates/{Clean Name}/CV.pdf`.
|
|
21
|
+
|
|
22
|
+
This is the **PDF companion** to `req-workday`. `req-workday` imports the roster
|
|
23
|
+
from the `.xlsx` (briefs + `CV.md` from embedded resume text); `req-bundle`
|
|
24
|
+
attaches the real binary CV. Run `req-bundle` **first** so the folders it
|
|
25
|
+
creates carry canonical names that `req-workday` then enriches — no duplicate
|
|
26
|
+
folders.
|
|
27
|
+
|
|
28
|
+
**How the split works.** Workday bundles have
|
|
29
|
+
**no per-candidate separator page** and the name printed on a CV often differs
|
|
30
|
+
from the roster name, so text-based splitting is unreliable. The one dependable
|
|
31
|
+
delimiter is the **PDF outline**: exactly one flat bookmark per candidate, its
|
|
32
|
+
destination being that candidate's first page. Candidate *i* = pages
|
|
33
|
+
`[bookmark(i), bookmark(i+1) − 1]`; the last runs to EOF; the leading Table of
|
|
34
|
+
Contents (before the first bookmark) is skipped. Multiple attachments for one
|
|
35
|
+
candidate sit under one bookmark, so the range captures them all.
|
|
36
|
+
`split-bundle.mjs` reads the outline with `pdfjs-dist` (`getOutline` +
|
|
37
|
+
`getPageIndex`) and extracts each range losslessly with `pdf-lib` (`copyPages`).
|
|
38
|
+
Full anatomy: [references/pdf-structure.md](references/pdf-structure.md).
|
|
39
|
+
|
|
40
|
+
## Trigger
|
|
41
|
+
|
|
42
|
+
- The user provides a Workday CV bundle PDF (a large combined resume export),
|
|
43
|
+
possibly split into `..._1_of_N.pdf … _N_of_N.pdf` parts.
|
|
44
|
+
- The user asks to split, unbundle, or extract individual CVs from a Workday
|
|
45
|
+
export before importing candidates.
|
|
46
|
+
|
|
47
|
+
## Prerequisites
|
|
48
|
+
|
|
49
|
+
- PDF libraries for the split (`bun`/`node`), installed via the standard guard:
|
|
50
|
+
`bun pm ls pdfjs-dist 2>/dev/null || bun install pdfjs-dist` and
|
|
51
|
+
`bun pm ls pdf-lib 2>/dev/null || bun install pdf-lib`.
|
|
52
|
+
- The `req-workday` parser dependencies, since the roster is produced by
|
|
53
|
+
`parse-workday.mjs` (see [../req-workday/SKILL.md](../req-workday/SKILL.md)):
|
|
54
|
+
`read-excel-file` and `fflate`.
|
|
55
|
+
|
|
56
|
+
## Inputs
|
|
57
|
+
|
|
58
|
+
- One or more bundle PDF paths (pass **all** parts of a multi-part export).
|
|
59
|
+
- The requisition `.xlsx` (same req as the bundle) — parsed by
|
|
60
|
+
`req-workday`'s `parse-workday.mjs` into the roster JSON this script consumes,
|
|
61
|
+
so folder names are identical to what `req-workday` will create.
|
|
62
|
+
|
|
63
|
+
## Outputs
|
|
64
|
+
|
|
65
|
+
- `2-Confidential/Candidates/{Clean Name}/CV.pdf` — the candidate's extracted CV
|
|
66
|
+
(created only after the dry-run manifest is reviewed and confirmed).
|
|
67
|
+
- A JSON manifest at `$HOME/.cache/fit/outpost/state/req-bundle-{Req ID}.json`
|
|
68
|
+
— the reviewable mapping of every bookmark (page range, matched name, status).
|
|
69
|
+
- Never overwrites an existing `CV.pdf`; a second CV for the same person is
|
|
70
|
+
staged as `CV-workday.pdf` / `CV-dup-N.pdf` and flagged for review.
|
|
71
|
+
|
|
72
|
+
<do_confirm_checklist goal="Verify the split is complete, correctly named, and non-destructive">
|
|
73
|
+
|
|
74
|
+
- [ ] Bundle page/bookmark counts sanity-checked; all parts of a multi-part
|
|
75
|
+
export passed together.
|
|
76
|
+
- [ ] Roster parsed from the matching `.xlsx`; bookmark count reconciled against
|
|
77
|
+
roster count.
|
|
78
|
+
- [ ] Dry-run manifest reviewed **before** any write; every `unmatched` bookmark
|
|
79
|
+
resolved to a candidate (or explicitly left staged).
|
|
80
|
+
- [ ] Folder names equal the roster `cleanName` (annotation stripped) so they
|
|
81
|
+
match what `req-workday` produces.
|
|
82
|
+
- [ ] Collisions (duplicate applications) and pre-existing `CV.pdf` files
|
|
83
|
+
handled without overwrite (`CV-dup-N.pdf` / `CV-workday.pdf`), each
|
|
84
|
+
flagged.
|
|
85
|
+
- [ ] After commit, output CV count reconciles with matched bookmarks; a few
|
|
86
|
+
PDFs spot-opened to confirm the right, complete candidate.
|
|
87
|
+
|
|
88
|
+
</do_confirm_checklist>
|
|
89
|
+
|
|
90
|
+
## Procedure
|
|
91
|
+
|
|
92
|
+
### 1. Set up
|
|
93
|
+
|
|
94
|
+
Confirm the bundle PDF path(s) and the matching `.xlsx` (glob on the req number,
|
|
95
|
+
not the timestamp — parts can carry different timestamps). Resolve `$HOME`
|
|
96
|
+
(never pass a literal `~` to write tools). Ensure dependencies:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
bun pm ls pdfjs-dist 2>/dev/null || bun install pdfjs-dist
|
|
100
|
+
bun pm ls pdf-lib 2>/dev/null || bun install pdf-lib
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### 2. Parse the roster
|
|
104
|
+
|
|
105
|
+
Produce the canonical candidate list with `req-workday`'s parser (the single
|
|
106
|
+
source of truth for `cleanName`s), saving the JSON for the split step:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
node .claude/skills/req-workday/scripts/parse-workday.mjs "<path-to-requisition.xlsx>" --summary
|
|
110
|
+
node .claude/skills/req-workday/scripts/parse-workday.mjs "<path-to-requisition.xlsx>" \
|
|
111
|
+
> "$HOME/.cache/fit/outpost/state/req-bundle-roster-{Req ID}.json"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### 3. Dry-run the split
|
|
115
|
+
|
|
116
|
+
Compute the mapping and manifest **without writing** any PDF. Pass every part of
|
|
117
|
+
a multi-part bundle in one invocation:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
node .claude/skills/req-bundle/scripts/split-bundle.mjs \
|
|
121
|
+
"<bundle_1_of_N.pdf>" "<bundle_2_of_N.pdf>" ... \
|
|
122
|
+
--roster "$HOME/.cache/fit/outpost/state/req-bundle-roster-{Req ID}.json" \
|
|
123
|
+
--candidates-dir 2-Confidential/Candidates \
|
|
124
|
+
--manifest "$HOME/.cache/fit/outpost/state/req-bundle-{Req ID}.json" \
|
|
125
|
+
--dry-run
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The script prints a summary (bookmarks, matched, unmatched, collisions,
|
|
129
|
+
existing-CV, roster-without-bookmark) and writes the full manifest. Matching
|
|
130
|
+
rules and edge cases: [references/matching.md](references/matching.md).
|
|
131
|
+
|
|
132
|
+
### 4. Reconcile and resolve
|
|
133
|
+
|
|
134
|
+
Review the manifest. Confirm `bookmarks_total` reconciles with the roster count
|
|
135
|
+
(expect a near-exact match). For each `unmatched` bookmark, use its `hints`
|
|
136
|
+
(nearest roster names by shared tokens) to identify the candidate; note the
|
|
137
|
+
correct `cleanName`. Investigate any `roster_unmatched_names` (applicants with
|
|
138
|
+
no CV in the bundle — usually withdrawn/no-attachment). Do not proceed while
|
|
139
|
+
unexpected mismatches remain unexplained.
|
|
140
|
+
|
|
141
|
+
### 5. Confirm, then commit
|
|
142
|
+
|
|
143
|
+
Summarize for the user: N CVs to write, M unmatched, K collisions/existing.
|
|
144
|
+
**Get explicit confirmation** before writing. Re-run without `--dry-run` (same
|
|
145
|
+
roster JSON) to create folders and write the CVs:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
node .claude/skills/req-bundle/scripts/split-bundle.mjs \
|
|
149
|
+
"<bundle parts...>" \
|
|
150
|
+
--roster "$HOME/.cache/fit/outpost/state/req-bundle-roster-{Req ID}.json" \
|
|
151
|
+
--candidates-dir 2-Confidential/Candidates \
|
|
152
|
+
--manifest "$HOME/.cache/fit/outpost/state/req-bundle-{Req ID}.json"
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
For bookmarks that were `unmatched` but you resolved by hand in Step 4, move the
|
|
156
|
+
staged file into the correct `2-Confidential/Candidates/{Clean Name}/CV.pdf`, or
|
|
157
|
+
add the missing name variant and re-run — never hand-edit PDFs.
|
|
158
|
+
|
|
159
|
+
### 6. Hand off and report
|
|
160
|
+
|
|
161
|
+
Point `req-workday` at the same `.xlsx` to build briefs/`CV.md` and link the CV
|
|
162
|
+
under each brief's `## CV` section. Flag every candidate that now has a `CV.pdf`
|
|
163
|
+
for `req-screen`. Report `Split {matched}/{bookmarks_total} CVs for {Req ID}`,
|
|
164
|
+
and list any unmatched bookmarks or roster applicants without a CV.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Matching bookmarks to the roster, and edge cases
|
|
2
|
+
|
|
3
|
+
How `split-bundle.mjs` maps each PDF bookmark to a canonical candidate folder,
|
|
4
|
+
and how it handles duplicates, existing files, and misses. The goal: folder
|
|
5
|
+
names identical to what `req-workday` produces, and **never** a destructive
|
|
6
|
+
overwrite.
|
|
7
|
+
|
|
8
|
+
## Why match at all
|
|
9
|
+
|
|
10
|
+
The bookmark title is a clean name, but folder names must equal the roster
|
|
11
|
+
`cleanName` — the applicant name from the `.xlsx` `Candidates` sheet with any
|
|
12
|
+
trailing annotation (`(Internal)`, `(Prior Worker)`, …) stripped. That
|
|
13
|
+
`cleanName` comes **directly from `req-workday`'s `parse-workday.mjs`** (the
|
|
14
|
+
single source of truth this skill consumes as JSON), so the two skills agree by
|
|
15
|
+
construction and never create duplicate folders. So: bookmark title → (match) →
|
|
16
|
+
roster `cleanName` → folder.
|
|
17
|
+
|
|
18
|
+
## Normalization (both sides)
|
|
19
|
+
|
|
20
|
+
Before comparing, each name is folded with `normalize()`:
|
|
21
|
+
|
|
22
|
+
1. Remove parentheticals — `(ΝΙΚΟΣ ΠΑΠΑΔΟΠΟΥΛΟΣ)`, `(Internal)`, `(Referral)`.
|
|
23
|
+
2. Remove bare annotation tokens — internal / referral / prior worker /
|
|
24
|
+
external.
|
|
25
|
+
3. Strip diacritics (NFKD) — `Geraño` → `gerano`.
|
|
26
|
+
4. Lowercase, keep `[a-z0-9 ]`, collapse whitespace.
|
|
27
|
+
|
|
28
|
+
## Match methods (conservative, deterministic)
|
|
29
|
+
|
|
30
|
+
Applied in order; the method is recorded in the manifest:
|
|
31
|
+
|
|
32
|
+
1. **`exact`** — normalized strings equal.
|
|
33
|
+
2. **`token-set`** — same set of tokens regardless of order (handles
|
|
34
|
+
"First Last" ↔ "Last First" and dropped middle names).
|
|
35
|
+
3. **`unmatched`** — no confident match. The script does **not** guess by
|
|
36
|
+
subset/Levenshtein (that risks merging two different "David …"s). Instead it
|
|
37
|
+
attaches `hints`: the nearest roster names by shared-token count, for a human
|
|
38
|
+
to resolve.
|
|
39
|
+
|
|
40
|
+
## Edge cases & how the script handles them
|
|
41
|
+
|
|
42
|
+
- **Duplicate applications (collisions).** The same person appears under two
|
|
43
|
+
bookmarks (e.g. across two parts of a multi-part bundle). Both resolve to one
|
|
44
|
+
folder. The first writes `CV.pdf`; the next becomes `CV-dup-2.pdf` (then
|
|
45
|
+
`-3`, …) with status `collision`. Review whether it's a true duplicate (keep
|
|
46
|
+
one) or a distinct newer packet (rename intentionally). Never silently
|
|
47
|
+
overwrite.
|
|
48
|
+
- **Pre-existing `CV.pdf`.** A candidate who already arrived via email
|
|
49
|
+
(`req-track`) may already have `CV.pdf`. The script writes `CV-workday.pdf`
|
|
50
|
+
with status `existing-cv` and flags it, so the richer existing artifact is
|
|
51
|
+
preserved. Decide per candidate which to keep.
|
|
52
|
+
- **Unmatched bookmark.** Staged in a folder named from the sanitized bookmark
|
|
53
|
+
title, status `unmatched`, with `hints`. Resolve by identifying the right
|
|
54
|
+
`cleanName` and moving the staged `CV.pdf` into that folder, or by adding the
|
|
55
|
+
missing name variant and re-running. A genuinely stray bookmark (someone not
|
|
56
|
+
on the roster) is worth flagging to the user.
|
|
57
|
+
- **Roster applicant with no bookmark** (`roster_unmatched_names`). Applied but
|
|
58
|
+
no CV in the bundle — usually withdrawn or never attached a resume. Expected;
|
|
59
|
+
just report it. Not an error.
|
|
60
|
+
- **No outline** (`no-outline` status for a file). The PDF has no bookmarks —
|
|
61
|
+
it is not a standard Workday bundle, or the outline was stripped. Do not
|
|
62
|
+
attempt a blind page-count split; stop and tell the user.
|
|
63
|
+
|
|
64
|
+
## Reconciliation checklist
|
|
65
|
+
|
|
66
|
+
- `bookmarks_total` ≈ roster count (small gaps explained by
|
|
67
|
+
`roster_unmatched_names`).
|
|
68
|
+
- `unmatched_bookmarks` == 0 after Step 4 resolution (or each is explained).
|
|
69
|
+
- `matched` + resolved unmatched == CVs you expect to write.
|
|
70
|
+
- Known repeat applicants line up with the reported `collisions`.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Workday CV Bundle — PDF anatomy
|
|
2
|
+
|
|
3
|
+
What a Workday "Download resumes / attachments" bundle looks like, and why the
|
|
4
|
+
PDF outline is the only reliable way to split it.
|
|
5
|
+
|
|
6
|
+
## The shape of a bundle
|
|
7
|
+
|
|
8
|
+
- A bundle is **one PDF that concatenates every applicant's attachments** — CV,
|
|
9
|
+
and often a cover letter and certificates too — back to back.
|
|
10
|
+
- **Front matter:** page 1 (and sometimes 2–3) is a whole-bundle **Table of
|
|
11
|
+
Contents** titled with the req, e.g. `4951493 - Principal Software Engineer`,
|
|
12
|
+
listing `Candidate Name | Candidate ID | Attachments`. The TOC has **no page
|
|
13
|
+
numbers** in its extracted text, so it is not a usable split key — skip it.
|
|
14
|
+
- **No per-candidate separator/cover page.** Each candidate's first attachment
|
|
15
|
+
begins directly on its own page. The TOC is the only inserted page.
|
|
16
|
+
|
|
17
|
+
## The delimiter: the PDF outline (bookmarks)
|
|
18
|
+
|
|
19
|
+
Every bundle carries a **flat, depth-0 PDF outline with exactly one bookmark per
|
|
20
|
+
candidate**. The bookmark's destination is that candidate's **first page**, and
|
|
21
|
+
its title is the **clean candidate name** (no `(Internal)` / `(Referral)`
|
|
22
|
+
suffix). This is the split key:
|
|
23
|
+
|
|
24
|
+
- Candidate *i* → pages `[bookmark(i).page, bookmark(i+1).page − 1]`.
|
|
25
|
+
- Last candidate → `[bookmark(last).page, EOF]`.
|
|
26
|
+
- A candidate's **multiple attachments are all under their single bookmark**, so
|
|
27
|
+
the page range captures the cover letter + CV + certs together. (This is fine
|
|
28
|
+
— a hiring manager wants the whole packet; `req-screen` reads the CV within.)
|
|
29
|
+
- Bookmark counts match roster counts exactly (verified live: 75↔75 on a
|
|
30
|
+
single-part export; 366↔366 across the four parts of a large export).
|
|
31
|
+
|
|
32
|
+
`split-bundle.mjs` reads this via `pdfjs-dist`: `doc.getOutline()` for the
|
|
33
|
+
items, then `doc.getPageIndex(dest[0])` (resolving named destinations through
|
|
34
|
+
`doc.getDestination()` first) to turn each bookmark into a 0-based page index,
|
|
35
|
+
then sorts by page. Page ranges are extracted losslessly with `pdf-lib`
|
|
36
|
+
(`copyPages`).
|
|
37
|
+
|
|
38
|
+
## What NOT to rely on
|
|
39
|
+
|
|
40
|
+
- **Separator-page detection** — there are none.
|
|
41
|
+
- **Name text at the top of a page** — a CV's printed name is the candidate's
|
|
42
|
+
own styling and often differs from the roster name (bookmark "Vivian Chen" →
|
|
43
|
+
CV reads "Wei-Ling (Vivian) Chen, PhD"). Match the **bookmark title**, not
|
|
44
|
+
page text.
|
|
45
|
+
- **Page-text extraction** — unnecessary and unreliable for boundary detection:
|
|
46
|
+
the outline gives exact boundaries and the `.xlsx` already carries the resume
|
|
47
|
+
text.
|
|
48
|
+
|
|
49
|
+
## Multi-part bundles (`_1_of_N` … `_N_of_N`)
|
|
50
|
+
|
|
51
|
+
When a req has many applicants, Workday emits several PDFs. Key facts:
|
|
52
|
+
|
|
53
|
+
- **Each part is self-contained:** its own TOC, its own ~100-candidate subset in
|
|
54
|
+
its own internal order (parts are *not* sequential slices of one ordering).
|
|
55
|
+
- **No candidate spans a part boundary** — every part's first bookmark is a
|
|
56
|
+
fresh candidate after its TOC, and its last candidate is fully contained.
|
|
57
|
+
- Therefore **process each part independently** and union the results; pass all
|
|
58
|
+
parts to `split-bundle.mjs` in one call.
|
|
59
|
+
- A person may legitimately appear in two parts (a genuine duplicate
|
|
60
|
+
application). That surfaces as a **collision** in the manifest (same folder
|
|
61
|
+
targeted twice) — handled per [matching.md](matching.md), not stitched.
|
|
62
|
+
|
|
63
|
+
## Observed reference numbers (live exports)
|
|
64
|
+
|
|
65
|
+
| Bundle | Pages | Bookmarks |
|
|
66
|
+
| ---------------------------------------- | ----- | --------- |
|
|
67
|
+
| Principal SWE req (single file) | 215 | 75 |
|
|
68
|
+
| Sr. Director req `_1_of_4` | 321 | 100 |
|
|
69
|
+
| Sr. Director req `_2_of_4` | 314 | 100 |
|
|
70
|
+
| Sr. Director req `_3_of_4` | 278 | 100 |
|
|
71
|
+
| Sr. Director req `_4_of_4` | 213 | 66 |
|
|
72
|
+
|
|
73
|
+
Note: one part's filename carried a slightly different timestamp than its
|
|
74
|
+
siblings — glob on the req number, not the timestamp.
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Split a Workday CV-bundle PDF into per-candidate CV.pdf files.
|
|
4
|
+
*
|
|
5
|
+
* Workday's "bundle" export concatenates every candidate's attachments (CV,
|
|
6
|
+
* cover letter, certificates) into one PDF with NO per-candidate separator page.
|
|
7
|
+
* The only reliable delimiter is the PDF outline (bookmarks): exactly one flat,
|
|
8
|
+
* depth-0 bookmark per candidate whose destination is that candidate's first
|
|
9
|
+
* page. Candidate i occupies pages [bookmark(i), bookmark(i+1) - 1]; the last
|
|
10
|
+
* candidate runs to the end of the file. Everything before the first bookmark is
|
|
11
|
+
* the front-matter Table of Contents and is skipped.
|
|
12
|
+
*
|
|
13
|
+
* This script reads the outline (pdfjs-dist), matches each bookmark title to a
|
|
14
|
+
* canonical candidate name from the parsed Workday roster (so folder names agree
|
|
15
|
+
* with req-workday), extracts the page range losslessly (pdf-lib), and writes it
|
|
16
|
+
* to 2-Confidential/Candidates/{Clean Name}/CV.pdf.
|
|
17
|
+
*
|
|
18
|
+
* It ALWAYS emits a JSON manifest describing every mapping and never overwrites
|
|
19
|
+
* an existing CV.pdf. With --dry-run it computes the manifest without touching
|
|
20
|
+
* disk. Multi-part bundles (`..._1_of_4.pdf` … `_4_of_4.pdf`) are self-contained:
|
|
21
|
+
* pass all parts as positional arguments; each part is processed independently.
|
|
22
|
+
*
|
|
23
|
+
* The roster is the stdout JSON of req-workday/scripts/parse-workday.mjs
|
|
24
|
+
* ({ requisition, candidates: [{ name, cleanName, ... }] }) — the single source
|
|
25
|
+
* of truth for candidate names, so this script needs no XLSX parser of its own.
|
|
26
|
+
*
|
|
27
|
+
* Requires (bun install): pdfjs-dist, pdf-lib.
|
|
28
|
+
*
|
|
29
|
+
* Usage:
|
|
30
|
+
* node split-bundle.mjs <bundle.pdf> [<bundle2.pdf> ...] \
|
|
31
|
+
* --roster <roster.json> \
|
|
32
|
+
* --candidates-dir <2-Confidential/Candidates> \
|
|
33
|
+
* --manifest <out.json> \
|
|
34
|
+
* [--dry-run]
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
38
|
+
import { basename, join } from "node:path";
|
|
39
|
+
|
|
40
|
+
let PDFDocument, pdfjs;
|
|
41
|
+
try {
|
|
42
|
+
({ PDFDocument } = await import("pdf-lib"));
|
|
43
|
+
pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
|
44
|
+
} catch {
|
|
45
|
+
console.error(
|
|
46
|
+
"Error: pdfjs-dist and/or pdf-lib not found. Install them first:\n bun install pdfjs-dist pdf-lib",
|
|
47
|
+
);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// --- CLI parsing --------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
if (
|
|
54
|
+
process.argv.includes("-h") ||
|
|
55
|
+
process.argv.includes("--help") ||
|
|
56
|
+
process.argv.length < 3
|
|
57
|
+
) {
|
|
58
|
+
console.log(`split-bundle — split a Workday CV bundle into per-candidate CV.pdf by PDF bookmarks
|
|
59
|
+
|
|
60
|
+
Usage:
|
|
61
|
+
node split-bundle.mjs <bundle.pdf> [<bundle2.pdf> ...] --roster <roster.json> \\
|
|
62
|
+
--candidates-dir <dir> --manifest <out.json> [--dry-run]
|
|
63
|
+
|
|
64
|
+
<roster.json> is the stdout of req-workday/scripts/parse-workday.mjs.`);
|
|
65
|
+
process.exit(process.argv.length < 3 ? 1 : 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function optValue(name) {
|
|
69
|
+
const i = process.argv.indexOf(name);
|
|
70
|
+
return i >= 0 && i + 1 < process.argv.length
|
|
71
|
+
? process.argv[i + 1]
|
|
72
|
+
: undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const dryRun = process.argv.includes("--dry-run");
|
|
76
|
+
const rosterPath = optValue("--roster");
|
|
77
|
+
const candidatesDir = optValue("--candidates-dir");
|
|
78
|
+
const manifestPath = optValue("--manifest");
|
|
79
|
+
const FLAGS_WITH_VALUE = new Set([
|
|
80
|
+
"--roster",
|
|
81
|
+
"--candidates-dir",
|
|
82
|
+
"--manifest",
|
|
83
|
+
]);
|
|
84
|
+
const bundles = process.argv.slice(2).filter((a, i, arr) => {
|
|
85
|
+
if (a.startsWith("--")) return false;
|
|
86
|
+
const prev = arr[i - 1];
|
|
87
|
+
return !(prev && FLAGS_WITH_VALUE.has(prev)); // drop values consumed by flags
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (!candidatesDir || !manifestPath || bundles.length === 0) {
|
|
91
|
+
console.error(
|
|
92
|
+
"Error: need <bundle.pdf...>, --candidates-dir, and --manifest.",
|
|
93
|
+
);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- Name normalization & matching -------------------------------------------
|
|
98
|
+
|
|
99
|
+
const SUFFIX_RE = /\b(internal|referral|prior worker|external|contractor)\b/gi;
|
|
100
|
+
const PAREN_RE = /\([^)]*\)/g;
|
|
101
|
+
|
|
102
|
+
/** Fold a display name to a comparable key: no parentheticals, no diacritics,
|
|
103
|
+
* lowercase alphanumerics only, whitespace collapsed. */
|
|
104
|
+
function normalize(name) {
|
|
105
|
+
let s = name || "";
|
|
106
|
+
s = s.replace(PAREN_RE, " ").replace(SUFFIX_RE, " ");
|
|
107
|
+
s = s.normalize("NFKD").replace(/\p{M}/gu, ""); // strip combining accents
|
|
108
|
+
s = s.toLowerCase().replace(/[^a-z0-9\s]/g, " ");
|
|
109
|
+
return s.replace(/\s+/g, " ").trim();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Make a bookmark title safe as a directory name (used only when unmatched). */
|
|
113
|
+
function sanitizeFolder(title) {
|
|
114
|
+
const s = (title || "").replace(/[/\\]/g, "-").replace(/\s+/g, " ").trim();
|
|
115
|
+
return s || "UNKNOWN";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
class Roster {
|
|
119
|
+
constructor(candidates) {
|
|
120
|
+
this.byExact = new Map(); // normalized name -> canonical cleanName
|
|
121
|
+
this.byTokens = new Map(); // sorted-token key -> canonical cleanName
|
|
122
|
+
this.all = []; // [{ key, clean }]
|
|
123
|
+
for (const c of candidates) {
|
|
124
|
+
const clean = (c.cleanName || c.name || "").trim();
|
|
125
|
+
if (!clean) continue;
|
|
126
|
+
const key = normalize(clean);
|
|
127
|
+
if (!key) continue;
|
|
128
|
+
if (!this.byExact.has(key)) this.byExact.set(key, clean);
|
|
129
|
+
const tkey = key.split(" ").sort().join(" ");
|
|
130
|
+
if (!this.byTokens.has(tkey)) this.byTokens.set(tkey, clean);
|
|
131
|
+
this.all.push({ key, clean });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Return { clean, method } — conservative: exact key, then token-set equality. */
|
|
136
|
+
match(title) {
|
|
137
|
+
const key = normalize(title);
|
|
138
|
+
if (!key) return { clean: null, method: "unmatched" };
|
|
139
|
+
if (this.byExact.has(key))
|
|
140
|
+
return { clean: this.byExact.get(key), method: "exact" };
|
|
141
|
+
const tkey = key.split(" ").sort().join(" ");
|
|
142
|
+
if (this.byTokens.has(tkey))
|
|
143
|
+
return { clean: this.byTokens.get(tkey), method: "token-set" };
|
|
144
|
+
return { clean: null, method: "unmatched" };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Nearest roster names by shared-token count — helps a human resolve a miss. */
|
|
148
|
+
hints(title, limit = 3) {
|
|
149
|
+
const tokens = new Set(normalize(title).split(" ").filter(Boolean));
|
|
150
|
+
const scored = [];
|
|
151
|
+
for (const { key, clean } of this.all) {
|
|
152
|
+
const shared = key.split(" ").filter((t) => tokens.has(t)).length;
|
|
153
|
+
if (shared) scored.push({ shared, clean });
|
|
154
|
+
}
|
|
155
|
+
scored.sort(
|
|
156
|
+
(a, b) => b.shared - a.shared || a.clean.localeCompare(b.clean),
|
|
157
|
+
);
|
|
158
|
+
const out = [];
|
|
159
|
+
for (const { clean } of scored) {
|
|
160
|
+
if (!out.includes(clean)) out.push(clean);
|
|
161
|
+
if (out.length >= limit) break;
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// --- PDF outline handling -----------------------------------------------------
|
|
168
|
+
|
|
169
|
+
/** Return [{ title, page }] (0-based page index) for every outline destination,
|
|
170
|
+
* in document order. pdfjs resolves both named and explicit destinations. */
|
|
171
|
+
async function readOutline(doc) {
|
|
172
|
+
const outline = (await doc.getOutline()) || [];
|
|
173
|
+
const items = [];
|
|
174
|
+
|
|
175
|
+
async function pageIndex(dest) {
|
|
176
|
+
const explicit =
|
|
177
|
+
typeof dest === "string" ? await doc.getDestination(dest) : dest;
|
|
178
|
+
if (!Array.isArray(explicit) || !explicit[0]) return null;
|
|
179
|
+
try {
|
|
180
|
+
return await doc.getPageIndex(explicit[0]);
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Flatten defensively (Workday bundles are flat, depth-0).
|
|
187
|
+
const stack = [...outline];
|
|
188
|
+
while (stack.length) {
|
|
189
|
+
const node = stack.shift();
|
|
190
|
+
const page = await pageIndex(node.dest);
|
|
191
|
+
if (node.title != null && page != null)
|
|
192
|
+
items.push({ title: String(node.title), page });
|
|
193
|
+
if (Array.isArray(node.items) && node.items.length)
|
|
194
|
+
stack.unshift(...node.items);
|
|
195
|
+
}
|
|
196
|
+
items.sort((a, b) => a.page - b.page);
|
|
197
|
+
return items;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function partLabel(path) {
|
|
201
|
+
const m = basename(path).match(/_(\d+)_of_(\d+)\b/);
|
|
202
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// --- Main ---------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
let requisition = {};
|
|
208
|
+
let rosterCandidates = [];
|
|
209
|
+
if (rosterPath && existsSync(rosterPath)) {
|
|
210
|
+
const data = JSON.parse(readFileSync(rosterPath, "utf8"));
|
|
211
|
+
rosterCandidates = data.candidates || [];
|
|
212
|
+
requisition = data.requisition || {};
|
|
213
|
+
}
|
|
214
|
+
const roster = new Roster(rosterCandidates);
|
|
215
|
+
|
|
216
|
+
const entries = [];
|
|
217
|
+
const matchedClean = new Set(); // normalized cleanNames that got a bookmark
|
|
218
|
+
const folderWritten = new Map(); // folder -> count (within this run, for collisions)
|
|
219
|
+
|
|
220
|
+
for (const bundle of bundles) {
|
|
221
|
+
// pdfjs detaches the ArrayBuffer it's handed, so give each library its own copy.
|
|
222
|
+
const doc = await pdfjs.getDocument({
|
|
223
|
+
data: new Uint8Array(readFileSync(bundle)),
|
|
224
|
+
useSystemFonts: true,
|
|
225
|
+
verbosity: 0,
|
|
226
|
+
}).promise;
|
|
227
|
+
const outline = await readOutline(doc);
|
|
228
|
+
const part = partLabel(bundle);
|
|
229
|
+
|
|
230
|
+
if (outline.length === 0) {
|
|
231
|
+
entries.push({
|
|
232
|
+
bundle,
|
|
233
|
+
part,
|
|
234
|
+
status: "no-outline",
|
|
235
|
+
note: "No PDF bookmarks found — cannot split this file by candidate.",
|
|
236
|
+
});
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// pdf-lib source (only loaded when we actually extract).
|
|
241
|
+
let src = null;
|
|
242
|
+
let total = 0;
|
|
243
|
+
if (!dryRun) {
|
|
244
|
+
src = await PDFDocument.load(new Uint8Array(readFileSync(bundle)));
|
|
245
|
+
total = src.getPageCount();
|
|
246
|
+
} else {
|
|
247
|
+
total = doc.numPages;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
for (let i = 0; i < outline.length; i++) {
|
|
251
|
+
const start = outline[i].page;
|
|
252
|
+
const end = i + 1 < outline.length ? outline[i + 1].page - 1 : total - 1;
|
|
253
|
+
const title = outline[i].title;
|
|
254
|
+
const { clean, method } = roster.match(title);
|
|
255
|
+
const matched = clean != null;
|
|
256
|
+
let folder, status, hints;
|
|
257
|
+
if (matched) {
|
|
258
|
+
matchedClean.add(normalize(clean));
|
|
259
|
+
folder = clean;
|
|
260
|
+
status = "matched";
|
|
261
|
+
hints = [];
|
|
262
|
+
} else {
|
|
263
|
+
folder = sanitizeFolder(title);
|
|
264
|
+
status = "unmatched";
|
|
265
|
+
hints = roster.hints(title);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const destDir = join(candidatesDir, folder);
|
|
269
|
+
const cvPath = join(destDir, "CV.pdf");
|
|
270
|
+
const seen = folderWritten.get(folder) || 0;
|
|
271
|
+
const existingCv = existsSync(cvPath);
|
|
272
|
+
let outName;
|
|
273
|
+
if (seen > 0) {
|
|
274
|
+
outName = `CV-dup-${seen + 1}.pdf`;
|
|
275
|
+
if (matched) status = "collision";
|
|
276
|
+
} else if (existingCv) {
|
|
277
|
+
outName = "CV-workday.pdf";
|
|
278
|
+
if (matched) status = "existing-cv";
|
|
279
|
+
} else {
|
|
280
|
+
outName = "CV.pdf";
|
|
281
|
+
}
|
|
282
|
+
folderWritten.set(folder, seen + 1);
|
|
283
|
+
const outPath = join(destDir, outName);
|
|
284
|
+
|
|
285
|
+
let action = dryRun ? "would-write" : "pending";
|
|
286
|
+
if (!dryRun) {
|
|
287
|
+
try {
|
|
288
|
+
mkdirSync(destDir, { recursive: true });
|
|
289
|
+
const out = await PDFDocument.create();
|
|
290
|
+
const idxs = [];
|
|
291
|
+
for (let p = start; p <= end; p++) idxs.push(p);
|
|
292
|
+
const copied = await out.copyPages(src, idxs);
|
|
293
|
+
for (const pg of copied) out.addPage(pg);
|
|
294
|
+
writeFileSync(outPath, await out.save());
|
|
295
|
+
action = "wrote";
|
|
296
|
+
} catch (exc) {
|
|
297
|
+
action = `error: ${exc.message || exc}`;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
entries.push({
|
|
302
|
+
bundle,
|
|
303
|
+
part,
|
|
304
|
+
order: i + 1,
|
|
305
|
+
title,
|
|
306
|
+
page_start: start + 1, // 1-based for humans
|
|
307
|
+
page_end: end + 1,
|
|
308
|
+
page_count: end - start + 1,
|
|
309
|
+
matched,
|
|
310
|
+
match_method: method,
|
|
311
|
+
clean_name: clean || "",
|
|
312
|
+
folder,
|
|
313
|
+
status,
|
|
314
|
+
out_path: outPath,
|
|
315
|
+
action,
|
|
316
|
+
hints,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const rosterUnmatched = [
|
|
322
|
+
...new Set(
|
|
323
|
+
rosterCandidates
|
|
324
|
+
.map((c) => c.cleanName || c.name)
|
|
325
|
+
.filter((n) => n && !matchedClean.has(normalize(n))),
|
|
326
|
+
),
|
|
327
|
+
].sort();
|
|
328
|
+
|
|
329
|
+
const bookmarkEntries = entries.filter((e) => "title" in e);
|
|
330
|
+
const summary = {
|
|
331
|
+
bundles: bundles.length,
|
|
332
|
+
bookmarks_total: bookmarkEntries.length,
|
|
333
|
+
matched: bookmarkEntries.filter((e) => e.matched).length,
|
|
334
|
+
unmatched_bookmarks: bookmarkEntries.filter((e) => !e.matched).length,
|
|
335
|
+
collisions: bookmarkEntries.filter((e) => e.status === "collision").length,
|
|
336
|
+
existing_cv: bookmarkEntries.filter((e) => e.status === "existing-cv").length,
|
|
337
|
+
roster_total: rosterCandidates.length,
|
|
338
|
+
roster_unmatched: rosterUnmatched.length,
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const manifest = {
|
|
342
|
+
generated_from: bundles,
|
|
343
|
+
requisition,
|
|
344
|
+
candidates_dir: candidatesDir,
|
|
345
|
+
dry_run: dryRun,
|
|
346
|
+
summary,
|
|
347
|
+
entries,
|
|
348
|
+
roster_unmatched_names: rosterUnmatched,
|
|
349
|
+
};
|
|
350
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
351
|
+
|
|
352
|
+
// Human summary to stdout.
|
|
353
|
+
const mode = dryRun ? "DRY RUN — nothing written" : "COMMIT";
|
|
354
|
+
console.log(
|
|
355
|
+
`[${mode}] ${requisition.id || "?"} ${requisition.title || ""}`.trimEnd(),
|
|
356
|
+
);
|
|
357
|
+
console.log(` bundles: ${summary.bundles}`);
|
|
358
|
+
console.log(` bookmarks: ${summary.bookmarks_total}`);
|
|
359
|
+
console.log(` matched → roster: ${summary.matched}`);
|
|
360
|
+
console.log(` unmatched bookmark: ${summary.unmatched_bookmarks}`);
|
|
361
|
+
console.log(` collisions (dupes): ${summary.collisions}`);
|
|
362
|
+
console.log(` existing CV.pdf: ${summary.existing_cv}`);
|
|
363
|
+
console.log(
|
|
364
|
+
` roster w/o bookmark:${summary.roster_unmatched} (of ${summary.roster_total})`,
|
|
365
|
+
);
|
|
366
|
+
console.log(` manifest: ${manifestPath}`);
|
|
367
|
+
if (summary.unmatched_bookmarks) {
|
|
368
|
+
console.log(" ⚠ unmatched bookmarks (resolve in the manifest):");
|
|
369
|
+
for (const e of bookmarkEntries) {
|
|
370
|
+
if (!e.matched) {
|
|
371
|
+
const hint = e.hints.length ? ` ~ ${e.hints.join(", ")}` : "";
|
|
372
|
+
console.log(
|
|
373
|
+
` - "${e.title}" (p${e.page_start}-${e.page_end})${hint}`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|