From 99f57e6d11299eeda79d34bd5dea4c07efc49015 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 (dev port) Same change as the PR into main (save-first E-01 to E-05), ported to dev: - save-first E-01: "Learn more or Save" ("Learn" when saved, also for a phrase saved on this browser). Help text matches. - save-first E-02: a logged-out learner gets Save on the word card; the phrase goes to chrome.storage.local (pendingSaves). phrase_saved gets logged_in (false / true). - save-first E-03: sign-in card from the 3rd local phrase, required at 50. - save-first E-04: after sign-in, local phrases go to the account with createPhrase and deviceId, one run at a time, stopping at the save limit. - save-first E-05: the toolbar popup shows "N phrases waiting". dev only (multilingual round 1): the save window keeps its language row for signed-in learners. A local save keeps the detected phrase language, and the sync picks the target language with resolveSaveRow (a language not added goes to the active language). The language goes with the bundle suggestion, the bundle and createPhrase; without language profiles nothing is sent, as before. 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 | 334 ++++++++++++++++++ src/common/services/pending-saves.ts | 157 ++++++++ src/console-crane.ts | 15 +- .../components/LocalSaveSection.vue | 199 +++++++++++ .../components/SaveWordSectionV2.vue | 1 + .../modules/word-detail/index.vue | 51 +-- 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 | 321 +++++++++++++++++ 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, 1768 insertions(+), 191 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 99260d4..47db5f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,6 +186,12 @@ The learner's languages come from the server's `language` module, built to the A - **One RTL list:** `RTL_LANGUAGES` in [catalog.ts](src/common/language/catalog.ts). Right-to-left text uses the bundled Vazirmatn (`static/assets/fonts`, added by [rtl-font.ts](src/common/helper/rtl-font.ts)). - **Data without `language` is English.** Query English with `languageFilter("en")` (`{ $in: ["en", null] }`). +### 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. @@ -449,7 +455,6 @@ 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) language-state.test.ts # round-1 rules: dark gate, own language, save row, plan limit, deep link language-catalog.test.ts # one RTL list, picker names, bundled Vazirmatn own-language.test.ts # translate request with / without a server profile; language store @@ -458,6 +463,12 @@ tests/ language-lookups.test.ts # saved-phrase lookup, bundle suggestion, last-used bundles per language language-analytics.test.ts # learning_language and target_language super properties tab-broadcast.test.ts # tab broadcast skips tabs without our content scripts quietly + 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..50f2e8c --- /dev/null +++ b/src/common/services/pending-saves-sync.ts @@ -0,0 +1,334 @@ +/** + * 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. + * - Languages (multilingual round 1): logged out there is no language state, + * so each phrase gets its language here, with the same rule as the save + * window (`resolveSaveRow` on the detected language). A language the + * learner has not added goes to the active language ("Save to "). + * Without language profiles nothing is sent, as before (an old server + * resolves it itself, B-25). + */ +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 { useLanguageStore } from "../../stores/language"; +import { languageFilter, resolveSaveRow } from "../language/state"; +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. + } +} + +/** + * A function that gives the language each phrase is saved to, or null for + * all (no language profiles: requests stay as before). Loads the learner's + * language state when the login has not loaded it yet. + */ +async function languagePicker(): Promise<(item: PendingSave) => string | null> { + let languages: ReturnType; + try { + languages = useLanguageStore(); + } catch { + // No Pinia in this context. + return () => null; + } + if (!languages.state) await languages.fetchLanguages(); + const state = languages.state; + if (!languages.isMultilingual || !state) return () => null; + + return (item) => { + const row = resolveSaveRow({ + state, + caps: languages.caps, + detected: item.detected_language ?? null, + }); + return row.target ?? row.active; + }; +} + +async function findBundleByTitle( + userId: string, + title: string, + language: string | null +): Promise { + const existing = await dataProvider.findOne({ + database: DATABASE.USER_CONTENT, + collection: COLLECTIONS.PHRASE_BUNDLE, + query: { + refId: userId, + title, + ...(language ? { language: languageFilter(language) } : {}), + }, + }); + 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, + language: string | null +): Promise { + const existingId = await findBundleByTitle(userId, title, language); + if (existingId) return existingId; + try { + const created: any = await dataProvider.insertOne({ + database: DATABASE.USER_CONTENT, + collection: COLLECTIONS.PHRASE_BUNDLE, + // Bundles carry their language (E-07, B-09). + doc: { title, refId: userId, ...(language ? { language } : {}) }, + }); + 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, language); + 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, + language: string | null, + cache: Map +): Promise { + // Per page and language: a German page bundle is no bundle for English. + const pageKey = `${item.sourceUrl || ""}\n${item.pageTitle || ""}\n${language || ""}`; + 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 || "", + ...(language ? { language } : {}), + }, + }); + 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, language)); + 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 languageFor = await languagePicker(); + 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 language = languageFor(item); + const bundleId = await bundleFor(userId, item, language, 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, + // The language it is saved to (E-07); only with language profiles. + ...(language ? { language } : {}), + }, + }); + 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..21e468a --- /dev/null +++ b/src/common/services/pending-saves.ts @@ -0,0 +1,157 @@ +/** + * 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[]; + /** + * The language detected for the phrase (multilingual round 1). Logged out + * there is no language state, so the sync picks the target language with + * the learner's state after sign-in. + */ + detected_language?: string | null; + 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..ef2481f --- /dev/null +++ b/src/console-crane/components/LocalSaveSection.vue @@ -0,0 +1,199 @@ + + + diff --git a/src/console-crane/components/SaveWordSectionV2.vue b/src/console-crane/components/SaveWordSectionV2.vue index e85b2ce..e4fe4e2 100644 --- a/src/console-crane/components/SaveWordSectionV2.vue +++ b/src/console-crane/components/SaveWordSectionV2.vue @@ -565,6 +565,7 @@ async function savePhrase(kind?: "saved" | "savedInstead") { analytic.track("phrase_saved", { freemium: profileStore.isFreemium, ...(language ? { language, language_row: row.value?.kind } : {}), + 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 978d042..d9dbb35 100644 --- a/src/console-crane/modules/word-detail/index.vue +++ b/src/console-crane/modules/word-detail/index.vue @@ -247,16 +247,12 @@ @practice="(p) => startPracticeWithAI(p.language)" @back="backToVideo" @saved="(doc) => (lastSavedLanguage = doc?.language || null)" /> - -