diff --git a/src/common/helper/url-normalise.ts b/src/common/helper/url-normalise.ts new file mode 100644 index 0000000..a047ca4 --- /dev/null +++ b/src/common/helper/url-normalise.ts @@ -0,0 +1,30 @@ +/** + * Normalise a source page URL so saves from the same content group together. + * Mirrors the server-side normaliser (server/src/modules/translation/url-normalise.ts): + * drops query strings, fragments, and timestamps; lowercases the host; removes a + * trailing slash. Returns the input unchanged when it cannot be parsed. + */ +export function normaliseSourceUrl(raw: string): string { + if (!raw || typeof raw !== "string") return ""; + + const trimmed = raw.trim(); + + let url: URL; + try { + url = new URL(trimmed); + } catch { + return trimmed; + } + + url.hash = ""; + url.search = ""; + url.hostname = url.hostname.toLowerCase(); + + let normalised = url.toString(); + + if (normalised.endsWith("/") && url.pathname !== "/") { + normalised = normalised.slice(0, -1); + } + + return normalised; +} diff --git a/src/common/services/bundle-suggestion.service.ts b/src/common/services/bundle-suggestion.service.ts new file mode 100644 index 0000000..e4baba9 --- /dev/null +++ b/src/common/services/bundle-suggestion.service.ts @@ -0,0 +1,56 @@ +import { authentication, functionProvider } from "@modular-rest/client"; +import { normaliseSourceUrl } from "../helper/url-normalise"; +import type { BundleSuggestion } from "../../console-crane/modules/word-detail/types"; + +/** + * Per-page bundle suggestion: which bundle the save modal should default to for + * the current page + logged-in user. Called once per page (first word-detail + * open) and cached client-side by normalised URL so repeated word lookups on + * the same page reuse the result. + */ +export class BundleSuggestionService { + static instance = new BundleSuggestionService(); + + // Cache of in-flight / resolved suggestions keyed by normalised URL. + private cache = new Map>(); + + /** Clear the cache (e.g. after a save creates a new bundle for this page). */ + clear(url?: string) { + if (url) this.cache.delete(normaliseSourceUrl(url)); + else this.cache.clear(); + } + + async getForCurrentPage(): Promise { + const empty: BundleSuggestion = { matchedBundle: null, suggestedName: null }; + + // Logged-in only; anonymous users get nothing to suggest. + if (!authentication.user?.id) return empty; + if (typeof location === "undefined") return empty; + + const key = normaliseSourceUrl(location.href); + if (!key) return empty; + + const cached = this.cache.get(key); + if (cached) return cached; + + const pageTitle = typeof document !== "undefined" ? document.title : ""; + const request = functionProvider + .run({ + name: "getBundleSuggestionForPage", + args: { + refId: authentication.user?.id, + pageTitle, + pageUrl: location.href, + }, + }) + .catch((error) => { + // Best-effort; never block the save flow. + console.error("Bundle suggestion error:", error); + this.cache.delete(key); + return empty; + }); + + this.cache.set(key, request); + return request; + } +} diff --git a/src/common/services/phrase.service.ts b/src/common/services/phrase.service.ts new file mode 100644 index 0000000..13bf8fc --- /dev/null +++ b/src/common/services/phrase.service.ts @@ -0,0 +1,32 @@ +import { dataProvider, authentication } from "@modular-rest/client"; +import { COLLECTIONS, DATABASE } from "../static/global"; +import type { PhraseType } from "../types/phrase.type"; + +/** + * Single source of truth for "has the user already saved this phrase?". + * + * Matches by phrase text (the saved unit) + owner only. The translation is + * intentionally excluded: the AI returns a slightly different translation on + * each call, so matching on it would make an already-saved phrase look unsaved. + * + * Returns the saved phrase document, or null when not logged in, the input is + * empty, the phrase isn't saved, or the lookup fails. + */ +export async function findSavedPhrase( + phrase: string +): Promise { + const refId = authentication.user?.id; + const text = (phrase || "").trim(); + if (!refId || !text) return null; + + try { + const doc = await dataProvider.findOne({ + database: DATABASE.USER_CONTENT, + collection: COLLECTIONS.PHRASE, + query: { refId, phrase: text }, + }); + return (doc as PhraseType) || null; + } catch { + return null; + } +} diff --git a/src/common/services/translate.service.ts b/src/common/services/translate.service.ts index 426fc27..96c8027 100644 --- a/src/common/services/translate.service.ts +++ b/src/common/services/translate.service.ts @@ -7,7 +7,11 @@ import { Dictionary } from "../types/general.type"; import proxy from "./proxy.service"; import { functionProvider } from "@modular-rest/client"; -import { LanguageLearningData } from "../../console-crane/modules/word-detail/types"; +import { + Chunk, + LanguageLearningData, + TranslationAdvice, +} from "../../console-crane/modules/word-detail/types"; import { LanguageDetector } from "../helper/language-detection"; import { useSettingsStore } from "../store/settings"; @@ -171,6 +175,7 @@ export class TranslateService { // Add context and phrase to the result data.context = context; data.phrase = text; + if (!Array.isArray(data.chunks)) data.chunks = []; // Cache the result this.cacheResult(cacheKey, data); @@ -183,6 +188,31 @@ export class TranslateService { } } + /** + * Conversational advisor for the save modal's "fix this?" chat. + * Returns either a plain-text reply or an updated chunks list. + */ + async fetchTranslationAdvice(params: { + phrase: string; + context: string; + message: string; + currentChunks?: Chunk[]; + history?: { role: "user" | "assistant"; text: string }[]; + }): Promise { + return functionProvider.run({ + name: "translationAdvice", + args: { + phrase: params.phrase, + context: params.context || "", + message: params.message, + currentChunks: params.currentChunks || [], + history: params.history || [], + sourceLanguage: "auto", + targetLanguage: this.languageTitle, + }, + }); + } + async translateByDictionaryapi(word: string) { let url = { url: "https://api.dictionaryapi.dev/api/v2/entries/en/" + encodeURI(word), diff --git a/src/console-crane/components/SaveWordSectionV2.vue b/src/console-crane/components/SaveWordSectionV2.vue index 4542d8f..32da0ec 100644 --- a/src/console-crane/components/SaveWordSectionV2.vue +++ b/src/console-crane/components/SaveWordSectionV2.vue @@ -4,16 +4,11 @@ :isDisabled="!!(isSaving || isAtLimit)" @action="savePhrase" @upgrade="handleUpgrade" class="mb-4"> - + +

- {{ title }} +

{{ context }}

+ + +
+
+ +
+

+ Ask anything about this phrase — meaning, grammar, usage. You can also say e.g. "highlight 'had to'". +

+
+ + + +
@@ -45,6 +142,16 @@ + +
+ +
+
- - - - -