@gafj/gafj 0.1.17 → 0.1.20

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.
@@ -22,6 +22,10 @@ The ownership verb is the lead verb of the wording, in the form the source uses.
22
22
 
23
23
  The bullet or sentence that states the result, verbatim; usually one, at most three when the source states the same result more than once. The first wording is the one you would recommend. Never repeat a wording across records. Keep the source's punctuation.
24
24
 
25
+ ## The header
26
+
27
+ The candidate's name, email, phone, city and state, LinkedIn, and website go in `contact`, each quoted from the source; leave out any the source does not show.
28
+
25
29
  ## The other sections
26
30
 
27
31
  Education, volunteer and community work, awards, certifications and licences, memberships and affiliations go in `entries`, one each, never as accomplishments. `org` is the school, organisation, or issuer as written; `title` the degree, role, award, or credential as written; `dates` as written; `detail` one quoted line if the source gives one (honours, GPA, a description). Quote each; leave out what the source does not state. Employment itself is not an entry: it is carried by the accomplishments' company, role, and dates.
@@ -25,6 +25,8 @@ module.exports = obj({
25
25
  scope: ATOM,
26
26
  tags: arr(str("identifier", { max: 40 }), 0, 8),
27
27
  }, ["title", "company", "role", "wordings"]), 1, 40),
28
+ // the header: whatever the source states, quoted
29
+ contact: obj({ name: ATOM, email: ATOM, phone: ATOM, location: ATOM, linkedin: ATOM, website: ATOM }, []),
28
30
  // the resume's other sections, each an org, a title, dates, and one line of detail, all quoted
29
31
  entries: arr(obj({
30
32
  kind: str("identifier", { enum: ["education", "volunteer", "award", "certification", "affiliation"] }),
package/http/api.js CHANGED
@@ -180,6 +180,9 @@ route("POST", "/api/setup/dismiss", (c, p, b) => setup.dismiss(c.db, c.home, c.c
180
180
 
181
181
  // settings (screen 6)
182
182
  const settings = require("../store/settings");
183
+ const contact = require("../store/contact");
184
+ route("GET", "/api/profile/contact", (c) => contact.get(c.db, c.candidate_id));
185
+ route("POST", "/api/profile/contact", (c, p, b) => contact.set(c.db, { candidate_id: c.candidate_id, contact: b.contact || b, now: c.now(), actor: ACTOR("contact") }));
183
186
  route("GET", "/api/settings", (c) => settings.getSettings(c.db, c.home, c.candidate_id));
184
187
  route("PUT", "/api/settings", (c, p, b) => { const r = settings.putSettings(c.db, c.home, c.candidate_id, b, { now: c.now(), actor: ACTOR("settings") }); if (c.reload) c.reload(); return r; });
185
188
  route("PUT", "/api/settings/providers/:id", (c, p, b) => settings.putProvider(c.home, p.id, b));
package/http/guard.js CHANGED
@@ -91,7 +91,7 @@ function readJson(req, cap = BODY_CAP) {
91
91
  const chunks = [];
92
92
  req.on("data", (c) => {
93
93
  size += c.length;
94
- if (size > cap) { reject(Object.assign(new Error("body larger than 1 MB"), { status: 413 })); req.destroy(); return; }
94
+ if (size > cap) { req.removeAllListeners("data"); req.resume(); reject(Object.assign(new Error(`body larger than ${Math.round(cap / 1024 / 1024)} MB; paste less`), { status: 413 })); return; }
95
95
  chunks.push(c);
96
96
  });
97
97
  req.on("end", () => {
package/http/hosted.js CHANGED
@@ -91,6 +91,7 @@ class Tenants {
91
91
  cfg = { candidate_id: id, route: "credits", providers: [], thresholds: { ...DEFAULT_THRESHOLDS }, hosted: true, email: who.email || null };
92
92
  writeConfig(home, cfg);
93
93
  require("../store/onboarding").openBatch(t.db, { candidate_id: id, now: nowIso, actor: { type: "ui", ref: "signup" } });
94
+ try { require("../store/contact").set(t.db, { candidate_id: id, contact: { name: who.name || "", email: who.email || "" }, now: nowIso, actor: { type: "ui", ref: "signup" } }); } catch (e) { /* an odd address from the provider is not worth failing sign-up over */ }
94
95
  }
95
96
  return t;
96
97
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gafj/gafj",
3
- "version": "0.1.17",
3
+ "version": "0.1.20",
4
4
  "description": "GAF-J: a local campaign engine for job search. One SQLite file, one ingest door, your own AI subscription.",
5
5
  "homepage": "https://gaf-j.com",
6
6
  "repository": {
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * The resume header: name, email, phone, city, and links. Lives in the candidate's profile,
5
+ * edited in Settings, and prefilled once from what the model quoted off the first resume;
6
+ * a prefill never overwrites a field the candidate has typed.
7
+ */
8
+
9
+ const { withTx } = require("./tx");
10
+ const { appendEvent } = require("./events");
11
+ const { TransitionError } = require("./transitions");
12
+
13
+ const FIELDS = ["name", "email", "phone", "location", "linkedin", "website"];
14
+ const MAX = { name: 120, email: 160, phone: 40, location: 120, linkedin: 200, website: 200 };
15
+ const EMPTY = Object.fromEntries(FIELDS.map((k) => [k, ""]));
16
+
17
+ function stored(db, candidate_id) {
18
+ const row = db.prepare("SELECT name, profile_json FROM candidate WHERE id = ?").get(candidate_id);
19
+ if (!row) throw new TransitionError("unknown candidate");
20
+ return { name: row.name || "", contact: { ...EMPTY, ...((JSON.parse(row.profile_json || "{}").contact) || {}) } };
21
+ }
22
+
23
+ /** The header as typed; with no header name yet, the candidate's display name stands in. */
24
+ function get(db, candidate_id) {
25
+ const { name, contact } = stored(db, candidate_id);
26
+ return contact.name ? contact : { ...contact, name };
27
+ }
28
+
29
+ function clean(patch) {
30
+ const out = {};
31
+ for (const k of FIELDS) {
32
+ if (patch[k] === undefined) continue;
33
+ let v = String(patch[k] || "").trim().replace(/\s+/g, " ").slice(0, MAX[k]);
34
+ if (k === "email" && v && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) throw new TransitionError("email does not look like an address");
35
+ if ((k === "linkedin" || k === "website") && v && !/^https?:\/\//i.test(v)) v = "https://" + v.replace(/^\/+/, "");
36
+ out[k] = v;
37
+ }
38
+ return out;
39
+ }
40
+
41
+ /** Save the fields given; the others stay. The candidate's display name follows the header name. */
42
+ function set(db, { candidate_id, contact, now, actor }) {
43
+ const patch = clean(contact || {});
44
+ return withTx(db, (d) => {
45
+ const row = d.prepare("SELECT profile_json FROM candidate WHERE id = ?").get(candidate_id);
46
+ if (!row) throw new TransitionError("unknown candidate");
47
+ const profile = JSON.parse(row.profile_json || "{}");
48
+ profile.contact = { ...EMPTY, ...(profile.contact || {}), ...patch };
49
+ d.prepare("UPDATE candidate SET profile_json = ?, name = CASE WHEN ? <> '' THEN ? ELSE name END WHERE id = ?").run(JSON.stringify(profile), profile.contact.name, profile.contact.name, candidate_id);
50
+ appendEvent(d, { candidate_id, entity: "candidate", entity_id: candidate_id, event: "contact_set", actor_type: actor.type, actor_ref: actor.ref, payload: { keys: Object.keys(patch) }, at: now });
51
+ return profile.contact;
52
+ });
53
+ }
54
+
55
+ /** Fill only the empty fields from what a resume quoted. Returns the keys filled. */
56
+ function prefill(db, { candidate_id, contact, now, actor }) {
57
+ let patch;
58
+ try { patch = clean(contact || {}); } catch (e) { return []; }
59
+ const cur = stored(db, candidate_id).contact; // the typed header only: a display name that was never typed here does not block the resume's
60
+ const fill = {};
61
+ for (const k of FIELDS) if (patch[k] && !cur[k]) fill[k] = patch[k];
62
+ if (!Object.keys(fill).length) return [];
63
+ set(db, { candidate_id, contact: fill, now, actor });
64
+ return Object.keys(fill);
65
+ }
66
+
67
+ /** The header lines for a rendered resume: the name, then whatever contact the candidate gave. */
68
+ function headerLines(db, candidate_id) {
69
+ const c = get(db, candidate_id);
70
+ const line = [c.location, c.email, c.phone, c.linkedin, c.website].filter(Boolean).join(" | ");
71
+ return [c.name, line].filter(Boolean);
72
+ }
73
+
74
+ module.exports = { get, set, prefill, headerLines, FIELDS };
@@ -132,6 +132,7 @@ function normalizeAtoms(content) {
132
132
  if (Array.isArray(a.wordings)) a.wordings = a.wordings.map(q);
133
133
  if (Array.isArray(a.metrics)) a.metrics = a.metrics.map((m) => (m && typeof m === "object" ? { value: String(m.value), ...(m.outcome !== undefined ? { outcome: m.outcome } : {}) } : m));
134
134
  }
135
+ if (content.contact && typeof content.contact === "object") for (const k of Object.keys(content.contact)) content.contact[k] = q(content.contact[k]);
135
136
  if (Array.isArray(content.entries)) for (const e of content.entries) { if (e && typeof e === "object") for (const k of ["org", "title", "dates", "detail"]) if (k in e) e[k] = q(e[k]); }
136
137
  return content;
137
138
  }
@@ -148,6 +149,7 @@ function checkDraft(content, text) {
148
149
  a.wordings = (a.wordings || []).map((w) => (typeof w === "string" ? { value: w, start: -1, end: -1 } : w));
149
150
  a.metrics = (a.metrics || []).map((m) => ({ ...m, start: -1, end: -1 }));
150
151
  }
152
+ for (const k of Object.keys(content.contact || {})) { if (typeof content.contact[k] === "string") content.contact[k] = { value: content.contact[k], start: -1, end: -1 }; checkAtom(content.contact[k], text, `contact.${k}`, errors); }
151
153
  for (const e of content.entries || []) for (const k of ["org", "title", "dates", "detail"]) if (typeof e[k] === "string") e[k] = { value: e[k], start: -1, end: -1 };
152
154
  (content.entries || []).forEach((e, i) => {
153
155
  const p = `entries[${i}]`;
@@ -197,8 +199,9 @@ function proposeFromSource(db, { candidate_id, source_document_id, content, ai_a
197
199
  const positions = require("./positions");
198
200
  positions.derive(d, { candidate_id, now });
199
201
  const entries = positions.addEntries(d, { candidate_id, source_document_id: s.id, entries: (content.entries || []).map((e) => ({ kind: e.kind, org: e.org.value, title: e.title.value, dates: e.dates ? e.dates.value : "", detail: e.detail ? e.detail.value : "" })), now });
200
- appendEvent(d, { candidate_id, entity: "source_document", entity_id: s.id, event: "proposed", actor_type: actor.type, actor_ref: actor.ref, payload: { pending: ids.length, entries, style: content.style_observed || null }, at: now });
201
- return { accepted: true, errors: [], pending_ids: ids, entries, style_observed: content.style_observed || null };
202
+ const contact = require("./contact").prefill(d, { candidate_id, contact: Object.fromEntries(Object.entries(content.contact || {}).map(([k, v]) => [k, v && v.value])), now, actor });
203
+ appendEvent(d, { candidate_id, entity: "source_document", entity_id: s.id, event: "proposed", actor_type: actor.type, actor_ref: actor.ref, payload: { pending: ids.length, entries, contact, style: content.style_observed || null }, at: now });
204
+ return { accepted: true, errors: [], pending_ids: ids, entries, contact, style_observed: content.style_observed || null };
202
205
  });
203
206
  }
204
207
  const span = (a) => (a ? { start: a.start, end: a.end } : null);
package/store/render.js CHANGED
@@ -40,8 +40,10 @@ function renderNode(value, node, depth, out) {
40
40
  }
41
41
  }
42
42
 
43
- function renderResume(c, out) {
44
- out.push(`# ${c.headline}`, "", (c.specialties || []).join(" · "), "");
43
+ function renderResume(c, out, who = []) {
44
+ if (who.length) { out.push(`# ${who[0]}`, ""); for (const l of who.slice(1)) out.push(l); out.push("", `## ${c.headline}`, ""); }
45
+ else out.push(`# ${c.headline}`, "");
46
+ out.push((c.specialties || []).join(" · "), "");
45
47
  if (c.headerLinks && c.headerLinks.length) out.push(c.headerLinks.join(" | "), "");
46
48
  out.push("## Career Highlights", "");
47
49
  for (const h of c.careerHighlights || []) out.push(`- **${h.leadIn}** ${h.proof && h.proof.text || ""}`);
@@ -77,7 +79,7 @@ function renderMarkdown(db, doc) {
77
79
  const content = JSON.parse(doc.content_json);
78
80
  const out = [];
79
81
  const head = header(db, doc);
80
- if (doc.kind === "resume") renderResume(content, out);
82
+ if (doc.kind === "resume") renderResume(content, out, require("./contact").headerLines(db, doc.candidate_id));
81
83
  else if (doc.kind === "email") out.push(`**Subject:** ${content.subject}`, "", content.body && content.body.text || "", "");
82
84
  else {
83
85
  out.push(`# ${label(doc.kind)}${head.length ? ": " + head[0] : ""}`, "");
@@ -10,10 +10,7 @@
10
10
  if (!c.firebase || !c.firebase.apiKey) { say("sign-in is not configured on this server yet", true); document.getElementById("google").disabled = true; return; }
11
11
  if (typeof firebase === "undefined") { say("could not load the sign-in library", true); return; }
12
12
  firebase.initializeApp({ apiKey: c.firebase.apiKey, authDomain: c.firebase.authDomain, projectId: c.firebase.projectId });
13
- document.getElementById("google").addEventListener("click", function () {
14
- say("opening Google");
15
- var provider = new firebase.auth.GoogleAuthProvider();
16
- firebase.auth().signInWithPopup(provider).then(function (r) { return r.user.getIdToken(true); }).then(function (idToken) {
13
+ var finish = function (idToken) {
17
14
  tokenBox.value = idToken;
18
15
  return fetch("/auth/session", { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ id_token: idToken }) })
19
16
  .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
@@ -22,7 +19,17 @@
22
19
  if (document.getElementById("local").open) { say("signed in as " + (x.j.email || x.j.uid) + "; copy the token for your PC, or open the app"); return; }
23
20
  location.href = "/";
24
21
  });
25
- }).catch(function (e) { say(e && e.message ? e.message : "sign-in failed", true); });
22
+ };
23
+ var fail = function (e) { say(e && e.message ? e.message : "sign-in failed", true); };
24
+ // a phone, or a browser that blocks popups, comes back here from Google's own page
25
+ firebase.auth().getRedirectResult().then(function (r) { if (r && r.user) { say("finishing sign-in"); return r.user.getIdToken(true).then(finish); } }).catch(fail);
26
+ document.getElementById("google").addEventListener("click", function () {
27
+ say("opening Google");
28
+ var provider = new firebase.auth.GoogleAuthProvider();
29
+ var small = window.innerWidth < 700 || /Android|iPhone|iPad/i.test(navigator.userAgent);
30
+ if (small) { firebase.auth().signInWithRedirect(provider).catch(fail); return; }
31
+ firebase.auth().signInWithPopup(provider).then(function (r) { return r.user.getIdToken(true); }).then(finish)
32
+ .catch(function (e) { if (e && /popup/i.test(e.code || "")) firebase.auth().signInWithRedirect(provider).catch(fail); else fail(e); });
26
33
  });
27
34
  }).catch(function () { say("could not reach the server", true); });
28
35
  })();
package/ui/lib.js CHANGED
@@ -41,7 +41,7 @@ function zoneOffsetMs(utcMs, tz) {
41
41
  }
42
42
 
43
43
  export function useRoute() {
44
- const parse = () => ({ path: (location.hash.replace(/^#/, "") || "/") });
44
+ const parse = () => { const h = location.hash.replace(/^#/, "") || "/"; const q = h.indexOf("?"); return { path: q < 0 ? h : h.slice(0, q) || "/", query: new URLSearchParams(q < 0 ? "" : h.slice(q + 1)) }; };
45
45
  const [route, setRoute] = useState(parse);
46
46
  useEffect(() => { const on = () => setRoute(parse()); addEventListener("hashchange", on); return () => removeEventListener("hashchange", on); }, []);
47
47
  return route;
@@ -90,3 +90,38 @@ export function useInstall() {
90
90
  const standalone = matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
91
91
  return { canInstall: !!installer.event, installed: installer.installed || standalone, install: async () => { if (!installer.event) return; installer.event.prompt(); await installer.event.userChoice; installer.event = null; } };
92
92
  }
93
+
94
+ /**
95
+ * A job posting as pasted: markdown links become their text, bare URLs and tracking lines go, blank runs
96
+ * collapse. A LinkedIn copy carries a wall of overlay URLs that says nothing about the job.
97
+ */
98
+ export function cleanPosting(text) {
99
+ return String(text || "")
100
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, "")
101
+ .replace(/\[([^\]]*)\]\((?:https?:)?\/\/[^)\s]*(?:\s+"[^"]*")?\)/g, "$1")
102
+ .replace(/\((?:https?:)?\/\/[^)\s]{20,}\)/g, "")
103
+ .split(/\r?\n/)
104
+ .map((l) => l.replace(/\s+$/, ""))
105
+ .filter((l, i, all) => !/^\s*(?:https?:)?\/\/\S+\s*$/.test(l) || (/\/(jobs?|careers?|positions?|openings?)\//i.test(l) && all.findIndex((x) => /^\s*(?:https?:)?\/\/\S+\s*$/.test(x) && /\/(jobs?|careers?|positions?|openings?)\//i.test(x)) === i))
106
+ .join("\n")
107
+ .replace(/[ \t]{3,}/g, " ")
108
+ .replace(/\n{3,}/g, "\n\n")
109
+ .trim();
110
+ }
111
+
112
+ const NOISE = /\b(ago|hours?|days?|weeks?|applicants?|promoted|easy apply|reposted|save|share|show more|about the job|job details|united states|remote|hybrid|on-site|full-time|part-time|contract|tailor my resume|help me stand out|create cover letter)\b/i;
113
+ const TITLE = /\b(director|manager|engineer|analyst|lead|head|vp|vice president|specialist|coordinator|supervisor|associate|consultant|architect|designer|developer|scientist|officer|president|chief|planner|buyer|controller|administrator|technician|representative|recruiter|nurse|teacher|intern|accountant|assistant|generalist|partner|strategist|writer|editor)\b/i;
114
+ /** Title and company guessed from a posting's first lines; both are the person's to correct. */
115
+ export function guessPosting(text) {
116
+ const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, 20);
117
+ const plain = (l) => l.length <= 90 && !/^https?:/i.test(l) && !/^(at|company|location|posted|apply|share)\b/i.test(l) && !NOISE.test(l);
118
+ const title = lines.find((l) => plain(l) && TITLE.test(l) && !/[.:;]$/.test(l) && !/\b(company|inc|llc|corp|group|ltd)\.?$/i.test(l)) || lines.find(plain) || "";
119
+ let company = "";
120
+ for (const l of lines) { const m = l.match(/^(?:at|company[:\s]+|employer[:\s]+)\s*([^|·,]{2,60})/i); if (m) { company = m[1].trim(); break; } }
121
+ const i = lines.findIndex((l) => l === title);
122
+ const nameish = (l) => { const c = l.split(/\s[·|]\s/)[0].replace(/^[·|\-\s]+/, "").trim(); return c && c !== title && c.length <= 60 && !/^https?:/i.test(c) && !/\d{4}/.test(c) && !NOISE.test(c) && !/[.:;]$/.test(c) && c.split(/\s+/).length <= 8 ? c : ""; };
123
+ // the line after the title ("Company · City"), else a short name-like line above it, else one soon after
124
+ if (!company) company = [lines[i + 1] || ""].map(nameish).find(Boolean) || lines.slice(0, i).map(nameish).find(Boolean) || lines.slice(i + 2, i + 6).map(nameish).find(Boolean) || "";
125
+ const url = lines.find((l) => /^https?:\/\//i.test(l)) || "";
126
+ return { title, company, url };
127
+ }
package/ui/screens/kb.js CHANGED
@@ -90,6 +90,22 @@ function Excerpt({ text, span }) {
90
90
  const EMPTY = { title: "", company: "", role: "", dates: "", summary: "", metrics: "", verbs: "", wordings: "", tenure: "" };
91
91
  const toDraft = (f) => ({ title: f.title, company: f.company, role: f.role, dates: f.dates, summary: f.summary,
92
92
  metrics: f.metrics.split(/[;,]/).map((s) => s.trim()).filter(Boolean), verbs: f.verbs.split(/[;,]/).map((s) => s.trim()).filter(Boolean), wordings: f.wordings.split(/\n/).map((s) => s.trim()).filter(Boolean), ...(f.tenure && f.tenure.trim() ? { tenure: f.tenure.trim() } : {}) });
93
+ /** The resume header: name, email, phone, city, links. Prefilled from the first resume; yours to change here or in Settings. */
94
+ export function ContactForm({ setErr, compact }) {
95
+ const [c, setC] = useState(null);
96
+ const [saved, setSaved] = useState(null);
97
+ const [busy, setBusy] = useState(false);
98
+ useEffect(() => { api.get("/api/profile/contact").then((r) => { setC(r); setSaved(r); }).catch((e) => setErr(e.message)); }, []);
99
+ if (!c) return html`<p class="muted">loading</p>`;
100
+ const dirty = saved && Object.keys(c).some((k) => (c[k] || "") !== (saved[k] || ""));
101
+ const f = (k, ph, type = "text") => html`<input type=${type} placeholder=${ph} value=${c[k] || ""} onInput=${(e) => setC({ ...c, [k]: e.target.value })} />`;
102
+ return html`<form class="inline" onSubmit=${async (e) => { e.preventDefault(); setBusy(true); try { setErr(""); const r = await api.post("/api/profile/contact", { contact: c }); setC(r); setSaved(r); } catch (e2) { setErr(e2.message); } setBusy(false); }}>
103
+ ${compact ? "" : html`<p class="small muted" style="flex-basis:100%;margin:0">What goes at the top of every resume. Nothing here is checked against a source; it is yours to type.</p>`}
104
+ ${f("name", "full name")}${f("email", "email", "email")}${f("phone", "phone")}${f("location", "city, state")}${f("linkedin", "linkedin.com/in/…")}${f("website", "website")}
105
+ <button class="primary small" disabled=${!dirty || busy}>${busy ? "saving" : "Save"}</button>
106
+ </form>`;
107
+ }
108
+
93
109
  /** Positions: jobs, schools, volunteer roles, awards, certifications, affiliations. One row each, confirmed once; a job's records inherit its company, role, and dates. */
94
110
  const KIND = {
95
111
  employment: { heading: "Jobs", org: "company", title: "title", records: true, where: true },
@@ -206,6 +222,8 @@ export function KB({ live }) {
206
222
  <div class="card"><p class="small muted">A record with no confirmed figure, or an ownership verb with no scope. An answer extends that record; nothing is stored until you confirm it under Pending.</p><ul class="list">${(d.holes || []).map((h) => html`<li key=${h.hole_id}><div><b>?</b> <b>${h.title}</b> <span class="muted">${h.company}, ${h.role}</span><br />${h.question}
207
223
  ${answering === h.hole_id ? html`<${DraftForm} label="Add answer as pending" initial=${fromDraft(h.draft)} onSubmit=${(draft) => { post("/api/kb/pending", { draft: { ...draft, replaces: h.draft.replaces } }); setAnswering(null); }} />` : html`<button class="small" onClick=${() => setAnswering(h.hole_id)}>Answer</button>`}</div></li>`)}
208
224
  ${(d.holes || []).length ? "" : html`<li class="muted">no holes: every record carries a confirmed figure and every ownership claim a scope</li>`}</ul></div>
225
+ <h2>Header (name and contact)</h2>
226
+ <div class="card"><${ContactForm} setErr=${setErr} /></div>
209
227
  <h2>Jobs, education, and the rest (confirm each once)</h2>
210
228
  <div class="card"><${Positions} reload=${load} setErr=${setErr} /></div>
211
229
  <h2>Pending (written, not yet confirmed)</h2>
@@ -1,5 +1,5 @@
1
1
  import { html, useState, useEffect } from "../vendor/preact.mjs";
2
- import { api } from "../lib.js";
2
+ import { cleanPosting, guessPosting, api } from "../lib.js";
3
3
 
4
4
  /** Screen 2: paste a posting, store and scan locally, edit the proposed categories, freeze, log the application. */
5
5
 
@@ -39,21 +39,13 @@ export function Postings({ live, id }) {
39
39
  const [form, setForm] = useState({ company: "", title: "", source_url: "", location: "", comp_text: "", raw_text: "" });
40
40
  const [guessed, setGuessed] = useState({ company: true, title: true });
41
41
  // the paste is all a person should have to give; company and title are guessed from its first lines and can be corrected
42
- const onPaste = (text) => {
43
- const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, 12);
42
+ const onPaste = (raw) => {
43
+ const text = cleanPosting(raw);
44
+ const g = guessPosting(text);
44
45
  const next = { ...form, raw_text: text };
45
- if (guessed.title) {
46
- const t = lines.find((l) => l.length <= 90 && !/^https?:/i.test(l) && !/^(at|company|location|posted|apply|share)\b/i.test(l));
47
- next.title = t || "";
48
- }
49
- if (guessed.company) {
50
- let c = "";
51
- for (const l of lines) { const m = l.match(/^(?:at|company[:\s]+|employer[:\s]+)\s*([^|·,]{2,60})/i); if (m) { c = m[1].trim(); break; } }
52
- if (!c) { const i = lines.findIndex((l) => l === next.title); const cand = lines[i + 1] || ""; if (cand && cand.length <= 50 && !/^https?:/i.test(cand) && !/\d{4}/.test(cand)) c = cand.replace(/^[·|\-\s]+/, ""); }
53
- next.company = c;
54
- }
55
- const url = lines.find((l) => /^https?:\/\//i.test(l));
56
- if (url && !form.source_url) next.source_url = url;
46
+ if (guessed.title) next.title = g.title;
47
+ if (guessed.company) next.company = g.company;
48
+ if (g.url && !form.source_url) next.source_url = g.url;
57
49
  setForm(next);
58
50
  };
59
51
  const [err, setErr] = useState("");
@@ -1,5 +1,6 @@
1
1
  import { html, useState, useEffect } from "../vendor/preact.mjs";
2
2
  import { api, useInstall } from "../lib.js";
3
+ import { ContactForm } from "./kb.js";
3
4
 
4
5
  /** Screen 6: AI route, providers (keys write-only), house rules per kind, data folder, backup, startup. */
5
6
 
@@ -18,6 +19,14 @@ export function Settings({ live }) {
18
19
  const inst = useInstall();
19
20
  const load = () => Promise.all([api.get("/api/settings"), api.get("/api/usage").catch(() => null)]).then(([x, u]) => { setS(x); setUsage(u); setErr(""); }).catch((e) => setErr(e.message));
20
21
  useEffect(() => { load(); }, [live]);
22
+ // back from Stripe: the URL carries paid=1 (or paid=0 for a cancel); check the balance once and drop the flag
23
+ useEffect(() => {
24
+ const m = location.hash.match(/[?&]paid=(\d)/);
25
+ if (!m) return;
26
+ history.replaceState(null, "", location.pathname + location.hash.replace(/[?&]paid=\d/, ""));
27
+ if (m[1] !== "1") { setMsg("checkout cancelled; nothing was charged"); return; }
28
+ api.get("/api/credits/balance").then((r) => { setMsg(`payment received: ${r.available} credits available`); load(); }).catch((e) => setErr(e.message));
29
+ }, []);
21
30
  const put = async (patch) => { setMsg(""); try { setS(await api.put("/api/settings", patch)); setMsg("saved"); } catch (e) { setErr(e.message); } };
22
31
  const post = async (path, body, label) => { setMsg(""); try { const r = await api.post(path, body); setMsg(label ? `${label}: ${r.file || r.message || JSON.stringify(r)}` : JSON.stringify(r)); await load(); } catch (e) { setErr(e.message); } };
23
32
  if (err && !s) return html`<p class="error">${err}</p>`;
@@ -31,10 +40,13 @@ export function Settings({ live }) {
31
40
  ${err ? html`<p class="error">${err}</p>` : ""}${msg ? html`<p class="small muted">${msg}</p>` : ""}
32
41
  <h2>App</h2>
33
42
  <div class="card">
34
- ${inst.installed ? html`<p>Installed as an app. It opens in its own window from the Start menu or dock.</p>`
43
+ ${s.hosted ? html`<p class="small muted">On a phone: your browser's share or menu button, then "Add to Home Screen". On a PC: the install icon at the right end of the address bar, or the browser menu, "Install GAF-J". Either way it opens in its own window and stays signed in.</p>`
44
+ : inst.installed ? html`<p>Installed as an app. It opens in its own window from the Start menu or dock.</p>`
35
45
  : inst.canInstall ? html`<div class="row"><button class="primary" onClick=${inst.install}>Install GAF-J as an app</button><span class="small muted">own window, Start menu entry, no browser chrome</span></div>`
36
46
  : html`<p class="small muted">To install as an app: in Chrome or Edge use the install icon at the right end of the address bar, or the browser menu, "Install GAF-J". The page must be open at its 127.0.0.1 address (it is). No service worker is registered on purpose: the UI is never served from a cache, so a stale screen cannot outlive the store.</p>`}
37
47
  </div>
48
+ <h2>Header (name and contact)</h2>
49
+ <div class="card"><${ContactForm} setErr=${setErr} /></div>
38
50
  <h2>AI route</h2>
39
51
  <div class="card">
40
52
  ${(s.hosted
@@ -85,7 +97,8 @@ export function Settings({ live }) {
85
97
  <div class="card">
86
98
  ${s.hosted ? html`<p class="small muted">Signed in as ${s.email || "you"}. Documents run on the included AI and are paid in credits; the relay runs named operations on the packet the app built, never a free prompt, and never stores packet or reply text.</p>
87
99
  <div class="row"><button type="button" onClick=${async () => { setMsg(""); try { const r = await api.get("/api/credits/balance"); setMsg(`balance: ${r.available} available`); await load(); } catch (e2) { setErr(e2.message); } }}>check balance</button>
88
- ${[["starter", "Starter"], ["campaign", "Campaign"], ["season", "Season"]].map(([k, label]) => html`<button type="button" key=${k} onClick=${async () => { setMsg(""); try { const r = await api.post("/api/credits/checkout", { pack: k }); location.href = r.url; } catch (e2) { setErr(e2.message); } }}>buy ${label}</button>`)}</div>
100
+ ${[["starter", "25 credits, $9"], ["campaign", "100 credits, $29"], ["season", "300 credits, $69"]].map(([k, label]) => html`<button type="button" key=${k} onClick=${async () => { setMsg(""); try { const r = await api.post("/api/credits/checkout", { pack: k }); location.href = r.url; } catch (e2) { setErr(e2.message); } }}>buy ${label}</button>`)}</div>
101
+ <p class="small muted">Stripe takes the card; GAF-J never sees it. Credits arrive within a few seconds of paying and never expire. A document costs a few credits, reading a resume a few more; the exact prices are shown before each run.</p>
89
102
  ${s.credits && s.credits.last_balance ? html`<p class="small">${s.credits.last_balance.available} credits available (${s.credits.last_balance.reserved} held), checked ${s.credits.last_balance.at}</p>` : ""}` : html`<p class="small muted">A paid route for people without their own AI subscription. The relay is written and tested but not deployed; nothing here works until it is. The relay runs named operations on the packet the app built, never a free prompt, and never stores packet or reply text.</p>
90
103
  <form class="inline" onSubmit=${(e) => { e.preventDefault(); const f = e.target.elements; put({ credits: { relay_url: f.relay.value, ...(f.token.value ? { id_token: f.token.value } : {}) } }); f.token.value = ""; }}>
91
104
  <input name="relay" placeholder="relay url (https)" value=${(s.credits && s.credits.relay_url) || ""} />
@@ -1,6 +1,6 @@
1
1
  import { html, useState, useEffect } from "../vendor/preact.mjs";
2
- import { api } from "../lib.js";
3
- import { Onboarding, DraftForm, fromDraft, Positions } from "./kb.js";
2
+ import { api, cleanPosting, guessPosting } from "../lib.js";
3
+ import { Onboarding, DraftForm, fromDraft, Positions, ContactForm } from "./kb.js";
4
4
  import { PacketModal } from "./packet.js";
5
5
 
6
6
  /**
@@ -12,7 +12,7 @@ import { PacketModal } from "./packet.js";
12
12
 
13
13
  const KIND_LABEL = { anthropic: "Anthropic (Claude)", openai_compatible: "OpenAI, or any OpenAI-compatible server", gemini: "Google Gemini" };
14
14
 
15
- function StepAi({ s, reload }) {
15
+ function StepAi({ s, reload, hosted }) {
16
16
  const [pick, setPick] = useState(null);
17
17
  const [key, setKey] = useState("");
18
18
  const [kind, setKind] = useState("anthropic");
@@ -32,6 +32,15 @@ function StepAi({ s, reload }) {
32
32
  await reload();
33
33
  } catch (e2) { setMsg(e2.message); }
34
34
  };
35
+ if (hosted) return html`<div>
36
+ <p>GAF-J never writes text itself. It hands a packet of your confirmed facts to an AI, then checks every figure that comes back. Here the AI is included and paid in credits; or use a chat you already pay for and paste.</p>
37
+ <div class="ai-options">
38
+ <button class=${pick === "credits" ? "picked" : ""} onClick=${() => setPick("credits")}><b>Included AI</b><span class="small muted">Best. Everything runs in one click. Paid in credits per document; reading your resumes costs a few. Buy credits under Settings when you are ready; the first steps here are free.</span></button>
39
+ <button class=${pick === "paste" ? "picked" : ""} onClick=${() => setPick("paste")}><b>Paste</b><span class="small muted">Any chat you already pay for: ChatGPT, Claude, Gemini. You copy a packet in and paste the reply back, each time.</span></button>
40
+ </div>
41
+ ${pick === "credits" ? html`<div style="margin-top:12px"><button class="primary" onClick=${() => choose("credits")}>Use the included AI</button></div>` : ""}
42
+ ${pick === "paste" ? html`<div style="margin-top:12px"><p class="small muted">Fine for a first try. Each step below will show a packet to copy into your chat and a box to paste the reply into.</p><button class="primary" onClick=${() => choose("paste")}>Use paste</button></div>` : ""}
43
+ </div>`;
35
44
  return html`<div>
36
45
  <p>GAF-J never writes text itself. It hands a packet of your confirmed facts to an AI you already have, then checks every figure that comes back. How will you connect one?</p>
37
46
  <div class="ai-options">
@@ -55,13 +64,12 @@ function StepPosting({ reload }) {
55
64
  const [title, setTitle] = useState("");
56
65
  const [g, setG] = useState({ company: true, title: true });
57
66
  const [err, setErr] = useState("");
58
- const onPaste = (t) => {
67
+ const onPaste = (raw) => {
68
+ const t = cleanPosting(raw);
59
69
  setText(t);
60
- const lines = t.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, 12);
61
- let ti = title, co = company;
62
- if (g.title) ti = lines.find((l) => l.length <= 90 && !/^https?:/i.test(l) && !/^(at|company|location|posted|apply|share)\b/i.test(l)) || "";
63
- if (g.company) { co = ""; for (const l of lines) { const m = l.match(/^(?:at|company[:\s]+|employer[:\s]+)\s*([^|·,]{2,60})/i); if (m) { co = m[1].trim(); break; } } if (!co) { const i = lines.findIndex((l) => l === ti); const c = lines[i + 1] || ""; if (c && c.length <= 50 && !/^https?:/i.test(c) && !/\d{4}/.test(c)) co = c.replace(/^[·|\-\s]+/, ""); } }
64
- setTitle(ti); setCompany(co);
70
+ const gu = guessPosting(t);
71
+ if (g.title) setTitle(gu.title);
72
+ if (g.company) setCompany(gu.company);
65
73
  };
66
74
  const store = async (e) => {
67
75
  e.preventDefault(); setErr("");
@@ -98,10 +106,12 @@ export function Welcome({ live, onDone }) {
98
106
  setBusy("asking"); try { const r = await api.post("/api/run/discover", {}); if (r.status !== "passed") setErr("refused: " + (r.errors || []).join("; ")); await load(); } catch (e) { setErr(e.message); } setBusy("");
99
107
  };
100
108
  const body = {
101
- ai: html`<${StepAi} s=${setup} reload=${load} />`,
109
+ ai: html`<${StepAi} s=${setup} reload=${load} hosted=${!!me.hosted} />`,
102
110
  upload: html`<div><p>Your resumes: Word, PDF, Markdown, or plain text. Five to ten of your most different versions is plenty; near-identical copies only add duplicates to merge. The text is read on this PC. Nothing is uploaded anywhere.</p><${Onboarding} d=${ob} reload=${load} setErr=${setErr} route=${me.route} compact=${true} /></div>`,
103
111
  extract: html`<div><p>${direct ? "Your AI reads each file and proposes records where every number and phrase is quoted from the file; anything it cannot quote is refused. One click runs the whole pile." : "For each file, Extract opens a packet: copy it into your chat, paste the JSON reply back. Your AI proposes records where every number and phrase is quoted from the file."}</p><${Onboarding} d=${ob} reload=${load} setErr=${setErr} route=${me.route} compact=${true} /></div>`,
104
- positions: html`<div><p>Before the records: the jobs, schools, volunteer roles, awards, and certifications themselves, each once. Your AI quoted them from your resumes. Fix a name, add a city, correct a date, then confirm. Every record under a job follows it.</p><${Positions} reload=${load} setErr=${setErr} compact=${true} /></div>`,
112
+ positions: html`<div><p>Before the records: the jobs, schools, volunteer roles, awards, and certifications themselves, each once. Your AI quoted them from your resumes. Fix a name, add a city, correct a date, then confirm. Every record under a job follows it.</p>
113
+ <div class="small muted" style="margin:14px 0 4px;text-transform:uppercase;letter-spacing:.04em">Your header</div><${ContactForm} setErr=${setErr} compact=${true} />
114
+ <${Positions} reload=${load} setErr=${setErr} compact=${true} /></div>`,
105
115
  confirm: html`<div><p>These are the records your AI proposed. Confirm the ones you can stand behind in an interview, edit what is off, dismiss the rest. Only confirmed records can ever appear in a document.</p>
106
116
  <ul class="list">${kb.pending.map((p) => html`<li key=${p.pending_id}><div><b>${p.draft.title}</b> <span class="muted">${p.draft.company}, ${p.draft.role}</span>
107
117
  <div class="small muted">metrics: ${(p.draft.metrics || []).join(", ") || "none"} · verbs: ${(p.draft.verbs || []).join(", ") || "none"}${p.draft.dates ? " · " + p.draft.dates : ""}</div>