feat: homelab deployment - NPM + IONOS DNS + monitoring + NAS backup
- Restructured docker-compose for Nginx Proxy Manager (no custom nginx) - Added domain wordly.art configuration - Added Prometheus + Grafana monitoring stack with pre-configured dashboards - Added PostgreSQL backup script to NAS (daily/weekly/monthly rotation) - Added alert rules for backend, system, and Docker metrics - Updated deployment guide for NPM + IONOS DNS homelab setup - Added marketing plan document - PDF translator and watermark support - Enhanced middleware, routes, and translator modules Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -10,10 +10,13 @@ import re
|
||||
import tempfile
|
||||
import os
|
||||
import time
|
||||
import zipfile
|
||||
import io
|
||||
import concurrent.futures
|
||||
from pathlib import Path
|
||||
from typing import Dict, Set, List, Tuple, Optional, Callable, Any
|
||||
|
||||
from lxml import etree
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.worksheet.worksheet import Worksheet
|
||||
from openpyxl.cell.cell import Cell
|
||||
@@ -172,10 +175,13 @@ class ExcelTranslator:
|
||||
|
||||
text_elements: List[Tuple[str, Callable[[str], None]]] = []
|
||||
sheet_names_to_translate = []
|
||||
chart_translations: List[Dict[str, Any]] = []
|
||||
|
||||
for sheet_idx, sheet_name in enumerate(workbook.sheetnames):
|
||||
worksheet = workbook[sheet_name]
|
||||
self._collect_from_worksheet(worksheet, text_elements)
|
||||
# Collect header/footer text
|
||||
self._collect_from_header_footer(worksheet, text_elements)
|
||||
sheet_names_to_translate.append(sheet_name)
|
||||
|
||||
# Emit progress after each sheet collection (ensures < 500ms latency)
|
||||
@@ -193,6 +199,9 @@ class ExcelTranslator:
|
||||
for sheet_name in sheet_names_to_translate:
|
||||
text_elements.append((sheet_name, None))
|
||||
|
||||
# Collect chart text from ZIP
|
||||
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)
|
||||
@@ -298,6 +307,10 @@ 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)
|
||||
|
||||
workbook.close()
|
||||
|
||||
processing_time_ms = round((time.time() - start_time) * 1000, 2)
|
||||
@@ -477,6 +490,159 @@ class ExcelTranslator:
|
||||
|
||||
text_elements.append((original_value, make_setter(cell)))
|
||||
|
||||
def _collect_from_header_footer(
|
||||
self, worksheet: Worksheet, text_elements: List[Tuple[str, Callable[[str], None]]]
|
||||
) -> None:
|
||||
"""Collect text from worksheet headers and footers.
|
||||
|
||||
Headers/footers can contain text like "Page &P of &N" or "Confidential - &D".
|
||||
We translate the static text portions, preserving the &X codes.
|
||||
"""
|
||||
for section in worksheet.oddHeader, worksheet.oddFooter, worksheet.evenHeader, worksheet.evenFooter, worksheet.firstHeader, worksheet.firstFooter:
|
||||
if section is None:
|
||||
continue
|
||||
# openpyxl Header/Footer sections have .left, .center, .right attributes
|
||||
for attr in ('left', 'center', 'right'):
|
||||
text = getattr(section, attr, None)
|
||||
if text and isinstance(text, str) and text.strip():
|
||||
# Extract translatable text (remove &X codes for translation, keep structure)
|
||||
import re as _re
|
||||
# Split on &X codes (like &P, &N, &D, &F, &A, etc.)
|
||||
parts = _re.split(r'(&[A-Za-z])', text)
|
||||
for i, part in enumerate(parts):
|
||||
if part and not part.startswith('&') and part.strip():
|
||||
original = part.strip()
|
||||
|
||||
def make_hf_setter(sec, attribute, idx):
|
||||
def setter(translated):
|
||||
current = getattr(sec, attribute, '') or ''
|
||||
parts_local = _re.split(r'(&[A-Za-z])', current)
|
||||
if idx < len(parts_local):
|
||||
parts_local[idx] = translated
|
||||
setattr(sec, attribute, ''.join(parts_local))
|
||||
return setter
|
||||
|
||||
text_elements.append((original, make_hf_setter(section, attr, i)))
|
||||
|
||||
def _collect_charts_from_zip(
|
||||
self, input_path: Path, text_elements: List[Tuple[str, Callable[[str], None]]],
|
||||
chart_translations: List[Dict[str, Any]]
|
||||
) -> None:
|
||||
"""Parse chart XML from the .xlsx ZIP and collect translatable text."""
|
||||
_NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
_NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart"
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(input_path, 'r') as zf:
|
||||
chart_files = [name for name in zf.namelist() if name.startswith('xl/charts/') and name.endswith('.xml')]
|
||||
|
||||
for chart_file in chart_files:
|
||||
try:
|
||||
chart_xml = etree.fromstring(zf.read(chart_file))
|
||||
seen_texts: set = set()
|
||||
|
||||
# Collect from <a:t> elements (titles, axis labels, legend text)
|
||||
for t_elem in chart_xml.iter(f'{{{_NS_A}}}t'):
|
||||
if t_elem.text and t_elem.text.strip() and t_elem.text.strip() not in seen_texts:
|
||||
seen_texts.add(t_elem.text.strip())
|
||||
entry = {
|
||||
'chart_file': chart_file,
|
||||
'original': t_elem.text.strip(),
|
||||
'translated': None,
|
||||
}
|
||||
chart_translations.append(entry)
|
||||
|
||||
def make_chart_setter(entries, idx):
|
||||
def setter(text):
|
||||
entries[idx]['translated'] = text.strip()
|
||||
return setter
|
||||
|
||||
text_elements.append(
|
||||
(t_elem.text.strip(), make_chart_setter(chart_translations, len(chart_translations) - 1))
|
||||
)
|
||||
|
||||
# Collect from <c:v> elements (category names, series names)
|
||||
for v_elem in chart_xml.iter(f'{{{_NS_C}}}v'):
|
||||
text = v_elem.text
|
||||
if text and text.strip() and not text.strip().replace('.', '').replace('-', '').replace(',', '').isdigit():
|
||||
if text.strip() not in seen_texts:
|
||||
seen_texts.add(text.strip())
|
||||
entry = {
|
||||
'chart_file': chart_file,
|
||||
'original': text.strip(),
|
||||
'translated': None,
|
||||
}
|
||||
chart_translations.append(entry)
|
||||
|
||||
def make_chart_v_setter(entries, idx):
|
||||
def setter(text):
|
||||
entries[idx]['translated'] = text.strip()
|
||||
return setter
|
||||
|
||||
text_elements.append(
|
||||
(text.strip(), make_chart_v_setter(chart_translations, len(chart_translations) - 1))
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_log_error("excel_chart_parse_error", chart_file=chart_file, error=str(e))
|
||||
|
||||
except Exception as e:
|
||||
_log_error("excel_charts_zip_error", error=str(e))
|
||||
|
||||
def _apply_chart_translations(self, output_path: Path, chart_translations: List[Dict[str, Any]]) -> None:
|
||||
"""Re-inject chart translations into the .xlsx ZIP."""
|
||||
if not chart_translations:
|
||||
return
|
||||
|
||||
translated_entries = [e for e in chart_translations if 'translated' in e and e['translated']]
|
||||
if not translated_entries:
|
||||
return
|
||||
|
||||
_NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
_NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart"
|
||||
|
||||
chart_files_to_update: Dict[str, List[Dict]] = {}
|
||||
for entry in translated_entries:
|
||||
cf = entry['chart_file']
|
||||
if cf not in chart_files_to_update:
|
||||
chart_files_to_update[cf] = []
|
||||
chart_files_to_update[cf].append(entry)
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(output_path, 'r') as zf_in:
|
||||
existing_entries = zf_in.namelist()
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf_out:
|
||||
for item in existing_entries:
|
||||
data = zf_in.read(item)
|
||||
|
||||
if item in chart_files_to_update:
|
||||
try:
|
||||
chart_xml = etree.fromstring(data)
|
||||
for entry in chart_files_to_update[item]:
|
||||
for t_elem in chart_xml.iter(f'{{{_NS_A}}}t'):
|
||||
if t_elem.text and t_elem.text.strip() == entry['original']:
|
||||
t_elem.text = entry['translated']
|
||||
break
|
||||
else:
|
||||
for v_elem in chart_xml.iter(f'{{{_NS_C}}}v'):
|
||||
if v_elem.text and v_elem.text.strip() == entry['original']:
|
||||
v_elem.text = entry['translated']
|
||||
break
|
||||
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))
|
||||
|
||||
zf_out.writestr(item, data)
|
||||
|
||||
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))
|
||||
|
||||
except Exception as e:
|
||||
_log_error("excel_chart_zip_rewrite_error", error=str(e))
|
||||
|
||||
def _translate_images(self, worksheet: Worksheet, target_language: str) -> None:
|
||||
"""
|
||||
Translate text in images using vision model.
|
||||
|
||||
Reference in New Issue
Block a user