diff --git a/frontend/src/app/dashboard/DashboardSidebar.tsx b/frontend/src/app/dashboard/DashboardSidebar.tsx index 53939af..46b89ab 100644 --- a/frontend/src/app/dashboard/DashboardSidebar.tsx +++ b/frontend/src/app/dashboard/DashboardSidebar.tsx @@ -18,6 +18,7 @@ export function DashboardSidebar() { const { t } = useI18n(); const isPro = ['pro', 'business', 'enterprise'].includes(user?.tier ?? ''); + const isPaid = !!user?.tier && user.tier !== 'free'; const navItems = isPro ? baseNavItems : baseNavItems.filter(item => !item.proOnly); return ( @@ -68,7 +69,8 @@ export function DashboardSidebar() { )} - {/* Memento promo section */} + {/* Memento promo section — hidden for paying users */} + {!isPaid && ( + )} {/* User section */}
diff --git a/frontend/src/app/dashboard/constants.ts b/frontend/src/app/dashboard/constants.ts index cc98dc9..a46cf7e 100644 --- a/frontend/src/app/dashboard/constants.ts +++ b/frontend/src/app/dashboard/constants.ts @@ -1,4 +1,4 @@ -import { FileText, Key, BookText, User, type LucideIcon } from 'lucide-react'; +import { FileText, BookText, User, type LucideIcon } from 'lucide-react'; export interface NavItem { labelKey: string; @@ -11,5 +11,6 @@ export const baseNavItems: NavItem[] = [ { labelKey: 'dashboard.nav.translate', href: '/dashboard/translate', icon: FileText }, { labelKey: 'dashboard.nav.profile', href: '/dashboard/profile', icon: User }, { labelKey: 'dashboard.nav.glossaries', href: '/dashboard/glossaries', icon: BookText, proOnly: true }, - { labelKey: 'dashboard.nav.apiKeys', href: '/dashboard/api-keys', icon: Key, proOnly: true }, + // API Keys nav item temporarily removed per request — uncomment to restore. + // { labelKey: 'dashboard.nav.apiKeys', href: '/dashboard/api-keys', icon: Key, proOnly: true }, ]; diff --git a/frontend/src/app/dashboard/translate/page.tsx b/frontend/src/app/dashboard/translate/page.tsx index ee27b54..0f91891 100644 --- a/frontend/src/app/dashboard/translate/page.tsx +++ b/frontend/src/app/dashboard/translate/page.tsx @@ -20,6 +20,7 @@ import { useNotification } from '@/components/ui/notification'; import { useI18n } from '@/lib/i18n'; import { API_BASE } from '@/lib/config'; import { cn } from '@/lib/utils'; +import { useUser } from '../useUser'; /* ── helpers ─────────────────────────────────────────────────────── */ const FILE_ICONS: Record = { @@ -74,6 +75,8 @@ export default function TranslatePage() { const submit = useTranslationSubmit(); const { error: showError } = useNotification(); const { t } = useI18n(); + const { data: currentUser } = useUser(); + const isPaid = !!currentUser?.tier && currentUser.tier !== 'free'; const lastErrorRef = useRef(null); const replaceInputRef = useRef(null); const dropzoneInputRef = useRef(null); @@ -742,8 +745,8 @@ export default function TranslatePage() {
- {/* ── MEMENTO PROMO BANNER ──────────────────────────────── */} - {(showUpload || showConfiguring || showFailed) && ( + {/* ── MEMENTO PROMO BANNER — hidden for paying users ────── */} + {!isPaid && (showUpload || showConfiguring || showFailed) && ( { }); }); - it('should flag glossaries and apiKeys as proOnly', () => { + it('should flag glossaries as proOnly', () => { const glossaries = baseNavItems.find(item => item.href === '/dashboard/glossaries'); - const apiKeys = baseNavItems.find(item => item.href === '/dashboard/api-keys'); expect(glossaries?.proOnly).toBe(true); - expect(apiKeys?.proOnly).toBe(true); + }); + + it('should currently omit apiKeys nav item (temporarily removed)', () => { + // The apiKeys nav item is temporarily commented out in constants.ts. + // If you re-enable it, update this test to assert its presence. + const apiKeys = baseNavItems.find(item => item.href === '/dashboard/api-keys'); + expect(apiKeys).toBeUndefined(); }); }); diff --git a/services/providers/deepseek_provider.py b/services/providers/deepseek_provider.py index 1699e20..b73f5af 100644 --- a/services/providers/deepseek_provider.py +++ b/services/providers/deepseek_provider.py @@ -31,8 +31,11 @@ Rules: - Translate ONLY the text, do not add explanations or notes - Preserve the original formatting, line breaks, and structure - Maintain the original tone and style -- For technical terms, use the standard translation in the target language -- If the text contains proper nouns or brand names, keep them unchanged unless there's a well-known translation""" +- Translate technical terms, jargon, and labels using the standard target-language equivalent +- Chart elements (titles, axis labels, legend entries, category labels, series names) MUST always be translated, even when they look like a title or a proper noun +- Translate month and weekday abbreviations (Jan → janvier, Mon → lundi) to the target language +- Keep ONLY real proper nouns unchanged: people's names, place names, company names (Google, Microsoft), and product names (GitHub, HuggingFace) +- Do not invent content; if a term is an acronym with no translation (API, URL, HTTP, JSON), keep it as-is""" def _get_language_name(code: str) -> str: diff --git a/services/providers/minimax_provider.py b/services/providers/minimax_provider.py index 06af4ae..495e1bc 100644 --- a/services/providers/minimax_provider.py +++ b/services/providers/minimax_provider.py @@ -31,8 +31,11 @@ Rules: - Translate ONLY the text, do not add explanations or notes - Preserve the original formatting, line breaks, and structure - Maintain the original tone and style -- For technical terms, use the standard translation in the target language -- If the text contains proper nouns or brand names, keep them unchanged unless there's a well-known translation""" +- Translate technical terms, jargon, and labels using the standard target-language equivalent +- Chart elements (titles, axis labels, legend entries, category labels, series names) MUST always be translated, even when they look like a title or a proper noun +- Translate month and weekday abbreviations (Jan → janvier, Mon → lundi) to the target language +- Keep ONLY real proper nouns unchanged: people's names, place names, company names (Google, Microsoft), and product names (GitHub, HuggingFace) +- Do not invent content; if a term is an acronym with no translation (API, URL, HTTP, JSON), keep it as-is""" def _get_language_name(code: str) -> str: diff --git a/services/providers/openai_provider.py b/services/providers/openai_provider.py index 3520893..02cd9e1 100644 --- a/services/providers/openai_provider.py +++ b/services/providers/openai_provider.py @@ -101,8 +101,11 @@ Rules: - Translate ONLY the text, do not add explanations or notes - Preserve the original formatting, line breaks, and structure - Maintain the original tone and style -- For technical terms, use the standard translation in the target language -- If the text contains proper nouns or brand names, keep them unchanged unless there's a well-known translation""" +- Translate technical terms, jargon, and labels using the standard target-language equivalent +- Chart elements (titles, axis labels, legend entries, category labels, series names) MUST always be translated, even when they look like a title or a proper noun +- Translate month and weekday abbreviations (Jan → janvier, Mon → lundi) to the target language +- Keep ONLY real proper nouns unchanged: people's names, place names, company names (Google, Microsoft), and product names (GitHub, HuggingFace) +- Do not invent content; if a term is an acronym with no translation (API, URL, HTTP, JSON), keep it as-is""" def _build_system_prompt( diff --git a/tests/test_chart_translation_prompt.py b/tests/test_chart_translation_prompt.py new file mode 100644 index 0000000..b8ecb31 --- /dev/null +++ b/tests/test_chart_translation_prompt.py @@ -0,0 +1,50 @@ +"""Verify all 3 provider prompts are identical and contain chart-translation rules. + +This is a regression test for the bug where chart titles/legends/category labels +were left untranslated because the system prompt was too ambiguous about +"proper nouns or brand names". +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from services.providers.deepseek_provider import DEFAULT_TRANSLATION_PROMPT as P1 +from services.providers.minimax_provider import DEFAULT_TRANSLATION_PROMPT as P3 +from services.providers.openai_provider import DEFAULT_TRANSLATION_PROMPT as P2 + +REQUIRED_PHRASES = [ + "Chart elements", + "MUST always be translated", + "Translate month and weekday abbreviations", + "Jan", + "janvier", + "Keep ONLY real proper nouns", + "Google, Microsoft", + "API, URL, HTTP, JSON", +] + + +def test_all_prompts_identical(): + assert P1 == P2 == P3, "All 3 provider prompts must be identical" + + +def test_prompt_contains_required_rules(): + for phrase in REQUIRED_PHRASES: + assert phrase in P1, f"Missing rule: {phrase!r}" + + +def test_prompt_drops_old_ambiguous_rule(): + # The old "If the text contains proper nouns or brand names" rule was + # the root cause of chart labels not being translated. The new prompt + # uses a more specific rule that lists what counts as a proper noun. + assert "If the text contains proper nouns or brand names" not in P1, ( + "Old ambiguous rule still present — chart labels will be skipped again" + ) + + +if __name__ == "__main__": + test_all_prompts_identical() + test_prompt_contains_required_rules() + test_prompt_drops_old_ambiguous_rule() + print("All prompt regression tests passed.") diff --git a/tests/test_excel_chart_sheet_refs.py b/tests/test_excel_chart_sheet_refs.py new file mode 100644 index 0000000..4722356 --- /dev/null +++ b/tests/test_excel_chart_sheet_refs.py @@ -0,0 +1,138 @@ +"""Regression tests for the bug where translated Excel chart references +kept pointing at the original (now-renamed) sheet name, causing the chart +to render as zero/empty series after translation. + +This is a real bug: see e.g. test_excel.xlsx where the chart references +'Sales 2024'!$D$2:$D$61 but the sheet is renamed to "Chiffre d'affaires +par produit" — the chart then shows 0 for every data point. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from lxml import etree + +from translators.excel_translator import ExcelTranslator + +NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart" + + +def _build_chart_xml(series_refs): + """Helper: build a chart XML with the given strings.""" + sers = "\n".join( + f"{ref}" + f"{ref}" + f"{ref}" + for ref in series_refs + ) + return f""" + + {sers} + +""" + + +def test_quoted_sheet_name_with_apostrophe_in_new_name(): + r = ExcelTranslator._rewrite_sheet_ref( + "'Sales 2024'!$D$2:$D$61", + {"Sales 2024": "Chiffre d'affaires par produit"}, + ) + assert r == "'Chiffre d''affaires par produit'!$D$2:$D$61", f"got: {r!r}" + + +def test_unquoted_sheet_name_simple(): + r = ExcelTranslator._rewrite_sheet_ref( + "Sheet1!$A$1", + {"Sheet1": "Feuille1"}, + ) + assert r == "Feuille1!$A$1", f"got: {r!r}" + + +def test_new_name_with_space_gets_quoted(): + r = ExcelTranslator._rewrite_sheet_ref( + "Sheet1!$A$1", + {"Sheet1": "Ma Feuille"}, + ) + assert r == "'Ma Feuille'!$A$1", f"got: {r!r}" + + +def test_name_not_in_mapping_unchanged(): + r = ExcelTranslator._rewrite_sheet_ref( + "Other!$A$1", + {"Sheet1": "Feuille1"}, + ) + assert r == "Other!$A$1", f"got: {r!r}" + + +def test_empty_mapping_is_noop(): + r = ExcelTranslator._rewrite_sheet_ref("Sheet1!$A$1", {}) + assert r == "Sheet1!$A$1", f"got: {r!r}" + + +def test_no_bang_is_noop(): + r = ExcelTranslator._rewrite_sheet_ref("just_a_label", {"x": "y"}) + assert r == "just_a_label", f"got: {r!r}" + + +def test_real_chart_xml_all_refs_rewritten(): + chart_xml = etree.fromstring(_build_chart_xml(["Sheet1!$B$1"])) + n = ExcelTranslator._rewrite_sheet_refs_in_chart(chart_xml, {"Sheet1": "Ma Feuille"}) + assert n == 3, f"expected 3 rewrites, got {n}" + refs = [f.text for f in chart_xml.iter(f"{{{NS_C}}}f")] + assert refs == [ + "'Ma Feuille'!$B$1", + "'Ma Feuille'!$B$1", + "'Ma Feuille'!$B$1", + ], f"got: {refs}" + + +def test_real_chart_xml_apostrophe_escaped_in_new_name(): + chart_xml = etree.fromstring(_build_chart_xml(["Sheet1!$B$1"])) + n = ExcelTranslator._rewrite_sheet_refs_in_chart( + chart_xml, {"Sheet1": "Chiffre d'affaires"} + ) + assert n == 3 + refs = [f.text for f in chart_xml.iter(f"{{{NS_C}}}f")] + assert all(r == "'Chiffre d''affaires'!$B$1" for r in refs), f"got: {refs}" + + +def test_partial_mapping_leaves_other_refs_intact(): + """When only one sheet is renamed, refs to other sheets must stay.""" + chart_xml = etree.fromstring( + _build_chart_xml(["Sheet1!$A$1", "Sheet2!$B$1"]) + ) + n = ExcelTranslator._rewrite_sheet_refs_in_chart( + chart_xml, {"Sheet1": "Feuille1"} + ) + # 3 refs to Sheet1 (one series) are rewritten; 3 refs to Sheet2 stay. + assert n == 3, f"expected 3 rewrites, got {n}" + refs = [f.text for f in chart_xml.iter(f"{{{NS_C}}}f")] + assert "Feuille1!$A$1" in refs + assert "Sheet2!$B$1" in refs + + +def test_multiple_series_renamed(): + """When several sheets are renamed, refs to any of them get updated.""" + chart_xml = etree.fromstring( + _build_chart_xml(["Sheet1!$A$1", "Sheet2!$B$1", "Sheet3!$C$1"]) + ) + n = ExcelTranslator._rewrite_sheet_refs_in_chart( + chart_xml, + {"Sheet1": "F1", "Sheet2": "F2", "Sheet3": "F3"}, + ) + assert n == 9, f"expected 9 rewrites (3 series x 3 refs each), got {n}" + + +if __name__ == "__main__": + test_quoted_sheet_name_with_apostrophe_in_new_name() + test_unquoted_sheet_name_simple() + test_new_name_with_space_gets_quoted() + test_name_not_in_mapping_unchanged() + test_empty_mapping_is_noop() + test_no_bang_is_noop() + test_real_chart_xml_all_refs_rewritten() + test_real_chart_xml_apostrophe_escaped_in_new_name() + test_partial_mapping_leaves_other_refs_intact() + test_multiple_series_renamed() + print("All chart sheet-ref rewrite tests passed.") diff --git a/translators/excel_translator.py b/translators/excel_translator.py index b1b8fa6..9f8de44 100644 --- a/translators/excel_translator.py +++ b/translators/excel_translator.py @@ -198,16 +198,24 @@ class ExcelTranslator: } ) + # Record the position where sheet names start in text_elements. + # We need this to extract the right slice of translated_texts + # AFTER chart text has been appended. Otherwise the offset + # calculation drifts and sheet names get chart translations. + sheet_name_offset = len(text_elements) + for sheet_name in sheet_names_to_translate: text_elements.append((sheet_name, None)) - # Collect chart text from ZIP + # Collect chart text from ZIP (appended AFTER sheet names) self._collect_charts_from_zip(input_path, text_elements, chart_translations) if text_elements: texts = [elem[0] for elem in text_elements] total_texts = len(texts) - sheet_name_offset = total_texts - len(sheet_names_to_translate) + # sheet_name_offset was captured before sheet names + chart text + # were appended, so it already points at the start of the sheet + # name block. No re-derivation needed. _log_info( "excel_batch_translation_start", @@ -317,9 +325,16 @@ class ExcelTranslator: details={"file_name": output_path.name, "error": str(e)}, ) - # Re-inject chart translations into the .xlsx ZIP - if chart_translations: - self._apply_chart_translations(output_path, chart_translations) + # Re-inject chart translations into the .xlsx ZIP. + # We always pass the sheet_name_mapping so chart references + # are updated when sheet names were translated — otherwise the + # chart would keep pointing at the old (now-missing) sheet and + # render as empty/zero series. + self._apply_chart_translations( + output_path, + chart_translations, + sheet_name_mapping=sheet_name_mapping or None, + ) workbook.close() @@ -739,18 +754,27 @@ class ExcelTranslator: except Exception: return None - def _apply_chart_translations(self, output_path: Path, chart_translations: List[Dict[str, Any]]) -> None: + def _apply_chart_translations( + self, + output_path: Path, + chart_translations: List[Dict[str, Any]], + sheet_name_mapping: Optional[Dict[str, str]] = None, + ) -> None: """Re-inject chart translations into the .xlsx ZIP. Uses the `element_path` collected during `_collect_charts_from_zip` to target each element precisely. Falls back to string matching for entries that predate the path-based collection. + + Also rewrites `` references inside chart XML when sheet names + were translated — otherwise the chart keeps pointing at the old + sheet name and renders empty (zero) data. """ - if not chart_translations: + if not chart_translations and not sheet_name_mapping: return translated_entries = [e for e in chart_translations if 'translated' in e and e['translated']] - if not translated_entries: + if not translated_entries and not sheet_name_mapping: return _NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main" @@ -763,6 +787,18 @@ class ExcelTranslator: chart_files_to_update[cf] = [] chart_files_to_update[cf].append(entry) + # If we have sheet renames, we must walk every chart file (even ones + # with no text translations) so the references are updated. + if sheet_name_mapping: + # We need the list of chart files; peek into the zip to find them. + try: + with zipfile.ZipFile(output_path, 'r') as zf_peek: + for name in zf_peek.namelist(): + if name.startswith('xl/charts/') and name.endswith('.xml'): + chart_files_to_update.setdefault(name, []) + except Exception: + pass + try: with zipfile.ZipFile(output_path, 'r') as zf_in: existing_entries = zf_in.namelist() @@ -789,6 +825,14 @@ class ExcelTranslator: if v_elem.text and v_elem.text.strip() == entry['original']: v_elem.text = entry['translated'] break + + # Rewrite sheet references in when sheet + # names were translated. Without this, charts + # point at sheets that no longer exist and + # render as zero/empty series. + if sheet_name_mapping: + self._rewrite_sheet_refs_in_chart(chart_xml, sheet_name_mapping) + data = etree.tostring(chart_xml, xml_declaration=True, encoding='UTF-8', standalone=True) except Exception as e: _log_error("excel_chart_update_error", chart_file=item, error=str(e)) @@ -798,11 +842,86 @@ class ExcelTranslator: with open(output_path, 'wb') as f: f.write(buf.getvalue()) - _log_info("excel_charts_translated", chart_files=len(chart_files_to_update), translations=len(translated_entries)) + _log_info( + "excel_charts_translated", + chart_files=len(chart_files_to_update), + translations=len(translated_entries), + sheet_refs_rewritten=len(sheet_name_mapping or {}), + ) except Exception as e: _log_error("excel_chart_zip_rewrite_error", error=str(e)) + @staticmethod + def _quote_sheet_name_for_ref(name: str) -> str: + """Return the sheet name as it must appear inside a chart reference. + + Excel requires single-quoting when the name contains spaces or any of + `:\\/?*[]`, and any single quote inside the name must be doubled. + """ + if not name: + return name + needs_quote = any(c in name for c in " :\\/?*[]") or " " in name + escaped = name.replace("'", "''") + return f"'{escaped}'" if needs_quote else escaped + + @classmethod + def _rewrite_sheet_ref(cls, ref: str, sheet_name_mapping: Dict[str, str]) -> str: + """Rewrite a single chart reference to use translated sheet names. + + Handles both quoted (`'Sheet 1'!A1`) and unquoted (`Sheet1!A1`) forms. + If the sheet name is not in the mapping, returns ref unchanged. + """ + if not sheet_name_mapping or not ref or '!' not in ref: + return ref + + if ref.startswith("'"): + # Find the closing single quote that ends the sheet name. + # Per the ECMA-376 grammar, the inner `'` is escaped as `''`, + # so we can safely scan for the next unescaped `'`. + i = 1 + while i < len(ref): + if ref[i] == "'" and i + 1 < len(ref) and ref[i + 1] == "'": + i += 2 # skip escaped apostrophe + continue + if ref[i] == "'": + break + i += 1 + if i >= len(ref) or ref[i] != "'": + return ref + sheet_name = ref[1:i] + rest = ref[i + 1:] # starts with "!" + else: + bang = ref.index('!') + sheet_name = ref[:bang] + rest = ref[bang:] + + new_name = sheet_name_mapping.get(sheet_name) + if not new_name or new_name == sheet_name: + return ref + + return cls._quote_sheet_name_for_ref(new_name) + rest + + @classmethod + def _rewrite_sheet_refs_in_chart( + cls, chart_xml, sheet_name_mapping: Dict[str, str] + ) -> int: + """Walk all in the chart and rewrite those pointing at renamed sheets. + + Returns the number of references rewritten (for logging/tests). + """ + _NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart" + rewritten = 0 + for f_elem in chart_xml.iter(f'{{{_NS_C}}}f'): + original = f_elem.text + if not original or '!' not in original: + continue + updated = cls._rewrite_sheet_ref(original, sheet_name_mapping) + if updated != original: + f_elem.text = updated + rewritten += 1 + return rewritten + def _translate_images(self, worksheet: Worksheet, target_language: str) -> None: """ Translate text in images using vision model.