Skip to content
Closed
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@ tests/
console-crane-bridge.test.ts # window CustomEvent emit/listen contracts
console-crane-store.test.ts # toggleConsoleCrane / goBack / canGoBack / isOnMainPage
translate.service.test.ts # cache hit/miss + 24h TTL eviction (vi.useFakeTimers)
translate-analytics-args.test.ts # install id (deviceId) and trigger sent with translation calls (E-01, E-05)
settings-host.test.ts # nibbleDisabledDomains normalize / toggle
language-detection.test.ts # RTL detection, title lookup, supported codes
selection-popup.test.ts # @mousedown.prevent.stop regression
Expand Down
57 changes: 57 additions & 0 deletions src/common/helper/install-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* The install's analytics id (A3 in the analytics work list): one random id
* per install, kept in chrome.storage.local under `analyticsAnonymousId`.
*
* Mixpanel uses it as the anonymous `distinct_id` / `$device_id`
* (plugins/mixpanel.ts), and server calls that fire an analytics event send it
* as `deviceId` (E-01) so server events land on the same person.
*
* This module has no mixpanel import, so it is safe in every context: content
* scripts, the popup and the background service worker.
*/

export const ANON_ID_KEY = "analyticsAnonymousId";

/** crypto.randomUUID is missing on plain-http pages; getRandomValues is not. */
export function newAnonymousId(): string {
const b = crypto.getRandomValues(new Uint8Array(16));
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}

// One read-or-create at a time per context, so two callers on the first page
// ever do not mint two different ids.
let pending: Promise<string | undefined> | null = null;

/**
* Read the stored install id, creating and storing it when there is none yet.
* Not cached after it settles: logout rotates the id (resetAnalyticsIdentity),
* and the next call must see the new one.
*
* Resolves `undefined` only when there is no extension context (e.g. the
* content script was orphaned by an update); callers then send no id.
*/
export function getInstallId(): Promise<string | undefined> {
if (pending) return pending;
pending = new Promise<string | undefined>((resolve) => {
try {
chrome.storage.local.get(ANON_ID_KEY, (data) => {
let id = data?.[ANON_ID_KEY] as string | undefined;
if (id) return resolve(id);
id = newAnonymousId();
try {
chrome.storage.local.set({ [ANON_ID_KEY]: id }, () => resolve(id));
} catch {
resolve(id);
}
});
} catch {
resolve(undefined);
}
}).finally(() => {
pending = null;
});
return pending;
}
47 changes: 43 additions & 4 deletions src/common/services/translate.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ import { resolveTranslationLanguage } from "../language/state";
import { languageName } from "../language/catalog";
import type { LanguageState } from "../language/types";
import { toSupportedLanguageCode } from "../helper/default-language";
import { getInstallId } from "../helper/install-id";

/**
* What asked for a translation (E-05). Sent as `trigger` so the server's
* `translation_requested` event can split requests by source (B-03).
*/
export type TranslationTrigger =
| "caption"
| "word"
| "selection"
| "definition"
| "detail";

// Cache interface for translation results
interface TranslationCache {
Expand Down Expand Up @@ -145,7 +157,26 @@ export class TranslateService {
});
}

async fetchSimpleTranslation(text: string | string[], context: string = "") {
/**
* Analytics args for calls that fire a server event (E-01, E-05). `userId`
* stays for old servers; `deviceId` is the install's analytics id, read
* from storage on each call so it is never empty while the id loads and
* follows the new id after logout.
*/
private async analyticsArgs(trigger?: TranslationTrigger) {
const deviceId = await getInstallId();
return {
userId: authentication.user?.id,
...(deviceId ? { deviceId } : {}),
...(trigger ? { trigger } : {}),
};
}

async fetchSimpleTranslation(
text: string | string[],
context: string = "",
trigger?: TranslationTrigger
) {
// Clean expired cache entries
this.cleanExpiredCache();

Expand All @@ -162,6 +193,7 @@ export class TranslateService {

// If not cached, fetch from API
try {
const analytics = await this.analyticsArgs(trigger);
const result = await withAuthRetry(() =>
functionProvider.run<string>({
name: "translateWithContext",
Expand All @@ -173,7 +205,7 @@ export class TranslateService {
phrase: text,
context: context || "",
// For the server-side translation_requested analytics event.
userId: authentication.user?.id,
...analytics,
},
})
);
Expand All @@ -189,7 +221,11 @@ export class TranslateService {
}
}

async fetchDetailedTranslation(text: string, context: string = "") {
async fetchDetailedTranslation(
text: string,
context: string = "",
trigger: TranslationTrigger = "detail"
) {
// Clean expired cache entries
this.cleanExpiredCache();

Expand All @@ -206,6 +242,7 @@ export class TranslateService {

// If not cached, fetch from API
try {
const analytics = await this.analyticsArgs(trigger);
const data = await withAuthRetry(() =>
functionProvider.run<LanguageLearningData>({
name: "translateWithContext",
Expand All @@ -217,7 +254,7 @@ export class TranslateService {
phrase: text,
context: context || "",
// For the server-side translation_requested analytics event.
userId: authentication.user?.id,
...analytics,
},
})
);
Expand Down Expand Up @@ -250,6 +287,7 @@ export class TranslateService {
history?: { role: "user" | "assistant"; text: string }[];
}): Promise<TranslationAdvice> {
const targetLanguage = await this.requireLanguageTitle();
const { deviceId } = await this.analyticsArgs();
return withAuthRetry(() =>
functionProvider.run<TranslationAdvice>({
name: "translationAdvice",
Expand All @@ -262,6 +300,7 @@ export class TranslateService {
sourceLanguage: "auto",
targetLanguage,
...this.translationLanguageArg(),
...(deviceId ? { deviceId } : {}),
},
})
);
Expand Down
4 changes: 4 additions & 0 deletions src/console-crane/components/SaveWordSectionV2.vue
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ import {
getSubturtleDashboardUrlWithToken,
} from "../../common/static/global";
import { TranslateService } from "../../common/services/translate.service";
import { getInstallId } from "../../common/helper/install-id";
import { PhraseType, PhraseBundleType } from "../../common/types/phrase.type";
import { useDefaultBundleStore } from "../../stores/default-bundle";
import { useProfileStore } from "../../stores/profile";
Expand Down Expand Up @@ -546,11 +547,14 @@ async function savePhrase(kind?: "saved" | "savedInstead") {
return;
}

// The install's analytics id, for the server's first-save event (E-01).
const deviceId = await getInstallId();
const result = await functionProvider
.run<PhraseType>({
name: "createPhrase",
args: {
...phraseArgs(),
...(deviceId ? { deviceId } : {}),
bundleIds: toAdd,
// Round 1 (E-07): the language it is saved to. Only with language
// profiles; an old server resolves it itself (B-25).
Expand Down
3 changes: 2 additions & 1 deletion src/console-crane/modules/word-detail/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -733,7 +733,8 @@ async function fetchWordDetail() {

wordData.value = await TranslateService.instance.fetchDetailedTranslation(
cleaned,
context
context,
"detail"
);
} catch (err: any) {
console.error("Failed to fetch translation:", err);
Expand Down
3 changes: 2 additions & 1 deletion src/nibble/components/SelectionPopup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,8 @@ async function onIconClick(e: MouseEvent) {
try {
const result = await TranslateService.instance.fetchSimpleTranslation(
props.text,
props.context
props.context,
"selection"
);
translation.value = String(result ?? "").trim();
mode.value = translation.value ? "translated" : "error";
Expand Down
21 changes: 7 additions & 14 deletions src/plugins/mixpanel.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import mixpanel, { Config } from "mixpanel-browser";
import { ANON_ID_KEY, newAnonymousId } from "../common/helper/install-id";

/**
* NO STORAGE ON THE HOST PAGE. This module runs inside content scripts, where
Expand Down Expand Up @@ -29,16 +30,7 @@ mixpanel.register({
});
export const analytic = mixpanel;

export const ANON_ID_KEY = "analyticsAnonymousId";

/** crypto.randomUUID is missing on plain-http pages; getRandomValues is not. */
function newAnonymousId(): string {
const b = crypto.getRandomValues(new Uint8Array(16));
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
export { ANON_ID_KEY };

function useAnonymousId(id: string) {
// identify() may already have run (a logged-in user whose profile loaded
Expand All @@ -48,10 +40,11 @@ function useAnonymousId(id: string) {
}

/**
* Load (or create) the per-install anonymous id. Events tracked in the few
* milliseconds before the storage read answers carry mixpanel's random
* per-page id. Two scripts creating the id at once on the first page ever
* can disagree once; the last write wins from then on.
* Load (or create) the per-install anonymous id. The key and format live in
* helper/install-id.ts, which server calls use to send it as `deviceId`.
* Events tracked in the few milliseconds before the storage read answers
* carry mixpanel's random per-page id. Two scripts creating the id at once on
* the first page ever can disagree once; the last write wins from then on.
*/
function applyStoredAnonymousId() {
try {
Expand Down
2 changes: 1 addition & 1 deletion src/stores/marker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ export const useMarkerStore = defineStore("marker", {
const _this = this;

TranslateService.instance
.fetchSimpleTranslation(translatingList, this.context)
.fetchSimpleTranslation(translatingList, this.context, "word")
.then((res: string) => {
_this.sourceLanguage = "auto";

Expand Down
4 changes: 2 additions & 2 deletions src/subtitle/_support-template/components/Subtitle.vue
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export default defineComponent({
this.translatedLines = [];

TranslateService.instance
.fetchSimpleTranslation(translatingList)
.fetchSimpleTranslation(translatingList, "", "caption")
.then((translatedText) => {
this.sourceLanguage = "auto";

Expand All @@ -226,7 +226,7 @@ export default defineComponent({
let translatingList = [word];

TranslateService.instance
.fetchSimpleTranslation(translatingList)
.fetchSimpleTranslation(translatingList, "", "word")
.then((translatedText) => {
this.sourceLanguage = "auto";

Expand Down
2 changes: 1 addition & 1 deletion src/subtitle/web_netflix/components/Subtitle.vue
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ export default defineComponent({
this.isTranslatingWholeCaption = true;

TranslateService.instance
.fetchSimpleTranslation(translatingList)
.fetchSimpleTranslation(translatingList, "", "caption")
.then((translationResult) => {
translatingList.forEach((result, i) => {
this.translatedLines.push(translationResult);
Expand Down
Loading
Loading