From fcfa211d241fb3b5728c8267adf902dca61e770e Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Fri, 16 Oct 2026 21:51:44 +0300 Subject: [PATCH 1/2] feat(save): save-first E-01 to E-05 let logged-out learners save and sign in later - save-first E-01: the selection popup button says "Learn more or Save" ("Learn" when saved, now also for a phrase saved on this browser, whose translation is reused). The popup help text matches. - save-first E-02: a logged-out learner gets Save on the word card instead of "Login to save this phrase" (new LocalSaveSection). The phrase goes to chrome.storage.local (pendingSaves, one list) with everything createPhrase needs plus the page URL and title. One copy per phrase. phrase_saved gets logged_in: false (logged-in saves get true). - save-first E-03: from the 3rd local phrase, each save shows "Sign in to keep your N phrases and review them tomorrow" with Sign in (login source save_prompt) and Not now. At 50 local phrases the card replaces Save. Events save-signin-card_viewed (count, required), _clicked, _dismissed. - save-first E-04: after sign-in (login window, popup open, or a page load with a signed-in user) each local phrase goes to the account with createPhrase and deviceId, and leaves the list only when the server accepts it. The bundle comes from getBundleSuggestionForPage for the saved page, else "Saved phrases". One run at a time (a lock in chrome.storage.local), stops at the save limit and keeps the rest. Event pending-saves_synced (sent, failed, left). - save-first E-05: the toolbar popup shows "N phrases waiting. Sign in to keep them" to logged-out learners with local phrases. - The old save-login-prompt_viewed / _clicked events go with the old button; the sign-in card events replace them. Work list: subturtle-doc docs/tech/save-first-implementation.md Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 13 +- src/common/services/pending-saves-sync.ts | 282 ++++++++++++++++++ src/common/services/pending-saves.ts | 151 ++++++++++ src/console-crane.ts | 15 +- .../components/LocalSaveSection.vue | 196 ++++++++++++ .../components/SaveWordSectionV2.vue | 5 +- .../modules/word-detail/index.vue | 50 +--- src/nibble/components/SelectionPopup.vue | 7 +- src/popup/components/PendingSavesBanner.vue | 71 +++++ src/popup/views/HelpView.vue | 2 +- src/popup/views/HomeView.vue | 4 + src/popup/views/LoginView.vue | 39 ++- tests/local-save.test.ts | 214 +++++++++++++ tests/login-analytics.test.ts | 62 +++- tests/memory-chrome-storage.ts | 51 ++++ tests/pending-saves-banner.test.ts | 86 ++++++ tests/pending-saves-sync.test.ts | 227 ++++++++++++++ tests/pending-saves.test.ts | 115 +++++++ tests/save-login-prompt.test.ts | 137 --------- tests/selection-popup-label.test.ts | 78 +++++ tests/translate-card.test.ts | 2 +- 21 files changed, 1615 insertions(+), 192 deletions(-) create mode 100644 src/common/services/pending-saves-sync.ts create mode 100644 src/common/services/pending-saves.ts create mode 100644 src/console-crane/components/LocalSaveSection.vue create mode 100644 src/popup/components/PendingSavesBanner.vue create mode 100644 tests/local-save.test.ts create mode 100644 tests/memory-chrome-storage.ts create mode 100644 tests/pending-saves-banner.test.ts create mode 100644 tests/pending-saves-sync.test.ts create mode 100644 tests/pending-saves.test.ts delete mode 100644 tests/save-login-prompt.test.ts create mode 100644 tests/selection-popup-label.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 207f57f..21f875d 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 can save on the word card. [LocalSaveSection](src/console-crane/components/LocalSaveSection.vue) keeps the 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, sign-in replaces Save. + +[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 on the word card, sign-in card at 3, required 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..d109ecd --- /dev/null +++ b/src/console-crane/components/LocalSaveSection.vue @@ -0,0 +1,196 @@ + + + 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 || []" /> - -