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
13 changes: 12 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,12 @@ When extending `SettingsObject`, you must update **four** sync points (search th

And the `SettingsObject` type in [src/common/types/messaging.ts](src/common/types/messaging.ts).

### Save first, sign in later (pending saves)

A logged-out learner gets two choices on the word card from the first phrase: "Save without login" and "Login to save". [LocalSaveSection](src/console-crane/components/LocalSaveSection.vue) keeps a "Save without login" phrase in `chrome.storage.local` (`pendingSaves`, a list; see [pending-saves.ts](src/common/services/pending-saves.ts)) with everything `createPhrase` needs plus the page URL and title. From the 3rd local phrase a soft sign-in card shows; at 50, only sign-in is offered.

[pending-saves-sync.ts](src/common/services/pending-saves-sync.ts) sends the list to the account after sign-in. It runs from `LoginView` after a login, from the popup ([PendingSavesBanner](src/popup/components/PendingSavesBanner.vue)), and from `console-crane.ts` when `isLogin` turns true, so a cut-off run carries on at the next start. A lock in `chrome.storage.local` keeps tabs from running together. The bundle comes from `getBundleSuggestionForPage` for the saved page (it needs a signed-in user, so it can't run at save time). It does **not** use `withAuthRetry`: that falls back to the shared anonymous account, and a user's phrases must never go there.

### Marker store (subtitle surfaces only)

[src/stores/marker.ts](src/stores/marker.ts) — central authority for word marking, hover, anchors, auto-clear timers. Used by `<Word>` / `<WordSelectionRectangle>` / `<SelectionAnchor>` / `<TranslatedPhrase>` in [src/subtitle/components/specific/](src/subtitle/components/specific/). **Nibble does not use the marker store** — it reads `text` + `context` from `window.getSelection()` and forwards them through the ConsoleCrane bridge.
Expand Down Expand Up @@ -438,7 +444,12 @@ tests/
login-password.test.ts # popup password form (ENABLE_PASSWORD_AUTH=true)
login-password-disabled.test.ts # popup password form hidden when flag is unset
login-analytics.test.ts # user_logged-in source, login_failed source + reason (E-03)
save-login-prompt.test.ts # "Login to save" prompt viewed once per word view, clicked (E-03)
local-save.test.ts # save-first: logged-out "Save without login" / "Login to save", sign-in card at 3, login only at 50
pending-saves.test.ts # save-first: local list (one copy per phrase, cap, remove, change listener)
pending-saves-sync.test.ts # save-first: send after sign-in (bundle, deviceId, lock, limit, failures)
pending-saves-banner.test.ts # save-first: popup "N phrases waiting" line
selection-popup-label.test.ts # save-first: "Learn more or Save" / "Learn" (also for local saves)
memory-chrome-storage.ts # helper (not a test): in-memory chrome.storage.local with promises
install-tracking.test.ts # onInstalled: extension_installed / extension_updated + welcome page (E-02)
e2e/
extension-fixture.ts # chromium.launchPersistentContext + extension load
Expand Down
282 changes: 282 additions & 0 deletions src/common/services/pending-saves-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
/**
* Send local saves to the account after sign-in (save-first E-04).
*
* Runs after a login completes (LoginView), when the toolbar popup opens
* signed in, and when a page's console-crane script sees a signed-in user, so
* a sync that was cut off (the login window closed, the browser quit) carries
* on at the next start.
*
* - One run at a time: an in-page promise, plus a lock in chrome.storage.local
* with an end time, so two tabs do not send the same list together. If they
* still race, nothing breaks: `createPhrase` reuses a phrase with the same
* text for the same user, and adds it to a bundle only once.
* - A phrase leaves the list only after the server accepted it.
* - The Starter save limit stops the run; the rest stay for later.
* - No `withAuthRetry`: on an auth error it falls back to the shared anonymous
* account, and a user's phrases must never be saved there.
*/
import {
authentication,
dataProvider,
functionProvider,
} from "@modular-rest/client";
import { COLLECTIONS, DATABASE } from "../static/global";
import { getInstallId } from "../helper/install-id";
import { analytic } from "../../plugins/mixpanel";
import { log } from "../helper/log";
import {
listPendingSaves,
removePendingSave,
type PendingSave,
} from "./pending-saves";

export const SYNC_LOCK_KEY = "pendingSavesSyncLock";
/** A lock older than this is from a run that died; take it over. */
export const SYNC_LOCK_MS = 2 * 60 * 1000;
/** The bundle used when the server has no name for the phrase's page. */
export const FALLBACK_BUNDLE_TITLE = "Saved phrases";

export interface SyncResult {
/** Phrases the server accepted (removed from the local list). */
sent: number;
/** Phrases that failed for another reason (kept, tried again next time). */
failed: number;
/** Phrases still in the local list after the run. */
left: number;
/** The run stopped at the Starter save limit. */
limitReached: boolean;
/** The run did not start: not signed in, nothing to send, or another run holds the lock. */
skipped: boolean;
}

const SKIPPED: SyncResult = {
sent: 0,
failed: 0,
left: 0,
limitReached: false,
skipped: true,
};

let running: Promise<SyncResult> | null = null;

/** The signed-in user's id, or null for logged out / the anonymous account. */
function signedInUserId(): string | null {
const user = authentication.user;
if (!authentication.isLogin || !user?.id) return null;
return user.type?.toLowerCase() === "user" ? user.id : null;
}

/** The server refused a new phrase because the plan's save limit is reached. */
export function isSaveLimitError(err: unknown): boolean {
let text = "";
try {
text = typeof err === "string" ? err : JSON.stringify(err) || "";
} catch {
// Not serialisable; fall back to the message.
}
text += ` ${(err as any)?.message ?? ""}`;
return text.includes("TIER_LIMIT_REACHED");
}

async function acquireLock(): Promise<string | null> {
const owner = Math.random().toString(36).slice(2);
try {
const data = await chrome.storage.local.get(SYNC_LOCK_KEY);
const lock = data?.[SYNC_LOCK_KEY];
if (lock && typeof lock.until === "number" && lock.until > Date.now()) {
return null;
}
await chrome.storage.local.set({
[SYNC_LOCK_KEY]: { owner, until: Date.now() + SYNC_LOCK_MS },
});
// Read back: if another tab wrote at the same moment, the last write wins
// and only that tab goes on.
const check = await chrome.storage.local.get(SYNC_LOCK_KEY);
return check?.[SYNC_LOCK_KEY]?.owner === owner ? owner : null;
} catch {
return null;
}
}

async function refreshLock(owner: string): Promise<void> {
await chrome.storage.local.set({
[SYNC_LOCK_KEY]: { owner, until: Date.now() + SYNC_LOCK_MS },
});
}

async function releaseLock(owner: string): Promise<void> {
try {
const data = await chrome.storage.local.get(SYNC_LOCK_KEY);
if (data?.[SYNC_LOCK_KEY]?.owner === owner) {
await chrome.storage.local.remove(SYNC_LOCK_KEY);
}
} catch {
// The lock ends by itself after SYNC_LOCK_MS.
}
}

async function findBundleByTitle(
userId: string,
title: string
): Promise<string | null> {
const existing = await dataProvider.findOne<any>({
database: DATABASE.USER_CONTENT,
collection: COLLECTIONS.PHRASE_BUNDLE,
query: { refId: userId, title },
});
return existing?._id || null;
}

/** Use the user's bundle with this title, or create it (the refId+title index is unique). */
async function findOrCreateBundle(userId: string, title: string): Promise<string> {
const existingId = await findBundleByTitle(userId, title);
if (existingId) return existingId;
try {
const created: any = await dataProvider.insertOne({
database: DATABASE.USER_CONTENT,
collection: COLLECTIONS.PHRASE_BUNDLE,
doc: { title, refId: userId },
});
analytic.track("phrase-bundle_created", { source: "pending_saves" });
if (created?._id) return created._id;
} catch (error) {
// Created at the same moment by another run: use that one.
const fallbackId = await findBundleByTitle(userId, title);
if (fallbackId) return fallbackId;
throw error;
}
throw new Error(`Could not create the bundle "${title}"`);
}

/**
* The bundle for one local phrase: the user's bundle that already has a
* phrase from that page, or a bundle named by the server's suggestion for the
* page title, or FALLBACK_BUNDLE_TITLE. Cached per page within one run.
*/
async function bundleFor(
userId: string,
item: PendingSave,
cache: Map<string, string>
): Promise<string> {
const pageKey = `${item.sourceUrl || ""}\n${item.pageTitle || ""}`;
const cached = cache.get(pageKey);
if (cached) return cached;

let title = FALLBACK_BUNDLE_TITLE;
let bundleId: string | null = null;
if (item.sourceUrl || item.pageTitle) {
try {
const suggestion = await functionProvider.run<any>({
name: "getBundleSuggestionForPage",
args: {
refId: userId,
pageTitle: item.pageTitle || "",
pageUrl: item.sourceUrl || "",
},
});
bundleId = suggestion?.matchedBundle?._id || null;
if (suggestion?.suggestedName?.trim()) {
title = suggestion.suggestedName.trim();
}
} catch (error) {
// Best effort: the fallback bundle is fine.
log("pending saves: bundle suggestion failed", error);
}
}

const id = bundleId || (await findOrCreateBundle(userId, title));
cache.set(pageKey, id);
return id;
}

async function run(userId: string): Promise<SyncResult> {
const items = await listPendingSaves();
if (!items.length) return SKIPPED;

const owner = await acquireLock();
if (!owner) return SKIPPED;

const result: SyncResult = {
sent: 0,
failed: 0,
left: 0,
limitReached: false,
skipped: false,
};

try {
const deviceId = await getInstallId();
const bundles = new Map<string, string>();

// Oldest first, so a limit keeps the newest phrases for later.
const queue = [...items].sort((a, b) => a.savedAt - b.savedAt);
for (const item of queue) {
// Stop if the user signed out (or changed) during the run.
if (signedInUserId() !== userId) break;
await refreshLock(owner);

try {
const bundleId = await bundleFor(userId, item, bundles);
await functionProvider.run({
name: "createPhrase",
args: {
phrase: item.phrase,
translation: item.translation,
translation_language: item.translation_language,
...(deviceId ? { deviceId } : {}),
bundleIds: [bundleId],
refId: userId,
type: "linguistic",
context: item.context || "",
direction: item.direction,
language_info: item.language_info,
linguistic_data: item.linguistic_data,
chunks: item.chunks || [],
sourceUrl: item.sourceUrl,
},
});
await removePendingSave(item.phrase);
result.sent++;
} catch (error) {
if (isSaveLimitError(error)) {
result.limitReached = true;
break;
}
log("pending saves: createPhrase failed", error);
result.failed++;
}
}
} finally {
result.left = (await listPendingSaves()).length;
await releaseLock(owner);
}

analytic.track("pending-saves_synced", {
sent: result.sent,
failed: result.failed,
left: result.left,
...(result.limitReached ? { limit_reached: true } : {}),
});
return result;
}

/**
* Send every local phrase to the signed-in user's account. Safe to call often:
* it does nothing when logged out, when the list is empty, or while another
* run (in this page or another tab) is going.
*/
export function syncPendingSaves(): Promise<SyncResult> {
if (running) return running;
const userId = signedInUserId();
if (!userId) return Promise.resolve(SKIPPED);

running = run(userId)
.catch((error) => {
log("pending saves: sync failed", error);
return SKIPPED;
})
.finally(() => {
running = null;
});
return running;
}
Loading
Loading