diff --git a/CONTEXT.md b/CONTEXT.md
index 1c02515..8fb9066 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -21,6 +21,15 @@ stored resume file is Evidence. Extracted jobs, schools, and skills are
Career Facts that point at that file.
_Avoid_: Attachment, proof
+**Linked Evidence**:
+Evidence captured from a public link, such as a review, article, talk, demo,
+press release, social post, or award, and attached to a Role. It may be pinned
+to one achievement or event on that Role. The occupant records what they did
+and whether the source names them or is about work they contributed to. The
+captured copy stays private; the occupant's record is published only when they
+mark it public.
+_Avoid_: Public mention, press clipping, source
+
**Career Fact**:
A versioned assertion about the occupant's identity, work, education, skills,
achievements, or preferences, linked to its evidence.
diff --git a/core/career-memory.ts b/core/career-memory.ts
index 15f97a5..d80b598 100644
--- a/core/career-memory.ts
+++ b/core/career-memory.ts
@@ -11,7 +11,15 @@ export type Sensitivity = "private" | "restricted" | "public";
export type Evidence = {
id: string;
occupantId: string;
- sourceType: "user" | "resume" | "worklog" | "work-map" | "document" | "import" | "message";
+ sourceType:
+ | "user"
+ | "resume"
+ | "worklog"
+ | "work-map"
+ | "document"
+ | "import"
+ | "message"
+ | "link";
sourceRef: string;
title: string;
capturedAt: string;
diff --git a/core/index.ts b/core/index.ts
index e2eb78f..cfb9934 100644
--- a/core/index.ts
+++ b/core/index.ts
@@ -15,10 +15,12 @@ export {
keptOnboardingHistory,
nextOnboardingSourceIndex,
onboardingCheckItems,
+ onboardingDone,
onboardingExtractSummary,
profileAfterOnboarding,
ONBOARDING_NO_PLACE,
type OnboardingCheckItem,
+ type OnboardingDone,
type OnboardingLinkAnswer,
type OnboardingPlaceAnswer,
} from "./onboarding-review";
@@ -34,8 +36,11 @@ export {
holdVerifiedRoles,
parseHistoryEvidence,
queuedResumeDuplicate,
+ resumesToQueue,
rememberSetAside,
rememberSkills,
+ reviseSkills,
+ skillLines,
roleFieldDiffs,
roleTookFile,
type HeldHistory,
@@ -346,12 +351,20 @@ export {
} from "./map-settings";
export {
EMPTY_WORK_MAP_DETAILS,
+ LINKED_EVIDENCE_CREDITS,
+ LINKED_EVIDENCE_KINDS,
attachFactsToWorkMapRole,
buildWorkMapSnapshot,
+ groupLinkedEvidence,
+ isLinkedEvidenceUrl,
+ linkedEvidenceFromPage,
normalizeSlug,
normalizeWorkMapDetails,
+ planLinkedEvidenceFacts,
planWorkMapPublicationSettings,
planWorkMapRoleFactSync,
+ prepareLinkedEvidence,
+ prepareLinkedEvidenceDelete,
prepareWorkMapLocation,
prepareWorkMapRoleCreate,
nominatimAddressLine,
@@ -359,6 +372,10 @@ export {
roleCoverPhoto,
publicationAllowsSnapshot,
publicationNeedsAudienceConfirm,
+ type LinkedEvidence,
+ type LinkedEvidenceError,
+ type PreparedLinkedEvidence,
+ type PublishedLinkedEvidence,
type PreparedWorkMapLocation,
type WorkMapClaim,
type WorkMapLocation,
@@ -372,6 +389,12 @@ export {
type WorkMapShare,
type WorkMapSnapshot,
} from "./work-map";
+export {
+ LINKED_PAGE_TEXT_LIMIT,
+ isPublicAddress,
+ readLinkedPage,
+ type LinkedPage,
+} from "./linked-page";
export {
careerFrame,
careerMoments,
diff --git a/core/linked-page.test.ts b/core/linked-page.test.ts
new file mode 100644
index 0000000..b44856b
--- /dev/null
+++ b/core/linked-page.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vitest";
+import { isPublicAddress, readLinkedPage } from "./linked-page";
+
+describe("reading a linked page", () => {
+ it("keeps the title, social tags, and readable text", () => {
+ const page = readLinkedPage(`
+
+ Fold review & verdict
+
+
+
+
+
+
+ The crease
+ You cannot feel the fold anymore.
+ SECRET_NOSCRIPT
+ `);
+ expect(page.title).toBe("Fold review & verdict");
+ expect(page.meta).toEqual({
+ "og:title": 'The crease is "gone"',
+ "og:site_name": "Reviews Weekly",
+ description: "Hands on.",
+ });
+ expect(page.text).toContain("The crease You cannot feel the fold anymore.");
+ expect(page.text).not.toContain("SECRET_");
+ expect(page.text).not.toContain("color");
+ });
+
+ it("caps the stored text", () => {
+ const page = readLinkedPage(`${"word ".repeat(100_000)}
`);
+ expect(page.text.length).toBeLessThanOrEqual(100_000);
+ });
+});
+
+describe("public addresses", () => {
+ it("allows addresses on the public internet", () => {
+ for (const address of ["93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"]) {
+ expect(isPublicAddress(address)).toBe(true);
+ }
+ });
+
+ it("refuses this machine, the local network, and reserved ranges", () => {
+ for (const address of [
+ "127.0.0.1",
+ "10.0.0.5",
+ "172.16.4.2",
+ "192.168.1.1",
+ "169.254.169.254",
+ "100.64.0.1",
+ "0.0.0.0",
+ "224.0.0.1",
+ "::1",
+ "::",
+ "fe80::1",
+ "fd00::1",
+ "::ffff:127.0.0.1",
+ "::ffff:7f00:1",
+ "not-an-address",
+ ]) {
+ expect(isPublicAddress(address)).toBe(false);
+ }
+ });
+});
diff --git a/core/linked-page.ts b/core/linked-page.ts
new file mode 100644
index 0000000..ad3b015
--- /dev/null
+++ b/core/linked-page.ts
@@ -0,0 +1,129 @@
+export const LINKED_PAGE_TEXT_LIMIT = 100_000;
+
+export type LinkedPage = {
+ title: string;
+ meta: Record;
+ text: string;
+};
+
+export function readLinkedPage(html: string): LinkedPage {
+ const title = decodeEntities(
+ /]*>([\s\S]*?)<\/title>/i.exec(html)?.[1] ?? "",
+ )
+ .replace(/\s+/g, " ")
+ .trim();
+ const meta: Record = {};
+ for (const tag of html.match(/ ]*>/gi) ?? []) {
+ const attributes = readAttributes(tag);
+ const key = (attributes.property ?? attributes.name ?? "").toLowerCase();
+ const content = attributes.content;
+ if (!key || content === undefined || key in meta) continue;
+ meta[key] = decodeEntities(content).trim();
+ }
+ const text = decodeEntities(
+ html
+ .replace(//g, " ")
+ .replace(//gi, " ")
+ .replace(/<(script|style|noscript|template|svg)\b[\s\S]*?<\/\1>/gi, " ")
+ .replace(/<[^>]+>/g, " "),
+ )
+ .replace(/\s+/g, " ")
+ .trim()
+ .slice(0, LINKED_PAGE_TEXT_LIMIT);
+ return { title, meta, text };
+}
+
+function readAttributes(tag: string): Record {
+ const attributes: Record = {};
+ const pattern = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
+ for (const match of tag.matchAll(pattern)) {
+ attributes[match[1].toLowerCase()] = match[2] ?? match[3] ?? match[4] ?? "";
+ }
+ return attributes;
+}
+
+function decodeEntities(value: string): string {
+ const named: Record = {
+ amp: "&",
+ lt: "<",
+ gt: ">",
+ quot: '"',
+ apos: "'",
+ nbsp: " ",
+ };
+ return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (entity, body: string) => {
+ if (body[0] === "#") {
+ const code =
+ body[1] === "x" || body[1] === "X"
+ ? Number.parseInt(body.slice(2), 16)
+ : Number.parseInt(body.slice(1), 10);
+ return Number.isFinite(code) && code > 0 && code <= 0x10ffff
+ ? String.fromCodePoint(code)
+ : entity;
+ }
+ return named[body.toLowerCase()] ?? entity;
+ });
+}
+
+export function isPublicAddress(address: string): boolean {
+ const v4 = parseIpv4(address);
+ if (v4) return isPublicIpv4(v4);
+ const v6 = parseIpv6(address);
+ if (!v6) return false;
+ if (v6.every((group) => group === 0)) return false;
+ if (v6.slice(0, 7).every((group) => group === 0) && v6[7] === 1) return false;
+ if (v6.slice(0, 5).every((group) => group === 0) && v6[5] === 0xffff) {
+ return isPublicIpv4(embeddedIpv4(v6));
+ }
+ if (v6[0] === 0x64 && v6[1] === 0xff9b && v6.slice(2, 6).every((g) => g === 0)) {
+ return isPublicIpv4(embeddedIpv4(v6));
+ }
+ if ((v6[0] & 0xfe00) === 0xfc00) return false;
+ if ((v6[0] & 0xffc0) === 0xfe80) return false;
+ if ((v6[0] & 0xff00) === 0xff00) return false;
+ if (v6[0] === 0x2001 && v6[1] === 0x0db8) return false;
+ return true;
+}
+
+function isPublicIpv4([a, b, c]: number[]): boolean {
+ if (a === 0 || a === 10 || a === 127 || a >= 224) return false;
+ if (a === 100 && b >= 64 && b <= 127) return false;
+ if (a === 169 && b === 254) return false;
+ if (a === 172 && b >= 16 && b <= 31) return false;
+ if (a === 192 && b === 168) return false;
+ if (a === 192 && b === 0 && (c === 0 || c === 2)) return false;
+ if (a === 198 && (b === 18 || b === 19)) return false;
+ if (a === 198 && b === 51 && c === 100) return false;
+ if (a === 203 && b === 0 && c === 113) return false;
+ return true;
+}
+
+function embeddedIpv4(groups: number[]): number[] {
+ return [groups[6] >> 8, groups[6] & 0xff, groups[7] >> 8, groups[7] & 0xff];
+}
+
+function parseIpv4(value: string): number[] | null {
+ if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(value)) return null;
+ const parts = value.split(".").map(Number);
+ return parts.every((part) => part <= 255) ? parts : null;
+}
+
+function parseIpv6(value: string): number[] | null {
+ let address = value.replace(/^\[|\]$/g, "").split("%")[0] ?? "";
+ if (!address.includes(":")) return null;
+ const tail = /(\d{1,3}(?:\.\d{1,3}){3})$/.exec(address);
+ if (tail) {
+ const v4 = parseIpv4(tail[1]);
+ if (!v4) return null;
+ address = `${address.slice(0, -tail[1].length)}${((v4[0] << 8) | v4[1]).toString(16)}:${((v4[2] << 8) | v4[3]).toString(16)}`;
+ }
+ const halves = address.split("::");
+ if (halves.length > 2) return null;
+ const head = halves[0] ? halves[0].split(":") : [];
+ const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
+ const missing = 8 - head.length - rest.length;
+ if (halves.length === 1 ? missing !== 0 : missing < 1) return null;
+ const groups = [...head, ...Array(halves.length === 2 ? missing : 0).fill("0"), ...rest];
+ if (!groups.every((group) => /^[0-9a-f]{1,4}$/i.test(group))) return null;
+ return groups.map((group) => Number.parseInt(group, 16));
+}
diff --git a/core/model-onboarding.test.ts b/core/model-onboarding.test.ts
index a46bdc6..b356f67 100644
--- a/core/model-onboarding.test.ts
+++ b/core/model-onboarding.test.ts
@@ -168,6 +168,18 @@ describe("planModelOnboarding", () => {
).toBe("Qwen3.8-27B-Q6_K.gguf");
});
+ it("leaves out embedding and reranking models, which cannot read a resume", () => {
+ expect(
+ presentRangeHits([
+ {
+ baseUrl: DEFAULT_LOCAL_BASE_URL,
+ models: ["nomic-embed-text:latest", "qwen3.8:27b", "mxbai-embed-large", "bge-m3", "all-MiniLM-L6-v2"],
+ },
+ { baseUrl: LLAMA_CPP_BASE_URL, models: ["bge-reranker-v2-m3.gguf", "text-embedding-3-small"] },
+ ]),
+ ).toEqual([{ baseUrl: DEFAULT_LOCAL_BASE_URL, models: ["qwen3.8:27b"] }]);
+ });
+
it("keeps addresses that listed models and orders them by port", () => {
expect(
presentRangeHits([
diff --git a/core/model-onboarding.ts b/core/model-onboarding.ts
index f7bb113..b0683de 100644
--- a/core/model-onboarding.ts
+++ b/core/model-onboarding.ts
@@ -76,12 +76,15 @@ export function modelCountLabel(count: number): string {
return count === 1 ? "1 model" : `${count} models`;
}
+/** A model list gives names only, so embedding and reranking models are told apart by name. */
+const NON_READER = /embed|rerank|minilm|(^|[^a-z])bge([^a-z]|$)/i;
+
export function presentRangeHits(
hits: readonly { baseUrl: string; models: readonly string[] }[],
): LocalScanHit[] {
const kept = hits.flatMap((hit) => {
const baseUrl = hit.baseUrl.trim();
- const models = hit.models.map((id) => id.trim()).filter(Boolean);
+ const models = hit.models.map((id) => id.trim()).filter((id) => id && !NON_READER.test(presentModelId(id)));
if (!baseUrl || models.length === 0) return [];
return [{ baseUrl, models }];
});
diff --git a/core/onboarding-review.test.ts b/core/onboarding-review.test.ts
index 7efb829..63cb7fb 100644
--- a/core/onboarding-review.test.ts
+++ b/core/onboarding-review.test.ts
@@ -5,6 +5,7 @@ import {
keptOnboardingHistory,
nextOnboardingSourceIndex,
onboardingCheckItems,
+ onboardingDone,
onboardingExtractSummary,
profileAfterOnboarding,
} from "./onboarding-review";
@@ -74,20 +75,26 @@ function resume(): ExtractedResume {
}
describe("onboardingExtractSummary", () => {
- it("states how many jobs, schools, and internships were extracted", () => {
- expect(onboardingExtractSummary(onboardingCheckItems(resume()))).toBe(
- "2 jobs, 1 school, and 0 internships were extracted.",
+ it("states how many resumes, jobs, schools, and internships were extracted", () => {
+ expect(onboardingExtractSummary(onboardingCheckItems(resume()), 1)).toBe(
+ "1 resume, 2 jobs, 1 school, and 0 internships were extracted.",
+ );
+ });
+
+ it("uses the plural when more than one resume was read", () => {
+ expect(onboardingExtractSummary(onboardingCheckItems(resume()), 2)).toBe(
+ "2 resumes, 2 jobs, 1 school, and 0 internships were extracted.",
);
});
it("uses the singular for one job and one school", () => {
const items = onboardingCheckItems(resume()).filter((item) => item.org !== "Dropped Co");
- expect(onboardingExtractSummary(items)).toBe("1 job, 1 school, and 0 internships were extracted.");
+ expect(onboardingExtractSummary(items, 1)).toBe("1 resume, 1 job, 1 school, and 0 internships were extracted.");
});
it("leaves out a role the occupant added", () => {
const items = [...onboardingCheckItems(resume()), blankOnboardingCheckItem("Job", -1)];
- expect(onboardingExtractSummary(items)).toBe("2 jobs, 1 school, and 0 internships were extracted.");
+ expect(onboardingExtractSummary(items, 1)).toBe("1 resume, 2 jobs, 1 school, and 0 internships were extracted.");
});
});
@@ -261,4 +268,48 @@ describe("profileAfterOnboarding", () => {
expect(next.linkedinUrl).toBe("https://www.linkedin.com/in/ada");
expect(next.portfolioUrl).toBe("https://ada.example");
});
+
+ it("keeps links and a place read from the resume when the answers are blank", () => {
+ const blank: ProfileFields = {
+ ...current,
+ address: "",
+ city: "",
+ state: "",
+ linkedinUrl: "",
+ githubUrl: "",
+ portfolioUrl: "",
+ };
+ const next = profileAfterOnboarding({
+ current: blank,
+ extracted: resume().profile,
+ fullName: "Ada Lovelace",
+ place: { street: "", city: "", state: "" },
+ links: { linkedinUrl: "", githubUrl: "https://github.com/typed", portfolioUrl: "" },
+ });
+ expect(next.city).toBe("Allentown");
+ expect(next.state).toBe("PA");
+ expect(next.linkedinUrl).toBe("https://www.linkedin.com/in/other");
+ expect(next.githubUrl).toBe("https://github.com/typed");
+ expect(next.portfolioUrl).toBe("https://other.example");
+ });
+});
+
+describe("onboardingDone", () => {
+ it("counts what was saved and how many roles were set aside", () => {
+ expect(onboardingDone({ jobs: 6, schools: 1, skills: 14, resumes: 2, setAside: 3 })).toEqual({
+ tiles: [
+ { count: 6, label: "jobs" },
+ { count: 1, label: "school" },
+ { count: 14, label: "skills" },
+ { count: 2, label: "resumes kept as evidence" },
+ ],
+ setAside: "3 set aside. Import a resume from Career History to bring them back.",
+ });
+ });
+
+ it("leaves out the set-aside line when nothing was set aside", () => {
+ const done = onboardingDone({ jobs: 0, schools: 0, skills: 0, resumes: 1, setAside: 0 });
+ expect(done.setAside).toBeNull();
+ expect(done.tiles.at(-1)).toEqual({ count: 1, label: "resume kept as evidence" });
+ });
});
diff --git a/core/onboarding-review.ts b/core/onboarding-review.ts
index 44662bb..60bf7ca 100644
--- a/core/onboarding-review.ts
+++ b/core/onboarding-review.ts
@@ -39,20 +39,51 @@ export type OnboardingLinkAnswer = {
portfolioUrl: string;
};
-/** How many jobs, schools, and internships the file yielded. Added records are not part of the extract. */
-export function onboardingExtractSummary(items: OnboardingCheckItem[]): string {
+/** How many resumes, jobs, schools, and internships the read yielded. Added records are not part of the extract. */
+export function onboardingExtractSummary(items: OnboardingCheckItem[], resumes: number): string {
const extracted = items.filter((item) => item.sourceIndex >= 0);
const jobs = extracted.filter((item) => item.kind === "Job").length;
const schools = extracted.filter((item) => item.kind === "School").length;
// An internship is kept as a job until the role records that kind.
const internships = 0;
- return `${countLabel(jobs, "job", "jobs")}, ${countLabel(schools, "school", "schools")}, and ${countLabel(internships, "internship", "internships")} were extracted.`;
+ return `${countLabel(resumes, "resume", "resumes")}, ${countLabel(jobs, "job", "jobs")}, ${countLabel(schools, "school", "schools")}, and ${countLabel(internships, "internship", "internships")} were extracted.`;
}
function countLabel(count: number, singular: string, plural: string): string {
return `${count} ${count === 1 ? singular : plural}`;
}
+export type OnboardingDone = {
+ tiles: Array<{ count: number; label: string }>;
+ setAside: string | null;
+};
+
+/** What the last step reports once the session is saved. */
+export function onboardingDone(saved: {
+ jobs: number;
+ schools: number;
+ skills: number;
+ resumes: number;
+ setAside: number;
+}): OnboardingDone {
+ const word = (count: number, singular: string, plural: string) => (count === 1 ? singular : plural);
+ return {
+ tiles: [
+ { count: saved.jobs, label: word(saved.jobs, "job", "jobs") },
+ { count: saved.schools, label: word(saved.schools, "school", "schools") },
+ { count: saved.skills, label: word(saved.skills, "skill", "skills") },
+ {
+ count: saved.resumes,
+ label: word(saved.resumes, "resume kept as evidence", "resumes kept as evidence"),
+ },
+ ],
+ setAside:
+ saved.setAside > 0
+ ? `${saved.setAside} set aside. Import a resume from Career History to bring them back.`
+ : null,
+ };
+}
+
/** Roles and schools the screen can confirm. */
export function onboardingCheckItems(data: ExtractedResume): OnboardingCheckItem[] {
const jobs = data.experience.map((job, sourceIndex) =>
@@ -208,15 +239,6 @@ function keptJob(
? { label: place, address: place, latitude: item.lat, longitude: item.lng }
: (base?.site ?? null);
return {
- company: "",
- title: "",
- location: "",
- startDate: "",
- endDate: "",
- isCurrent: false,
- description: "",
- achievements: [],
- site: null,
...base,
company: item.org.trim() || "Untitled role",
title: item.title.trim() || "Role",
@@ -242,14 +264,6 @@ function keptSchool(
? { label: place, address: place, latitude: item.lat, longitude: item.lng }
: (base?.site ?? null);
return {
- institution: "",
- degree: "",
- field: "",
- location: "",
- startDate: "",
- endDate: "",
- description: "",
- site: null,
...base,
institution: item.org.trim() || "Untitled school",
degree: item.title.trim(),
@@ -263,8 +277,8 @@ function keptSchool(
}
/**
- * Profile to store after onboarding. A skipped place or links answer leaves
- * those fields as they already are. A blank field in a given answer does too.
+ * Profile to store after onboarding. A skipped place or links answer, or a
+ * blank field in one, keeps the saved value, or the resume's when none is saved.
*/
export function profileAfterOnboarding(input: {
current: ProfileFields;
@@ -281,22 +295,14 @@ export function profileAfterOnboarding(input: {
fullName: input.fullName.trim().replace(/\s+/g, " "),
};
if (input.place) {
- next.address = input.place.street.trim() || input.current.address;
- next.city = input.place.city.trim() || input.current.city;
- next.state = input.place.state.trim() || input.current.state;
- } else {
- next.address = input.current.address;
- next.city = input.current.city;
- next.state = input.current.state;
+ next.address = input.place.street.trim() || base.address;
+ next.city = input.place.city.trim() || base.city;
+ next.state = input.place.state.trim() || base.state;
}
if (input.links) {
- next.linkedinUrl = input.links.linkedinUrl.trim() || input.current.linkedinUrl;
- next.githubUrl = input.links.githubUrl.trim() || input.current.githubUrl;
- next.portfolioUrl = input.links.portfolioUrl.trim() || input.current.portfolioUrl;
- } else {
- next.linkedinUrl = input.current.linkedinUrl;
- next.githubUrl = input.current.githubUrl;
- next.portfolioUrl = input.current.portfolioUrl;
+ next.linkedinUrl = input.links.linkedinUrl.trim() || base.linkedinUrl;
+ next.githubUrl = input.links.githubUrl.trim() || base.githubUrl;
+ next.portfolioUrl = input.links.portfolioUrl.trim() || base.portfolioUrl;
}
return next;
}
diff --git a/core/projection.test.ts b/core/projection.test.ts
index 82134f2..c0fcec6 100644
--- a/core/projection.test.ts
+++ b/core/projection.test.ts
@@ -34,6 +34,7 @@ const snapshot: WorkMapSnapshot = {
achievements: ["Cut recovery from 42 to 11 minutes."],
locations: [],
media: [],
+ linkedEvidence: [],
milestones: [],
events: [],
techStack: [],
diff --git a/core/resume-compare.test.ts b/core/resume-compare.test.ts
index 1a143d0..2be399b 100644
--- a/core/resume-compare.test.ts
+++ b/core/resume-compare.test.ts
@@ -12,9 +12,12 @@ import {
holdVerifiedRoles,
parseHistoryEvidence,
queuedResumeDuplicate,
+ resumesToQueue,
rememberSetAside,
rememberSkills,
+ reviseSkills,
roleFieldDiffs,
+ skillLines,
roleTookFile,
type HeldRole,
} from "./resume-compare";
@@ -45,6 +48,14 @@ describe("queued resumes", () => {
expect(queuedResumeDuplicate(["abc", "def"], "ABC")).toBe(true);
expect(queuedResumeDuplicate(["abc"], "xyz")).toBe(false);
});
+
+ it("keeps a selection of new files and skips ones already queued", () => {
+ expect(resumesToQueue(["abc"], ["abc", "def", "ghi", "def"])).toEqual({ indexes: [1, 2], skipped: 2 });
+ });
+
+ it("keeps every file when none are queued yet", () => {
+ expect(resumesToQueue([], ["abc", "def"])).toEqual({ indexes: [0, 1], skipped: 0 });
+ });
});
describe("resume compare", () => {
@@ -229,6 +240,18 @@ describe("historyFromHeld", () => {
{ name: "Machining", sourceFile: 0 },
]);
});
+ it("revises found skills from one line each", () => {
+ const found = [
+ { name: "Machining", sourceFile: 1 },
+ { name: "Welding", sourceFile: 0 },
+ ];
+ expect(reviseSkills(found, [" machining ", "", "PLC", "plc", "CAD"], 0)).toEqual([
+ { name: "machining", sourceFile: 1 },
+ { name: "PLC", sourceFile: 0 },
+ { name: "CAD", sourceFile: 0 },
+ ]);
+ expect(skillLines(found)).toBe("Machining\nWelding");
+ });
});
describe("parseHistoryEvidence", () => {
diff --git a/core/resume-compare.ts b/core/resume-compare.ts
index fafb0cb..87d7cd1 100644
--- a/core/resume-compare.ts
+++ b/core/resume-compare.ts
@@ -71,6 +71,22 @@ export function queuedResumeDuplicate(digests: readonly string[], digest: string
return digests.some((row) => row.trim().toLowerCase() === next);
}
+/** Indexes from one selection that are not already queued. A repeat inside the selection counts once. */
+export function resumesToQueue(existing: readonly string[], incoming: readonly string[]): { indexes: number[]; skipped: number } {
+ const seen = [...existing];
+ const indexes: number[] = [];
+ let skipped = 0;
+ for (let index = 0; index < incoming.length; index += 1) {
+ if (queuedResumeDuplicate(seen, incoming[index] ?? "")) {
+ skipped += 1;
+ continue;
+ }
+ seen.push(incoming[index] ?? "");
+ indexes.push(index);
+ }
+ return { indexes, skipped };
+}
+
/** Roles that survived Verify, attributed to the file they came from. */
export function holdVerifiedRoles(items: OnboardingCheckItem[], fileIndex: number): HeldRole[] {
return items.filter((item) => !isBlankOnboardingCheckItem(item)).map((item) => holdRole(item, fileIndex));
@@ -286,6 +302,29 @@ export function rememberSkills(
return next;
}
+/** A typed skill not found in any file is credited to `fileIndex`. */
+export function reviseSkills(
+ found: readonly HeldSkill[],
+ lines: readonly string[],
+ fileIndex: number,
+): HeldSkill[] {
+ const seen = new Set();
+ const next: HeldSkill[] = [];
+ for (const line of lines) {
+ const name = line.trim();
+ const key = name.toLowerCase();
+ if (!name || seen.has(key)) continue;
+ seen.add(key);
+ const match = found.find((skill) => skill.name.trim().toLowerCase() === key);
+ next.push({ name, sourceFile: match?.sourceFile ?? fileIndex });
+ }
+ return next;
+}
+
+export function skillLines(skills: readonly HeldSkill[]): string {
+ return skills.map((skill) => skill.name).join("\n");
+}
+
/** One history write for the whole session. Each field still names the file it came from. */
export function historyFromHeld(roles: readonly HeldRole[], skills: readonly HeldSkill[]): HeldHistory {
const experience: ExtractedJob[] = [];
diff --git a/core/work-map.test.ts b/core/work-map.test.ts
index e4e5b5e..6825f64 100644
--- a/core/work-map.test.ts
+++ b/core/work-map.test.ts
@@ -4,9 +4,14 @@ import {
EMPTY_WORK_MAP_DETAILS,
attachFactsToWorkMapRole,
buildWorkMapSnapshot,
+ groupLinkedEvidence,
+ linkedEvidenceFromPage,
roleCoverPhoto,
+ planLinkedEvidenceFacts,
planWorkMapPublicationSettings,
planWorkMapRoleFactSync,
+ prepareLinkedEvidence,
+ prepareLinkedEvidenceDelete,
prepareWorkMapLocation,
prepareWorkMapRoleCreate,
nominatimAddressLine,
@@ -514,6 +519,401 @@ describe("Work Map role create", () => {
});
});
+describe("Linked Evidence", () => {
+ it("accepts a review that credits work on the role", () => {
+ expect(
+ prepareLinkedEvidence(
+ {
+ url: " https://reviews.example/fold-hands-on ",
+ title: " The crease is finally gone ",
+ outlet: " Reviews Weekly ",
+ publishedOn: "2026-08-14",
+ kind: "review",
+ contribution: " Designed the hinge fix the review praises. ",
+ credit: "about-my-work",
+ quote: " You cannot feel the fold anymore. ",
+ timestamp: "",
+ pin: { kind: "achievement", statement: "Reduced recovery time." },
+ isPublic: true,
+ },
+ sampleRole(),
+ ),
+ ).toEqual({
+ ok: true,
+ value: {
+ url: "https://reviews.example/fold-hands-on",
+ title: "The crease is finally gone",
+ outlet: "Reviews Weekly",
+ publishedOn: "2026-08-14",
+ kind: "review",
+ contribution: "Designed the hinge fix the review praises.",
+ credit: "about-my-work",
+ quote: "You cannot feel the fold anymore.",
+ timestamp: "",
+ pin: { kind: "achievement", statement: "Reduced recovery time." },
+ isPublic: true,
+ },
+ });
+ });
+
+ it("only links to a web page without credentials", () => {
+ for (const url of [
+ "",
+ "reviews.example/fold",
+ "javascript:alert(1)",
+ "file:///etc/passwd",
+ "https://me:secret@reviews.example/fold",
+ ]) {
+ expect(prepareLinkedEvidence(linkedInput({ url }), sampleRole())).toEqual({
+ ok: false,
+ error: "url-invalid",
+ });
+ }
+ });
+
+ it("needs a title, a known kind, and an honest credit", () => {
+ const role = sampleRole();
+ expect(prepareLinkedEvidence(linkedInput({ title: " " }), role)).toEqual({
+ ok: false,
+ error: "title-required",
+ });
+ expect(prepareLinkedEvidence(linkedInput({ kind: "rumor" }), role)).toEqual({
+ ok: false,
+ error: "kind-invalid",
+ });
+ expect(prepareLinkedEvidence(linkedInput({ credit: "" }), role)).toEqual({
+ ok: false,
+ error: "credit-invalid",
+ });
+ expect(
+ prepareLinkedEvidence(linkedInput({ kind: "talk", credit: "names-me" }), role),
+ ).toMatchObject({ ok: true, value: { kind: "talk", credit: "names-me" } });
+ });
+
+ it("pins only to an achievement or event on the same role", () => {
+ const role = sampleRole({
+ details: {
+ ...structuredClone(EMPTY_WORK_MAP_DETAILS),
+ events: [
+ {
+ id: "demo-day",
+ date: "2026-05-02",
+ title: "Demo day",
+ detail: "",
+ isPublic: true,
+ },
+ ],
+ },
+ });
+ expect(
+ prepareLinkedEvidence(linkedInput({ pin: { kind: "event", id: "demo-day" } }), role),
+ ).toMatchObject({ ok: true, value: { pin: { kind: "event", id: "demo-day" } } });
+ expect(
+ prepareLinkedEvidence(
+ linkedInput({
+ pin: { kind: "achievement", statement: " reduced recovery time. " },
+ }),
+ role,
+ ),
+ ).toMatchObject({
+ ok: true,
+ value: { pin: { kind: "achievement", statement: "Reduced recovery time." } },
+ });
+ for (const pin of [
+ { kind: "event", id: "other-role-event" },
+ { kind: "achievement", statement: "Won an award I never mentioned." },
+ { kind: "milestone", id: "demo-day" },
+ ]) {
+ expect(prepareLinkedEvidence(linkedInput({ pin }), role)).toEqual({
+ ok: false,
+ error: "pin-missing",
+ });
+ }
+ expect(prepareLinkedEvidence(linkedInput({ pin: null }), role)).toMatchObject({
+ ok: true,
+ value: { pin: null },
+ });
+ });
+
+ it("keeps the publish date and video moment readable", () => {
+ const role = sampleRole();
+ for (const publishedOn of ["", "2026-08-14"]) {
+ expect(prepareLinkedEvidence(linkedInput({ publishedOn }), role)).toMatchObject({
+ ok: true,
+ });
+ }
+ for (const publishedOn of ["August 2026", "2026-13-01", "2026-02-30"]) {
+ expect(prepareLinkedEvidence(linkedInput({ publishedOn }), role)).toEqual({
+ ok: false,
+ error: "date-invalid",
+ });
+ }
+ for (const timestamp of ["", "14:32", "1:02:03"]) {
+ expect(prepareLinkedEvidence(linkedInput({ timestamp }), role)).toMatchObject({
+ ok: true,
+ value: { timestamp },
+ });
+ }
+ for (const timestamp of ["soon", "14:75", "1:2:3"]) {
+ expect(prepareLinkedEvidence(linkedInput({ timestamp }), role)).toEqual({
+ ok: false,
+ error: "timestamp-invalid",
+ });
+ }
+ });
+
+ it("removes by id", () => {
+ expect(prepareLinkedEvidenceDelete({ id: " linked-1 " })).toEqual({
+ ok: true,
+ value: { id: "linked-1" },
+ });
+ expect(prepareLinkedEvidenceDelete({ id: "" })).toEqual({
+ ok: false,
+ error: "linked-evidence-missing",
+ });
+ });
+
+ it("fills the form from the page's own tags", () => {
+ expect(
+ linkedEvidenceFromPage({
+ url: "https://www.reviews.example/fold-hands-on",
+ title: "Fold review | Reviews Weekly",
+ meta: {
+ "og:title": "The crease is finally gone",
+ "og:site_name": "Reviews Weekly",
+ "og:type": "article",
+ "article:published_time": "2026-08-14T09:30:00-04:00",
+ },
+ }),
+ ).toEqual({
+ title: "The crease is finally gone",
+ outlet: "Reviews Weekly",
+ publishedOn: "2026-08-14",
+ kind: "article",
+ });
+ });
+
+ it("falls back to the page title and host, and reads videos as talks", () => {
+ expect(
+ linkedEvidenceFromPage({
+ url: "https://www.video.example/watch?v=demo",
+ title: " Demo day keynote ",
+ meta: { "og:type": "video.other", "article:published_time": "soon" },
+ }),
+ ).toEqual({
+ title: "Demo day keynote",
+ outlet: "video.example",
+ publishedOn: "",
+ kind: "talk",
+ });
+ });
+
+ it("backs the pinned achievement with the captured copy and lets go when unpinned", () => {
+ const facts = [
+ sampleFact({
+ id: "fact-recovery",
+ factType: "achievement",
+ value: { statement: "Reduced recovery time." },
+ evidenceIds: ["resume"],
+ }),
+ sampleFact({
+ id: "fact-award",
+ factType: "achievement",
+ value: { statement: "Won the design award." },
+ evidenceIds: ["capture-1"],
+ }),
+ ];
+ const pinned = (statement: string, evidenceId: string) => ({
+ evidenceId,
+ pin: { kind: "achievement" as const, statement },
+ });
+
+ expect(
+ planLinkedEvidenceFacts({
+ roleId: "role-1",
+ facts,
+ before: null,
+ after: pinned("Reduced recovery time.", "capture-1"),
+ }),
+ ).toEqual([
+ {
+ action: "supersede",
+ entityType: "career-fact",
+ entityId: "fact-recovery",
+ values: {
+ factType: "achievement",
+ subjectId: "role-1",
+ value: { statement: "Reduced recovery time." },
+ evidenceIds: ["resume", "capture-1"],
+ },
+ },
+ ]);
+
+ expect(
+ planLinkedEvidenceFacts({
+ roleId: "role-1",
+ facts: [
+ sampleFact({
+ id: "fact-recovery",
+ factType: "achievement",
+ value: { statement: "Reduced recovery time." },
+ evidenceIds: ["resume", "capture-1"],
+ }),
+ ],
+ before: pinned("Reduced recovery time.", "capture-1"),
+ after: null,
+ }),
+ ).toEqual([
+ expect.objectContaining({
+ entityId: "fact-recovery",
+ values: expect.objectContaining({ evidenceIds: ["resume"] }),
+ }),
+ ]);
+
+ expect(
+ planLinkedEvidenceFacts({
+ roleId: "role-1",
+ facts,
+ before: pinned("Won the design award.", "capture-1"),
+ after: null,
+ }),
+ ).toEqual([]);
+
+ expect(
+ planLinkedEvidenceFacts({
+ roleId: "role-1",
+ facts,
+ before: { evidenceId: "capture-1", pin: { kind: "event", id: "demo-day" } },
+ after: { evidenceId: null, pin: { kind: "achievement", statement: "Reduced recovery time." } },
+ }),
+ ).toEqual([]);
+ });
+
+ it("groups evidence under the achievement it supports for readers", () => {
+ const review = { id: "review", pin: { kind: "achievement" as const, statement: "Solved the crease." } };
+ const talk = { id: "talk", pin: { kind: "event" as const, id: "demo-day" } };
+ const press = { id: "press", pin: null };
+ const stale = { id: "stale", pin: { kind: "achievement" as const, statement: "Old wording." } };
+ expect(
+ groupLinkedEvidence(["Solved the crease.", "Mentored interns."], [review, talk, press, stale]),
+ ).toEqual({
+ achievements: [
+ { statement: "Solved the crease.", evidence: [review] },
+ { statement: "Mentored interns.", evidence: [] },
+ ],
+ other: [talk, press, stale],
+ });
+ expect(groupLinkedEvidence(["Solved the crease."], undefined)).toEqual({
+ achievements: [{ statement: "Solved the crease.", evidence: [] }],
+ other: [],
+ });
+ });
+
+ it("publishes only public Linked Evidence when the evidence section is on", () => {
+ const role = sampleRole({
+ linkedEvidence: [
+ {
+ id: "shout-out",
+ evidenceId: "SECRET_CAPTURE",
+ url: "https://video.example/demo-day",
+ title: "Demo day keynote",
+ outlet: "video.example",
+ publishedOn: "2026-05-02",
+ kind: "talk",
+ contribution: "Built the live demo.",
+ credit: "names-me",
+ quote: "Huge thanks to the engineer who fixed the fold.",
+ timestamp: "14:32",
+ pin: { kind: "event", id: "private-event" },
+ isPublic: true,
+ },
+ {
+ id: "draft",
+ evidenceId: null,
+ url: "https://SECRET_LINK.example/",
+ title: "SECRET_TITLE",
+ outlet: "",
+ publishedOn: "",
+ kind: "article",
+ contribution: "",
+ credit: "about-my-work",
+ quote: "",
+ timestamp: "",
+ pin: null,
+ isPublic: false,
+ },
+ ],
+ details: {
+ ...structuredClone(EMPTY_WORK_MAP_DETAILS),
+ events: [
+ {
+ id: "private-event",
+ date: "2026-05-02",
+ title: "Demo day",
+ detail: "",
+ isPublic: false,
+ },
+ ],
+ },
+ });
+ const publish = (
+ sections: WorkMapPublicationSettings["sections"],
+ visibility: WorkMapPublicationSettings["visibility"] = "public",
+ ) =>
+ buildWorkMapSnapshot({
+ id: "snapshot-1",
+ occupantId: "local",
+ profile: {
+ fullName: "Sory Kaba",
+ headline: "",
+ city: "",
+ state: "",
+ bio: "",
+ linkedinUrl: "",
+ githubUrl: "",
+ portfolioUrl: "",
+ },
+ roles: [role],
+ skills: [],
+ settings: {
+ slug: "sory",
+ targetRole: "",
+ theme: "dark",
+ visibility,
+ sections,
+ hideCurrentEmployer: false,
+ showExactLocations: false,
+ expiresAt: null,
+ },
+ sourceFingerprint: "abc",
+ createdAt: "2026-10-17T20:00:00.000Z",
+ });
+
+ const published = publish(["history", "milestones", "evidence"]);
+ expect(published.roles[0].linkedEvidence).toEqual([
+ {
+ id: "shout-out",
+ url: "https://video.example/demo-day",
+ title: "Demo day keynote",
+ outlet: "video.example",
+ publishedOn: "2026-05-02",
+ kind: "talk",
+ contribution: "Built the live demo.",
+ credit: "names-me",
+ quote: "Huge thanks to the engineer who fixed the fold.",
+ timestamp: "14:32",
+ pin: null,
+ isPublic: true,
+ },
+ ]);
+ expect(JSON.stringify(published)).not.toContain("SECRET_");
+ expect(publish(["history"]).roles[0].linkedEvidence).toEqual([]);
+ expect(
+ publish(["history", "evidence"], "anonymous").roles[0].linkedEvidence,
+ ).toEqual([]);
+ });
+});
+
describe("Work Map publication settings", () => {
const current: WorkMapPublicationSettings = {
slug: "career-map",
@@ -694,6 +1094,7 @@ function sampleRole(overrides: Partial = {}): WorkMapRole {
isPublic: false,
},
],
+ linkedEvidence: [],
details: {
...structuredClone(EMPTY_WORK_MAP_DETAILS),
compensation: {
@@ -719,6 +1120,25 @@ function sampleRole(overrides: Partial = {}): WorkMapRole {
};
}
+function linkedInput(
+ overrides: Record = {},
+): Record {
+ return {
+ url: "https://reviews.example/fold-hands-on",
+ title: "The crease is finally gone",
+ outlet: "Reviews Weekly",
+ publishedOn: "2026-08-14",
+ kind: "review",
+ contribution: "",
+ credit: "about-my-work",
+ quote: "",
+ timestamp: "",
+ pin: null,
+ isPublic: false,
+ ...overrides,
+ };
+}
+
function sampleFact(
overrides: Partial & Pick,
): CareerFactVersion {
diff --git a/core/work-map.ts b/core/work-map.ts
index 35d29a1..8e8436f 100644
--- a/core/work-map.ts
+++ b/core/work-map.ts
@@ -36,6 +36,187 @@ export function roleCoverPhoto(media: WorkMapMedia[]): WorkMapMedia | null {
return null;
}
+export const LINKED_EVIDENCE_KINDS = [
+ "review",
+ "article",
+ "talk",
+ "press-release",
+ "social-post",
+ "award",
+] as const;
+
+export const LINKED_EVIDENCE_CREDITS = ["names-me", "about-my-work"] as const;
+
+export type LinkedEvidence = {
+ id: string;
+ evidenceId: string | null;
+ url: string;
+ title: string;
+ outlet: string;
+ publishedOn: string;
+ kind: (typeof LINKED_EVIDENCE_KINDS)[number];
+ contribution: string;
+ credit: (typeof LINKED_EVIDENCE_CREDITS)[number];
+ quote: string;
+ timestamp: string;
+ pin:
+ | { kind: "achievement"; statement: string }
+ | { kind: "event"; id: string }
+ | null;
+ isPublic: boolean;
+};
+
+export type PreparedLinkedEvidence = Omit;
+
+export type PublishedLinkedEvidence = Omit;
+
+export type LinkedEvidenceError =
+ | "url-invalid"
+ | "title-required"
+ | "kind-invalid"
+ | "credit-invalid"
+ | "pin-missing"
+ | "date-invalid"
+ | "timestamp-invalid";
+
+export function prepareLinkedEvidence(
+ input: Record,
+ role: Pick,
+):
+ | { ok: true; value: PreparedLinkedEvidence }
+ | { ok: false; error: LinkedEvidenceError } {
+ const url = text(input.url);
+ if (!isLinkedEvidenceUrl(url)) return { ok: false, error: "url-invalid" };
+ const title = text(input.title);
+ if (!title) return { ok: false, error: "title-required" };
+ const kind = oneOf(LINKED_EVIDENCE_KINDS, input.kind);
+ if (!kind) return { ok: false, error: "kind-invalid" };
+ const credit = oneOf(LINKED_EVIDENCE_CREDITS, input.credit);
+ if (!credit) return { ok: false, error: "credit-invalid" };
+ const pin = linkedEvidencePin(input.pin, role);
+ if (pin === undefined) return { ok: false, error: "pin-missing" };
+ const publishedOn = text(input.publishedOn);
+ if (publishedOn && !isCalendarDate(publishedOn)) {
+ return { ok: false, error: "date-invalid" };
+ }
+ const timestamp = text(input.timestamp);
+ if (timestamp && !/^(?:\d+:[0-5]\d:[0-5]\d|\d{1,3}:[0-5]\d)$/.test(timestamp)) {
+ return { ok: false, error: "timestamp-invalid" };
+ }
+ return {
+ ok: true,
+ value: {
+ url,
+ title,
+ outlet: text(input.outlet),
+ publishedOn,
+ kind,
+ contribution: text(input.contribution),
+ credit,
+ quote: text(input.quote),
+ timestamp,
+ pin,
+ isPublic: input.isPublic === true,
+ },
+ };
+}
+
+export function groupLinkedEvidence>(
+ achievements: string[],
+ items: T[] | undefined,
+): { achievements: Array<{ statement: string; evidence: T[] }>; other: T[] } {
+ const grouped = achievements.map((statement) => ({ statement, evidence: [] as T[] }));
+ const other: T[] = [];
+ for (const item of items ?? []) {
+ const wanted =
+ item.pin?.kind === "achievement" ? item.pin.statement.trim().toLowerCase() : null;
+ const home = wanted
+ ? grouped.find((entry) => entry.statement.trim().toLowerCase() === wanted)
+ : undefined;
+ if (home) home.evidence.push(item);
+ else other.push(item);
+ }
+ return { achievements: grouped, other };
+}
+
+export function prepareLinkedEvidenceDelete(input: {
+ id?: unknown;
+}):
+ | { ok: true; value: { id: string } }
+ | { ok: false; error: "linked-evidence-missing" } {
+ const id = text(input.id);
+ if (!id) return { ok: false, error: "linked-evidence-missing" };
+ return { ok: true, value: { id } };
+}
+
+export function linkedEvidenceFromPage(page: {
+ url: string;
+ title: string;
+ meta: Record;
+}): Pick {
+ const published = text(page.meta["article:published_time"]).slice(0, 10);
+ return {
+ title: text(page.meta["og:title"]) || text(page.title),
+ outlet: text(page.meta["og:site_name"]) || pageHost(page.url),
+ publishedOn: isCalendarDate(published) ? published : "",
+ kind: text(page.meta["og:type"]).startsWith("video") ? "talk" : "article",
+ };
+}
+
+function pageHost(value: string): string {
+ try {
+ return new URL(value).hostname.replace(/^www\./, "");
+ } catch {
+ return "";
+ }
+}
+
+function linkedEvidencePin(
+ value: unknown,
+ role: Pick,
+): LinkedEvidence["pin"] | undefined {
+ if (value == null) return null;
+ if (typeof value !== "object") return undefined;
+ const pin = value as Record;
+ if (pin.kind === "event") {
+ const id = text(pin.id);
+ return role.details.events.some((event) => event.id === id)
+ ? { kind: "event", id }
+ : undefined;
+ }
+ if (pin.kind === "achievement") {
+ const wanted = text(pin.statement).toLowerCase();
+ const statement = role.achievements.find(
+ (achievement) => achievement.trim().toLowerCase() === wanted,
+ );
+ return statement ? { kind: "achievement", statement: statement.trim() } : undefined;
+ }
+ return undefined;
+}
+
+function isCalendarDate(value: string): boolean {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
+ const date = new Date(`${value}T00:00:00Z`);
+ return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
+}
+
+function oneOf(allowed: readonly T[], value: unknown): T | null {
+ return (allowed as readonly unknown[]).includes(value) ? (value as T) : null;
+}
+
+export function isLinkedEvidenceUrl(value: string): boolean {
+ try {
+ const url = new URL(value);
+ return (
+ (url.protocol === "http:" || url.protocol === "https:") &&
+ !url.username &&
+ !url.password
+ );
+ } catch {
+ return false;
+ }
+}
+
export type WorkMapMoment = {
id: string;
date: string;
@@ -108,6 +289,7 @@ export type WorkMapRole = {
claims: WorkMapClaim[];
locations: WorkMapLocation[];
media: WorkMapMedia[];
+ linkedEvidence: LinkedEvidence[];
details: WorkMapRoleDetails;
};
@@ -117,6 +299,7 @@ export type WorkMapPublishSection =
| "map"
| "milestones"
| "media"
+ | "evidence"
| "skills"
| "contact";
@@ -188,6 +371,7 @@ export type WorkMapSnapshot = {
achievements: string[];
locations: WorkMapLocation[];
media: WorkMapMedia[];
+ linkedEvidence: PublishedLinkedEvidence[];
milestones: WorkMapMoment[];
events: WorkMapMoment[];
techStack: string[];
@@ -323,6 +507,10 @@ export function buildWorkMapSnapshot(input: {
media: sections.has("media")
? role.media.filter((item) => item.isPublic)
: [],
+ linkedEvidence:
+ sections.has("evidence") && !anonymous
+ ? publishedLinkedEvidence(role, sections.has("milestones"))
+ : [],
milestones: sections.has("milestones")
? role.details.milestones.filter((item) => item.isPublic)
: [],
@@ -366,6 +554,24 @@ export function buildWorkMapSnapshot(input: {
};
}
+function publishedLinkedEvidence(
+ role: WorkMapRole,
+ eventsPublished: boolean,
+): PublishedLinkedEvidence[] {
+ const publicEvents = new Set(
+ eventsPublished
+ ? role.details.events.filter((event) => event.isPublic).map((event) => event.id)
+ : [],
+ );
+ return role.linkedEvidence
+ .filter((item) => item.isPublic)
+ .map(({ evidenceId: _private, ...item }) =>
+ item.pin?.kind === "event" && !publicEvents.has(item.pin.id)
+ ? { ...item, pin: null }
+ : item,
+ );
+}
+
function publishedRoleConditions(details: WorkMapRoleDetails) {
const detailsNormalized = normalizeWorkMapDetails(details);
const shared: {
@@ -530,6 +736,57 @@ export function planWorkMapRoleFactSync(input: {
return operations;
}
+type LinkedEvidenceBacking = {
+ evidenceId: string | null;
+ pin: LinkedEvidence["pin"];
+};
+
+export function planLinkedEvidenceFacts(input: {
+ roleId: string;
+ facts: CareerFactVersion[];
+ before: LinkedEvidenceBacking | null;
+ after: LinkedEvidenceBacking | null;
+}): ChangeOperation[] {
+ const achievements = canonicalFacts(input.facts).filter(
+ (fact) => fact.factType === "achievement" && fact.subjectId === input.roleId,
+ );
+ const backedFact = (backing: LinkedEvidenceBacking | null) => {
+ if (!backing?.evidenceId || backing.pin?.kind !== "achievement") return null;
+ const wanted = backing.pin.statement.toLowerCase();
+ return achievements.find((fact) => factStatement(fact).toLowerCase() === wanted) ?? null;
+ };
+ const next = new Map();
+ const from = backedFact(input.before);
+ if (from && input.before?.evidenceId) {
+ next.set(
+ from.id,
+ from.evidenceIds.filter((id) => id !== input.before?.evidenceId),
+ );
+ }
+ const to = backedFact(input.after);
+ if (to && input.after?.evidenceId) {
+ next.set(to.id, unique([...(next.get(to.id) ?? to.evidenceIds), input.after.evidenceId]));
+ }
+ const operations: ChangeOperation[] = [];
+ for (const [factId, evidenceIds] of next) {
+ const fact = achievements.find((item) => item.id === factId);
+ if (!fact || evidenceIds.length === 0) continue;
+ if (stableStringify(evidenceIds) === stableStringify(fact.evidenceIds)) continue;
+ operations.push({
+ action: "supersede",
+ entityType: "career-fact",
+ entityId: fact.id,
+ values: {
+ factType: "achievement",
+ subjectId: input.roleId,
+ value: fact.value,
+ evidenceIds,
+ },
+ });
+ }
+ return operations;
+}
+
export function planWorkMapPublicationSettings(input: {
current: WorkMapPublicationSettings;
next: WorkMapPublicationSettings;
diff --git a/relay/app.test.ts b/relay/app.test.ts
index ddf0642..f883733 100644
--- a/relay/app.test.ts
+++ b/relay/app.test.ts
@@ -50,6 +50,76 @@ const projection = {
createdAt: "2026-09-19T20:00:00.000Z",
};
+describe("Linked Evidence on a publication", () => {
+ it("shows sources under the achievement they support and keeps links safe", async () => {
+ const db = openRelayDatabase(":memory:");
+ const app = createRelayApp(db, "owner-secret");
+ const publish = (slug: string, roles: unknown[]) =>
+ app.request(`/relay/publications/${slug}`, {
+ method: "PUT",
+ headers: {
+ authorization: "Bearer owner-secret",
+ "content-type": "application/json",
+ },
+ body: JSON.stringify({ projection: { ...projection, slug, roles } }),
+ });
+ try {
+ await publish("evidence", [
+ {
+ ...projection.roles[0],
+ linkedEvidence: [
+ {
+ id: "review",
+ url: "https://reviews.example/fold",
+ title: "The crease is gone",
+ outlet: "Reviews Weekly",
+ publishedOn: "2026-08-14",
+ kind: "review",
+ contribution: "Designed the hinge fix.",
+ credit: "names-me",
+ quote: "",
+ timestamp: "",
+ pin: { kind: "achievement", statement: "Cut recovery from 42 to 11 minutes." },
+ isPublic: true,
+ },
+ {
+ id: "odd",
+ url: "javascript:alert(1)",
+ title: "",
+ outlet: "",
+ publishedOn: "",
+ kind: "talk",
+ contribution: "",
+ credit: "about-my-work",
+ quote: "Thanks to the team.",
+ timestamp: "14:32",
+ pin: null,
+ isPublic: true,
+ },
+ ],
+ },
+ ]);
+ const html = await (await app.request("/r/evidence")).text();
+ const achievement = html.indexOf("Cut recovery from 42 to 11 minutes.");
+ const review = html.indexOf('href="https://reviews.example/fold"');
+ expect(achievement).toBeGreaterThan(-1);
+ expect(review).toBeGreaterThan(achievement);
+ expect(html).toContain("Credited by name");
+ expect(html).toContain("Covers work they contributed to");
+ expect(html).toContain("at 14:32");
+ expect(html).not.toContain("javascript:alert");
+ expect(html).not.toContain("