@gafj/gafj 0.1.19 → 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +1 -0
- package/core/scoring.js +3 -1
- package/http/guard.js +1 -1
- package/http/hosted.js +31 -2
- package/package.json +1 -1
- package/store/setup.js +4 -2
- package/ui/app.css +4 -1
- package/ui/hosted/signin.css +2 -3
- package/ui/hosted/signin.html +1 -2
- package/ui/lib.js +35 -0
- package/ui/screens/postings.js +23 -16
- package/ui/screens/welcome.js +19 -12
package/bin/cli.js
CHANGED
|
@@ -321,6 +321,7 @@ async function main(argv) {
|
|
|
321
321
|
const { createHostedServer } = require("../http/hosted");
|
|
322
322
|
const { firebaseVerifier } = require("../http/identity");
|
|
323
323
|
const { Log } = require("../store/log");
|
|
324
|
+
const pkg = require("../package.json");
|
|
324
325
|
const env = process.env;
|
|
325
326
|
const root = env.GAFJ_HOSTED_ROOT || "/data";
|
|
326
327
|
if (!env.GAFJ_FIREBASE_PROJECT_ID) { process.stderr.write("serve-hosted needs GAFJ_FIREBASE_PROJECT_ID (and GAFJ_FIREBASE_API_KEY, GAFJ_FIREBASE_AUTH_DOMAIN for the sign-in page)\n"); process.exit(2); }
|
package/core/scoring.js
CHANGED
|
@@ -128,8 +128,10 @@ function conceptTags(requirementText) {
|
|
|
128
128
|
return hit;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/** Lines a job board adds around a posting; never a requirement of the job. */
|
|
132
|
+
const BOARD_NOISE = /\b(your profile and resume match|see how you compare|applicants?\b.*\b(ago|clicked)|easy apply|promoted by|reposted|set alert for similar|show more|show less|tailor my resume|help me stand out|create cover letter|people you can reach out to|meet the hiring team|skills you have|sign in to|referrals increase)\b/i;
|
|
131
133
|
function lines(text) {
|
|
132
|
-
return String(text).split(/\r?\n|(?<=[.;])\s{2,}/).map((l) => l.trim()).filter(
|
|
134
|
+
return String(text).split(/\r?\n|(?<=[.;])\s{2,}/).map((l) => l.trim()).filter((l) => l && !BOARD_NOISE.test(l));
|
|
133
135
|
}
|
|
134
136
|
|
|
135
137
|
/** Every figure in a document: money, percents, counts, durations. */
|
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(
|
|
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
|
@@ -34,8 +34,34 @@ const { UI_DIR } = require("./server");
|
|
|
34
34
|
const COOKIE = "gafj_hs";
|
|
35
35
|
const SESSION_TTL_MS = 30 * 24 * 3600 * 1000;
|
|
36
36
|
const IDLE_CLOSE_MS = 10 * 60 * 1000;
|
|
37
|
-
const SIGNIN_CSP = "default-src 'self'; script-src 'self' https://www.gstatic.com https://apis.google.com; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://identitytoolkit.googleapis.com https://securetoken.googleapis.com https://www.googleapis.com https://apis.google.com; frame-src https://*.firebaseapp.com https://accounts.google.com; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'";
|
|
37
|
+
const SIGNIN_CSP = "default-src 'self'; script-src 'self' https://www.gstatic.com https://apis.google.com; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://identitytoolkit.googleapis.com https://securetoken.googleapis.com https://www.googleapis.com https://apis.google.com; frame-src 'self' https://*.firebaseapp.com https://accounts.google.com; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'";
|
|
38
38
|
const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".webmanifest": "application/manifest+json", ".ico": "image/x-icon", ".woff2": "font/woff2" };
|
|
39
|
+
/**
|
|
40
|
+
* Google sign-in's handler pages live on <project>.firebaseapp.com. Served from another site, the
|
|
41
|
+
* popup and redirect flows depend on third-party storage that browsers now block, so the app
|
|
42
|
+
* serves them itself: everything under /__/ is fetched from Firebase and passed through, and the
|
|
43
|
+
* sign-in page uses this host as its auth domain. Firebase's own headers come back with the page;
|
|
44
|
+
* only hop-by-hop and cookie headers are dropped.
|
|
45
|
+
*/
|
|
46
|
+
const PASS_REQ = new Set(["accept", "accept-language", "content-type", "content-length", "referer", "user-agent", "x-requested-with", "origin"]);
|
|
47
|
+
const DROP_RES = new Set(["set-cookie", "transfer-encoding", "connection", "keep-alive", "content-encoding", "content-length", "strict-transport-security", "alt-svc"]);
|
|
48
|
+
async function proxyAuth(req, res, { projectId, fetchImpl, originOf }) {
|
|
49
|
+
const upstream = `https://${projectId}.firebaseapp.com${req.url}`;
|
|
50
|
+
const headers = {};
|
|
51
|
+
for (const [k, v] of Object.entries(req.headers)) if (PASS_REQ.has(k)) headers[k] = v;
|
|
52
|
+
if (headers.origin) headers.origin = `https://${projectId}.firebaseapp.com`;
|
|
53
|
+
if (headers.referer) headers.referer = headers.referer.replace(originOf, `https://${projectId}.firebaseapp.com`);
|
|
54
|
+
headers["accept-encoding"] = "identity";
|
|
55
|
+
const body = req.method === "GET" || req.method === "HEAD" ? undefined : await new Promise((resolve, reject) => { const c = []; req.on("data", (d) => c.push(d)); req.on("end", () => resolve(Buffer.concat(c))); req.on("error", reject); });
|
|
56
|
+
const r = await fetchImpl(upstream, { method: req.method, headers, body, redirect: "manual" });
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const [k, v] of r.headers.entries()) if (!DROP_RES.has(k.toLowerCase())) out[k] = v;
|
|
59
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
60
|
+
out["content-length"] = String(buf.length);
|
|
61
|
+
res.writeHead(r.status, out);
|
|
62
|
+
return res.end(buf);
|
|
63
|
+
}
|
|
64
|
+
|
|
39
65
|
const BLOCKED_IN_HOSTED = [/^\/api\/settings\/providers/, /^\/api\/mcp-config$/, /^\/api\/restore-test$/];
|
|
40
66
|
|
|
41
67
|
function json(res, status, body) {
|
|
@@ -157,7 +183,9 @@ function createHostedServer(o) {
|
|
|
157
183
|
res.on("finish", () => log.write(res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warn" : "info", "request",
|
|
158
184
|
{ entity: "http", entity_id: `${req.method} ${p}`, actor: who ? "user" : "anon", ms: Date.now() - started, status: res.statusCode }));
|
|
159
185
|
try {
|
|
186
|
+
if (p === "/healthz" && req.method === "GET") return json(res, 200, { ok: true, version: o.version }); // the platform's check arrives under its own host name
|
|
160
187
|
if (host && String(req.headers.host || "").toLowerCase() !== host) return json(res, 421, { error: "wrong host" });
|
|
188
|
+
if (p.startsWith("/__/") && o.firebase && o.firebase.projectId) return proxyAuth(req, res, { projectId: o.firebase.projectId, fetchImpl: o.fetch || fetch, originOf: originOf(req) });
|
|
161
189
|
const mutation = req.method !== "GET" && req.method !== "HEAD";
|
|
162
190
|
securityHeaders(res, { noStore: isApi || p.startsWith("/auth/") || p.startsWith("/account/") });
|
|
163
191
|
|
|
@@ -166,7 +194,8 @@ function createHostedServer(o) {
|
|
|
166
194
|
res.writeHead(200, { "Content-Type": MIME[".html"], "Cache-Control": "no-cache" });
|
|
167
195
|
return fs.createReadStream(path.join(UI_DIR, "hosted", "signin.html")).pipe(res);
|
|
168
196
|
}
|
|
169
|
-
|
|
197
|
+
// the auth domain is this host, so Google's handler pages come through the proxy above and stay same-site
|
|
198
|
+
if (p === "/signin/config.json") return json(res, 200, { firebase: o.firebase ? { ...o.firebase, authDomain: String(req.headers.host || o.firebase.authDomain || "").toLowerCase() } : null, signed_in: !!sessionOf(req) });
|
|
170
199
|
if (p === "/auth/session" && req.method === "POST") {
|
|
171
200
|
const g = mutationCheck(req);
|
|
172
201
|
if (g) return json(res, g.status, { error: g.error });
|
package/package.json
CHANGED
package/store/setup.js
CHANGED
|
@@ -36,8 +36,10 @@ function setupView(db, homeDir, candidate_id) {
|
|
|
36
36
|
{ key: "discover", title: "Answer what's missing", done: questions > 0, detail: questions ? `${questions} question${questions === 1 ? "" : "s"} asked` : null },
|
|
37
37
|
{ key: "posting", title: "Paste your first job posting", done: postings > 0, detail: postings ? `${postings} posting${postings === 1 ? "" : "s"}` : null },
|
|
38
38
|
];
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
// a hosted account signed in to use the included AI; which AI is not a question there (Paste stays a Settings option)
|
|
40
|
+
const shown = cfg.hosted ? steps.filter((s) => s.key !== "ai") : steps;
|
|
41
|
+
const next = shown.find((s) => !s.done);
|
|
42
|
+
return { steps: shown, next: next ? next.key : null, complete: !next, dismissed: !!(cfg.setup && cfg.setup.dismissed), route: cfg.route || "paste" };
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
/** The user picked how they will use AI; the route is set and the step is done. */
|
package/ui/app.css
CHANGED
|
@@ -130,7 +130,10 @@ form.inline input, form.inline select { min-width: 120px; }
|
|
|
130
130
|
table.cats { border-collapse: collapse; width: 100%; font-size: 13px; }
|
|
131
131
|
table.cats th, table.cats td { border-bottom: 1px solid var(--line); padding: 6px; text-align: left; vertical-align: top; }
|
|
132
132
|
table.cats th { font-family: var(--mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 500; }
|
|
133
|
-
table.cats input:not([type=checkbox]) { width: 100%; }
|
|
133
|
+
table.cats input:not([type=checkbox]) { width: 100%; box-sizing: border-box; }
|
|
134
|
+
table.cats td:first-child { min-width: 150px; }
|
|
135
|
+
table.cats td:nth-child(2), table.cats td:nth-child(3) { width: 58px; }
|
|
136
|
+
table.cats td:nth-child(5) { font-family: var(--sans); font-size: 12.5px; color: var(--muted); }
|
|
134
137
|
input.narrow { width: 76px; }
|
|
135
138
|
pre.wrap { white-space: pre-wrap; }
|
|
136
139
|
/* KB "receipt" moment: the confirmed span highlighted inline against its muted source sentence. */
|
package/ui/hosted/signin.css
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
.signin { max-width: 420px; margin: 10vh auto; padding: 32px 28px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; text-align: center; }
|
|
2
|
-
.signin .mark { width:
|
|
3
|
-
.signin
|
|
4
|
-
.signin .sub { color: var(--muted); font-size: 14px; line-height: 1.6; margin: 0 0 22px; text-align: left; }
|
|
2
|
+
.signin .mark { width: 80px; height: 80px; border-radius: 18px; margin-bottom: 18px; }
|
|
3
|
+
.signin .sub { color: var(--muted); font-family: var(--sans); font-size: 14px; line-height: 1.6; margin: 0 0 22px; text-align: left; }
|
|
5
4
|
.signin button.primary { width: 100%; padding: 11px; font-size: 15px; }
|
|
6
5
|
.signin details { text-align: left; margin-top: 22px; border-top: 1px solid var(--line); padding-top: 12px; }
|
|
7
6
|
.signin textarea { min-height: 70px; font-size: 11px; }
|
package/ui/hosted/signin.html
CHANGED
|
@@ -10,8 +10,7 @@
|
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<main class="signin">
|
|
13
|
-
<img class="mark" src="/icon-192.png" alt="" width="
|
|
14
|
-
<h1>GAF-J</h1>
|
|
13
|
+
<img class="mark" src="/icon-192.png" alt="GAF-J" width="80" height="80">
|
|
15
14
|
<p class="sub">Sign in with Google. Your record, documents, and campaign live in your account here, encrypted, yours to export or delete in one click.</p>
|
|
16
15
|
<button id="google" class="primary" type="button">Continue with Google</button>
|
|
17
16
|
<p id="status" class="small muted" hidden></p>
|
package/ui/lib.js
CHANGED
|
@@ -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/postings.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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
|
+
import { PacketModal } from "./packet.js";
|
|
3
4
|
|
|
4
5
|
/** Screen 2: paste a posting, store and scan locally, edit the proposed categories, freeze, log the application. */
|
|
5
6
|
|
|
@@ -39,25 +40,29 @@ export function Postings({ live, id }) {
|
|
|
39
40
|
const [form, setForm] = useState({ company: "", title: "", source_url: "", location: "", comp_text: "", raw_text: "" });
|
|
40
41
|
const [guessed, setGuessed] = useState({ company: true, title: true });
|
|
41
42
|
// 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 = (
|
|
43
|
-
const
|
|
43
|
+
const onPaste = (raw) => {
|
|
44
|
+
const text = cleanPosting(raw);
|
|
45
|
+
const g = guessPosting(text);
|
|
44
46
|
const next = { ...form, raw_text: text };
|
|
45
|
-
if (guessed.title)
|
|
46
|
-
|
|
47
|
-
|
|
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;
|
|
47
|
+
if (guessed.title) next.title = g.title;
|
|
48
|
+
if (guessed.company) next.company = g.company;
|
|
49
|
+
if (g.url && !form.source_url) next.source_url = g.url;
|
|
57
50
|
setForm(next);
|
|
58
51
|
};
|
|
59
52
|
const [err, setErr] = useState("");
|
|
60
53
|
const [msg, setMsg] = useState("");
|
|
54
|
+
const [me, setMe] = useState(null);
|
|
55
|
+
const [modal, setModal] = useState(null);
|
|
56
|
+
const [asking, setAsking] = useState(false);
|
|
57
|
+
useEffect(() => { api.get("/api/me").then(setMe).catch(() => {}); }, []);
|
|
58
|
+
const direct = me && (me.route === "byo_key" || me.route === "credits");
|
|
59
|
+
// the AI reads the posting and proposes the five to eight things the employer is really asking for, each with its verbatim line
|
|
60
|
+
const askAi = async (posting_id) => {
|
|
61
|
+
if (!direct) { setModal({ op: "categories", posting_id }); return; }
|
|
62
|
+
setAsking(true); setErr(""); setMsg("");
|
|
63
|
+
try { const r = await api.post(`/api/run/categories?posting_id=${encodeURIComponent(posting_id)}`, {}); if (r.status !== "passed") setErr("refused: " + (r.errors || []).join("; ")); else setMsg("your AI proposed the categories; edit and freeze below"); await load(); } catch (e) { setErr(e.message); }
|
|
64
|
+
setAsking(false);
|
|
65
|
+
};
|
|
61
66
|
const load = async () => { setRows(await api.get("/api/postings")); if (id) setSel(await api.get(`/api/postings/${id}`)); };
|
|
62
67
|
useEffect(() => { load().catch((e) => setErr(e.message)); }, [live, id]);
|
|
63
68
|
const store = async (e) => {
|
|
@@ -95,7 +100,9 @@ export function Postings({ live, id }) {
|
|
|
95
100
|
<div class="row"><span class="tag">${sel.status}</span><span class="muted small">${[sel.location, sel.comp_text].filter(Boolean).join(" · ")}</span>
|
|
96
101
|
${sel.status === "proposed" ? html`<button class="primary" onClick=${() => act(`/api/postings/${sel.posting_id}/accept`)}>Accept</button>` : ""}
|
|
97
102
|
${sel.status === "proposed" ? html`<button onClick=${() => act(`/api/postings/${sel.posting_id}/archive`)}>Archive</button>` : ""}
|
|
103
|
+
<button class="primary" disabled=${asking} onClick=${() => askAi(sel.posting_id)}>${asking ? "asking" : "Ask your AI for categories"}</button>
|
|
98
104
|
<button onClick=${() => act(`/api/postings/${sel.posting_id}/scan`)}>Scan again</button></div>
|
|
105
|
+
${modal ? html`<${PacketModal} op="categories" label="Categories" target=${{ posting_id: modal.posting_id }} onClose=${() => setModal(null)} onSaved=${load} />` : ""}
|
|
99
106
|
<details><summary>raw text (${sel.raw_text.length} chars)</summary><pre class="mono wrap">${sel.raw_text}</pre></details>
|
|
100
107
|
</div>
|
|
101
108
|
${frozen ? html`<div class="card"><b>fit ${frozen.fit_score} · ${frozen.verdict.toUpperCase()}</b> <span class="muted small">${frozen.verdict_reason}</span>
|
|
@@ -103,7 +110,7 @@ export function Postings({ live, id }) {
|
|
|
103
110
|
${!rows.find((r) => r.posting_id === sel.posting_id && r.application_id) && sel.status === "accepted" ? html`<div class="row"><button class="primary" onClick=${async () => { const r = await act("/api/applications", { posting_id: sel.posting_id }); if (r) location.hash = "#/applications/" + r.application_id; }}>Log application</button></div>` : ""}
|
|
104
111
|
</div>` : ""}
|
|
105
112
|
<h3>${frozen ? "Re-score" : "Proposed categories"} ${proposed ? html`<span class="tag">draft from ${proposed.scored_at.slice(0, 10)}</span>` : ""}</h3>
|
|
106
|
-
${proposed || frozen ? html`<div class="card"
|
|
113
|
+
${proposed || frozen ? html`<div class="card">${proposed && !frozen ? html`<p class="small muted">${direct ? "A quick local scan made this draft. \"Ask your AI for categories\" above replaces it with a read of the whole posting." : "A quick local scan made this draft; your AI can do better through the button above."}</p>` : ""}<${CategoryEditor} key=${(proposed || frozen).scoring_id} posting=${sel} scoring=${proposed || frozen} onDone=${load} /></div>` : html`<p class="muted">no categories yet; ask your AI above, or scan the posting</p>`}
|
|
107
114
|
${sel.gaps.length ? html`<h3>KB gaps (${sel.gaps.length})</h3><ul class="list">${sel.gaps.map((g) => html`<li key=${g.gap_id}>${g.question} <span class="small muted">"${g.requirement_text}"</span> ${g.resolved_at ? html`<span class="tag passed">answered</span>` : html`<a class="small" href="#/kb">answer</a>`}</li>`)}</ul>` : ""}
|
|
108
115
|
` : html`<p class="muted">pick a posting</p>`}
|
|
109
116
|
</div>
|
package/ui/screens/welcome.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { html, useState, useEffect } from "../vendor/preact.mjs";
|
|
2
|
-
import { api } from "../lib.js";
|
|
2
|
+
import { api, cleanPosting, guessPosting } from "../lib.js";
|
|
3
3
|
import { Onboarding, DraftForm, fromDraft, Positions, ContactForm } from "./kb.js";
|
|
4
4
|
import { PacketModal } from "./packet.js";
|
|
5
5
|
|
|
@@ -58,29 +58,36 @@ function StepAi({ s, reload, hosted }) {
|
|
|
58
58
|
</div>`;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
function StepPosting({ reload }) {
|
|
61
|
+
function StepPosting({ reload, direct }) {
|
|
62
62
|
const [text, setText] = useState("");
|
|
63
63
|
const [company, setCompany] = useState("");
|
|
64
64
|
const [title, setTitle] = useState("");
|
|
65
65
|
const [g, setG] = useState({ company: true, title: true });
|
|
66
66
|
const [err, setErr] = useState("");
|
|
67
|
-
const
|
|
67
|
+
const [busy, setBusy] = useState(false);
|
|
68
|
+
const onPaste = (raw) => {
|
|
69
|
+
const t = cleanPosting(raw);
|
|
68
70
|
setText(t);
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
if (g.
|
|
72
|
-
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]+/, ""); } }
|
|
73
|
-
setTitle(ti); setCompany(co);
|
|
71
|
+
const gu = guessPosting(t);
|
|
72
|
+
if (g.title) setTitle(gu.title);
|
|
73
|
+
if (g.company) setCompany(gu.company);
|
|
74
74
|
};
|
|
75
75
|
const store = async (e) => {
|
|
76
76
|
e.preventDefault(); setErr("");
|
|
77
|
-
|
|
77
|
+
setBusy(true);
|
|
78
|
+
try {
|
|
79
|
+
const r = await api.post("/api/postings", { company, title, raw_text: text });
|
|
80
|
+
await api.post(`/api/postings/${r.posting_id}/scan`, {});
|
|
81
|
+
if (direct) { try { await api.post(`/api/run/categories?posting_id=${encodeURIComponent(r.posting_id)}`, {}); } catch (e3) { /* the local scan's draft stands; the posting page can ask again */ } }
|
|
82
|
+
await reload();
|
|
83
|
+
} catch (e2) { setErr(e2.message); }
|
|
84
|
+
setBusy(false);
|
|
78
85
|
};
|
|
79
86
|
return html`<form onSubmit=${store}>
|
|
80
87
|
<p>Paste the text of a job you want, exactly as the site shows it. GAF-J scores it against your record and shows what the posting asks for beside what you have. Everything else (the resume, the prep) starts from there.</p>
|
|
81
88
|
<textarea required placeholder="the whole posting" value=${text} onInput=${(e) => onPaste(e.target.value)}></textarea>
|
|
82
89
|
<div class="row"><input required placeholder="company (guessed; fix if wrong)" value=${company} onInput=${(e) => { setG({ ...g, company: false }); setCompany(e.target.value); }} /><input required placeholder="title (guessed; fix if wrong)" value=${title} onInput=${(e) => { setG({ ...g, title: false }); setTitle(e.target.value); }} /></div>
|
|
83
|
-
<div class="row"><button class="primary"
|
|
90
|
+
<div class="row"><button class="primary" disabled=${busy}>${busy ? (direct ? "storing, then asking your AI what it wants" : "storing") : "Store and score"}</button>${err ? html`<span class="error small">${err}</span>` : ""}</div>
|
|
84
91
|
</form>`;
|
|
85
92
|
}
|
|
86
93
|
|
|
@@ -124,10 +131,10 @@ export function Welcome({ live, onDone }) {
|
|
|
124
131
|
${kb.questions.length ? html`<ul class="list">${kb.questions.map((q) => html`<li key=${q.question_id}><div><span class="tag">${q.kind}</span> <b>${q.title}</b> <span class="muted">${q.company}</span><br />${q.question}
|
|
125
132
|
${answering === q.question_id ? html`<${DraftForm} label="Add answer" initial=${fromDraft(q.draft)} onSubmit=${(draft) => { post(`/api/kb/questions/${q.question_id}/answer`, { draft }); setAnswering(null); }} />` : html`<div class="row"><button class="small" onClick=${() => setAnswering(q.question_id)}>Answer</button><button class="small danger" onClick=${() => post(`/api/kb/questions/${q.question_id}/dismiss`, {})}>Skip</button></div>`}</div></li>`)}</ul>`
|
|
126
133
|
: html`<div class="row"><button class="primary" disabled=${!!busy} onClick=${discover}>${busy ? "asking" : "Ask what's missing"}</button><button onClick=${() => post("/api/setup/dismiss", { dismissed: true })}>Later</button></div>`}</div>`,
|
|
127
|
-
posting: html`<${StepPosting} reload=${load} />`,
|
|
134
|
+
posting: html`<${StepPosting} reload=${load} direct=${direct} />`,
|
|
128
135
|
};
|
|
129
136
|
return html`<div class="welcome">
|
|
130
|
-
<div class="welcome-head"><img src="/icon-192.png" alt="" width="40" height="40" /><div><b>Welcome to GAF-J${me.name ? ", " + me.name : ""}</b><div class="small muted"
|
|
137
|
+
<div class="welcome-head"><img src="/icon-192.png" alt="" width="40" height="40" /><div><b>Welcome to GAF-J${me.name ? ", " + me.name : ""}</b><div class="small muted">${["", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight"][setup.steps.length] || setup.steps.length} short steps, then the app is yours.</div></div>
|
|
131
138
|
<span class="links"><a class="small" href="#/settings">Settings</a><a class="small" href="#/" onClick=${async (e) => { e.preventDefault(); await api.post("/api/setup/dismiss", {}); onDone(); }}>skip for now</a></span></div>
|
|
132
139
|
<ol class="stepper">${setup.steps.map((st, i) => html`<li key=${st.key} class=${(st.done ? "done" : "") + (i === idx ? " now" : "") + (i <= nextIdx ? " can" : "")} onClick=${() => { if (i <= nextIdx) setView(i); }} title=${i <= nextIdx ? "open this step" : ""}><span class="n mono">${st.done ? "✓" : i + 1}</span>${st.title}</li>`)}</ol>
|
|
133
140
|
${err ? html`<p class="error">${err}</p>` : ""}
|