Skip to content

Commit e69fbb0

Browse files
authored
PDF Manager (#551)
1 parent ce3a41a commit e69fbb0

50 files changed

Lines changed: 5063 additions & 1250 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

package.json

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,8 @@
2929
"storybook": "storybook dev -p 6006",
3030
"storybook:build": "storybook build",
3131
"idb:add-migration": "node scripts/create-idb-migration.mjs",
32-
"pdf:clean": "node scripts/clean-pdf.mjs",
33-
"pdf:remove-fields": "node scripts/remove-pdf-fields.mjs",
34-
"pdf:rename-fields": "node scripts/rename-pdf-fields.mjs",
35-
"pdf:schema": "node scripts/extract-pdf-schema.mjs",
36-
"pdf:define": "node scripts/define-pdf.mjs",
37-
"pdf:nudge-fields": "node scripts/nudge-pdf-fields.mjs",
32+
"pdf:schema": "tsx scripts/extract-pdf-schema.ts",
33+
"pdf:manage": "vite --config pdf-manager/vite.config.ts --open",
3834
"typegen": "sanity schema extract --enforce-required-fields && sanity typegen generate"
3935
},
4036
"simple-git-hooks": {
@@ -56,6 +52,7 @@
5652
"@astrojs/sitemap": "^3.7.2",
5753
"@axe-core/playwright": "^4.11.3",
5854
"@cantoo/pdf-lib": "^2.6.5",
55+
"@hono/node-server": "^2.0.4",
5956
"@internationalized/date": "^3.12.0",
6057
"@maskito/core": "^5.2.2",
6158
"@portabletext/react": "^6.2.0",
@@ -78,6 +75,7 @@
7875
"d3-selection": "^3.0.0",
7976
"fathom-client": "^3.7.2",
8077
"groq": "^5.25.1",
78+
"hono": "^4.12.23",
8179
"idb": "^8.0.3",
8280
"language-name-map": "^0.3.0",
8381
"react": "^19.2.6",
@@ -120,6 +118,7 @@
120118
"@types/sanitize-html": "^2.16.1",
121119
"@types/topojson-client": "^3.1.5",
122120
"@types/topojson-specification": "^1.0.5",
121+
"@vitejs/plugin-react": "^4.3.4",
123122
"@vitest/coverage-v8": "4.1.6",
124123
"@vueless/storybook-dark-mode": "^10.0.8",
125124
"autoprefixer": "^10.5.0",
@@ -131,11 +130,14 @@
131130
"lodash": "^4.18.1",
132131
"nano-staged": "^1.0.2",
133132
"optimo": "^0.0.27",
133+
"pdfjs-dist": "^4.10.38",
134134
"react-compiler-runtime": "^1.0.0",
135135
"sass-embedded": "^1.99.0",
136136
"sharp": "^0.34.5",
137137
"simple-git-hooks": "^2.13.1",
138138
"storybook": "^10.4.0",
139+
"tsx": "^4.22.3",
140+
"vite": "^6.3.5",
139141
"vitest": "4.1.6",
140142
"wrangler": "^4.91.0"
141143
},

pdf-manager/index.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>PDF Manager</title>
7+
</head>
8+
<body>
9+
<div id="root"></div>
10+
<script type="module" src="/src/main.jsx"></script>
11+
</body>
12+
</html>

pdf-manager/lib/api.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { getRequestListener } from "@hono/node-server";
2+
import { Hono } from "hono";
3+
import type { ViteDevServer } from "vite";
4+
import {
5+
handleAddPdf,
6+
handleGetFields,
7+
handleGetJurisdictions,
8+
handleGetPdfBytes,
9+
handleListPdfs,
10+
handlePreviewReplace,
11+
handleReplacePdf,
12+
handleSaveFields,
13+
} from "./handlers.ts";
14+
15+
const app = new Hono()
16+
.get("/api/pdfs", handleListPdfs)
17+
.get("/api/jurisdictions", handleGetJurisdictions)
18+
.post("/api/pdfs", handleAddPdf)
19+
.get("/api/pdf/:id/bytes", handleGetPdfBytes)
20+
.get("/api/pdf/:id/fields", handleGetFields)
21+
.post("/api/pdf/:id/fields", handleSaveFields)
22+
.put("/api/pdf/:id/file", handleReplacePdf)
23+
.post("/api/pdf/:id/preview", handlePreviewReplace);
24+
25+
app.notFound((c) => c.json({ error: "Not found" }, 404));
26+
app.onError((err, c) => {
27+
console.error("[pdf-manager api]", err);
28+
return c.json({ error: err.message }, 500);
29+
});
30+
31+
export function createApiPlugin() {
32+
const handler = getRequestListener(app.fetch);
33+
return {
34+
name: "pdf-manager-api",
35+
configureServer(server: ViteDevServer) {
36+
server.middlewares.use((req, res, next) => {
37+
if (!req.url?.startsWith("/api/")) return next();
38+
handler(req, res);
39+
});
40+
},
41+
};
42+
}

pdf-manager/lib/catalog.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { existsSync, readdirSync, readFileSync } from "node:fs";
2+
import { dirname, join } from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
5+
const __dirname = dirname(fileURLToPath(import.meta.url));
6+
const ROOT = join(__dirname, "../..");
7+
export const PDFS_DIR = join(ROOT, "src/pdfs");
8+
9+
interface PdfMeta {
10+
title: string;
11+
code: string;
12+
jurisdiction: string;
13+
}
14+
15+
export interface PdfEntry {
16+
id: string;
17+
jurisdiction: string;
18+
title: string;
19+
code: string;
20+
fieldCount: number;
21+
pdfPath: string;
22+
pdfDir: string;
23+
}
24+
25+
/** Parses index.ts for PDF metadata via regex. */
26+
function parsePdfMetadata(pdfDir: string): PdfMeta | null {
27+
const indexPath = join(pdfDir, "index.ts");
28+
if (!existsSync(indexPath)) return null;
29+
const content = readFileSync(indexPath, "utf8");
30+
return {
31+
title: content.match(/\btitle:\s*"([^"]+)"/)?.[1] ?? "",
32+
code: content.match(/\bcode:\s*"([^"]+)"/)?.[1] ?? "",
33+
jurisdiction: content.match(/\bjurisdiction:\s*"([^"]+)"/)?.[1] ?? "",
34+
};
35+
}
36+
37+
/** Counts fields in schema.ts by counting PDF class references. */
38+
function countSchemaFields(pdfDir: string): number {
39+
const schemaPath = join(pdfDir, "schema.ts");
40+
if (!existsSync(schemaPath)) return 0;
41+
const content = readFileSync(schemaPath, "utf8");
42+
return (
43+
content.match(/:\s*PDF(?:TextField|CheckBox|RadioGroup|Dropdown)/g) ?? []
44+
).length;
45+
}
46+
47+
export function listAllPdfs(): PdfEntry[] {
48+
const result: PdfEntry[] = [];
49+
for (const entry of readdirSync(PDFS_DIR, { withFileTypes: true })) {
50+
if (!entry.isDirectory() || entry.name === "utils") continue;
51+
const jurisdictionDir = join(PDFS_DIR, entry.name);
52+
for (const pdfEntry of readdirSync(jurisdictionDir, {
53+
withFileTypes: true,
54+
})) {
55+
if (!pdfEntry.isDirectory()) continue;
56+
const pdfDir = join(jurisdictionDir, pdfEntry.name);
57+
const pdfFile = readdirSync(pdfDir).find((f) => f.endsWith(".pdf"));
58+
if (!pdfFile) continue;
59+
const meta = parsePdfMetadata(pdfDir);
60+
if (!meta) continue;
61+
result.push({
62+
id: pdfEntry.name,
63+
jurisdiction: meta.jurisdiction || entry.name.toUpperCase(),
64+
title: meta.title,
65+
code: meta.code,
66+
fieldCount: countSchemaFields(pdfDir),
67+
pdfPath: join(pdfDir, pdfFile),
68+
pdfDir,
69+
});
70+
}
71+
}
72+
return result;
73+
}
74+
75+
export function findPdfById(
76+
id: string | undefined,
77+
): { pdfDir: string; pdfPath: string } | null {
78+
if (!id || id.includes("..")) return null;
79+
for (const entry of readdirSync(PDFS_DIR, { withFileTypes: true })) {
80+
if (!entry.isDirectory() || entry.name === "utils") continue;
81+
const pdfDir = join(PDFS_DIR, entry.name, id);
82+
if (!existsSync(pdfDir)) continue;
83+
const pdfFile = readdirSync(pdfDir).find((f) => f.endsWith(".pdf"));
84+
if (pdfFile) return { pdfDir, pdfPath: join(pdfDir, pdfFile) };
85+
}
86+
return null;
87+
}

0 commit comments

Comments
 (0)