diff --git a/CLAUDE.md b/CLAUDE.md index 207f57f..71fa434 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `` / `` / `` / `` 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. @@ -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 diff --git a/src/common/services/pending-saves-sync.ts b/src/common/services/pending-saves-sync.ts new file mode 100644 index 0000000..11d43ac --- /dev/null +++ b/src/common/services/pending-saves-sync.ts @@ -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 | 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 { + 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 { + await chrome.storage.local.set({ + [SYNC_LOCK_KEY]: { owner, until: Date.now() + SYNC_LOCK_MS }, + }); +} + +async function releaseLock(owner: string): Promise { + 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 { + const existing = await dataProvider.findOne({ + 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 { + 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 +): Promise { + 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({ + 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 { + 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(); + + // 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 { + 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; +} diff --git a/src/common/services/pending-saves.ts b/src/common/services/pending-saves.ts new file mode 100644 index 0000000..82412f2 --- /dev/null +++ b/src/common/services/pending-saves.ts @@ -0,0 +1,151 @@ +/** + * Save first, sign in later (save-first E-02). + * + * A logged-out learner's saved phrases live here, in chrome.storage.local + * (one key, a list), until they sign in. Then pending-saves-sync.ts sends each + * one to their account with `createPhrase` and removes it from the list. + * + * chrome.storage, never the page's localStorage: content scripts share the + * host page's localStorage (see CLAUDE.md, "Gotchas"). chrome.storage.local + * is also what the popup and every tab read, so the count stays the same + * everywhere. + */ + +export const PENDING_SAVES_KEY = "pendingSaves"; + +/** The soft sign-in card shows from this many local phrases on (save-first E-03). */ +export const SIGNIN_NUDGE_AT = 3; + +/** At this many local phrases, sign-in is required to save more (save-first E-03). */ +export const MAX_PENDING_SAVES = 50; + +/** + * Everything `createPhrase` needs, captured at save time. The page URL and + * title are kept so the sync can ask the server for a bundle name for that + * page (the suggestion needs a signed-in user, so it cannot run now). + */ +export interface PendingSave { + phrase: string; + translation: string; + translation_language: string; + context?: string; + direction?: { source: "ltr" | "rtl"; target: "ltr" | "rtl" }; + language_info?: { source: string; target: string }; + linguistic_data?: any; + chunks?: any[]; + sourceUrl?: string; + pageTitle?: string; + savedAt: number; +} + +/** The key two saves of "the same phrase" share: the server matches on this too. */ +export function phraseKey(phrase: string): string { + return (phrase || "").trim(); +} + +export async function listPendingSaves(): Promise { + try { + const data = await chrome.storage.local.get(PENDING_SAVES_KEY); + const list = data?.[PENDING_SAVES_KEY]; + return Array.isArray(list) ? list : []; + } catch { + // No extension context (an orphaned content script after an update). + return []; + } +} + +async function writePendingSaves(list: PendingSave[]): Promise { + await chrome.storage.local.set({ [PENDING_SAVES_KEY]: list }); +} + +export async function countPendingSaves(): Promise { + return (await listPendingSaves()).length; +} + +export async function findPendingSave( + phrase: string +): Promise { + const key = phraseKey(phrase); + if (!key) return null; + const list = await listPendingSaves(); + return list.find((p) => phraseKey(p.phrase) === key) || null; +} + +export type AddPendingSaveResult = + | { status: "added"; count: number } + | { status: "exists"; count: number } + | { status: "full"; count: number }; + +/** + * Add a phrase to the local list. Saving the same phrase again keeps one copy + * (the newer data replaces the old one). A new phrase is refused when the list + * already holds MAX_PENDING_SAVES. + */ +export async function addPendingSave( + item: Omit & { savedAt?: number } +): Promise { + const key = phraseKey(item.phrase); + const list = await listPendingSaves(); + // A plain JSON copy: callers pass Vue props (reactive proxies), which + // chrome.storage cannot store as they are. + const entry: PendingSave = JSON.parse( + JSON.stringify({ + ...item, + phrase: key, + translation: (item.translation || "").trim(), + savedAt: item.savedAt ?? Date.now(), + }) + ); + + const index = list.findIndex((p) => phraseKey(p.phrase) === key); + if (index >= 0) { + list[index] = entry; + await writePendingSaves(list); + return { status: "exists", count: list.length }; + } + + if (list.length >= MAX_PENDING_SAVES) { + return { status: "full", count: list.length }; + } + + list.push(entry); + await writePendingSaves(list); + return { status: "added", count: list.length }; +} + +/** Remove one phrase (after the server accepted it). Returns how many are left. */ +export async function removePendingSave(phrase: string): Promise { + const key = phraseKey(phrase); + const list = (await listPendingSaves()).filter( + (p) => phraseKey(p.phrase) !== key + ); + await writePendingSaves(list); + return list.length; +} + +/** + * Call `cb` with the new count whenever the list changes in any tab or page. + * Returns a function that stops listening. + */ +export function onPendingSavesChange(cb: (count: number) => void): () => void { + const listener = ( + changes: Record, + area: string + ) => { + if (area !== "local" || !(PENDING_SAVES_KEY in changes)) return; + const list = changes[PENDING_SAVES_KEY].newValue; + cb(Array.isArray(list) ? list.length : 0); + }; + try { + chrome.storage.onChanged.addListener(listener); + } catch { + return () => {}; + } + return () => { + try { + chrome.storage.onChanged.removeListener(listener); + } catch { + // Extension context gone; nothing to remove. + } + }; +} diff --git a/src/console-crane.ts b/src/console-crane.ts index c9a3b4f..809a996 100644 --- a/src/console-crane.ts +++ b/src/console-crane.ts @@ -9,7 +9,8 @@ import ConsoleCrane from "./console-crane/index.vue"; import { initConsoleCraneApp } from "./console-crane/initializer"; import { useConsoleCraneStore } from "./console-crane/stores/console-crane"; import { addPlugins } from "./plugins/install"; -import { loginWithLastSession } from "./plugins/modular-rest"; +import { isLogin, loginWithLastSession } from "./plugins/modular-rest"; +import { syncPendingSaves } from "./common/services/pending-saves-sync"; import { useSettingsStore } from "./common/store/settings"; import { emitState, @@ -30,6 +31,18 @@ log("ConsoleCrane using version", VERSION); loginWithLastSession(); + // Phrases saved before sign-in go to the account once a user is signed in + // here: right after a login, or on the next page load if an earlier run was + // cut off (save-first E-04). One console-crane runs per page; the sync's + // lock keeps tabs from sending the same list together. + watch( + isLogin, + (signedIn) => { + if (signedIn) void syncPendingSaves(); + }, + { immediate: true } + ); + await initConsoleCraneApp(vueApp); const store = useConsoleCraneStore(); diff --git a/src/console-crane/components/LocalSaveSection.vue b/src/console-crane/components/LocalSaveSection.vue new file mode 100644 index 0000000..b300ff7 --- /dev/null +++ b/src/console-crane/components/LocalSaveSection.vue @@ -0,0 +1,226 @@ + + + diff --git a/src/console-crane/components/SaveWordSectionV2.vue b/src/console-crane/components/SaveWordSectionV2.vue index 168054c..8207842 100644 --- a/src/console-crane/components/SaveWordSectionV2.vue +++ b/src/console-crane/components/SaveWordSectionV2.vue @@ -342,7 +342,10 @@ async function savePhrase() { }, }) .then((result) => { - analytic.track("phrase_saved", { freemium: profileStore.isFreemium }); + analytic.track("phrase_saved", { + freemium: profileStore.isFreemium, + logged_in: true, + }); return result; }); diff --git a/src/console-crane/modules/word-detail/index.vue b/src/console-crane/modules/word-detail/index.vue index f86583a..8680b10 100644 --- a/src/console-crane/modules/word-detail/index.vue +++ b/src/console-crane/modules/word-detail/index.vue @@ -244,16 +244,11 @@ :direction="wordData?.direction" :language_info="wordData?.language_info" :linguistic_data="wordData?.linguistic_data" :chunks="wordData?.chunks || []" /> - -