diff --git a/.github/workflows/frontend-ui.yml b/.github/workflows/frontend-ui.yml index ce7f61e..d69a2f5 100644 --- a/.github/workflows/frontend-ui.yml +++ b/.github/workflows/frontend-ui.yml @@ -8,6 +8,7 @@ on: - "package.json" - "tests/*.test.js" - "Launch PDF Magic.command" + - "serve.py" - ".github/workflows/frontend-ui.yml" pull_request: paths: @@ -15,6 +16,7 @@ on: - "package.json" - "tests/*.test.js" - "Launch PDF Magic.command" + - "serve.py" - ".github/workflows/frontend-ui.yml" jobs: @@ -27,3 +29,5 @@ jobs: node-version: "22" - name: Run frontend unit tests and validate the Mac launcher run: npm test + - name: Test local file upload server + run: python3 -m unittest discover -s tests -p 'test_local_server.py' -v diff --git a/.gitignore b/.gitignore index 7a22182..7d118f1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__/ .google-books-browser/ *-pages/ +.pdfmagic-uploads/ diff --git a/Launch PDF Magic.command b/Launch PDF Magic.command index 6873310..da08a07 100755 --- a/Launch PDF Magic.command +++ b/Launch PDF Magic.command @@ -7,8 +7,8 @@ url="http://127.0.0.1:4173/" is_toolbench_running() { local page - page="$(curl --silent --fail --max-time 2 "$url" 2>/dev/null)" || return 1 - [[ "$page" == *"PDF Magic Toolbench"* ]] + page="$(curl --silent --fail --max-time 2 "${url}api/status" 2>/dev/null)" || return 1 + [[ "$page" == *'"upload": true'* ]] } if is_toolbench_running; then @@ -18,12 +18,13 @@ if is_toolbench_running; then fi if lsof -nP -iTCP:4173 -sTCP:LISTEN >/dev/null 2>&1; then - echo "Port 4173 is already in use by another service; not opening an unrelated website." + echo "Port 4173 is occupied by an older static server or another application." + echo "Stop it with Control-C, then start PDF Magic again to enable file imports." exit 1 fi echo "Starting PDF Magic on $url" -python3 -m http.server 4173 --bind 127.0.0.1 --directory web & +python3 serve.py & server_pid=$! trap 'kill "$server_pid" 2>/dev/null || true' EXIT diff --git a/README.md b/README.md index 11819b9..f978930 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,19 @@ The dependency-free [web interface](web/index.html) keeps Google Books export, OCR, local AI review, and compression in separate reusable modules. Each panel validates its own inputs and builds the corresponding local CLI command. +**Choose input files by clicking the drop zone or dragging them into it.** +Supported inputs include PDF scans, JSON tagging reports, and JSONL AI reviews. +The localhost Python web server streams an unmodified copy into +`.pdfmagic-uploads/` and substitutes the staged path into the built command. +No manual file path is needed for inputs. You can delete this ignored staging +directory when the imports are no longer needed. + +The upload endpoint is bound to `127.0.0.1` and rejects cross-origin requests. +The selected documents stay on your Mac. A static deployment (for example, +Netlify) can display the toolbench but cannot provide this local upload +endpoint. The interface still builds commands for Terminal; it does not +execute PDF processing or Google Books captures itself. + ```sh npm run serve ``` @@ -16,9 +29,9 @@ npm run serve Open `http://localhost:4173` in a browser. On macOS, **double-click [`Launch PDF Magic.command`](Launch%20PDF%20Magic.command)** in Finder to start the local server and automatically open the site in your default browser. -Alternatively, run `npm run open` from the repository root. If PDF Magic -is already serving port 4173, the launcher opens the existing instance -rather than starting another. The launcher binds its new server to localhost; +Alternatively, run `npm run open` from the repository root. If the upload-capable server is already listening on port 4173, the +launcher opens that instance instead of starting another. If an older static +server occupies the port, stop it with Control-C before relaunching. The launcher binds its new server to localhost; leave its Terminal window open to keep the server running. The interface follows the system appearance by default. Use the **Dark mode / diff --git a/package.json b/package.json index 12809db..25e7f22 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "test": "node --test tests/*.test.js", - "serve": "python3 -m http.server 4173 --directory web", + "serve": "python3 serve.py", "open": "bash './Launch PDF Magic.command'" } } diff --git a/serve.py b/serve.py new file mode 100644 index 0000000..af9f2d8 --- /dev/null +++ b/serve.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Local-only PDF Magic web toolbench and streaming file-import endpoint.""" + +import json +import re +import uuid +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +ROOT = Path(__file__).resolve().parent +WEB_DIR = ROOT / "web" +UPLOAD_DIR = ROOT / ".pdfmagic-uploads" +HOST = "127.0.0.1" +PORT = 4173 +CHUNK_SIZE = 1024 * 1024 +ALLOWED_EXTENSIONS = {".pdf", ".json", ".jsonl"} + + +def safe_filename(raw): + """Avoid traversal and preserve a legible name after a random prefix.""" + basename = raw.replace("\\", "/").rsplit("/", 1)[-1] + ext = Path(basename).suffix.lower() + if ext not in ALLOWED_EXTENSIONS: + raise ValueError("Only PDF, JSON, and JSONL files can be imported.") + stem = re.sub(r"[^\w .()\-]", "_", basename[: -len(ext)]).strip(" .")[:100] + return (stem or "document") + ext + + +class ToolbenchHandler(SimpleHTTPRequestHandler): + upload_dir = UPLOAD_DIR + + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(WEB_DIR), **kwargs) + + def respond_json(self, status, value): + body = json.dumps(value).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.end_headers() + self.wfile.write(body) + + def trusted_request(self): + host = self.headers.get("Host", "") + port = self.server.server_address[1] + return host in {f"localhost:{port}", f"127.0.0.1:{port}"} + + def do_GET(self): + if urlsplit(self.path).path == "/api/status": + if not self.trusted_request(): + return self.respond_json(403, {"error": "Untrusted host."}) + return self.respond_json(200, {"upload": True}) + return super().do_GET() + + def do_POST(self): + if urlsplit(self.path).path != "/api/upload": + return self.respond_json(404, {"error": "Not found."}) + port = self.server.server_address[1] + origin = self.headers.get("Origin", "") + if (not self.trusted_request() or origin not in { + f"http://localhost:{port}", f"http://127.0.0.1:{port}" + }): + return self.respond_json(403, {"error": "Local, same-origin uploads only."}) + if self.headers.get("Content-Type", "").split(";")[0] != "application/octet-stream": + return self.respond_json(415, {"error": "Expected a raw file upload."}) + try: + size = int(self.headers.get("Content-Length", "")) + except ValueError: + return self.respond_json(411, {"error": "Content-Length is required."}) + if size <= 0: + return self.respond_json(400, {"error": "Cannot upload an empty file."}) + filename = parse_qs(urlsplit(self.path).query).get("name", [""])[0] + try: + safe_name = safe_filename(filename) + except ValueError as error: + return self.respond_json(400, {"error": str(error)}) + + self.upload_dir.mkdir(parents=True, exist_ok=True) + destination = self.upload_dir / f"{uuid.uuid4().hex}-{safe_name}" + remaining = size + try: + with destination.open("xb") as output: + while remaining: + data = self.rfile.read(min(CHUNK_SIZE, remaining)) + if not data: + raise ConnectionError("Upload interrupted.") + output.write(data) + remaining -= len(data) + except (OSError, ConnectionError): + destination.unlink(missing_ok=True) + return self.respond_json(400, {"error": "Upload failed or was interrupted."}) + return self.respond_json(201, {"path": str(destination.resolve()), "name": safe_name}) + + +def main(): + server = ThreadingHTTPServer((HOST, PORT), ToolbenchHandler) + print(f"PDF Magic running at http://{HOST}:{PORT}/", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/tests/file-inputs.test.js b/tests/file-inputs.test.js new file mode 100644 index 0000000..34355da --- /dev/null +++ b/tests/file-inputs.test.js @@ -0,0 +1,36 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ocr } from "../web/js/tools/ocr.js"; +import { aiReview } from "../web/js/tools/ai-review.js"; +import { compression } from "../web/js/tools/compression.js"; + +test("source file fields are genuine file-picker targets", () => { + for (const [tool, name, accept] of [ + [ocr, "input", ".pdf"], + [aiReview, "pdf", ".pdf"], + [aiReview, "report", ".json"], + [compression, "input", ".pdf"], + [compression, "review", ".jsonl"], + ]) { + assert.deepEqual( + { type: tool.fields.find(f => f.name === name)?.type, + accept: tool.fields.find(f => f.name === name)?.accept }, + { type: "file", accept }, + ); + } +}); + +test("command builders accept staged paths with spaces and keep output editable", () => { + assert.equal( + ocr.build({ input: "/tmp/PDF Magic/scan.pdf", output: "enhanced.pdf" }), + "./ocr-scanned-pdf.sh '/tmp/PDF Magic/scan.pdf' enhanced.pdf", + ); + assert.equal( + aiReview.build({ pdf: "/tmp/book.pdf", report: "/tmp/blocks.json", output: "review.jsonl" }), + "python3 ollama-review-ocr.py /tmp/book.pdf /tmp/blocks.json review.jsonl", + ); + assert.equal( + compression.build({ input: "/tmp/book.pdf", output: "compressed.pdf", review: "/tmp/notes.jsonl", mode: "tagged" }), + "./compress-and-tag-ocr-pdf.sh /tmp/book.pdf compressed.pdf /tmp/notes.jsonl", + ); +}); diff --git a/tests/test_local_server.py b/tests/test_local_server.py new file mode 100644 index 0000000..52e6b8d --- /dev/null +++ b/tests/test_local_server.py @@ -0,0 +1,103 @@ +"""Test local PDF Magic uploads, origin checks, and directory isolation.""" + +import http.client +import json +import tempfile +import threading +import unittest +from http.server import ThreadingHTTPServer +from pathlib import Path + +from serve import ToolbenchHandler, safe_filename + + +class ServerTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + folder = Path(self.temp.name) + + class TestHandler(ToolbenchHandler): + upload_dir = folder + + def log_message(self, *_args): + pass + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), TestHandler) + self.port = self.server.server_address[1] + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join() + self.temp.cleanup() + + def request(self, method, path, data=None, origin=None, host=None): + client = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5) + headers = {"Host": host or f"127.0.0.1:{self.port}"} + if data is not None: + headers["Content-Type"] = "application/octet-stream" + if origin is not None: + headers["Origin"] = origin + client.request(method, path, body=data, headers=headers) + response = client.getresponse() + result = (response.status, json.loads(response.read())) + client.close() + return result + + def test_status(self): + self.assertEqual(self.request("GET", "/api/status"), (200, {"upload": True})) + + def test_upload_stages_bytes_without_changing_name_or_source(self): + payload = b"%PDF-1.7\n" + b"X" * 65550 + status, body = self.request( + "POST", "/api/upload?name=My%20Book.pdf", + data=payload, origin=f"http://127.0.0.1:{self.port}", + ) + self.assertEqual(status, 201) + self.assertEqual(body["name"], "My Book.pdf") + path = Path(body["path"]) + self.assertEqual(path.parent, Path(self.temp.name)) + self.assertEqual(path.read_bytes(), payload) + + def test_uploads_never_overwrite_one_another(self): + kwargs = {"data": b"{}", "origin": f"http://127.0.0.1:{self.port}"} + first = self.request("POST", "/api/upload?name=a.json", **kwargs) + second = self.request("POST", "/api/upload?name=a.json", **kwargs) + self.assertNotEqual(first[1]["path"], second[1]["path"]) + + def test_cross_site_request_cannot_stage_files(self): + status, _ = self.request( + "POST", "/api/upload?name=a.pdf", + data=b"sample", origin="https://evil.example", + ) + self.assertEqual(status, 403) + self.assertEqual(list(Path(self.temp.name).iterdir()), []) + + def test_missing_origin_is_rejected(self): + status, _ = self.request("POST", "/api/upload?name=a.pdf", data=b"sample") + self.assertEqual(status, 403) + + def test_dns_rebinding_host_is_rejected(self): + status, _ = self.request( + "POST", "/api/upload?name=a.pdf", data=b"sample", + origin=f"http://127.0.0.1:{self.port}", host="evil.example", + ) + self.assertEqual(status, 403) + + def test_bad_extensions_rejected(self): + status, _ = self.request( + "POST", "/api/upload?name=malicious.sh", data=b"sample", + origin=f"http://127.0.0.1:{self.port}", + ) + self.assertEqual(status, 400) + + def test_filename_cannot_escape_staging_directory(self): + self.assertEqual(safe_filename("../../unsafe.pdf"), "unsafe.pdf") + self.assertEqual(safe_filename(r"C:\\tmp\\scan.jsonl"), "scan.jsonl") + self.assertRaises(ValueError, safe_filename, "../script.py") + + +if __name__ == "__main__": + unittest.main() diff --git a/web/js/components/tool-panel.js b/web/js/components/tool-panel.js index c28df9a..e36fa24 100644 --- a/web/js/components/tool-panel.js +++ b/web/js/components/tool-panel.js @@ -6,6 +6,13 @@ function element(tag, className, text) { } function fieldControl(field) { + if (field.type === "file") { + const input = document.createElement("input"); + input.type = "file"; + input.accept = field.accept; + return input; + } + if (field.type === "select") { const select = document.createElement("select"); for (const option of field.options) { @@ -40,7 +47,9 @@ export class ToolPanel extends HTMLElement { const data = {}; for (const field of this._definition.fields) { const control = form.elements.namedItem(field.name); - data[field.name] = field.type === "checkbox" ? control.checked : control.value; + data[field.name] = field.type === "file" + ? (control.dataset.uploadedPath || "") + : field.type === "checkbox" ? control.checked : control.value; } return data; } @@ -62,13 +71,104 @@ export class ToolPanel extends HTMLElement { const form = document.createElement("form"); const fields = element("div", "fields"); + let pendingUploads = 0; for (const field of definition.fields) { const wrapper = element("div", `field${field.wide ? " wide" : ""}`); const control = fieldControl(field); control.id = `${definition.id}-${field.name}`; control.name = field.name; - if (field.type === "checkbox") { + if (field.type === "file") { + const label = element("label", "", field.label); + label.htmlFor = control.id; + if (field.required) label.append(element("span", "required", " *")); + + const zone = element("div", "file-drop-zone"); + const symbol = element("span", "file-drop-symbol", "⇧"); + symbol.setAttribute("aria-hidden", "true"); + const instruction = element("span", "file-drop-instruction", + `Drop ${field.accept === ".pdf" ? "a PDF" : field.accept.toUpperCase() + " file"} here or click to choose`); + const hint = element("span", "file-drop-hint", "A copy stays on this Mac."); + const feedback = element("span", "file-drop-status", "No file selected"); + feedback.setAttribute("role", "status"); + feedback.setAttribute("aria-live", "polite"); + feedback.id = `${control.id}-feedback`; + control.setAttribute("aria-describedby", feedback.id); + zone.append(symbol, instruction, hint, feedback, control); + wrapper.append(label, zone); + if (field.help) wrapper.append(element("span", "help", field.help)); + + let generation = 0; + async function importFile(file) { + if (!file) return; + const current = ++generation; + delete control.dataset.uploadedPath; + zone.classList.remove("file-ready", "file-error"); + if (!file.name.toLowerCase().endsWith(field.accept)) { + feedback.textContent = `Select a ${field.accept} file.`; + zone.classList.add("file-error"); + return; + } + + pendingUploads += 1; + build.disabled = true; + zone.classList.add("file-uploading"); + feedback.textContent = `Importing ${file.name}…`; + try { + const response = await fetch( + `/api/upload?name=${encodeURIComponent(file.name)}`, + { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: file }, + ); + if (!response.ok) { + throw new Error([404, 405, 501].includes(response.status) + ? "Use the local server (npm run serve or npm run open) to import files." + : `Local file import failed (HTTP ${response.status}).`); + } + const result = await response.json(); + if (!result.path || typeof result.path !== "string") { + throw new Error("The local server did not return a file path."); + } + if (current !== generation) return; + control.dataset.uploadedPath = result.path; + zone.classList.add("file-ready"); + feedback.textContent = `Ready: ${file.name}`; + } catch (error) { + if (current !== generation) return; + zone.classList.add("file-error"); + feedback.textContent = error.message || "The file could not be imported."; + } finally { + pendingUploads -= 1; + zone.classList.remove("file-uploading"); + build.disabled = pendingUploads > 0; + } + } + + control.addEventListener("change", () => importFile(control.files?.[0])); + zone.addEventListener("dragenter", (event) => { + event.preventDefault(); + zone.classList.add("file-drag-over"); + }); + zone.addEventListener("dragover", (event) => { + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; + zone.classList.add("file-drag-over"); + }); + zone.addEventListener("dragleave", (event) => { + if (!zone.contains(event.relatedTarget)) zone.classList.remove("file-drag-over"); + }); + zone.addEventListener("drop", (event) => { + event.preventDefault(); + zone.classList.remove("file-drag-over"); + const files = event.dataTransfer?.files; + if (!files?.length) return; + if (files.length !== 1) { + feedback.textContent = "Select one file at a time."; + zone.classList.add("file-error"); + return; + } + importFile(files[0]); + }); + } else if (field.type === "checkbox") { const check = element("label", "check-field"); check.htmlFor = control.id; check.append(control, document.createTextNode(field.label)); @@ -105,6 +205,7 @@ export class ToolPanel extends HTMLElement { form.addEventListener("submit", (event) => { event.preventDefault(); + if (pendingUploads > 0) return; status.classList.remove("error"); try { output.textContent = definition.build(this.values(form)); diff --git a/web/js/tools/ai-review.js b/web/js/tools/ai-review.js index 9afed0d..ccbdd1e 100644 --- a/web/js/tools/ai-review.js +++ b/web/js/tools/ai-review.js @@ -8,8 +8,8 @@ export const aiReview = { title: "AI OCR review", summary: "Review OCR blocks against page images with local Ollama models.", fields: [ - { name: "pdf", label: "OCR PDF", required: true, placeholder: "scan-ocr.pdf" }, - { name: "report", label: "Tagging report", required: true, placeholder: "scan-ocr.tagging.json" }, + { name: "pdf", label: "OCR PDF", type: "file", accept: ".pdf", required: true }, + { name: "report", label: "Tagging report", type: "file", accept: ".json", required: true }, { name: "output", label: "Review output", required: true, placeholder: "review-v1.jsonl", wide: true }, { name: "pages", label: "Pages", placeholder: "1-10,14,20", help: "Leave blank to review every page." }, { name: "dpi", label: "Render DPI", type: "number", min: 1, placeholder: "180" }, diff --git a/web/js/tools/compression.js b/web/js/tools/compression.js index e5ceceb..cdd3e4d 100644 --- a/web/js/tools/compression.js +++ b/web/js/tools/compression.js @@ -19,9 +19,9 @@ export const compression = { { value: "compression-only", label: "Compression only" }, ], }, - { name: "input", label: "Input PDF", required: true, placeholder: "scan-ocr.pdf" }, + { name: "input", label: "Input PDF", type: "file", accept: ".pdf", required: true }, { name: "output", label: "Output PDF", required: true, placeholder: "scan-compact.pdf" }, - { name: "review", label: "AI review JSONL", placeholder: "review-v1.jsonl", help: "Used by the compress-and-tag workflow." }, + { name: "review", label: "AI review JSONL", type: "file", accept: ".jsonl", help: "Optional input for the compress-and-tag workflow." }, { name: "report", label: "Compression report", placeholder: "compression.json", help: "Used by compression-only mode." }, { name: "description", label: "Description", placeholder: "Optional; for tagged PDF" }, { name: "keywords", label: "Tags / keywords", placeholder: "Comma-separated; for tagged PDF" }, diff --git a/web/js/tools/ocr.js b/web/js/tools/ocr.js index efdc779..6f3f164 100644 --- a/web/js/tools/ocr.js +++ b/web/js/tools/ocr.js @@ -8,7 +8,7 @@ export const ocr = { title: "Searchable OCR", summary: "Create a searchable, structured derivative while preserving the source scan.", fields: [ - { name: "input", label: "Source PDF", required: true, placeholder: "scan.pdf", wide: true }, + { name: "input", label: "Source PDF", type: "file", accept: ".pdf", required: true, wide: true }, { name: "output", label: "Output PDF", placeholder: "scan-enhanced-ocr.pdf", wide: true, help: "Optional; the CLI derives a sibling filename when omitted." }, { name: "title", label: "Title", placeholder: "Preserve original if blank" }, { name: "author", label: "Author", placeholder: "Preserve or detect if blank" }, diff --git a/web/styles.css b/web/styles.css index 126b758..6d56666 100644 --- a/web/styles.css +++ b/web/styles.css @@ -246,6 +246,45 @@ input:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px v .help { color: var(--muted); font-size: .73rem; line-height: 1.4; } +/* Accessible drag-and-drop targets with the existing light/dark tokens. */ +.file-drop-zone { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: .35rem; + min-height: 10rem; + padding: 1.25rem .85rem; + border: 2px dashed var(--line); + border-radius: .9rem; + background: var(--field-bg); + text-align: center; + transition: border-color .15s, background-color .15s; +} +.file-drop-zone:hover, .file-drop-zone:focus-within, .file-drop-zone.file-drag-over { + border-color: var(--accent); + background: var(--accent-soft); +} +.file-drop-zone:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; } +.file-drop-zone.file-ready { border-color: var(--green); } +.file-drop-zone.file-error { border-color: var(--error); } +.file-drop-zone input[type="file"] { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} +.file-drop-symbol { color: var(--accent); font-size: 1.7rem; line-height: 1; } +.file-drop-instruction { color: var(--ink); font-size: .88rem; font-weight: 750; } +.file-drop-hint, .file-drop-status { color: var(--muted); font-size: .73rem; line-height: 1.4; } +.file-drop-status { overflow-wrap: anywhere; font-weight: 650; } +.file-ready .file-drop-status { color: var(--green); } +.file-error .file-drop-status { color: var(--error); } + + .check-field { display: flex; align-items: center;