Skip to content
Merged
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
30 changes: 30 additions & 0 deletions src/common/helper/url-normalise.ts
Original file line number Diff line number Diff line change
@@ -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;
}
56 changes: 56 additions & 0 deletions src/common/services/bundle-suggestion.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, Promise<BundleSuggestion>>();

/** 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<BundleSuggestion> {
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<BundleSuggestion>({
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;
}
}
32 changes: 32 additions & 0 deletions src/common/services/phrase.service.ts
Original file line number Diff line number Diff line change
@@ -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<PhraseType | null> {
const refId = authentication.user?.id;
const text = (phrase || "").trim();
if (!refId || !text) return null;

try {
const doc = await dataProvider.findOne<PhraseType>({
database: DATABASE.USER_CONTENT,
collection: COLLECTIONS.PHRASE,
query: { refId, phrase: text },
});
return (doc as PhraseType) || null;
} catch {
return null;
}
}
32 changes: 31 additions & 1 deletion src/common/services/translate.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
Expand All @@ -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<TranslationAdvice> {
return functionProvider.run<TranslationAdvice>({
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),
Expand Down
Loading
Loading