Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion PRD.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 12 additions & 1 deletion progress.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
16 changes: 15 additions & 1 deletion src/content/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
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") {
sendResponse({ status: "ready" });
} 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;
});
182 changes: 182 additions & 0 deletions src/evidence/clipper.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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([]);
});
});
78 changes: 78 additions & 0 deletions src/evidence/clipper.ts
Original file line number Diff line number Diff line change
@@ -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);
}
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export type {
FieldPenalty,
ClaimPenalty,
RiskScoreBreakdown,
EvidenceClip,
ScanResult,
} from "./scan.js";
2 changes: 2 additions & 0 deletions src/types/scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ describe("scan types", () => {
timestamp: new Date().toISOString(),
fields: [],
claims: [],
evidence: [],
riskBreakdown: {
score: 0,
maxScore: 100,
Expand All @@ -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,
);
Expand Down
11 changes: 11 additions & 0 deletions src/types/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -53,4 +63,5 @@ export interface ScanResult {
fields: FieldResult[];
claims: ClaimFlag[];
riskBreakdown?: RiskScoreBreakdown;
evidence: EvidenceClip[];
}
Loading