From f4b00babf4da6f37f31cb94e99b99003250f6934 Mon Sep 17 00:00:00 2001 From: voidReq <100312344+voidReq@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:57:27 -0400 Subject: [PATCH] GUI: give NUauto your resume and transcript (kept in local/, pushed at once) Setup's resume step and a new Settings > Your files card take a dropped or chosen PDF (POST /api/files). NUauto keeps its own copy as local/resume.pdf / local/transcript.pdf (mode 600), points resume_path / transcript_path at it, and pushes it to the homelab right away (sync.push_documents) instead of at the next push. The transcript can be removed (here and on the homelab). The homelab's page shows the copies it has, read-only. The native file dialog is gone; PDFs found on the computer are copied in the same way. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- docs/ARCHITECTURE.md | 8 ++- docs/DEPLOY.md | 5 +- docs/GUI.md | 21 +++++-- docs/PIPELINE.md | 4 +- docs/REMOTE.md | 1 + src/nuauto/config.py | 26 ++++++--- src/nuauto/gui.py | 55 +++++++++++++----- src/nuauto/gui_static/app.js | 54 ++++++++++++++--- src/nuauto/gui_static/setup.js | 23 ++++---- src/nuauto/onboard.py | 102 ++++++++++++++++++++++----------- src/nuauto/sync.py | 25 ++++++-- tests/test_gui.py | 20 +++++++ tests/test_homelab_app.py | 2 + tests/test_setup.py | 4 +- 15 files changed, 261 insertions(+), 93 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d22731e..dc1ba6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,9 @@ SAFETY RULES (most important) profile. The script selects only the exact "resume_label" saved in profile.json and never uploads anything. (Added 2026-10-12, my request.) The company-site agent may also upload my unofficial transcript (local_config.json - "transcript_path", GUI Settings; the homelab gets a copy as local/transcript.pdf), only where a page asks for one. + "transcript_path"; GUI Settings > Your files keeps it as local/transcript.pdf; the homelab gets a copy there), only where + a page asks for one. (2026-10-20) The resume and the transcript given in the GUI are NUauto's own copies in local/, + pushed to the homelab at once (docs/GUI.md, YOUR FILES). 7. (Changed 2026-10-03; GUI added 2026-10-05.) `nuauto apply` submits every Approved row, one at a time, with no per-job prompt: Approved in the sheet IS my approval. It runs only with me there: in a real terminal, or from the GUI (`nuauto gui`: I click Start, the browser is visible, Stop = diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6e5bd62..6b66f20 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -35,7 +35,7 @@ MODULES (src/nuauto/) update_after() is the guard; bank() is the answer-bank command; assist_target(), nuworks_side(). inspect_form.py read-only lister of an Apply form. main(); FIELDS_JS is reused by apply.py. - sync.py laptop<->homelab rsync. pull(), push(), push_session(), server_busy(), deploy_now(); PACKAGE/CODE + sync.py laptop<->homelab rsync. pull(), push(), push_documents(), push_session(), server_busy(), deploy_now(); PACKAGE/CODE list the files that reach the homelab (not sent when deploy_from_git is on). insights.py summarize(pool, details, rows) -> pay (hourly(), buckets, median, middle half), places (place(), by state), categories, sheet statuses; text() for `nuauto insights`; gui.py's /api/insights. @@ -49,7 +49,9 @@ MODULES (src/nuauto/) Task (one child process + its JSON-lines questions), the pages' data (state, review, apply, company, answers, settings, setup), open_terminal(), screenshots(). Static files: gui_static/. onboard.py the setup wizard's steps (steps(), check_client, open_and_prepare, resume_preview, check_prefs, - save_webhook, scheduler_*, launcher_create) and the read-only NUworks reads (resume labels, terms). + save_webhook, scheduler_*, launcher_create), your files (save_document / remove_document / + document_info: the resume and transcript copies in local/) and the read-only NUworks reads (resume + labels, terms). demo.py demo mode: FakeClient/FakeWorksheet (sheet), serve_nuworks (fake NUworks pages in Playwright), the fake claude (fake_claude, run through a two-line script), sample data, forced states; setup() / update(). window.py which window (kind(): mac / gtk / app / tab), run_mac() (pywebview, main thread), open_gtk() (the GTK @@ -128,7 +130,7 @@ STATE ON DISK (repo root; all gitignored except as noted) profile.json), answers.json (answers.py), Google files (token.json, google_login.txt; sheet.py), NUworks session (session_cookies.json, browser_profile/, browser_profile.lock; browser.py), assist_profile/ (assist.py), web_secret.txt (web.py), discord_webhook.txt (daily.py), - gui.lock (gui.py), onboard.json (onboard.py). Demo mode: all of these under a temp NUAUTO_STATE_DIR. + gui.lock (gui.py), onboard.json, resume.pdf, transcript.pdf (onboard.py). Demo mode: all of these under a temp NUAUTO_STATE_DIR. Which of these sync to the homelab: docs/DEPLOY.md. Also in the repo, tracked: prompts/ (Claude prompts), tests/ (offline checks), deploy/systemd/. diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index ecc0f94..191c7ba 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -111,8 +111,9 @@ SECRETS AND STATE (never print or log any of these; all mode 600) own ones imported from Bitwarden (`nuauto accounts import`); with remote_assist merged both ways by sync.share (docs/REMOTE.md), else never synced; `nuauto accounts export` (or Settings) writes Bitwarden's import file - transcript.pdf (yes) (yes) your unofficial transcript (transcript_path, any place on the laptop), for the assistant to - upload; with remote_assist sync.push copies it there (via a 600 copy in local/) + transcript.pdf (yes) (yes) your unofficial transcript (transcript_path: since 2026-10-20 the GUI's copy here, or + any place on the laptop), for the assistant to upload; with remote_assist + sync.push copies it there (via a 600 copy in local/), at once when given in the GUI gmail_token.json yes (yes) the read-only Gmail login for the assistant's email codes (`nuauto login gmail`; renewed by `nuauto login google`); with remote_assist sync.push (if newer) gui.lock yes (yes) the running GUI (pid, port, a secret that only opens a window); not synced (the diff --git a/docs/GUI.md b/docs/GUI.md index 0e27fad..2bc9fd7 100644 --- a/docs/GUI.md +++ b/docs/GUI.md @@ -158,7 +158,7 @@ SCREENS a dialog, the browser's own find Settings every health check (run them now), logins (Google, NUworks, Claude Code, and Gmail: the read-only login for the assistant's email codes, with a link to turn the Gmail API on), resume label, applications per - week (the weekly cap), week start; + week (the weekly cap), week start; Your files (the resume and the transcript: YOUR FILES); the company-site assistant: account email, effort (low / medium / high), assistants at once (1-3, on each machine; no homelab needed), the folders it may read, your writeups (assist_writeups: the first place it looks for essays), its job-site logins (how many; Export for Bitwarden: the import file in ~/Downloads, @@ -184,7 +184,7 @@ SCREENS (remote_auto {"nuworks", "other"}), its hours (remote_hours), assistants at once (assist_slots), Pair this computer, Open it; setup, logs, quit. On the homelab's page one This homelab card: Paired devices (each one, "this one", Unpair, Make a pairing link for another device) and its assistant's queue settings (no - on / off: the laptop's); the email and effort only (the transcript, folders, export: on your laptop); no + on / off: the laptop's); the email and effort only (folders, export: on your laptop); Your files read-only; no Password manager card (Bitwarden unlocks on the laptop), no Quit Setup the wizard (below); opens by itself until setup is done (never on the homelab's page: setup is the laptop's; there it says so, and the steps it can do are preferences, Discord and the resume label) @@ -202,7 +202,8 @@ SETUP WIZARD (onboard.py + gui_static/setup.js; each step checks itself) accepted; saved local/client_secret.json, mode 600); then the Google login sheet create one (Sheets API spreadsheets.create; gspread's create needs the Drive API) or paste the address (ID extracted, access tested). Empty = set up; other data = refused, never overwritten. - resume native file dialog (osascript / zenity / kdialog) or a path; the PDF must have text + resume drop the PDF or choose it (the page's own file chooser), or one it found (PDFs called resume / CV in + Documents, Downloads, Desktop); the PDF must have text. NUauto keeps its own copy (YOUR FILES) nuworks login (Firefox window; you type nothing into NUauto), then the resume label read from one job's real Apply popup (read-only: opens it, reads the Resume dropdown, Cancel) preferences what you study first (fields.py: Engineering or CS, Business, Health sciences or nursing, Natural @@ -405,14 +406,25 @@ API (JSON; all need the cookie; POSTs need X-NUauto: 1) {on, check, waiting, own, only, bw}, homelab_assistant {available, on, app, auto (any tab), auto_tabs {nuworks, other}, hours, slots, review_url}, where, homelab (the laptop: onboard.homelab_link) and (the homelab) devices, this + POST /api/files {"kind": "resume" | "transcript", "data": the PDF in base64} or {"kind", "remove": true} + (transcript only) -> settings; up to 10 MB (the only request over 2 MB); the homelab: 409 POST /api/logins/export Bitwarden's import file of the logins not exported yet ({"every": true}: all) -> {"path", "count"} GET /api/setup wizard steps; POST /api/setup {"action": ack|client_upload|client_downloads| - sheet_create|sheet_link|resume_pick|resume_set|labels_read|label_set|terms_read| + sheet_create|sheet_link|resume_set (a path it found: copied in)|labels_read|label_set|terms_read| prefs_save|discord_save|discord_test|discord_remove|schedule_on|schedule_off|launcher} GET /api/logs, /api/file?path= past runs (the assistant's own screenshots are in a run's browser/); a screenshot or log under logs/ only POST /api/quit +YOUR FILES (2026-10-20, my request: "a way to upload a resume and a transcript into the gui") +- The resume (Setup's resume step, or Settings > Your files) and the transcript (Settings > Your files; optional, Remove + takes it out) are given to NUauto by dropping or choosing the PDF in the page. NUauto keeps its own copy, + local/resume.pdf / local/transcript.pdf (mode 600; onboard.save_document: a PDF, up to 10 MB, the resume with text + Claude can read), and points resume_path / transcript_path at it. So changing the PDF you had elsewhere changes + nothing: give NUauto the new one. An older resume_path / transcript_path anywhere on the laptop still works. +- With a homelab the laptop pushes the copies there at once (sync.push_documents; a removed transcript is deleted + there too), not at the next push. The homelab's page shows the copies it has (name, size, when) and can't change them. + FILES IT WRITES (all under local/, mode 700, except as noted) gui.lock pid, port and a secret that only lets a second start ask for a window (removed on exit); on the homelab nuauto-web's (its runs check it) @@ -420,6 +432,7 @@ FILES IT WRITES (all under local/, mode 700, except as noted) review_devices.json, review_pair.json (homelab) the paired devices' key hashes, the open pairing links' hashes browser_profile.lock who uses the NUworks browser (pid, what, since) onboard.json what the read-only NUworks reads found (resume labels, terms) + resume.pdf, transcript.pdf YOUR FILES (mode 600) local_config.json gains tos_ack, preferences, tools (saved tool paths) through the wizard (mode 644, as before) scheduler: ~/.config/systemd/user/nuauto-daily.{timer,service} or ~/Library/LaunchAgents/com.nuauto.daily.plist launcher: ~/.local/share/applications/nuauto.desktop (+ icon) or ~/Applications/NUauto.app diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index b0da8d5..8ece4ea 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -137,6 +137,6 @@ year's lists are new repos: changing the term moves the addresses with it); off Discord: new pool internships after a run; the morning message adds the last day's internship counts. RESUME -- The PDF at local_config.json resume_path (the only file in its folder). The homelab - gets a copy as resume.pdf. Updated 2026-10-02 16:44 (minor: past job title/description). +- The PDF at local_config.json resume_path: since 2026-10-20 the GUI keeps the one you give it as local/resume.pdf + (docs/GUI.md, YOUR FILES). The homelab gets a copy as resume.pdf. Updated 2026-10-02 16:44 (minor: past job title/description). Jobs scored before that used the 10:03 version; not re-scored since the change is minor. diff --git a/docs/REMOTE.md b/docs/REMOTE.md index de3ef74..1d233ea 100644 --- a/docs/REMOTE.md +++ b/docs/REMOTE.md @@ -194,6 +194,7 @@ WHAT LIVES WHERE (with NUauto on the homelab) kept with it ("other_passwords"); the merge names the site. Sent as JSON over ssh, never printed or logged. discord_webhook.txt pushed by push() when the laptop's is newer (the homelab sends the messages). transcript (transcript_path) pushed by push() to local/transcript.pdf (mode 600); the agent there uploads it. + resume, transcript, given in the GUI pushed at once (sync.push_documents; docs/GUI.md, YOUR FILES). gmail_token.json pushed by push() when the laptop's is newer (`nuauto login google` / `login gmail`). your writeups (assist_writeups) copied by push() to ~/nuauto-notes/ on the homelab: only the files the agent may read anyway (sync.notes_files = assist.secret_path: no keys, tokens, .env, .git, NUauto's local/), diff --git a/src/nuauto/config.py b/src/nuauto/config.py index 8afc5f7..e131597 100644 --- a/src/nuauto/config.py +++ b/src/nuauto/config.py @@ -64,14 +64,24 @@ # host is deliberately NOT here; log in by hand with `nuauto login`. ALLOWED_HOSTS = {"northeastern-csm.symplicity.com"} # Job pool (jobs.py) -# Laptop: the resume at local_config resume_path. Homelab: a copy the laptop pushes to local/resume.pdf. -LAPTOP_RESUME = os.path.expanduser(LOCAL["resume_path"]) -RESUME_PATH = LAPTOP_RESUME if os.path.exists(LAPTOP_RESUME) else os.path.join(LOCAL_DIR, "resume.pdf") -# The unofficial transcript (optional), for the company-site assistant to upload where an application asks for one. -# Laptop: local_config "transcript_path". Homelab (remote_assist): a copy the laptop pushes to local/transcript.pdf. -LAPTOP_TRANSCRIPT = os.path.expanduser(LOCAL.get("transcript_path") or "") -TRANSCRIPT_PATH = (LAPTOP_TRANSCRIPT if LAPTOP_TRANSCRIPT and os.path.exists(LAPTOP_TRANSCRIPT) - else os.path.join(LOCAL_DIR, "transcript.pdf")) + + +def set_files(cfg): + """The resume and the transcript this process uses (at import, and again after a settings change). + Laptop: the resume at local_config resume_path (since 2026-10-20 the GUI keeps what you give it as local/resume.pdf + and points resume_path there). Homelab: a copy the laptop pushes to local/resume.pdf. + The unofficial transcript (optional), for the company-site assistant to upload where an application asks for one: + laptop local_config "transcript_path" (the GUI's: local/transcript.pdf); homelab (remote_assist): a copy the laptop + pushes to local/transcript.pdf.""" + global LAPTOP_RESUME, RESUME_PATH, LAPTOP_TRANSCRIPT, TRANSCRIPT_PATH + LAPTOP_RESUME = os.path.expanduser(cfg.get("resume_path") or "") + RESUME_PATH = LAPTOP_RESUME if os.path.exists(LAPTOP_RESUME) else os.path.join(LOCAL_DIR, "resume.pdf") + LAPTOP_TRANSCRIPT = os.path.expanduser(cfg.get("transcript_path") or "") + TRANSCRIPT_PATH = (LAPTOP_TRANSCRIPT if LAPTOP_TRANSCRIPT and os.path.exists(LAPTOP_TRANSCRIPT) + else os.path.join(LOCAL_DIR, "transcript.pdf")) + + +set_files(LOCAL) DATA_DIR = os.path.join(STATE_DIR, "data") # list, triage, details, scores, ratings, pool WORK_DIR = os.path.join(STATE_DIR, "work") # batch files exchanged with the Claude subagents ANSWERS_PATH = os.path.join(LOCAL_DIR, "answers.json") diff --git a/src/nuauto/gui.py b/src/nuauto/gui.py index 3a72fca..b90116e 100644 --- a/src/nuauto/gui.py +++ b/src/nuauto/gui.py @@ -26,6 +26,7 @@ its window opens for you, and you click Start there. Never with --no-open there (CLAUDECODE): that would print the agent the URL with this start's secret. Agents test with --demo (fake data) or --view (real data, read-only). """ +import base64 import fcntl import hmac import html @@ -1664,6 +1665,7 @@ def settings_get(): "tools": {name: config.tool_path(name) for name in ("claude", "npx")}, "paths": {"state": config.STATE_DIR, "logs": config.LOGS_DIR}, "demo": config.DEMO, "efforts": list(EFFORTS), "logins": logins_summary(), + "files": {kind: onboard.document_info(kind) for kind in onboard.DOCUMENTS}, "password_manager": onboard.password_manager({c.id: c for c in APP.checks()}), "homelab_assistant": onboard.homelab_assistant(here=REMOTE), "where": "homelab" if REMOTE else "laptop", "homelab": onboard.homelab_link() if not REMOTE else None, @@ -1682,6 +1684,8 @@ def write_local(updates): restart_needed = {"sheet_id", "resume_path", "week_start", "max_per_week", "server_hostname", "server_ssh", "server_dir"} & set(updates) if restart_needed: # config.py reads local_config.json once, at import apply_config(cfg) + elif "transcript_path" in updates: # (sync.push sends this process's transcript) + config.set_files(cfg) return cfg @@ -1690,9 +1694,7 @@ def apply_config(cfg): config.LOCAL = cfg config.SHEET_ID = cfg.get("sheet_id", "") config.WEEK_START = date.fromisoformat(cfg["week_start"]) if cfg.get("week_start") else None - config.LAPTOP_RESUME = os.path.expanduser(cfg.get("resume_path") or "") - config.RESUME_PATH = config.LAPTOP_RESUME if os.path.exists(config.LAPTOP_RESUME) else \ - os.path.join(config.LOCAL_DIR, "resume.pdf") + config.set_files(cfg) if not (REMOTE and config.DEMO): # the homelab demo has its made-up assistant on, whatever the file says config.REMOTE_ASSIST = bool(cfg.get("remote_assist")) and config.HAS_SERVER config.REMOTE_APP = config.HAS_SERVER and (config.REMOTE_ASSIST or bool(cfg.get("remote_app"))) @@ -1707,6 +1709,31 @@ def app_on(cfg): return bool(cfg.get("remote_assist") or cfg.get("remote_app")) +def files_post(body): + """Your resume or transcript, given to NUauto ({"kind", "data": the PDF in base64}, or {"kind", "remove": true}): + kept in local/ (onboard.save_document) and pushed to the homelab at once, so it never uses the old one.""" + from nuauto import onboard + kind = body.get("kind") + if kind not in onboard.DOCUMENTS: + raise Refused("Which file: resume or transcript?") + try: + if body.get("remove"): + onboard.remove_document(kind) + else: + try: + data = base64.b64decode(str(body.get("data") or ""), validate=True) + except ValueError: + raise Refused("That upload did not arrive whole: try again.") + onboard.save_document(kind, data) + except onboard.Refused as e: + raise Refused(str(e)) + APP.run_now(["quick"]) + if config.HAS_SERVER and not config.IS_SERVER and not config.DEMO: + from nuauto import sync + threading.Thread(target=sync.push_documents, args=(bool(body.get("remove")),), daemon=True).start() + return settings_get() + + def remote_changed(old, restart=False): """After settings the homelab acts on changed: the homelab gets them (sync.push), and nuauto-web restarts when its NUauto page starts or stops with them, or the Mark-done address changed (restart: it reads both at its start). In @@ -1939,15 +1966,12 @@ def setup_post(body): onboard.open_and_prepare(sid) onboard.save_config({"sheet_id": sid}) APP.refresh_rows(force=True) - elif a == "resume_pick": - return {"path": onboard.pick_file()} - elif a == "resume_set": + elif a == "resume_set": # one of the PDFs the step found on this computer: NUauto keeps its own copy path = os.path.abspath(os.path.expanduser(str(body.get("path", "")).strip())) - ok, text = onboard.resume_preview(path) - if not ok: - raise Refused(text) - onboard.save_config({"resume_path": path}) - APP.run_now(["quick"]) + if not os.path.isfile(path): + raise Refused("No file there.") + with open(path, "rb") as f: + files_post({"kind": "resume", "data": base64.b64encode(f.read(onboard.MAX_DOCUMENT + 1)).decode()}) elif a == "labels_read": task = APP.start("onboard_labels", "Reading your resumes on NUworks", config.self_cmd("onboard", "resume-labels")) return {"task": task.view()} @@ -2009,7 +2033,9 @@ class NotFound(Exception): REMOTE = False REMOTE_GROUPS_OFF = ("homelab", "bitwarden", "app") # the laptop's checks: ssh to the homelab, Bitwarden, the app's update REMOTE_POSTS_OFF = {"/api/quit": "NUauto on your homelab stays on (it is part of nuauto-web).", - "/api/logins/export": "The logins file for Bitwarden is written on your laptop (Settings there)."} + "/api/logins/export": "The logins file for Bitwarden is written on your laptop (Settings there).", + "/api/files": "Your resume and transcript are kept on your laptop: give NUauto there the new one " + "(Settings > Your files). It copies them here at once."} REQUEST = threading.local() # the paired device a request comes from (its name goes in the run logs) APP_SCREEN_SLOT = 0 # the virtual screen of a run you watch here (Apply, the NUworks side); 1-3: the assistant's _TERMINAL = "It runs in a terminal window on your laptop (you type there, never into this page)." @@ -2244,6 +2270,7 @@ def serve_remote(): "/api/answers": answers_put, "/api/settings": settings_put, "/api/logins/export": logins_export, + "/api/files": files_post, "/api/setup": setup_post, "/api/assistant": assistant_post, # (laptop) the homelab's page through the link: a tap on one of its sessions' screens, its run's question and Stop @@ -2392,6 +2419,7 @@ def do_GET(self): self._call(lambda: route(q)) MAX_BODY = 2_000_000 + MAX_UPLOAD = 14_000_000 # /api/files: a PDF of up to onboard.MAX_DOCUMENT, in base64 def _read_body(self): """The request body, read before anything else: a rejected request must not leave it on a kept-alive @@ -2400,7 +2428,8 @@ def _read_body(self): length = int(self.headers.get("Content-Length") or 0) except ValueError: length = -1 - if not 0 <= length <= self.MAX_BODY: + upload = urlparse(self.path).path == "/api/files" and not REMOTE # the homelab refuses it anyway + if not 0 <= length <= (self.MAX_UPLOAD if upload else self.MAX_BODY): self.close_connection = True return None return self.rfile.read(length) diff --git a/src/nuauto/gui_static/app.js b/src/nuauto/gui_static/app.js index 276072c..54f6217 100644 --- a/src/nuauto/gui_static/app.js +++ b/src/nuauto/gui_static/app.js @@ -142,6 +142,29 @@ function linkOut(url, text) { } function plural(n, word) { return `${n} ${word}${n === 1 ? "" : "s"}`; } + +// Your resume and transcript: a PDF you drop or choose goes to NUauto (/api/files), which keeps its own copy in local/ +// and pushes it to the homelab. Setup's resume step and Settings > Your files. +const MAX_PDF = 10000000; // onboard.MAX_DOCUMENT +async function sendPdf(kind, file) { + if (file.size > MAX_PDF) throw new Error(`${file.name} is over 10 MB.`); + const bytes = new Uint8Array(await file.arrayBuffer()); + let bin = ""; + for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); + return api.post("/api/files", { kind, data: btoa(bin) }); +} +function pdfDrop(testid, text, onfile) { // a box to drop the PDF on, with a button that opens the file chooser + const input = el("input", { type: "file", accept: ".pdf,application/pdf", hidden: true, testid: `${testid}-file` }); + const drop = el("div", { class: "drop", testid }, text, " ", el("button", { class: "btn small", text: "choose it", onclick: () => input.click() }), input); + input.addEventListener("change", () => { if (input.files[0]) onfile(input.files[0]); input.value = ""; }); + drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("over"); }); + drop.addEventListener("dragleave", () => drop.classList.remove("over")); + drop.addEventListener("drop", (e) => { e.preventDefault(); drop.classList.remove("over"); if (e.dataTransfer.files[0]) onfile(e.dataTransfer.files[0]); }); + return drop; +} +function pdfLine(f) { // what NUauto uses now: name, size, when it changed + return f ? `${f.name} · ${Math.max(1, Math.round(f.size / 1024))} KB · updated ${ago(f.modified)}` : "None yet."; +} function ordinal(n) { const t = n % 100, s = ["th", "st", "nd", "rd"][(t - 20) % 10] || ["th", "st", "nd", "rd"][t] || "th"; return n + s; } function gone(msg, title) { @@ -1911,6 +1934,7 @@ screens.settings = async (view) => { el("label", { class: "field" }, "Weekly limit counts from", el("span", { class: "muted small", text: "Fixed 7-day weeks from this day. Empty: any rolling 7 days." }), week), el("div", {}, el("button", { class: "btn primary", text: "Save", testid: "settings-save", onclick: async () => { try { d = await api.post("/api/settings", { resume_label: label.value, week_start: week.value, max_per_week: cap.value }); toast("Saved."); pollState(); draw(); } catch (e) { fail(e); } } })))), + filesCard(), assistantCard(), el("div", { class: "card stack", testid: "intern-settings" }, el("h2", { text: "Summer internships" }), internshipsPanel(d.internships, (r) => { if (r) { d = r; draw(); } else reload(); })), @@ -1974,6 +1998,25 @@ screens.settings = async (view) => { } // The company-site assistant (nuauto assist): the email for the accounts it makes, how hard it thinks, the folders it // may read, and its logins (one per job site; the passwords never reach this page) as Bitwarden's import file. + function filesCard() { // the resume and the transcript NUauto keeps (on the homelab's page: the copies it has) + const here = onHomelab(); + const give = (kind, done) => async (file) => { + try { d = await sendPdf(kind, file); toast(done); pollHealth(); draw(); } catch (e) { fail(e); } + }; + const part = (kind, title, help, done) => el("div", { class: "stack", testid: `file-${kind}` }, + el("div", { class: "row between" }, el("div", {}, el("b", { text: title }), el("div", { class: "small muted", testid: `file-${kind}-now`, text: pdfLine(d.files[kind]) })), + !here && kind === "transcript" && d.files.transcript ? el("button", { class: "btn small ghost", text: "Remove", testid: "file-transcript-remove", + onclick: () => confirmBox("Remove your transcript?", "The assistant then has none to upload (on your homelab too).", "Remove", async () => { + try { d = await api.post("/api/files", { kind, remove: true }); toast("Transcript removed."); draw(); } catch (e) { fail(e); } }) }) : null), + el("span", { class: "muted small", text: help }), + here ? null : pdfDrop(`file-${kind}-drop`, d.files[kind] ? "Drop a new PDF here, or" : "Drop the PDF here, or", give(kind, done))); + return el("div", { class: "card stack", testid: "files-settings" }, el("h2", { text: "Your files" }), + el("p", { class: "small muted", text: here ? "Copies from your laptop. Give NUauto there a new one (Settings > Your files): it copies it here at once." : + "NUauto keeps its own copy of each (in its private folder, readable only by you; your homelab, if you use one, gets a copy at once). To change one, drop the new PDF here." }), + part("resume", "Resume", "The PDF you use on NUworks: Claude compares each posting with it, and the assistant uploads it on company sites.", "Resume saved."), + part("transcript", "Transcript (optional)", "Your unofficial transcript: the assistant uploads it only where an application asks for one.", "Transcript saved.")); + } + function assistantCard() { const s = d.settings; const email = el("input", { type: "email", value: s.accounts_email || "", placeholder: "you@example.com", @@ -1988,8 +2031,6 @@ screens.settings = async (view) => { const slots = el("select", { "aria-label": "Assistants at once", testid: "set-assist-slots" }, ...[1, 2, 3].map((n) => el("option", { value: String(n), text: String(n) }))); slots.value = String(d.assist_slots); const lines = (t) => t.value.split("\n").map((x) => x.trim()).filter(Boolean); - const transcript = el("input", { type: "text", value: s.transcript_path || "", placeholder: "~/Documents/unofficial_transcript.pdf", - "aria-label": "Transcript", testid: "set-transcript" }); const L = d.logins; const exportBtn = el("button", { class: "btn small", testid: "btn-export-logins", disabled: !L.new, text: L.new ? `Export ${plural(L.new, "new login")} for Bitwarden` : "No new logins to export", onclick: async () => { @@ -2004,12 +2045,11 @@ screens.settings = async (view) => { draw(); } catch (e) { fail(e); } } }); - const here = onHomelab(); // the homelab's page: the transcript, the folders and the export file are the laptop's + const here = onHomelab(); // the homelab's page: the folders and the export file are the laptop's return el("div", { class: "card stack", testid: "assistant-settings" }, el("h2", { text: "Company-site assistant" }), el("label", { class: "field" }, "Account email", el("span", { class: "muted small", text: "For the accounts it makes on job sites (Workday and others). Each site gets its own random password; the assistant never sees it." }), email), el("label", { class: "field" }, "Effort", el("span", { class: "muted small", text: "How hard it thinks (Claude Code's effort). Nothing is submitted without your review either way." }), effort), - here ? el("p", { class: "small muted", text: "Your transcript and the folders it may read are files on your laptop: set them in NUauto there (the homelab gets copies)." }) : [ - el("label", { class: "field" }, "Transcript (optional)", el("span", { class: "muted small", text: "A PDF of your unofficial transcript. The assistant uploads it only where an application asks for a transcript (and copies it to your homelab if its assistant is on). Empty = none." }), transcript), + here ? el("p", { class: "small muted", text: "The folders it may read are on your laptop: set them in NUauto there (the homelab gets copies)." }) : [ el("label", { class: "field" }, "Folders it may read", el("span", { class: "muted small", text: "Your projects and writeups, one per line: for longer answers. Keys, tokens, .env files and NUauto's own logins are never read." }), folders), el("label", { class: "field" }, "Your writeups (optional)", el("span", { class: "muted small", text: "Folders of your own project or security writeups, one per line: the first place it looks for essay material (and, with its assistant on the homelab, copied there)." }), writeups)], el("label", { class: "field" }, "Assistants at once", el("span", { class: "muted small", text: "How many company-site applications may run side by side, each with its own browser: on each machine, 1 to 3." }), slots), @@ -2019,7 +2059,7 @@ screens.settings = async (view) => { try { const both = { accounts_email: email.value, assist_effort: effort.value, assist_slots: Number(slots.value) }; d = await api.post("/api/settings", here ? both : - { ...both, transcript_path: transcript.value, assist_read_paths: lines(folders), assist_writeups: lines(writeups) }); + { ...both, assist_read_paths: lines(folders), assist_writeups: lines(writeups) }); toast("Saved."); draw(); } catch (e) { fail(e); } } }))); @@ -2284,7 +2324,7 @@ document.addEventListener("keydown", (e) => { box.focus(); box.select(); }); -window.NUauto = { el, fill, api, action, toast, fail, modal, confirmBox, S, pollState, pollHealth, linkOut, plural, ago }; +window.NUauto = { el, fill, api, action, toast, fail, modal, confirmBox, S, pollState, pollHealth, linkOut, plural, ago, sendPdf, pdfDrop, pdfLine }; (async function start() { await pollState(); diff --git a/src/nuauto/gui_static/setup.js b/src/nuauto/gui_static/setup.js index 65034d5..87b70d0 100644 --- a/src/nuauto/gui_static/setup.js +++ b/src/nuauto/gui_static/setup.js @@ -3,7 +3,7 @@ "use strict"; (function () { - const { el, fill, api, action, toast, fail, confirmBox, S, pollState, pollHealth, linkOut } = window.NUauto; + const { el, fill, api, action, toast, fail, confirmBox, S, pollState, pollHealth, linkOut, sendPdf, pdfDrop, pdfLine } = window.NUauto; let open = null; // the step shown open (default: the first one not done) function chip(status) { @@ -111,20 +111,17 @@ }, resume(s, redraw) { - const path = el("input", { type: "text", value: s.data.path || "", placeholder: "/home/you/Documents/resume.pdf", "aria-label": "Resume file", testid: "setup-resume-path" }); - const use = async () => { if (await post({ action: "resume_set", path: path.value }, "Resume saved.")) redraw(); }; + const give = async (file) => { + try { await sendPdf("resume", file); toast("Resume saved."); pollHealth(); redraw(); } catch (e) { fail(e); } + }; return [ s.data.preview ? el("div", { class: "note small", testid: "setup-resume-preview" }, el("b", { text: "What Claude reads: " }), s.data.preview + "…") : null, - el("p", { text: "The PDF you use on NUworks. Claude compares each posting with it; keep it at this path and NUauto picks up changes." }), - el("div", { class: "row" }, path, - s.data.dialog ? el("button", { class: "btn", text: "Choose file…", testid: "setup-resume-pick", onclick: async () => { - const r = await post({ action: "resume_pick" }); - if (r && r.path) { path.value = r.path; use(); } - } }) : null, - el("button", { class: "btn primary", text: "Use this file", testid: "setup-resume-use", onclick: use })), - s.data.suggestions.length ? el("div", { class: "small" }, el("span", { class: "muted", text: "Found: " }), - ...s.data.suggestions.map((p) => el("button", { class: "btn small ghost", text: p.split("/").pop(), title: p, - onclick: () => { path.value = p; } }))) : null, + s.data.file ? el("p", { class: "small", testid: "setup-resume-now" }, el("b", { text: "Using: " }), pdfLine(s.data.file)) : null, + el("p", { text: "The PDF you use on NUworks. Claude compares each posting with it. NUauto keeps its own copy (and gives your homelab one): when you change your resume, drop the new one here or in Settings > Your files." }), + pdfDrop("setup-resume-drop", s.data.file ? "Drop a new PDF here, or" : "Drop your resume PDF here, or", give), + s.data.suggestions.length ? el("div", { class: "small" }, el("span", { class: "muted", text: "Found on this computer: " }), + ...s.data.suggestions.map((p) => el("button", { class: "btn small ghost", text: p.split("/").pop(), title: p, testid: "setup-resume-found", + onclick: async () => { if (await post({ action: "resume_set", path: p }, "Resume saved.")) redraw(); } }))) : null, ]; }, diff --git a/src/nuauto/onboard.py b/src/nuauto/onboard.py index 97ea0f4..322c787 100644 --- a/src/nuauto/onboard.py +++ b/src/nuauto/onboard.py @@ -27,7 +27,7 @@ import time import urllib.error import urllib.request -from datetime import date +from datetime import date, datetime from urllib.parse import urlparse from nuauto import config @@ -88,9 +88,7 @@ def save_config(updates): config.LOCAL = cfg config.SHEET_ID = cfg.get("sheet_id", "") config.WEEK_START = date.fromisoformat(cfg["week_start"]) if cfg.get("week_start") else None - config.LAPTOP_RESUME = os.path.expanduser(cfg.get("resume_path") or "") - config.RESUME_PATH = config.LAPTOP_RESUME if os.path.exists(config.LAPTOP_RESUME) else \ - os.path.join(config.LOCAL_DIR, "resume.pdf") + config.set_files(cfg) if "preferences" in updates: jobs.set_prefs(cfg["preferences"]) return cfg @@ -225,33 +223,6 @@ def sheet_status(): # ---------------------------------------------------------------- step: resume -def pick_file(): - """A native file dialog for the resume (osascript on macOS, zenity or kdialog on Linux). Path or None.""" - if config.DEMO: # no dialog an agent would have to click: the demo resume - path = os.path.join(config.STATE_DIR, "Demo_Student_Resume.pdf") - return path if os.path.exists(path) else None - try: - if sys.platform == "darwin": - r = subprocess.run(["osascript", "-e", 'POSIX path of (choose file with prompt "Choose your resume (PDF)" ' - 'of type {"com.adobe.pdf"})'], capture_output=True, text=True, timeout=600) - elif shutil.which("zenity"): - r = subprocess.run(["zenity", "--file-selection", "--title=Choose your resume (PDF)", - "--file-filter=PDF files | *.pdf *.PDF"], capture_output=True, text=True, timeout=600) - elif shutil.which("kdialog"): - r = subprocess.run(["kdialog", "--getopenfilename", os.path.expanduser("~"), "*.pdf *.PDF|PDF files"], - capture_output=True, text=True, timeout=600) - else: - return None - except (OSError, subprocess.TimeoutExpired): - return None - path = r.stdout.strip() - return path if r.returncode == 0 and path else None - - -def has_file_dialog(): - return config.DEMO or sys.platform == "darwin" or bool(shutil.which("zenity") or shutil.which("kdialog")) - - def resume_suggestions(): """PDFs called resume / CV in the usual folders, newest first.""" found = [] @@ -282,6 +253,71 @@ def resume_preview(path): return True, text[:500] +# ---------------------------------------------------------------- your files: the resume and the transcript +# What you give NUauto (Setup's resume step, Settings > Your files) is kept as its own copy in local/, mode 600, and +# local_config points at it: replacing the PDF you had elsewhere changes nothing until you give NUauto the new one. +# The laptop then pushes the copies to the homelab at once (gui.files_post -> sync.push_documents). + +DOCUMENTS = {"resume": ("resume.pdf", "resume_path"), "transcript": ("transcript.pdf", "transcript_path")} +MAX_DOCUMENT = 10_000_000 # bytes (the GUI reads at most this, base64, from a request: gui.Handler.MAX_UPLOAD) + + +def save_document(kind, data): + """Keep this PDF (bytes) as local/resume.pdf or local/transcript.pdf and use it from now on. The resume must have + text Claude can read (resume_preview); the transcript only has to open as a PDF. Refused: nothing changes.""" + name, key = DOCUMENTS[kind] + if not data: + raise Refused("That file is empty.") + if len(data) > MAX_DOCUMENT: + raise Refused(f"That file is over {MAX_DOCUMENT // 1_000_000} MB.") + if data[:5] != b"%PDF-": + raise Refused("That is not a PDF.") + os.makedirs(config.LOCAL_DIR, mode=0o700, exist_ok=True) + dest = os.path.join(config.LOCAL_DIR, name) + tmp = dest + ".tmp" + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "wb") as f: + f.write(data) + try: + if kind == "resume": + ok, text = resume_preview(tmp) + if not ok: + raise Refused(text) + else: + try: + import pypdf + pypdf.PdfReader(tmp).pages[0] + except Exception as e: + raise Refused(f"Could not read the PDF ({type(e).__name__}).") + except Refused: + os.remove(tmp) + raise + os.replace(tmp, dest) + save_config({key: dest}) + return dest + + +def remove_document(kind): + """No transcript any more: NUauto's copy is deleted (the resume can only be replaced).""" + if kind != "transcript": + raise Refused("NUauto needs a resume: give it a new one instead.") + name, key = DOCUMENTS[kind] + dest = os.path.join(config.LOCAL_DIR, name) + if os.path.exists(dest): + os.remove(dest) + save_config({key: ""}) + + +def document_info(kind): + """The PDF this machine uses ({name, size, modified}) or None. On the homelab: the copy the laptop pushed.""" + path = config.RESUME_PATH if kind == "resume" else config.TRANSCRIPT_PATH + if not os.path.isfile(path): + return None + st = os.stat(path) + return {"name": os.path.basename(path), "size": st.st_size, + "modified": datetime.fromtimestamp(st.st_mtime).isoformat(timespec="minutes")} + + # ---------------------------------------------------------------- step: preferences def check_categories(given): @@ -665,8 +701,8 @@ def add(id_, title, done, detail="", optional=False, **data): rok, rtext = resume_preview(path) if path else (False, "") add("resume", "Your resume", rok, f"{os.path.basename(path)}: Claude can read its text." if rok else (rtext or "Choose the PDF you use on NUworks."), - path=path, preview=rtext if rok else None, suggestions=resume_suggestions() if not rok else [], - dialog=has_file_dialog()) + file=document_info("resume") if rok else None, preview=rtext if rok else None, + suggestions=resume_suggestions() if not rok else []) nw = checks.get("nuworks") label = health.resume_label() nok = bool(nw and nw.status == health.OK) diff --git a/src/nuauto/sync.py b/src/nuauto/sync.py index 98d87ed..49a29f6 100644 --- a/src/nuauto/sync.py +++ b/src/nuauto/sync.py @@ -122,11 +122,7 @@ def push(code=None): ok &= _run(RSYNC + ["data/ratings.json", f"{REMOTE}/data/ratings.json"], "push ratings") if os.path.exists(config.DISCORD_WEBHOOK_PATH): # the homelab sends the Discord messages (newer there: kept) ok &= _run(RSYNC + ["-u", "local/discord_webhook.txt", f"{REMOTE}/local/discord_webhook.txt"], "push Discord webhook") - if os.path.exists(config.LAPTOP_RESUME): - ok &= _run(RSYNC + ["-c", config.LAPTOP_RESUME, f"{REMOTE}/local/resume.pdf"], "push resume") - if config.REMOTE_ASSIST and config.LAPTOP_TRANSCRIPT and os.path.exists(config.LAPTOP_TRANSCRIPT): - ok &= _run(RSYNC + ["-c", stage_private(config.LAPTOP_TRANSCRIPT, "transcript.pdf"), - f"{REMOTE}/local/transcript.pdf"], "push transcript") # only the assistant there uses it + ok &= _documents() if os.path.exists(config.TOKEN_PATH): ok &= _run(RSYNC + ["-u", "local/token.json", f"{REMOTE}/local/token.json"], "push Google token") if os.path.exists(config.GOOGLE_LOGIN_PATH): @@ -151,6 +147,25 @@ def server_busy(): return subprocess.run(SSH + [config.SERVER, "pgrep -f 'nuauto daily'"], capture_output=True).returncode == 0 +def _documents(removed=False): + """The resume and (with its assistant there) the transcript: the copies the homelab uses. removed: the transcript + was taken out here, so the homelab's copy goes too.""" + ok = True + if os.path.exists(config.LAPTOP_RESUME): + ok &= _run(RSYNC + ["-c", config.LAPTOP_RESUME, f"{REMOTE}/local/resume.pdf"], "push resume") + if config.REMOTE_ASSIST and config.LAPTOP_TRANSCRIPT and os.path.exists(config.LAPTOP_TRANSCRIPT): + ok &= _run(RSYNC + ["-c", stage_private(config.LAPTOP_TRANSCRIPT, "transcript.pdf"), + f"{REMOTE}/local/transcript.pdf"], "push transcript") # only the assistant there uses it + elif removed: + ok &= remote(f"rm -f {shlex.quote(config.SERVER_DIR + '/local/transcript.pdf')}", "remove the homelab's transcript") + return ok + + +def push_documents(removed=False): + """Right after you give NUauto a new resume or transcript (the GUI), so the homelab never uses the old one.""" + return remote_dirs("local") and _documents(removed) + + def push_session(): if server_busy(): print("Homelab is running its update right now; run `nuauto login` again in a few minutes to copy the session.") diff --git a/tests/test_gui.py b/tests/test_gui.py index 5d36428..a1ea71b 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -5,6 +5,7 @@ Apply -> a question -> submitted, Answers, Settings, Quit, Review's internship list -> the Other jobs tab; the last also starts a second server with internships off). No network: everything runs on demo.py's fakes. """ +import base64 import http.client import json import os @@ -202,6 +203,25 @@ def eventually(check, what, seconds=20): post("/api/settings", {"transcript_path": tpdf.replace(".pdf", ".txt")}, expect=409) assert post("/api/settings", {"transcript_path": f" {tpdf} "})["settings"]["transcript_path"] == tpdf assert post("/api/settings", {"transcript_path": ""})["settings"]["transcript_path"] == "" + # your files: a PDF given to NUauto is kept as its own copy in local/ (mode 600) and used from then on + b64 = lambda data: base64.b64encode(data).decode() + with open(os.path.join(STATE, "Demo_Student_Resume.pdf"), "rb") as f: + demo_resume = f.read() + post("/api/files", {"kind": "cover_letter", "data": b64(demo_resume)}, expect=409) + for bad in (b64(b"not a pdf"), "%%%", b64(b"%PDF-1.4"), b64(b"%PDF-" + b"0" * 10_000_000)): # no text / over 10 MB + post("/api/files", {"kind": "resume", "data": bad}, expect=409) + kept = os.path.join(STATE, "local", "resume.pdf") + assert not os.path.exists(kept) and not os.path.exists(kept + ".tmp") # a refused one leaves nothing + given = post("/api/files", {"kind": "resume", "data": b64(demo_resume)}) + assert given["settings"]["resume_path"] == kept and given["files"]["resume"]["size"] == len(demo_resume), given["files"] + with open(kept, "rb") as f: + assert f.read() == demo_resume and os.stat(kept).st_mode & 0o777 == 0o600 + post("/api/files", {"kind": "resume", "remove": True}, expect=409) # a resume is only ever replaced + tkept = os.path.join(STATE, "local", "transcript.pdf") + given = post("/api/files", {"kind": "transcript", "data": b64(demo_resume)}) + assert given["settings"]["transcript_path"] == tkept and given["files"]["transcript"]["name"] == "transcript.pdf" + given = post("/api/files", {"kind": "transcript", "remove": True}) + assert given["settings"]["transcript_path"] == "" and given["files"]["transcript"] is None and not os.path.exists(tkept) post("/api/logins/export", {}, expect=409) # nothing to export yet FAKE_PW = "Xy9!demo-not-real" with open(os.path.join(STATE, "local", "accounts.json"), "w") as f: diff --git a/tests/test_homelab_app.py b/tests/test_homelab_app.py index 3abbaa9..7d653e6 100644 --- a/tests/test_homelab_app.py +++ b/tests/test_homelab_app.py @@ -108,6 +108,8 @@ def eventually(check, what, seconds=20): assert "laptop" in post("/api/action", {"kind": kind}, expect=409)["error"], kind assert "stays on" in post("/api/quit", {}, expect=409)["error"] assert "laptop" in post("/api/logins/export", {}, expect=409)["error"] + assert "laptop" in post("/api/files", {"kind": "resume", "data": ""}, expect=409)["error"] # the laptop keeps them + assert get("/api/settings")["files"]["resume"]["size"] > 0 # what it has: (the homelab) the copy the laptop pushed assert "laptop" in post("/api/homelab", {"action": "check", "ssh": "homelab"}, expect=409)["error"] for setting in ({"resume_path": "/x.pdf"}, {"transcript_path": ""}, {"assist_read_paths": []}, {"password_manager": "bitwarden"}, {"remote_assist": False}, {"remote_app": False}): diff --git a/tests/test_setup.py b/tests/test_setup.py index 0090a97..e54b3cd 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -361,7 +361,7 @@ def done(page, step, timeout=60000): done(page, "google_login") page.click("[data-testid=setup-sheet-create]") done(page, "sheet") - page.click("[data-testid=setup-resume-pick]") + page.locator("[data-testid=setup-resume-found]").first.click() # one the demo has: NUauto keeps a copy done(page, "resume") page.click("[data-testid=setup-nuworks-login]") # Read my resumes the moment the login ends: the NUworks check the login starts is still running, gives the @@ -419,7 +419,7 @@ def done(page, step, timeout=60000): b.close() assert not errors, errors cfg = json.load(open(config.LOCAL_CONFIG_PATH)) - assert cfg["tos_ack"] and cfg["sheet_id"].startswith("DEMO-") and cfg["resume_path"].endswith("Demo_Student_Resume.pdf") + assert cfg["tos_ack"] and cfg["sheet_id"].startswith("DEMO-") and cfg["resume_path"] == os.path.join(config.LOCAL_DIR, "resume.pdf") assert cfg["preferences"]["term"] == "2027 - Fall" and cfg["preferences"]["class_year"] == "junior" assert cfg["preferences"]["term_id"] == "demo00000000000000000000fall2027" assert [t["name"] for t in cfg["preferences"]["tags"]] == ["AR/XR", "cars"]