Skip to content

Commit a271aff

Browse files
authored
Merge pull request #27 from codebridger/dev
Enhance Practice Now Features and Save Modal Improvements with Dashboard Deep-Linking
2 parents 6b8359b + 85d40b5 commit a271aff

34 files changed

Lines changed: 2431 additions & 328 deletions

CHANGELOG-DEV.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,36 @@
1+
# [1.12.0-dev.3](https://github.com/codebridger/subturtle-extension-apps/compare/v1.12.0-dev.2...v1.12.0-dev.3) (2026-05-25)
2+
3+
4+
### Features
5+
6+
* **practice-now:** emphasize practiced phrase + cover login flows ([8ff3408](https://github.com/codebridger/subturtle-extension-apps/commit/8ff340831d3ca35dcbc97ddfcf7088f7f382582b))
7+
* **practice-now:** open config to logged-out users + clearer CTAs ([2f09e05](https://github.com/codebridger/subturtle-extension-apps/commit/2f09e0577de43a55d995e9277446d313b058beef))
8+
* **practice-now:** voice session config + dashboard deep-link ([db1a3fc](https://github.com/codebridger/subturtle-extension-apps/commit/db1a3fc08f2579c2d6c958a2e8c289c95e592339))
9+
10+
# [1.12.0-dev.2](https://github.com/codebridger/subturtle-extension-apps/compare/v1.12.0-dev.1...v1.12.0-dev.2) (2026-05-24)
11+
12+
13+
### Bug Fixes
14+
15+
* **save-modal:** break circular import to console-crane store ([ab00130](https://github.com/codebridger/subturtle-extension-apps/commit/ab00130647d72703147f8a96df6faa8e3b1fbbe5))
16+
* **save-modal:** refetch bundle options so post-save chip shows title ([25499da](https://github.com/codebridger/subturtle-extension-apps/commit/25499da35925bebeb854f20973c3ae39b4dbd6f2))
17+
18+
19+
### Features
20+
21+
* **console-crane:** practice + flashcard-preview pages, near-translation actions ([224b9da](https://github.com/codebridger/subturtle-extension-apps/commit/224b9da79e8b9d2f094ae3f63b1f8524fcb78299))
22+
* **save-modal:** chunk highlights, AI advice chat, bundle suggestion ([9954c22](https://github.com/codebridger/subturtle-extension-apps/commit/9954c22f8822f5a06912247d0098d937c20ec6b5))
23+
* **save-modal:** in-field bundle chips with dirty-aware save + inline removal ([374cbb4](https://github.com/codebridger/subturtle-extension-apps/commit/374cbb4b0c957c5def27d6b9f0211b3ed4fe17f5))
24+
* **save-modal:** per-chunk definitions, merged pronunciation, reorder save ([f766040](https://github.com/codebridger/subturtle-extension-apps/commit/f76604023886a6d88b18831c9e492ef8253e11b7))
25+
* **saved-phrase:** DB-first lookup, reuse stored translation, no AI re-call ([1315cc8](https://github.com/codebridger/subturtle-extension-apps/commit/1315cc86c3421ab64560e68e70d0b282c662d3ba))
26+
27+
# [1.12.0-dev.1](https://github.com/codebridger/subturtle-extension-apps/compare/v1.11.2-dev.1...v1.12.0-dev.1) (2026-05-21)
28+
29+
30+
### Features
31+
32+
* announce extension presence on dashboard origins for install nudge [#86](https://github.com/codebridger/subturtle-extension-apps/issues/86)exkh0z3 ([69dcf1b](https://github.com/codebridger/subturtle-extension-apps/commit/69dcf1bc0c13f293c01592545ec2ccb1111d5f3e)), closes [#86exkh0z3](https://github.com/codebridger/subturtle-extension-apps/issues/86exkh0z3)
33+
134
## [1.11.2-dev.1](https://github.com/codebridger/subturtle-extension-apps/compare/v1.11.1...v1.11.2-dev.1) (2026-05-06)
235

336

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "subturtle-extension",
3-
"version": "1.11.2",
3+
"version": "1.12.0-dev.3",
44
"private": true,
55
"scripts": {
66
"dev": "webpack --watch",
@@ -60,4 +60,4 @@
6060
"vue": "3.5.17",
6161
"vue-router": "4.5.1"
6262
}
63-
}
63+
}

src/common/helper/url-normalise.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Normalise a source page URL so saves from the same content group together.
3+
* Mirrors the server-side normaliser (server/src/modules/translation/url-normalise.ts):
4+
* drops query strings, fragments, and timestamps; lowercases the host; removes a
5+
* trailing slash. Returns the input unchanged when it cannot be parsed.
6+
*/
7+
export function normaliseSourceUrl(raw: string): string {
8+
if (!raw || typeof raw !== "string") return "";
9+
10+
const trimmed = raw.trim();
11+
12+
let url: URL;
13+
try {
14+
url = new URL(trimmed);
15+
} catch {
16+
return trimmed;
17+
}
18+
19+
url.hash = "";
20+
url.search = "";
21+
url.hostname = url.hostname.toLowerCase();
22+
23+
let normalised = url.toString();
24+
25+
if (normalised.endsWith("/") && url.pathname !== "/") {
26+
normalised = normalised.slice(0, -1);
27+
}
28+
29+
return normalised;
30+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { authentication, functionProvider } from "@modular-rest/client";
2+
import { normaliseSourceUrl } from "../helper/url-normalise";
3+
import type { BundleSuggestion } from "../../console-crane/modules/word-detail/types";
4+
5+
/**
6+
* Per-page bundle suggestion: which bundle the save modal should default to for
7+
* the current page + logged-in user. Called once per page (first word-detail
8+
* open) and cached client-side by normalised URL so repeated word lookups on
9+
* the same page reuse the result.
10+
*/
11+
export class BundleSuggestionService {
12+
static instance = new BundleSuggestionService();
13+
14+
// Cache of in-flight / resolved suggestions keyed by normalised URL.
15+
private cache = new Map<string, Promise<BundleSuggestion>>();
16+
17+
/** Clear the cache (e.g. after a save creates a new bundle for this page). */
18+
clear(url?: string) {
19+
if (url) this.cache.delete(normaliseSourceUrl(url));
20+
else this.cache.clear();
21+
}
22+
23+
async getForCurrentPage(): Promise<BundleSuggestion> {
24+
const empty: BundleSuggestion = { matchedBundle: null, suggestedName: null };
25+
26+
// Logged-in only; anonymous users get nothing to suggest.
27+
if (!authentication.user?.id) return empty;
28+
if (typeof location === "undefined") return empty;
29+
30+
const key = normaliseSourceUrl(location.href);
31+
if (!key) return empty;
32+
33+
const cached = this.cache.get(key);
34+
if (cached) return cached;
35+
36+
const pageTitle = typeof document !== "undefined" ? document.title : "";
37+
const request = functionProvider
38+
.run<BundleSuggestion>({
39+
name: "getBundleSuggestionForPage",
40+
args: {
41+
refId: authentication.user?.id,
42+
pageTitle,
43+
pageUrl: location.href,
44+
},
45+
})
46+
.catch((error) => {
47+
// Best-effort; never block the save flow.
48+
console.error("Bundle suggestion error:", error);
49+
this.cache.delete(key);
50+
return empty;
51+
});
52+
53+
this.cache.set(key, request);
54+
return request;
55+
}
56+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { functionProvider } from "@modular-rest/client";
2+
3+
/**
4+
* AI-coach voice as returned by the server's `get-live-session-voices`
5+
* function. Kept structurally in sync with the dashboard's `CoachVoice`
6+
* (server: live_session/voices.ts) — the two repos build separately so the
7+
* shape is mirrored, not imported.
8+
*/
9+
export interface CoachVoice {
10+
name: string;
11+
label: string;
12+
description?: string;
13+
gender?: "female" | "male";
14+
avatarColor?: string;
15+
avatarUrl?: string | null;
16+
}
17+
18+
/**
19+
* Cached fetch of the coach voices. Singleton so every Practice now mount
20+
* shares one network call (mirrors BundleSuggestionService / TranslateService).
21+
*
22+
* No offline fallback: if the server can't return voices, the dashboard live
23+
* session can't run anyway, so an empty list is the honest result.
24+
*/
25+
export class LiveVoicesService {
26+
private static _instance: LiveVoicesService | null = null;
27+
28+
static get instance(): LiveVoicesService {
29+
if (!this._instance) this._instance = new LiveVoicesService();
30+
return this._instance;
31+
}
32+
33+
private cache: CoachVoice[] | null = null;
34+
private inflight: Promise<CoachVoice[]> | null = null;
35+
36+
async getVoices(): Promise<CoachVoice[]> {
37+
if (this.cache) return this.cache;
38+
if (this.inflight) return this.inflight;
39+
40+
this.inflight = functionProvider
41+
.run<CoachVoice[]>({ name: "get-live-session-voices", args: {} })
42+
.then((res) => {
43+
this.cache = res || [];
44+
return this.cache;
45+
})
46+
// Don't cache failures, so a transient error retries on the next open.
47+
.catch(() => [] as CoachVoice[])
48+
.finally(() => {
49+
this.inflight = null;
50+
});
51+
52+
return this.inflight;
53+
}
54+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { dataProvider, authentication } from "@modular-rest/client";
2+
import { COLLECTIONS, DATABASE } from "../static/global";
3+
import type { PhraseType } from "../types/phrase.type";
4+
5+
/**
6+
* Single source of truth for "has the user already saved this phrase?".
7+
*
8+
* Matches by phrase text (the saved unit) + owner only. The translation is
9+
* intentionally excluded: the AI returns a slightly different translation on
10+
* each call, so matching on it would make an already-saved phrase look unsaved.
11+
*
12+
* Returns the saved phrase document, or null when not logged in, the input is
13+
* empty, the phrase isn't saved, or the lookup fails.
14+
*/
15+
export async function findSavedPhrase(
16+
phrase: string
17+
): Promise<PhraseType | null> {
18+
const refId = authentication.user?.id;
19+
const text = (phrase || "").trim();
20+
if (!refId || !text) return null;
21+
22+
try {
23+
const doc = await dataProvider.findOne<PhraseType>({
24+
database: DATABASE.USER_CONTENT,
25+
collection: COLLECTIONS.PHRASE,
26+
query: { refId, phrase: text },
27+
});
28+
return (doc as PhraseType) || null;
29+
} catch {
30+
return null;
31+
}
32+
}

src/common/services/translate.service.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import { Dictionary } from "../types/general.type";
77

88
import proxy from "./proxy.service";
99
import { functionProvider } from "@modular-rest/client";
10-
import { LanguageLearningData } from "../../console-crane/modules/word-detail/types";
10+
import {
11+
Chunk,
12+
LanguageLearningData,
13+
TranslationAdvice,
14+
} from "../../console-crane/modules/word-detail/types";
1115
import { LanguageDetector } from "../helper/language-detection";
1216
import { useSettingsStore } from "../store/settings";
1317

@@ -171,6 +175,7 @@ export class TranslateService {
171175
// Add context and phrase to the result
172176
data.context = context;
173177
data.phrase = text;
178+
if (!Array.isArray(data.chunks)) data.chunks = [];
174179

175180
// Cache the result
176181
this.cacheResult(cacheKey, data);
@@ -183,6 +188,31 @@ export class TranslateService {
183188
}
184189
}
185190

191+
/**
192+
* Conversational advisor for the save modal's "fix this?" chat.
193+
* Returns either a plain-text reply or an updated chunks list.
194+
*/
195+
async fetchTranslationAdvice(params: {
196+
phrase: string;
197+
context: string;
198+
message: string;
199+
currentChunks?: Chunk[];
200+
history?: { role: "user" | "assistant"; text: string }[];
201+
}): Promise<TranslationAdvice> {
202+
return functionProvider.run<TranslationAdvice>({
203+
name: "translationAdvice",
204+
args: {
205+
phrase: params.phrase,
206+
context: params.context || "",
207+
message: params.message,
208+
currentChunks: params.currentChunks || [],
209+
history: params.history || [],
210+
sourceLanguage: "auto",
211+
targetLanguage: this.languageTitle,
212+
},
213+
});
214+
}
215+
186216
async translateByDictionaryapi(word: string) {
187217
let url = {
188218
url: "https://api.dictionaryapi.dev/api/v2/entries/en/" + encodeURI(word),

src/common/static/global.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ export const VERSION = require("../../../package.json").version;
44

55
export const SUBTURTLE_DASHBOARD_URL = process.env.SUBTURTLE_DASHBOARD_URL;
66

7-
export function getSubturtleDashboardUrlWithToken() {
7+
export function getSubturtleDashboardUrlWithToken(redirectPath?: string) {
88
const token = authentication.getToken;
9-
const url = `${process.env.SUBTURTLE_DASHBOARD_URL}/#/auth/login_with_token?token=${token}`;
10-
console.log("Subturtle dashboard url", url);
9+
let url = `${process.env.SUBTURTLE_DASHBOARD_URL}/#/auth/login_with_token?token=${token}`;
10+
// The dashboard's login_with_token page reads `redirect`, validates same-origin,
11+
// and pushes it after auth — so a deep-link survives the token handoff.
12+
if (redirectPath) {
13+
url += `&redirect=${encodeURIComponent(redirectPath)}`;
14+
}
1115
return url;
1216
}
1317

0 commit comments

Comments
 (0)