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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ dist/
.idea/
coverage/
.turbo/
test-results/
playwright-report/
33 changes: 32 additions & 1 deletion packages/core/src/url.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,27 @@
export function normalizeUrl(input: string): string {
// Well-known tracking parameter names. Compared case-insensitively.
// Sources: utm_* (Google Analytics), fbclid (Facebook), gclid (Google Ads),
// msclkid (Microsoft Ads), mc_cid/mc_eid (Mailchimp).
const TRACKING_PARAM_PREFIXES = ["utm_"];
const TRACKING_PARAM_EXACT = new Set([
"fbclid",
"gclid",
"msclkid",
"mc_cid",
"mc_eid",
]);

function isTrackingParam(name: string): boolean {
const lower = name.toLowerCase();
if (TRACKING_PARAM_EXACT.has(lower)) return true;
return TRACKING_PARAM_PREFIXES.some((p) => lower.startsWith(p));
}

export interface NormalizeUrlOptions {
/** Strip well-known tracking params (utm_*, fbclid, gclid, msclkid, mc_*). Default false. */
stripTrackingParams?: boolean;
}

export function normalizeUrl(input: string, opts: NormalizeUrlOptions = {}): string {
const u = new URL(input);

if (u.hash && !u.hash.startsWith("#!")) {
Expand All @@ -9,5 +32,13 @@ export function normalizeUrl(input: string): string {
u.pathname = u.pathname.replace(/\/+$/, "");
}

if (opts.stripTrackingParams) {
const keysToDelete: string[] = [];
for (const name of u.searchParams.keys()) {
if (isTrackingParam(name)) keysToDelete.push(name);
}
for (const name of keysToDelete) u.searchParams.delete(name);
}

return u.toString();
}
48 changes: 48 additions & 0 deletions packages/core/test/url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,52 @@ describe("normalizeUrl", () => {
it("throws on an invalid URL", () => {
expect(() => normalizeUrl("not a url")).toThrow();
});

describe("stripTrackingParams option", () => {
it("default (off): preserves utm_* params", () => {
expect(normalizeUrl("https://example.com/?utm_source=foo")).toBe(
"https://example.com/?utm_source=foo",
);
});

it("with stripTrackingParams: removes utm_* params", () => {
expect(
normalizeUrl("https://example.com/?utm_source=foo&utm_medium=bar", {
stripTrackingParams: true,
}),
).toBe("https://example.com/");
});

it("with stripTrackingParams: removes fbclid, gclid, msclkid, mc_*", () => {
expect(
normalizeUrl("https://example.com/?fbclid=a&gclid=b&msclkid=c&mc_cid=d&mc_eid=e", {
stripTrackingParams: true,
}),
).toBe("https://example.com/");
});

it("with stripTrackingParams: preserves non-tracking params", () => {
expect(
normalizeUrl("https://example.com/?utm_source=foo&q=real&page=2", {
stripTrackingParams: true,
}),
).toBe("https://example.com/?q=real&page=2");
});

it("with stripTrackingParams: ignores parameter case (utm_Source treated as tracking)", () => {
expect(
normalizeUrl("https://example.com/?UTM_SOURCE=foo&Utm_Medium=bar", {
stripTrackingParams: true,
}),
).toBe("https://example.com/");
});

it("stripping leaves an empty query string off the URL", () => {
expect(
normalizeUrl("https://example.com/path?utm_source=x", {
stripTrackingParams: true,
}),
).toBe("https://example.com/path");
});
});
});
6 changes: 5 additions & 1 deletion packages/extension-chrome/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ async function maybeReconcile(): Promise<void> {
const idMap = await IdMap.load();
const machineId = await getMachineId();
const nowIso = new Date().toISOString();
await reconcile(client, idMap, bar, other, machineId, nowIso);
await reconcile(client, idMap, bar, other, machineId, nowIso, settings.stripTrackingParams);
},
setStorage: (items) => chrome.storage.local.set(items),
removeStorage: (key) => chrome.storage.local.remove(key),
Expand Down Expand Up @@ -104,6 +104,10 @@ registerListeners({
getIdMap: async () => IdMap.load(),
getBarOtherIds,
getMachineId,
getStripTrackingParams: async () => {
const s = await loadSettings();
return s?.stripTrackingParams ?? false;
},
});

chrome.alarms.create(POLL_ALARM_NAME, { periodInMinutes: 5 });
Expand Down
6 changes: 5 additions & 1 deletion packages/extension-chrome/src/lib/bookmark-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@ export interface BuildBookmarkInput {
title: string;
machineId: string;
nowIso: string;
/** Strip tracking params (utm_, fbclid, gclid, etc.) at save time. Default false. */
stripTrackingParams?: boolean;
}

export function buildBookmark(input: BuildBookmarkInput): Bookmark {
return {
id: newUlid(),
url: normalizeUrl(input.url),
url: normalizeUrl(input.url, {
stripTrackingParams: input.stripTrackingParams ?? false,
}),
title: input.title,
folder: "",
tags: [],
Expand Down
10 changes: 7 additions & 3 deletions packages/extension-chrome/src/lib/listeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface ListenerDeps {
getIdMap: () => Promise<IdMap>;
getBarOtherIds: () => Promise<{ bar: string; other: string }>;
getMachineId: () => Promise<string>;
/** Defaults to false if not provided (preserves current behavior in tests). */
getStripTrackingParams?: () => Promise<boolean>;
}

let pending: Pending[] = [];
Expand Down Expand Up @@ -145,6 +147,7 @@ export async function flushPending(): Promise<void> {

const idMap = await deps.getIdMap();
const machineId = await deps.getMachineId();
const stripTrackingParams = (await deps.getStripTrackingParams?.()) ?? false;
const nowIso = new Date().toISOString();

// A node that has a create event in this same batch should accept
Expand Down Expand Up @@ -208,7 +211,7 @@ export async function flushPending(): Promise<void> {
(current) => {
toAdd.length = 0;
toRemove.length = 0;
return applyBatch(current, surviving, idMap, createUlids, machineId, nowIso, toAdd, toRemove);
return applyBatch(current, surviving, idMap, createUlids, machineId, nowIso, stripTrackingParams, toAdd, toRemove);
},
`sync ${surviving.length} change(s) from chrome@${machineId}`,
machineId,
Expand All @@ -235,6 +238,7 @@ function applyBatch(
createUlids: Map<string, string>,
machineId: string,
nowIso: string,
stripTrackingParams: boolean,
toAdd: Array<{ ulid: string; nodeId: string }>,
toRemove: string[],
): BookmarksFile {
Expand All @@ -247,7 +251,7 @@ function applyBatch(
if (id == null) continue;
const bm: Bookmark = {
id,
url: normalizeUrl(event.url),
url: normalizeUrl(event.url, { stripTrackingParams }),
title: event.title,
folder: "",
tags: [],
Expand All @@ -270,7 +274,7 @@ function applyBatch(
if (existing == null) continue;
const patch: Partial<Omit<Bookmark, "id">> = {};
if ("url" in event && event.url !== undefined) {
const normalized = normalizeUrl(event.url);
const normalized = normalizeUrl(event.url, { stripTrackingParams });
if (normalized !== existing.url) patch.url = normalized;
}
if ("title" in event && event.title !== undefined) {
Expand Down
3 changes: 2 additions & 1 deletion packages/extension-chrome/src/lib/reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export async function reconcile(
otherBookmarksId: string,
machineId: string,
nowIso: string,
stripTrackingParams = false,
): Promise<void> {
let remote: BookmarksFile;
try {
Expand Down Expand Up @@ -70,7 +71,7 @@ export async function reconcile(
const id = newUlid();
const bm: Bookmark = {
id,
url: normalizeUrl(local.url),
url: normalizeUrl(local.url, { stripTrackingParams }),
title: local.title,
folder: "",
tags: [],
Expand Down
8 changes: 8 additions & 0 deletions packages/extension-chrome/src/lib/save-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,25 @@ export type SaveResult =
message: string;
};

export interface SaveOptions {
stripTrackingParams?: boolean;
}

export async function saveBookmark(
client: GitHubClient,
page: PageInfo,
machineId: string,
nowIso: string,
opts: SaveOptions = {},
): Promise<SaveResult> {
const bookmark = buildBookmark({
url: page.url,
title: page.title,
machineId,
nowIso,
...(opts.stripTrackingParams !== undefined
? { stripTrackingParams: opts.stripTrackingParams }
: {}),
});
const commitMsg = `add bookmark from chrome@${machineId}`;

Expand Down
1 change: 1 addition & 0 deletions packages/extension-chrome/src/lib/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const settingsSchema = z.object({
owner: z.string().regex(/^[A-Za-z0-9_.-]+$/, "owner must be a single GitHub login"),
repo: z.string().regex(/^[A-Za-z0-9_.-]+$/, "repo must be a single GitHub repo name"),
branch: z.string().min(1),
stripTrackingParams: z.boolean().default(false),
});

export type Settings = z.infer<typeof settingsSchema>;
Expand Down
5 changes: 5 additions & 0 deletions packages/extension-chrome/src/options.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ <h1>gitmarks settings</h1>
<input id="branch" type="text" autocomplete="off" spellcheck="false" value="main" />
</label>

<label class="checkbox">
<input id="stripTrackingParams" type="checkbox" />
Strip tracking parameters (utm_*, fbclid, gclid, msclkid, mc_*) at save time
</label>

<div class="row">
<button id="validate" class="secondary">Validate</button>
<button id="save">Save</button>
Expand Down
3 changes: 3 additions & 0 deletions packages/extension-chrome/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const tokenInput = $<HTMLInputElement>("token");
const ownerInput = $<HTMLInputElement>("owner");
const repoInput = $<HTMLInputElement>("repo");
const branchInput = $<HTMLInputElement>("branch");
const stripTrackingParamsInput = $<HTMLInputElement>("stripTrackingParams");
const validateBtn = $<HTMLButtonElement>("validate");
const saveBtn = $<HTMLButtonElement>("save");
const status = $<HTMLParagraphElement>("status");
Expand All @@ -26,6 +27,7 @@ function readForm(): Settings {
owner: ownerInput.value.trim(),
repo: repoInput.value.trim(),
branch: branchInput.value.trim() || "main",
stripTrackingParams: stripTrackingParamsInput.checked,
};
}

Expand Down Expand Up @@ -55,6 +57,7 @@ async function loadIntoForm(): Promise<void> {
ownerInput.value = s.owner;
repoInput.value = s.repo;
branchInput.value = s.branch;
stripTrackingParamsInput.checked = s.stripTrackingParams;
}

validateBtn.addEventListener("click", async () => {
Expand Down
1 change: 1 addition & 0 deletions packages/extension-chrome/src/popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ async function render(): Promise<void> {
{ url: tab.url!, title: tab.title ?? tab.url! },
machineId,
new Date().toISOString(),
{ stripTrackingParams: settings.stripTrackingParams },
);
} catch (err) {
result = {
Expand Down
21 changes: 21 additions & 0 deletions packages/extension-chrome/test/bookmark-factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,27 @@ describe("buildBookmark", () => {
expect(bm.updated_at).toBe("2026-05-23T14:32:11Z");
});

it("strips tracking params when stripTrackingParams is true (issue #6)", () => {
const bm = buildBookmark({
url: "https://example.com/?utm_source=feed&q=real",
title: "Article",
machineId: "ABCDE12F",
nowIso: "2026-05-23T14:32:11Z",
stripTrackingParams: true,
});
expect(bm.url).toBe("https://example.com/?q=real");
});

it("preserves tracking params when stripTrackingParams is false (default)", () => {
const bm = buildBookmark({
url: "https://example.com/?utm_source=feed",
title: "Article",
machineId: "ABCDE12F",
nowIso: "2026-05-23T14:32:11Z",
});
expect(bm.url).toBe("https://example.com/?utm_source=feed");
});

it("generates a fresh ULID each call", () => {
const a = buildBookmark({
url: "https://example.com/",
Expand Down
19 changes: 18 additions & 1 deletion packages/extension-chrome/test/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,26 @@ describe("settings", () => {
owner: "alice",
repo: "bookmarks",
branch: "main",
stripTrackingParams: false,
};
await saveSettings(s);
expect(await loadSettings()).toEqual(s);
});

it("defaults stripTrackingParams to false when omitted in stored data", async () => {
// Legacy stored settings without the field should still parse via Zod's default.
await chrome.storage.local.set({
"gitmarks:settings": {
token: "t",
owner: "alice",
repo: "bookmarks",
branch: "main",
},
});
const loaded = await loadSettings();
expect(loaded?.stripTrackingParams).toBe(false);
});

it("throws SettingsCorruptError when the stored value is malformed", async () => {
await chrome.storage.local.set({ "gitmarks:settings": { not: "valid" } });
await expect(loadSettings()).rejects.toThrow(/invalid/);
Expand All @@ -28,14 +43,15 @@ describe("settings", () => {
owner: "o",
repo: "r",
branch: "main",
stripTrackingParams: false,
});
await clearSettings();
expect(await loadSettings()).toBeNull();
});

it("rejects an empty token at save time", async () => {
await expect(
saveSettings({ token: "", owner: "o", repo: "r", branch: "main" }),
saveSettings({ token: "", owner: "o", repo: "r", branch: "main", stripTrackingParams: false }),
).rejects.toThrow();
});

Expand All @@ -46,6 +62,7 @@ describe("settings", () => {
owner: "a/b",
repo: "r",
branch: "main",
stripTrackingParams: false,
}),
).rejects.toThrow();
});
Expand Down
Loading