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:
2026-05-10 11:43:28 +02:00
parent 16ac7ca2b9
commit ce8e150a61
110 changed files with 6935 additions and 4301 deletions

View File

@@ -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.