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

@@ -7,6 +7,8 @@ Updated to use new TranslationProvider interface with structured error handling.
"""
import time
import zipfile
import io
import concurrent.futures
from pathlib import Path
from typing import Dict, List, Tuple, Optional, Callable, Any
@@ -313,6 +315,9 @@ class PowerPointTranslator:
details={"file_name": output_path.name, "error": str(e)},
)
# Re-inject chart translations into chart XML parts
self._apply_chart_translations(output_path)
processing_time_ms = round((time.time() - start_time) * 1000, 2)
_log_info(
@@ -439,6 +444,10 @@ class PowerPointTranslator:
for sub_shape in shape.shapes:
self._collect_from_shape(sub_shape, text_elements)
# Chart shapes — text is stored in separate chart XML parts
if shape.shape_type == MSO_SHAPE_TYPE.CHART:
self._collect_from_chart_shape(shape, text_elements)
if hasattr(shape, "shapes"):
try:
for sub_shape in shape.shapes:
@@ -446,6 +455,82 @@ class PowerPointTranslator:
except Exception:
pass
def _collect_from_chart_shape(
self, shape: BaseShape, text_elements: List[Tuple[str, Callable[[str], None]]]
) -> None:
"""Collect translatable text from a chart shape.
Chart text (title, axis titles, series names, data labels) is stored
in a separate chart XML part, not in shape.text_frame.
"""
_NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
_NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart"
try:
chart_data = shape.chart
# Access the chart XML part through the chart's part
chart_part = chart_data.part
chart_xml = etree.fromstring(chart_part.blob)
# Collect text from <a:t> elements in chart XML
# These include: chart title, axis titles, legend entries, data labels
seen_texts: set = set()
chart_text_entries: List[Dict[str, Any]] = []
for t_elem in chart_xml.iter(f'{{{_NS_A}}}t'):
text = t_elem.text
if text and text.strip() and text.strip() not in seen_texts:
seen_texts.add(text.strip())
entry = {
'element': t_elem,
'original': text.strip(),
'translated': None,
}
chart_text_entries.append(entry)
def make_chart_setter(entries, idx):
def setter(translated_text):
entries[idx]['translated'] = translated_text.strip()
return setter
text_elements.append(
(text.strip(), make_chart_setter(chart_text_entries, len(chart_text_entries) - 1))
)
# Also collect from <c:v> (cell values used as category 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 = {
'element': v_elem,
'original': text.strip(),
'translated': None,
}
chart_text_entries.append(entry)
def make_chart_v_setter(entries, idx):
def setter(translated_text):
entries[idx]['translated'] = translated_text.strip()
return setter
text_elements.append(
(text.strip(), make_chart_v_setter(chart_text_entries, len(chart_text_entries) - 1))
)
# Store chart_part reference and entries for later re-injection
if chart_text_entries:
if not hasattr(self, '_chart_entries'):
self._chart_entries = []
self._chart_entries.append({
'chart_part': chart_part,
'entries': chart_text_entries,
})
except Exception as e:
_log_error("pptx_chart_collect_error", error=str(e))
def _collect_from_text_frame(
self, text_frame, text_elements: List[Tuple[str, Callable[[str], None]]]
) -> None:
@@ -472,5 +557,53 @@ class PowerPointTranslator:
text_elements.append((stripped, make_setter(run, leading, trailing)))
def _apply_chart_translations(self, output_path: Path) -> None:
"""Re-inject chart text translations by modifying chart XML parts in the .pptx ZIP."""
if not hasattr(self, '_chart_entries') or not self._chart_entries:
return
_NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
_NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart"
total_translated = 0
for chart_data in self._chart_entries:
entries = chart_data['entries']
chart_part = chart_data['chart_part']
translated_entries = [e for e in entries if e.get('translated')]
if not translated_entries:
continue
try:
chart_xml = etree.fromstring(chart_part.blob)
for entry in translated_entries:
# Try to find and update <a:t> elements
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']
total_translated += 1
break
else:
# Try <c:v> elements
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']
total_translated += 1
break
# Update the chart part blob
chart_part._blob = etree.tostring(chart_xml, xml_declaration=True, encoding='UTF-8', standalone=True)
except Exception as e:
_log_error("pptx_chart_update_error", error=str(e))
# Clean up
self._chart_entries = []
if total_translated > 0:
_log_info("pptx_charts_translated", total=total_translated)
pptx_translator = PowerPointTranslator()