From b881ffdc6a933b30dc107b4092bc5d1e759e4d4f Mon Sep 17 00:00:00 2001 From: kahboom Date: Sat, 28 Feb 2026 14:40:13 +0000 Subject: [PATCH] feat: add evidence clipper for capturing selected text as structured evidence (F050) Co-Authored-By: Claude Opus 4.6 --- PRD.json | 3 +- progress.txt | 13 ++- src/content/index.ts | 16 ++- src/evidence/clipper.test.ts | 182 +++++++++++++++++++++++++++++++++++ src/evidence/clipper.ts | 78 +++++++++++++++ src/types/index.ts | 1 + src/types/scan.test.ts | 2 + src/types/scan.ts | 11 +++ 8 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 src/evidence/clipper.test.ts create mode 100644 src/evidence/clipper.ts diff --git a/PRD.json b/PRD.json index 1354d78..c879e4f 100644 --- a/PRD.json +++ b/PRD.json @@ -179,7 +179,8 @@ "id": "F050", "phase": 2, "name": "Evidence Clipper", - "description": "Highlight and save text as evidence" + "description": "Highlight and save text as evidence", + "status": "passes" }, { "id": "F060", diff --git a/progress.txt b/progress.txt index 03d169b..fb218bb 100644 --- a/progress.txt +++ b/progress.txt @@ -46,4 +46,15 @@ F040 - Risk Score Model [PASSES] - New types: FieldPenalty, ClaimPenalty, RiskScoreBreakdown exported from types/index.ts - 18 new unit tests for scoring module (78 total passing) -Next task: F050 - Evidence Clipper (Phase 2) +F050 - Evidence Clipper [PASSES] +- clipper.ts: pure functions for creating and managing evidence clips +- createClip: builds EvidenceClip from selected text with context, URL, timestamp, and optional field/claim association +- extractContext: extracts surrounding text (configurable radius) with ellipsis for truncation +- addClip/removeClip: immutable list operations for managing clip collections +- getClipsForField/getClipsForClaim: filter clips by associated field key or claim keyword +- EvidenceClip type added to types/scan.ts with id, text, context, url, timestamp, fieldKey?, claimKeyword? +- ScanResult updated with evidence: EvidenceClip[] array +- Content script handles CLIP_EVIDENCE message: captures window selection, creates clip, returns to popup +- 21 new unit tests for clipper module (100 total passing) + +Next task: F060 - Threadmark JSON Export (Phase 2) diff --git a/src/content/index.ts b/src/content/index.ts index dfd651a..1117736 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -1,4 +1,5 @@ -import { captureSnapshot } from "./snapshot.js"; +import { captureSnapshot, extractTextContent } from "./snapshot.js"; +import { createClip } from "../evidence/clipper.js"; chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (message.type === "PING") { @@ -6,6 +7,19 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { } else if (message.type === "SCAN") { const snapshot = captureSnapshot(document, window.location.href); sendResponse({ status: "ok", snapshot }); + } else if (message.type === "CLIP_EVIDENCE") { + const selection = window.getSelection(); + const selectedText = selection?.toString().trim() || ""; + if (!selectedText) { + sendResponse({ status: "error", error: "No text selected." }); + return true; + } + const pageText = extractTextContent(document); + const clip = createClip(selectedText, pageText, window.location.href, { + fieldKey: message.fieldKey, + claimKeyword: message.claimKeyword, + }); + sendResponse({ status: "ok", clip }); } return true; }); diff --git a/src/evidence/clipper.test.ts b/src/evidence/clipper.test.ts new file mode 100644 index 0000000..825008d --- /dev/null +++ b/src/evidence/clipper.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import type { EvidenceClip } from "../types/scan.js"; +import { + createClip, + extractContext, + addClip, + removeClip, + getClipsForField, + getClipsForClaim, + resetClipCounter, +} from "./clipper.js"; + +beforeEach(() => { + resetClipCounter(); +}); + +describe("extractContext", () => { + const pageText = + "Welcome to our store. This product is made from 100% organic cotton sourced from certified farms. It is eco-friendly and sustainable. Shop now."; + + it("extracts surrounding context around selected text", () => { + const context = extractContext(pageText, "organic cotton"); + expect(context).toContain("organic cotton"); + expect(context.length).toBeGreaterThan("organic cotton".length); + }); + + it("adds ellipsis when context is truncated at start", () => { + const context = extractContext(pageText, "sustainable", 20); + expect(context.startsWith("…")).toBe(true); + }); + + it("adds ellipsis when context is truncated at end", () => { + const context = extractContext(pageText, "Welcome", 10); + expect(context.endsWith("…")).toBe(true); + }); + + it("returns full text when short enough", () => { + const short = "organic cotton"; + const context = extractContext(short, "organic cotton", 80); + expect(context).toBe("organic cotton"); + }); + + it("returns selected text when not found in page", () => { + const context = extractContext(pageText, "nonexistent phrase"); + expect(context).toBe("nonexistent phrase"); + }); +}); + +describe("createClip", () => { + const pageText = + "Our product uses biodegradable packaging for sustainability."; + const url = "https://example.com/product"; + + it("creates a clip with text, context, url, and timestamp", () => { + const clip = createClip("biodegradable packaging", pageText, url); + expect(clip).not.toBeNull(); + expect(clip!.text).toBe("biodegradable packaging"); + expect(clip!.context).toContain("biodegradable packaging"); + expect(clip!.url).toBe(url); + expect(clip!.timestamp).toBeTruthy(); + expect(clip!.id).toMatch(/^clip-\d+-\d+$/); + }); + + it("returns null for empty text", () => { + expect(createClip("", pageText, url)).toBeNull(); + }); + + it("returns null for whitespace-only text", () => { + expect(createClip(" \n\t ", pageText, url)).toBeNull(); + }); + + it("trims selected text", () => { + const clip = createClip(" biodegradable ", pageText, url); + expect(clip!.text).toBe("biodegradable"); + }); + + it("attaches optional fieldKey", () => { + const clip = createClip("biodegradable", pageText, url, { + fieldKey: "materials", + }); + expect(clip!.fieldKey).toBe("materials"); + expect(clip!.claimKeyword).toBeUndefined(); + }); + + it("attaches optional claimKeyword", () => { + const clip = createClip("biodegradable", pageText, url, { + claimKeyword: "biodegradable", + }); + expect(clip!.claimKeyword).toBe("biodegradable"); + expect(clip!.fieldKey).toBeUndefined(); + }); + + it("generates unique IDs for each clip", () => { + const clip1 = createClip("text1", pageText, url); + const clip2 = createClip("text2", pageText, url); + expect(clip1!.id).not.toBe(clip2!.id); + }); +}); + +function makeClip(overrides: Partial = {}): EvidenceClip { + return { + id: "clip-1", + text: "test text", + context: "...test text...", + url: "https://example.com", + timestamp: new Date().toISOString(), + ...overrides, + }; +} + +describe("addClip", () => { + it("appends a clip to the list", () => { + const clip = makeClip({ id: "clip-new" }); + const result = addClip([], clip); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("clip-new"); + }); + + it("does not mutate the original array", () => { + const original: EvidenceClip[] = []; + const clip = makeClip(); + addClip(original, clip); + expect(original).toHaveLength(0); + }); +}); + +describe("removeClip", () => { + it("removes a clip by ID", () => { + const clips = [makeClip({ id: "a" }), makeClip({ id: "b" })]; + const result = removeClip(clips, "a"); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("b"); + }); + + it("returns unchanged array when ID not found", () => { + const clips = [makeClip({ id: "a" })]; + const result = removeClip(clips, "nonexistent"); + expect(result).toHaveLength(1); + }); + + it("does not mutate the original array", () => { + const clips = [makeClip({ id: "a" })]; + removeClip(clips, "a"); + expect(clips).toHaveLength(1); + }); +}); + +describe("getClipsForField", () => { + it("filters clips by fieldKey", () => { + const clips = [ + makeClip({ id: "1", fieldKey: "materials" }), + makeClip({ id: "2", fieldKey: "warnings" }), + makeClip({ id: "3", fieldKey: "materials" }), + makeClip({ id: "4" }), + ]; + const result = getClipsForField(clips, "materials"); + expect(result).toHaveLength(2); + expect(result.every((c) => c.fieldKey === "materials")).toBe(true); + }); + + it("returns empty for no matches", () => { + const clips = [makeClip({ id: "1", fieldKey: "materials" })]; + expect(getClipsForField(clips, "brand")).toEqual([]); + }); +}); + +describe("getClipsForClaim", () => { + it("filters clips by claimKeyword", () => { + const clips = [ + makeClip({ id: "1", claimKeyword: "eco-friendly" }), + makeClip({ id: "2", claimKeyword: "organic" }), + makeClip({ id: "3", claimKeyword: "eco-friendly" }), + ]; + const result = getClipsForClaim(clips, "eco-friendly"); + expect(result).toHaveLength(2); + }); + + it("returns empty for no matches", () => { + const clips = [makeClip({ id: "1", claimKeyword: "organic" })]; + expect(getClipsForClaim(clips, "vegan")).toEqual([]); + }); +}); diff --git a/src/evidence/clipper.ts b/src/evidence/clipper.ts new file mode 100644 index 0000000..09da9c7 --- /dev/null +++ b/src/evidence/clipper.ts @@ -0,0 +1,78 @@ +import type { EvidenceClip } from "../types/scan.js"; + +let clipCounter = 0; + +export function generateClipId(): string { + clipCounter += 1; + return `clip-${Date.now()}-${clipCounter}`; +} + +export function resetClipCounter(): void { + clipCounter = 0; +} + +export function extractContext( + fullText: string, + selectedText: string, + radius: number = 80, +): string { + const idx = fullText.indexOf(selectedText); + if (idx === -1) return selectedText; + + const start = Math.max(0, idx - radius); + const end = Math.min(fullText.length, idx + selectedText.length + radius); + let context = fullText.slice(start, end).trim(); + + if (start > 0) context = "…" + context; + if (end < fullText.length) context = context + "…"; + + return context; +} + +export function createClip( + text: string, + pageText: string, + url: string, + options?: { fieldKey?: string; claimKeyword?: string }, +): EvidenceClip | null { + const trimmed = text.trim(); + if (!trimmed) return null; + + return { + id: generateClipId(), + text: trimmed, + context: extractContext(pageText, trimmed), + url, + timestamp: new Date().toISOString(), + fieldKey: options?.fieldKey, + claimKeyword: options?.claimKeyword, + }; +} + +export function addClip( + clips: EvidenceClip[], + clip: EvidenceClip, +): EvidenceClip[] { + return [...clips, clip]; +} + +export function removeClip( + clips: EvidenceClip[], + clipId: string, +): EvidenceClip[] { + return clips.filter((c) => c.id !== clipId); +} + +export function getClipsForField( + clips: EvidenceClip[], + fieldKey: string, +): EvidenceClip[] { + return clips.filter((c) => c.fieldKey === fieldKey); +} + +export function getClipsForClaim( + clips: EvidenceClip[], + claimKeyword: string, +): EvidenceClip[] { + return clips.filter((c) => c.claimKeyword === claimKeyword); +} diff --git a/src/types/index.ts b/src/types/index.ts index 938be1d..7c9cc4c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,5 +6,6 @@ export type { FieldPenalty, ClaimPenalty, RiskScoreBreakdown, + EvidenceClip, ScanResult, } from "./scan.js"; diff --git a/src/types/scan.test.ts b/src/types/scan.test.ts index 39214b2..1f790aa 100644 --- a/src/types/scan.test.ts +++ b/src/types/scan.test.ts @@ -51,6 +51,7 @@ describe("scan types", () => { timestamp: new Date().toISOString(), fields: [], claims: [], + evidence: [], riskBreakdown: { score: 0, maxScore: 100, @@ -61,6 +62,7 @@ describe("scan types", () => { expect(result.url).toContain("https://"); expect(result.category).toBe("general"); expect(result.fields).toEqual([]); + expect(result.evidence).toEqual([]); expect(result.riskBreakdown?.score).toBeLessThanOrEqual( result.riskBreakdown?.maxScore ?? 0, ); diff --git a/src/types/scan.ts b/src/types/scan.ts index 297d171..dc39583 100644 --- a/src/types/scan.ts +++ b/src/types/scan.ts @@ -45,6 +45,16 @@ export interface RiskScoreBreakdown { claimPenalties: ClaimPenalty[]; } +export interface EvidenceClip { + id: string; + text: string; + context: string; + url: string; + timestamp: string; + fieldKey?: string; + claimKeyword?: string; +} + export interface ScanResult { url: string; title: string; @@ -53,4 +63,5 @@ export interface ScanResult { fields: FieldResult[]; claims: ClaimFlag[]; riskBreakdown?: RiskScoreBreakdown; + evidence: EvidenceClip[]; }