diff --git a/remarkable/Dockerfile b/remarkable/Dockerfile new file mode 100755 index 0000000..7ad463d --- /dev/null +++ b/remarkable/Dockerfile @@ -0,0 +1,51 @@ +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y \ + rsync curl unzip python3 python3-pip \ + inkscape poppler-utils cron \ + php-cli \ + && rm -rf /var/lib/apt/lists/* + +# rmc + Farb-Patch +RUN pip3 install "rmc==0.3.0" "rmscene>=0.7.0" "pypdf" "rmrl" --break-system-packages --ignore-installed packaging \ + && python3 - <<'EOF' +import pathlib +import rmc.exporters.writing_tools as m +f = pathlib.Path(m.__file__) +src = f.read_text() +src = src.replace( + "self.base_color = RM_PALETTE[base_color_id]", + "self.base_color = RM_PALETTE.get(base_color_id, (255, 235, 0) if base_color_id == 9 else (128, 128, 128))" +) +old = ' segment_color = [min(int(abs(intensity - 1) * 255), 60)] * 3\n return "rgb" + str(tuple(segment_color))' +new = ' r = min(int(self.base_color[0] * intensity), 255)\n g = min(int(self.base_color[1] * intensity), 255)\n b = min(int(self.base_color[2] * intensity), 255)\n return "rgb" + str((r, g, b))' +src = src.replace(old, new) +old_b = ' segment_color = [int(rev_intensity * (255 - self.base_color[0])),\n int(rev_intensity * (255 - self.base_color[1])),\n int(rev_intensity * (255 - self.base_color[2]))]\n return "rgb" + str(tuple(segment_color))' +new_b = ' segment_color = [int(255 - intensity * (255 - c)) for c in self.base_color]\n return "rgb" + str(tuple(segment_color))' +src = src.replace(old_b, new_b) +src = src.replace("self.base_opacity = 0.1", "self.base_opacity = 0.3") +f.write_text(src) +EOF + +# rmapi binary +COPY backup/rmapi /usr/local/bin/rmapi +RUN chmod +x /usr/local/bin/rmapi + +# Skripte +COPY scripts/backup-official-cloud.sh /usr/local/bin/remarkable-backup.sh +COPY scripts/convert-to-pdf.sh /usr/local/bin/remarkable-convert.sh +COPY scripts/merge-annotations.py /usr/local/bin/remarkable-merge.py +RUN chmod +x /usr/local/bin/remarkable-merge.py +RUN chmod +x /usr/local/bin/remarkable-backup.sh /usr/local/bin/remarkable-convert.sh + +# Web-UI +COPY web/ /app/ + +# Entrypoint +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 8080 +ENTRYPOINT ["/entrypoint.sh"] diff --git a/remarkable/README.md b/remarkable/README.md new file mode 100755 index 0000000..2e9f4d9 --- /dev/null +++ b/remarkable/README.md @@ -0,0 +1,128 @@ +# reMarkable Backup (offizielle Cloud) + lokale Web-UI + +Dieses Projekt ist auf einen klaren Workflow reduziert: + +- Tablet bleibt in der **offiziellen reMarkable-Cloud** (OCR/Convert bleibt aktiv). +- `rmapi` laeuft im Docker-Container und zieht periodisch Daten aus der offiziellen Cloud. +- `.rmdoc`-Dateien werden automatisch zu **PDF konvertiert** (Originale bleiben erhalten). +- Lokale Ablage im Docker-Volume `remarkable_notizen` mit inkrementellem Stand + datierten Snapshots. +- Web-UI zum Browsen und Oeffnen von PDFs direkt im Browser. + +## Dateien in diesem Projekt + +``` +/opt/remarkable/ +├── docker-compose.yml Orchestrierung beider Container +├── backup/ +│ ├── Dockerfile Backup-Container (rmapi, rmc, inkscape) +│ └── entrypoint.sh Cron-Setup + Start +├── scripts/ +│ ├── backup-official-cloud.sh Backup + Snapshot-Versionierung +│ └── convert-to-pdf.sh .rmdoc → PDF Konvertierung +└── web/ + ├── Dockerfile Web-UI-Container (PHP) + ├── index.php Dateibaum + PDF-Viewer + └── index2.php Archiv-Suche +``` + +## Voraussetzungen + +- Docker + Docker Compose +- Einmaliger reMarkable-Login (Token wird im Volume gespeichert) + +## Ersteinrichtung + +### 1. Token einrichten + +```bash +cd /opt/remarkable +docker compose run --rm --entrypoint bash backup -c "rmapi ls" +# Code von https://my.remarkable.com/device/desktop/connect eingeben +``` + +Token wird im Volume `remarkable_rmapi-config` gespeichert und bleibt bei Container-Updates erhalten. + +### 2. Container starten + +```bash +cd /opt/remarkable +docker compose up -d +``` + +### 3. Erstes Backup manuell ausfuehren + +```bash +docker compose exec backup remarkable-backup.sh /data / +``` + +## Normalbetrieb + +Backup + PDF-Konvertierung laufen automatisch per Cron (Standard: taeglich 03:15). + +Web-UI erreichbar unter: `http://SERVER_IP:8080` + +## Datenstruktur (im Volume `remarkable_notizen`) + +``` +current/ Aktueller Stand (inkl. konvertierter PDFs) +snapshots/ + 2026-03-23_031500/ + latest -> ... +logs/ +``` + +## Umgebungsvariablen (docker-compose.yml) + +| Variable | Standard | Beschreibung | +|----------|----------|--------------| +| `BACKUP_CRON` | `15 3 * * *` | Cron-Zeitplan | +| `SNAPSHOT_KEEP` | `60` | Anzahl aufzubewahrender Snapshots | +| `RUN_ON_START` | `0` | Backup beim Container-Start ausfuehren (`1` = ja) | + +## Manuelles Backup mit Optionen + +```bash +# Vollsync (alle Dateien neu laden) +docker compose exec backup env BACKUP_FULL=1 remarkable-backup.sh /data / + +# Spiegelmodus (lokal geloeschte Dateien entfernen) +docker compose exec backup env BACKUP_MIRROR_DELETE=1 remarkable-backup.sh /data / +``` + +## PDF-Konvertierung (convert-to-pdf.sh) + +Das Skript konvertiert alle `.rmdoc`-Dateien nach dem Backup automatisch zu PDFs. + +### Verhalten nach Dokumenttyp + +| Typ | Inhalt im rmdoc | Ergebnis | +|-----|-----------------|---------| +| Handschrift-Notiz | `.rm`-Dateien | Seiten werden einzeln via `rmc` + `inkscape` konvertiert und mit `pdfunite` zusammengefuegt | +| Importiertes PDF | `.pdf`-Datei | PDF wird direkt extrahiert | +| Reines EPUB | nur `.epub` | Wird uebersprungen (kein PDF moeglich) | + +### Seitenreihenfolge + +Die Reihenfolge der Seiten wird aus der `.content`-Datei im rmdoc gelesen. Damit stimmt die Reihenfolge im PDF mit der des Tablets ueberein. + +### Bekannte Einschraenkungen + +- **Custom ARGB-Farben** (Highlighter, farbige Stifte): werden als grau dargestellt. `rmc` 0.3.0 unterstuetzt keine benutzerdefinierten ARGB-Farbcodes. Issue: https://github.com/ricklupton/rmc/issues +- **Neuere rm-Formate**: Einige `.rm`-Dateien enthalten Daten in einem neueren Format als `rmscene` unterstuetzt — diese Seiten koennen fehlerhaft oder leer erscheinen. + +### Konvertierung manuell neu ausfuehren + +```bash +# Alle PDFs loeschen und neu konvertieren +docker compose exec backup bash -c "find /data/current -name '*.pdf' -delete && remarkable-convert.sh /data/current" + +# Nur neue/geaenderte Dateien konvertieren (Standard) +docker compose exec backup remarkable-convert.sh /data/current +``` + +## Warum dieser Aufbau + +- OCR bleibt verfuegbar, weil die offizielle Cloud weiter genutzt wird. +- Kein Vendor-Lock fuer Aufbewahrung: Daten liegen lokal. +- `.rmdoc`-Originale bleiben erhalten — jederzeit zurueckspielbar. +- Vollstaendig containerisiert: keine Host-Abhaengigkeiten ausser Docker. diff --git a/remarkable/docker-compose.yml b/remarkable/docker-compose.yml new file mode 100755 index 0000000..9e3b0d8 --- /dev/null +++ b/remarkable/docker-compose.yml @@ -0,0 +1,22 @@ +services: + remarkable: + build: . + container_name: remarkable + volumes: + - notizen:/data + - rmapi-config:/root/.config/rmapi + environment: + - BASE_DIR=/data + - BACKUP_CRON=15 3 * * * + - SNAPSHOT_KEEP=60 + - RUN_ON_START=0 + - RM_BACKUP_ROOT=/data/current + - RM_USER=${RM_USER} + - RM_PASS=${RM_PASS} + ports: + - "8980:8080" + restart: unless-stopped + +volumes: + notizen: + rmapi-config: diff --git a/remarkable/entrypoint.sh b/remarkable/entrypoint.sh new file mode 100755 index 0000000..37897a2 --- /dev/null +++ b/remarkable/entrypoint.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_DIR="${BASE_DIR:-/data}" +BACKUP_CRON="${BACKUP_CRON:-15 3 * * *}" +SNAPSHOT_KEEP="${SNAPSHOT_KEEP:-60}" +RUN_ON_START="${RUN_ON_START:-0}" + +mkdir -p "${BASE_DIR}" /var/log + +# Cron-Job einrichten +CRON_CMD="SNAPSHOT_KEEP=${SNAPSHOT_KEEP} remarkable-backup.sh ${BASE_DIR} / && remarkable-convert.sh ${BASE_DIR}/current >> /var/log/rm-backup.log 2>&1" +echo "${BACKUP_CRON} root ${CRON_CMD}" > /etc/cron.d/remarkable +chmod 0644 /etc/cron.d/remarkable + +echo "== reMarkable Container ==" +echo "Cron: ${BACKUP_CRON}" +echo "Base: ${BASE_DIR}" +echo "Retention: ${SNAPSHOT_KEEP}" + +if [[ "${RUN_ON_START}" == "1" ]]; then + echo "Starte initiales Backup ..." + SNAPSHOT_KEEP="${SNAPSHOT_KEEP}" remarkable-backup.sh "${BASE_DIR}" / || true + remarkable-convert.sh "${BASE_DIR}/current" || true +fi + +# Cron starten +service cron start + +# PHP Web-Server im Vordergrund +echo "Web-UI: http://0.0.0.0:8080" +exec php -S 0.0.0.0:8080 -t /app diff --git a/remarkable/scripts/backup-official-cloud.sh b/remarkable/scripts/backup-official-cloud.sh new file mode 100755 index 0000000..db28692 --- /dev/null +++ b/remarkable/scripts/backup-official-cloud.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# reMarkable Backup (offizielle Cloud via rmapi) +# +# Ziel: +# - Inkrementeller Download in "current/". +# - Datierte Snapshots in "snapshots/YYYY-MM-DD_HHMMSS/". +# - Versionierung via Hardlinks (unveraenderte Dateien belegen kaum Zusatzplatz). +# +# Nutzung: +# ./backup-official-cloud.sh /www/remarkable/notizen / +# +set -euo pipefail + +BASE_DIR="${1:-/www/remarkable/notizen}" +REMOTE_DIR="${2:-/}" +TIMESTAMP="$(date +%F_%H%M%S)" + +CURRENT_DIR="${BASE_DIR}/current" +SNAPSHOT_ROOT="${BASE_DIR}/snapshots" +SNAPSHOT_DIR="${SNAPSHOT_ROOT}/${TIMESTAMP}" +LATEST_LINK="${SNAPSHOT_ROOT}/latest" +LOG_DIR="${BASE_DIR}/logs" +LOCK_FILE="${BASE_DIR}/.backup.lock" + +# Optional: Pfad, der ein echter Mountpoint sein muss (z. B. /www/remarkable/notizen). +# Wenn gesetzt und kein Mountpoint, wird abgebrochen (sicher gegen "auf Root-FS schreiben"). +MOUNT_CHECK_PATH="${MOUNT_CHECK_PATH:-}" + +# Optional: Anzahl alter Snapshots behalten (0 = unbegrenzt). +SNAPSHOT_KEEP="${SNAPSHOT_KEEP:-30}" + +mkdir -p "${CURRENT_DIR}" "${SNAPSHOT_ROOT}" "${LOG_DIR}" + +if [[ -n "${MOUNT_CHECK_PATH}" ]] && ! mountpoint -q "${MOUNT_CHECK_PATH}"; then + echo "Fehler: ${MOUNT_CHECK_PATH} ist nicht gemountet. Backup abgebrochen." + exit 1 +fi + +if ! command -v rmapi >/dev/null 2>&1; then + echo "Fehler: rmapi nicht im PATH." + exit 1 +fi + +if ! command -v rsync >/dev/null 2>&1; then + echo "Fehler: rsync fehlt. Bitte installieren." + exit 1 +fi + +exec 9>"${LOCK_FILE}" +if ! flock -n 9; then + echo "Backup laeuft bereits (Lock: ${LOCK_FILE})." + exit 1 +fi + +LOG_FILE="${LOG_DIR}/backup-${TIMESTAMP}.log" +exec > >(tee -a "${LOG_FILE}") 2>&1 + +echo "== reMarkable Backup Start ==" +echo "Base: ${BASE_DIR}" +echo "Remote: ${REMOTE_DIR}" +echo "Zeit: ${TIMESTAMP}" + +MGET_FLAGS=(-i) +if [[ "${BACKUP_FULL:-}" == "1" ]]; then + MGET_FLAGS=() +fi +if [[ "${BACKUP_MIRROR_DELETE:-}" == "1" ]]; then + MGET_FLAGS+=(-d) +fi + +echo "1) Aktualisiere current/ via rmapi mget ${MGET_FLAGS[*]:-(none)} ..." +rmapi mget "${MGET_FLAGS[@]}" -o "${CURRENT_DIR}" "${REMOTE_DIR}" + +# Leere Ordner anlegen (rmapi mget erstellt keine leeren Verzeichnisse) +echo "1b) Lege fehlende Ordner an ..." +rmapi find "${REMOTE_DIR}" 2>/dev/null | grep '^\[d\]' | sed 's/^\[d\] *//' | while read -r DIR; do + LOCAL_DIR="${CURRENT_DIR}/${DIR}" + mkdir -p "${LOCAL_DIR}" +done + +echo "2) Erzeuge Snapshot: ${SNAPSHOT_DIR}" +mkdir -p "${SNAPSHOT_DIR}" + +if [[ -L "${LATEST_LINK}" ]] && [[ -d "$(readlink -f "${LATEST_LINK}")" ]]; then + PREV_SNAPSHOT="$(readlink -f "${LATEST_LINK}")" + rsync -a --delete --link-dest="${PREV_SNAPSHOT}" "${CURRENT_DIR}/" "${SNAPSHOT_DIR}/" +else + rsync -a --delete "${CURRENT_DIR}/" "${SNAPSHOT_DIR}/" +fi + +ln -sfn "${SNAPSHOT_DIR}" "${LATEST_LINK}" + +if [[ "${SNAPSHOT_KEEP}" =~ ^[0-9]+$ ]] && (( SNAPSHOT_KEEP > 0 )); then + echo "3) Pruefe Retention (keep=${SNAPSHOT_KEEP}) ..." + mapfile -t SNAPSHOTS < <(ls -1 "${SNAPSHOT_ROOT}" | grep -E '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{6}$' | sort) + if (( ${#SNAPSHOTS[@]} > SNAPSHOT_KEEP )); then + DELETE_COUNT=$(( ${#SNAPSHOTS[@]} - SNAPSHOT_KEEP )) + for OLD in "${SNAPSHOTS[@]:0:DELETE_COUNT}"; do + rm -rf "${SNAPSHOT_ROOT}/${OLD}" + echo " entfernt: ${SNAPSHOT_ROOT}/${OLD}" + done + fi +fi + +# PDF-Konvertierung +if command -v remarkable-convert.sh >/dev/null 2>&1; then + echo "4) Konvertiere .rmdoc zu PDF ..." + remarkable-convert.sh "${CURRENT_DIR}" +fi + +echo "== Backup fertig ==" +echo "Current: ${CURRENT_DIR}" +echo "Snapshot: ${SNAPSHOT_DIR}" diff --git a/remarkable/scripts/convert-to-pdf.sh b/remarkable/scripts/convert-to-pdf.sh new file mode 100755 index 0000000..4f4f127 --- /dev/null +++ b/remarkable/scripts/convert-to-pdf.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# Konvertiert alle .rmdoc-Dateien in /mnt/remarkable/current zu PDFs. +# Originale bleiben erhalten. Bereits konvertierte Dateien werden übersprungen. +# +# Nutzung: +# ./convert-to-pdf.sh [BASE_DIR] +# BASE_DIR: Verzeichnis mit .rmdoc-Dateien (default: /mnt/remarkable/current) +set -euo pipefail + +BASE_DIR="${1:-/mnt/remarkable/current}" +TMPDIR_BASE="/tmp/rmdoc_convert" +CONVERTED=0 +SKIPPED=0 +FAILED=0 + +if ! command -v rmc >/dev/null 2>&1; then + echo "Fehler: rmc nicht im PATH. Installation: pip3 install rmc --break-system-packages" + exit 1 +fi +if ! command -v inkscape >/dev/null 2>&1; then + echo "Fehler: inkscape nicht im PATH. Installation: apt install inkscape" + exit 1 +fi +if ! command -v pdfunite >/dev/null 2>&1; then + echo "Fehler: pdfunite nicht im PATH. Installation: apt install poppler-utils" + exit 1 +fi + +while IFS= read -r -d '' RMDOC; do + PDF="${RMDOC%.rmdoc}.pdf" + + # Überspringen falls PDF bereits existiert und neuer als rmdoc + if [[ -f "${PDF}" ]] && [[ "${PDF}" -nt "${RMDOC}" ]]; then + (( SKIPPED++ )) || true + continue + fi + + # Primär: rmrl (direkt .rmdoc → PDF, kein Inkscape nötig) + if python3 -m rmrl "${RMDOC}" > "${PDF}" 2>/tmp/rmrl_debug.log && [[ -s "${PDF}" ]]; then + echo "OK: $(basename "${RMDOC}") (rmrl)" + pdftoppm -r 72 -jpeg -singlefile "${PDF}" "${PDF%.pdf}.thumb" 2>/dev/null || true + (( CONVERTED++ )) || true + rm -rf "${PDF}" # Platzhalter entfernen falls leer + continue + fi + + # Primär: rmrl (direkt .rmdoc → PDF, kein Inkscape nötig) + if python3 -m rmrl "${RMDOC}" > "${PDF}" 2>/tmp/rmrl_debug.log && [[ -s "${PDF}" ]]; then + echo "OK: $(basename "${RMDOC}") (rmrl)" + pdftoppm -r 72 -jpeg -singlefile "${PDF}" "${PDF%.pdf}.thumb" 2>/dev/null || true + (( CONVERTED++ )) || true + continue + fi + + TMPDIR="${TMPDIR_BASE}/$$_$(echo "${RMDOC%.rmdoc}" | md5sum | cut -c1-8)" + mkdir -p "${TMPDIR}" + + # rmdoc entpacken + if ! unzip -o -q "${RMDOC}" -d "${TMPDIR}" 2>/dev/null; then + echo "WARN: Konnte nicht entpacken: ${RMDOC}" + rm -rf "${TMPDIR}" + (( FAILED++ )) || true + continue + fi + + # Seitenreihenfolge aus .content lesen, sonst alphabetisch + RM_FILES=() + CONTENT_FILE="$(find "${TMPDIR}" -maxdepth 1 -name "*.content" | head -1)" + DOC_UUID="$(basename "${CONTENT_FILE%.content}")" + RM_DIR="${TMPDIR}/${DOC_UUID}" + + if [[ -f "${CONTENT_FILE}" ]] && [[ -d "${RM_DIR}" ]]; then + # Seiten-UUIDs aus .content in Reihenfolge extrahieren + mapfile -t PAGE_IDS < <(python3 -c " +import json, sys +try: + data = json.load(open('${CONTENT_FILE}')) + pages = data.get('cPages', {}).get('pages', []) + for p in pages: + print(p['id']) +except Exception as e: + sys.exit(1) +" 2>/dev/null) + + RM_FILES=() + for PAGE_ID in "${PAGE_IDS[@]}"; do + RM_FILE="${RM_DIR}/${PAGE_ID}.rm" + if [[ -f "${RM_FILE}" ]]; then + RM_FILES+=("${RM_FILE}") + fi + done + fi + + # Fallback: alle .rm-Dateien alphabetisch + if [[ ${#RM_FILES[@]} -eq 0 ]]; then + mapfile -t RM_FILES < <(find "${TMPDIR}" -name "*.rm" | sort) + fi + + EMBEDDED_PDF="$(find "${TMPDIR}" -maxdepth 2 -name "*.pdf" | head -1)" + + if [[ ${#RM_FILES[@]} -eq 0 ]]; then + # Kein Handschrift-Inhalt — eingebettete PDF direkt nutzen + if [[ -n "${EMBEDDED_PDF}" ]]; then + cp "${EMBEDDED_PDF}" "${PDF}" + echo "OK: $(basename "${RMDOC}") (eingebettete PDF)" + pdftoppm -r 72 -jpeg -singlefile "${PDF}" "${PDF%.pdf}.thumb" 2>/dev/null || true + (( CONVERTED++ )) || true + else + (( SKIPPED++ )) || true + fi + rm -rf "${TMPDIR}" + continue + fi + + # Annotiertes Import-Dokument: Annotations auf Original-PDF legen + if [[ -n "${EMBEDDED_PDF}" ]] && [[ ${#RM_FILES[@]} -gt 0 ]]; then + if python3 /usr/local/bin/remarkable-merge.py "${TMPDIR}" "${EMBEDDED_PDF}" "${PDF}" 2>/tmp/merge_debug.log && [[ -s "${PDF}" ]]; then + echo "OK: $(basename "${RMDOC}") (PDF + Annotationen)" + pdftoppm -r 72 -jpeg -singlefile "${PDF}" "${PDF%.pdf}.thumb" 2>/dev/null || true + (( CONVERTED++ )) || true + else + cp "${EMBEDDED_PDF}" "${PDF}" + echo "WARN: Merge fehlgeschlagen ($(cat /tmp/merge_debug.log | tail -1)), nutze Original-PDF: $(basename "${RMDOC}")" + pdftoppm -r 72 -jpeg -singlefile "${PDF}" "${PDF%.pdf}.thumb" 2>/dev/null || true + (( CONVERTED++ )) || true + fi + rm -rf "${TMPDIR}" + continue + fi + + # Jede Seite einzeln konvertieren, dann zusammenfügen + PAGE_PDFS=() + CONVERT_OK=true + for RM_FILE in "${RM_FILES[@]}"; do + PAGE_PDF="${TMPDIR}/$(basename "${RM_FILE%.rm}").pdf" + if rmc -t pdf -o "${PAGE_PDF}" "${RM_FILE}" 2>/dev/null && [[ -s "${PAGE_PDF}" ]]; then + PAGE_PDFS+=("${PAGE_PDF}") + else + CONVERT_OK=false + break + fi + done + + if [[ "${CONVERT_OK}" == true ]] && [[ ${#PAGE_PDFS[@]} -gt 0 ]]; then + if [[ ${#PAGE_PDFS[@]} -eq 1 ]]; then + cp "${PAGE_PDFS[0]}" "${PDF}" + else + pdfunite "${PAGE_PDFS[@]}" "${PDF}" 2>/dev/null + fi + if [[ -s "${PDF}" ]]; then + echo "OK: $(basename "${RMDOC}") (${#PAGE_PDFS[@]} Seiten)" + pdftoppm -r 72 -jpeg -singlefile "${PDF}" "${PDF%.pdf}.thumb" 2>/dev/null || true + (( CONVERTED++ )) || true + else + rm -f "${PDF}" + echo "WARN: Leere PDF für: $(basename "${RMDOC}")" + (( FAILED++ )) || true + fi + else + rm -f "${PDF}" + echo "WARN: Konvertierung fehlgeschlagen: $(basename "${RMDOC}")" + (( FAILED++ )) || true + fi + + rm -rf "${TMPDIR}" + +done < <(find "${BASE_DIR}" -name "*.rmdoc" -print0) + +echo "== Konvertierung fertig: ${CONVERTED} konvertiert, ${SKIPPED} übersprungen, ${FAILED} fehlgeschlagen ==" diff --git a/remarkable/scripts/merge-annotations.py b/remarkable/scripts/merge-annotations.py new file mode 100755 index 0000000..5a6121e --- /dev/null +++ b/remarkable/scripts/merge-annotations.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +import sys, os, subprocess, re, shutil, json, tempfile, struct +from pypdf import PdfReader, PdfWriter + +INKSCAPE_TIMEOUT = 30 + +def fallback_page(reader, i, path): + w = PdfWriter() + w.add_page(reader.pages[i]) + with open(path, 'wb') as fh: + w.write(fh) + +tmpdir = sys.argv[1] +bg_pdf = sys.argv[2] +output = sys.argv[3] + +content_file = next( + (os.path.join(tmpdir, f) for f in os.listdir(tmpdir) if f.endswith('.content')), None +) +if not content_file: + sys.exit(1) + +with open(content_file) as f: + data = json.load(f) + +doc_uuid = os.path.basename(content_file)[:-8] +rm_dir = os.path.join(tmpdir, doc_uuid) +page_ids = [p['id'] for p in data.get('cPages', {}).get('pages', [])] + +page_rm = {} +for i, pid in enumerate(page_ids): + rm = os.path.join(rm_dir, pid + '.rm') + if os.path.exists(rm): + page_rm[i] = rm + +if not page_rm: + sys.exit(1) + +reader = PdfReader(bg_pdf) +n_pages = len(reader.pages) +work = tempfile.mkdtemp() +parts = [] + +for i in range(n_pages): + part_out = os.path.join(work, 'p' + str(i) + '.pdf') + + if i not in page_rm: + fallback_page(reader, i, part_out) + parts.append(part_out) + continue + + try: + # PDF-Seitengröße in Punkten + pdf_w_pt = float(reader.pages[i].mediabox.width) + pdf_h_pt = float(reader.pages[i].mediabox.height) + dpi = 150 + png_w = int(round(pdf_w_pt * dpi / 72)) + png_h = int(round(pdf_h_pt * dpi / 72)) + + # Bg-Seite als PNG rendern + bg_base = os.path.join(work, 'bg' + str(i)) + subprocess.run( + ['pdftoppm', '-r', str(dpi), '-png', '-f', str(i+1), '-l', str(i+1), + '-singlefile', bg_pdf, bg_base], + capture_output=True, timeout=20 + ) + bg_png = bg_base + '.png' + if not os.path.exists(bg_png): + raise FileNotFoundError('PNG fehlt') + + # .rm -> SVG + svg_out = os.path.join(work, 'a' + str(i) + '.svg') + r = subprocess.run(['rmc', '-t', 'svg', '-o', svg_out, page_rm[i]], + capture_output=True, timeout=20) + if r.returncode != 0 or not os.path.exists(svg_out): + raise RuntimeError('rmc fehlgeschlagen') + + svg = open(svg_out).read() + + # Weissen Hintergrund entfernen + svg = re.sub( + r']*/?>', + lambda m: '' if re.search(r'fill\s*[=:]\s*["\']?\s*(?:white|#fff(?:fff)?)', m.group(0), re.I) else m.group(0), + svg + ) + + # ViewBox X-Offset auslesen + vb = re.search(r'viewBox=["\']([^"\']+)["\']', svg) + vb_vals = [float(x) for x in vb.group(1).split()] if vb else [0, 0, pdf_w_pt, pdf_h_pt] + vb_x = vb_vals[0] + vb_y = vb_vals[1] + + # Inneren SVG-Inhalt extrahieren + inner_m = re.search(r']*>(.*)', svg, re.DOTALL) + inner = inner_m.group(1) if inner_m else '' + + # Annotation-Koordinaten sind in PDF-Punkten + # Skalierung: PDF-Punkte -> PNG-Pixel + pts_to_px = png_w / pdf_w_pt + # reMarkable Seitenrand proportional zur PDF-Breite (empirisch: 75.5pt bei A4) + rm_margin_left = 75.5 * pdf_w_pt / 595.275 + # reMarkable Seitenrand proportional zur PDF-Breite (empirisch: 75.5pt bei A4) + rm_margin_left = 75.5 * pdf_w_pt / 595.275 + + # Composite SVG + comp_svg = os.path.join(work, 'c' + str(i) + '.svg') + with open(comp_svg, 'w') as fh: + fh.write('\n') + fh.write('\n') + fh.write(' \n') + fh.write(' \n') + fh.write(' ' + inner + '\n') + fh.write(' \n') + fh.write('') + + # Composite -> PDF + r = subprocess.run( + ['inkscape', comp_svg, '--export-type=pdf', '--export-filename=' + part_out], + capture_output=True, timeout=INKSCAPE_TIMEOUT + ) + if r.returncode != 0 or not os.path.exists(part_out) or os.path.getsize(part_out) < 100: + raise RuntimeError('inkscape fehlgeschlagen') + + parts.append(part_out) + print(' Seite ' + str(i+1) + ' mit Annotation', flush=True) + + except Exception as e: + print(' Seite ' + str(i+1) + ' Fallback: ' + str(e), flush=True) + fallback_page(reader, i, part_out) + parts.append(part_out) + +if len(parts) == 1: + shutil.copy(parts[0], output) +elif parts: + subprocess.run(['pdfunite'] + parts + [output], check=True) + +shutil.rmtree(work) diff --git a/remarkable/web/Dockerfile b/remarkable/web/Dockerfile new file mode 100755 index 0000000..acb955c --- /dev/null +++ b/remarkable/web/Dockerfile @@ -0,0 +1,7 @@ +FROM php:8.3-cli +RUN apt-get update && apt-get install -y unzip && rm -rf /var/lib/apt/lists/* +COPY backup/rmapi /usr/local/bin/rmapi +RUN chmod +x /usr/local/bin/rmapi +WORKDIR /app +COPY web/ /app +CMD ["php", "-S", "0.0.0.0:8080", "-t", "/app"] diff --git a/remarkable/web/auth.php b/remarkable/web/auth.php new file mode 100755 index 0000000..474b534 --- /dev/null +++ b/remarkable/web/auth.php @@ -0,0 +1,26 @@ + $s !== '' && $s !== '.' && $s !== '..'); + return '/' . implode('/', array_values($parts)); +} +function rmapiRun(string $bin, array $args): array { + $cmd = $bin; + foreach ($args as $a) $cmd .= ' ' . escapeshellarg((string)$a); + exec($cmd . ' 2>&1', $out, $code); + return ['out' => $out, 'ok' => $code === 0]; +} +function addWebDeleted(string $backup, string $path): void { + // Pfad ohne Erweiterung speichern (deckt .pdf und .rmdoc ab) + $clean = preg_replace('/\.(pdf|rmdoc)$/i', '', $path); + $file = dirname(rtrim($backup, '/')) . '/.web_deleted'; + file_put_contents($file, $clean . "\n", FILE_APPEND | LOCK_EX); +} +function rmapiRmRecursive(string $bin, string $cloudPath): void { + // rmapi find gibt Pfade relativ zum Parent zurück, z.B. + // find /trash/Scoreblatt → "[f] Scoreblatt/Notebook 4" + // Daher Parent-Pfad vorbauen + $parent = rtrim(dirname($cloudPath), '/'); + $out = []; + exec($bin . ' find ' . escapeshellarg($cloudPath) . ' 2>/dev/null', $out); + $files = []; + $dirs = []; + foreach ($out as $line) { + if (preg_match('/^\[(f|d)\] (.+)$/', trim($line), $m)) { + $abs = $parent . '/' . ltrim($m[2], '/'); + if ($m[1] === 'f') $files[] = $abs; + else $dirs[] = $abs; + } + } + // Dateien zuerst löschen + foreach ($files as $p) rmapiRun($bin, ['rm', $p]); + // Ordner von tief nach oben löschen + rsort($dirs); // tiefste zuerst via Länge + usort($dirs, fn($a,$b) => strlen($b) - strlen($a)); + foreach ($dirs as $p) rmapiRun($bin, ['rm', $p]); + // Den Pfad selbst löschen (falls nicht schon in $dirs) + if (!in_array($cloudPath, $dirs)) rmapiRun($bin, ['rm', $cloudPath]); +} +function listDir(string $backup, string $path, bool $showRmdoc, bool $showTrash = true): array { + $dir = rtrim($backup, '/') . ($path === '/' ? '' : $path); + if (!is_dir($dir)) return []; + $items = []; + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') continue; + if ($entry === 'trash' && $path === '/' ) continue; + $full = $dir . '/' . $entry; + $lname = strtolower($entry); + $isRmdoc = str_ends_with($lname, '.rmdoc'); + $isPdf = str_ends_with($lname, '.pdf'); + $isDir = is_dir($full); + if (!$isDir && !$isPdf && !($showRmdoc && $isRmdoc)) continue; + $items[] = [ + 'name' => $entry, + 'type' => $isDir ? 'folder' : 'file', + 'size' => is_file($full) ? filesize($full) : 0, + 'mtime' => filemtime($full), + 'is_pdf' => $isPdf, + 'is_rmdoc' => $isRmdoc, + ]; + } + return $items; +} +function formatSize(int $bytes): string { + if ($bytes < 1024) return $bytes . ' B'; + if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB'; + return round($bytes / 1048576, 1) . ' MB'; +} + +$action = $_POST['action'] ?? $_GET['action'] ?? ''; +$path = safePath($_POST['path'] ?? $_GET['path'] ?? '/'); +$flash = ''; +$flashOk = true; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if ($action === 'delete') { + $target = safePath($_POST['target'] ?? ''); + $localTarget = realpath($BACKUP . $target); + $realBackup = realpath($BACKUP); + if ($target !== '/' && $localTarget !== false && $realBackup !== false + && str_starts_with($localTarget, $realBackup . '/')) + { + $wasDir = is_dir($localTarget); + $cloudTarget = preg_replace('/\.(pdf|rmdoc)$/i', '', $target); + if ($wasDir) { + shell_exec('rm -rf ' . escapeshellarg($localTarget)); + clearstatcache(true, $localTarget); + $ok = !is_dir($localTarget); + } else { + $ok = @unlink($localTarget); + if (str_ends_with(strtolower($target), '.pdf')) { + @unlink($BACKUP . substr($target, 0, -4) . '.rmdoc'); + @unlink($BACKUP . substr($target, 0, -4) . '.thumb.jpg'); + } + } + if ($wasDir) { + rmapiRmRecursive($RMAPI, $cloudTarget); + $rmResult = ['ok' => true]; + } else { + $rmResult = rmapiRun($RMAPI, ['rm', $cloudTarget]); + } + if ($ok) { + addWebDeleted($BACKUP, $target); + $flash = 'Gelöscht: ' . $target; + $flashOk = true; + if (!$rmResult['ok']) $flash .= ' (wird beim nächsten Sync erneut entfernt)'; + } else { + $flash = 'Fehler beim Löschen.'; + $flashOk = false; + } + } + } + if ($action === 'delete_multi') { + $targets = is_array($_POST['targets'] ?? null) ? $_POST['targets'] : []; + $deleted = 0; + $realBackup = realpath($BACKUP); + foreach ($targets as $t) { + $t = safePath($t); + $lt = $t !== '/' ? realpath($BACKUP . $t) : false; + if (!$lt || !$realBackup || !str_starts_with($lt, $realBackup . '/')) continue; + $ct = preg_replace('/\.(pdf|rmdoc)$/i', '', $t); + if (is_dir($lt)) { + shell_exec('rm -rf ' . escapeshellarg($lt)); + clearstatcache(true, $lt); + if (!is_dir($lt)) { $deleted++; addWebDeleted($BACKUP, $t); rmapiRmRecursive($RMAPI, $ct); } + } else { + if (@unlink($lt)) { $deleted++; clearstatcache(true, $lt); addWebDeleted($BACKUP, $t); rmapiRun($RMAPI, ['rm', $ct]); } + } + } + $flash = $deleted . ' Element(e) gelöscht.'; + $flashOk = true; + } + if ($action === 'mkdir') { + $name = trim($_POST['name'] ?? ''); + $newPath = safePath(rtrim($path, '/') . '/' . $name); + if ($name !== '') { + $r = rmapiRun($RMAPI, ['mkdir', $newPath]); + if ($r['ok']) @mkdir($BACKUP . $newPath, 0755, true); + $flash = $r['ok'] ? 'Ordner erstellt: ' . $newPath : 'Fehler: ' . implode(' ', $r['out']); + $flashOk = $r['ok']; + } + } + if ($action === 'upload') { + $name = trim($_FILES['file']['name'] ?? ''); + if ($name !== '' && isset($_FILES['file']['tmp_name']) && is_uploaded_file($_FILES['file']['tmp_name'])) { + $safeName = preg_replace('/[^a-zA-Z0-9_\-\. ]/', '_', $name); + $tmpFile = $TMP . '/' . $safeName; + move_uploaded_file($_FILES['file']['tmp_name'], $tmpFile); + $r = rmapiRun($RMAPI, ['put', $tmpFile, rtrim($path, '/')]); + @unlink($tmpFile); + $flash = $r['ok'] ? 'Hochgeladen: ' . $name : 'Fehler: ' . implode(' ', $r['out']); + $flashOk = $r['ok']; + } + } + if ($action === 'move') { + $src = safePath($_POST['src'] ?? ''); + $dst = safePath($_POST['dst'] ?? ''); + if ($src !== '/' && $dst !== '' && $src !== $dst) { + $strip = fn(string $p) => preg_replace('/\.(pdf|rmdoc)$/i', '', $p); + $r = rmapiRun($RMAPI, ['mv', $strip($src), $strip($dst)]); + if ($r['ok']) { + $localSrc = realpath($BACKUP . $src); + if ($localSrc) @rename($localSrc, $BACKUP . $dst); + $rmdocSrc = realpath($BACKUP . $strip($src) . '.rmdoc'); + if ($rmdocSrc) @rename($rmdocSrc, $BACKUP . $strip($dst) . '.rmdoc'); + $thumbSrc = realpath($BACKUP . $strip($src) . '.thumb.jpg'); + if ($thumbSrc) @rename($thumbSrc, $BACKUP . $strip($dst) . '.thumb.jpg'); + } + $flash = $r['ok'] ? 'Verschoben nach: ' . $dst : 'Fehler: ' . implode(' ', $r['out']); + $flashOk = $r['ok']; + } + } + if ($action === 'move_multi') { + $targets = is_array($_POST['targets'] ?? null) ? $_POST['targets'] : []; + $destDir = safePath($_POST['destination'] ?? ''); + $moved = 0; + $strip = fn(string $p) => preg_replace('/\.(pdf|rmdoc)$/i', '', $p); + foreach ($targets as $t) { + $t = safePath($t); + if ($t === '/' || $destDir === '') continue; + $name = basename($t); + $dst = safePath($destDir . '/' . $name); + if ($dst === $t) continue; + $r = rmapiRun($RMAPI, ['mv', $strip($t), $strip($dst)]); + if ($r['ok']) { + $lt = realpath($BACKUP . $t); + if ($lt) @rename($lt, $BACKUP . $dst); + $rs = realpath($BACKUP . $strip($t) . '.rmdoc'); + if ($rs) @rename($rs, $BACKUP . $strip($dst) . '.rmdoc'); + $rt = realpath($BACKUP . $strip($t) . '.thumb.jpg'); + if ($rt) @rename($rt, $BACKUP . $strip($dst) . '.thumb.jpg'); + $moved++; + } + } + $flash = $moved . ' Element(e) verschoben nach: ' . $destDir; + $flashOk = true; + } +} + +if ($action === 'folders') { + header('Content-Type: application/json'); + $browsePath = safePath($_GET['path'] ?? '/'); + $dir = rtrim($BACKUP, '/') . ($browsePath === '/' ? '' : $browsePath); + $folders = []; + foreach (scandir($dir) ?: [] as $e) { + if ($e === '.' || $e === '..') continue; + if (is_dir($dir . '/' . $e)) $folders[] = $e; + } + echo json_encode(['path' => $browsePath, 'folders' => $folders]); + exit; +} +if ($action === 'view') { + $target = safePath($_GET['target'] ?? ''); + $realLocal = realpath($BACKUP . $target); + $realBackup = realpath($BACKUP); + if (!$realLocal || !$realBackup || !str_starts_with($realLocal, $realBackup . '/')) { + http_response_code(403); echo 'Zugriff verweigert.'; exit; + } + if (!is_file($realLocal)) { http_response_code(404); echo 'Nicht gefunden.'; exit; } + $ext = strtolower(pathinfo($realLocal, PATHINFO_EXTENSION)); + $mime = match($ext) { 'pdf' => 'application/pdf', 'jpg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', default => 'application/octet-stream' }; + header('Content-Type: ' . $mime); + header('Content-Disposition: inline; filename="' . basename($realLocal) . '"'); + header('Content-Length: ' . filesize($realLocal)); + readfile($realLocal); + exit; +} + +$showRmdoc = isset($_GET['rmdoc']) && $_GET['rmdoc'] === '1'; +$showTrash = isset($_GET['trash']) && $_GET['trash'] === '1'; +$items = listDir($BACKUP, $path, $showRmdoc, $showTrash); +$sort = $_GET['sort'] ?? 'name'; +$order = $_GET['order'] ?? 'asc'; + +usort($items, function ($a, $b) use ($sort, $order) { + if ($a['type'] !== $b['type']) return $a['type'] === 'folder' ? -1 : 1; + $cmp = strnatcasecmp($a['name'], $b['name']); + return $order === 'desc' ? -$cmp : $cmp; +}); + +$breadcrumbs = [['label' => 'reMarkable', 'path' => '/']]; +$parts = array_filter(explode('/', $path)); +$acc = ''; +foreach ($parts as $p) { $acc .= '/' . $p; $breadcrumbs[] = ['label' => $p, 'path' => $acc]; } +$parentPath = count($parts) > 0 ? safePath(dirname($path)) : '/'; + +$folders = array_values(array_filter($items, fn($i) => $i['type'] === 'folder')); +$files = array_values(array_filter($items, fn($i) => $i['type'] === 'file')); +?> + + + + + +reMarkable + + + + + + + +
+
+ +
+ + reMarkable +
+ + + + + +
+ + + + + + + + +
+
+
+ + +
+ + +
+ ' : '' ?> + +
+ + + +
+ + Einträge + + + + + rmdoc + +
+ + +
+ +

Keine Dokumente vorhanden

+

Backup noch nicht ausgeführt oder Ordner ist leer

+
+ + + + + +
+

Ordner

+
+ +
+ +
+ +
+ +
+ + +
+
+ +
+
+ + + +
+ + + + +
+

Dokumente

+
+ +
+ +
+ + + + +
+ + + + + +
+ + +
+
+ + + + + +
+
+
+ +
+

+

+ + 0): ?> · +

+
+
+ +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/remarkable/web/index2.php b/remarkable/web/index2.php new file mode 100755 index 0000000..5b7c42b --- /dev/null +++ b/remarkable/web/index2.php @@ -0,0 +1,157 @@ +getPathname(); + $relativePath = ltrim(substr($absolutePath, strlen($backupRootReal)), DIRECTORY_SEPARATOR); + if ($relativePath === '') { + continue; + } + + $relativePathLower = mb_strtolower($relativePath); + if ($query !== '' && !str_contains($relativePathLower, $qLower)) { + continue; + } + + $files[] = [ + 'relative' => str_replace(DIRECTORY_SEPARATOR, '/', $relativePath), + 'is_dir' => $entry->isDir(), + 'size' => $entry->isFile() ? $entry->getSize() : 0, + 'mtime' => $entry->getMTime(), + 'is_pdf' => $entry->isFile() && str_ends_with(strtolower($relativePath), '.pdf'), + ]; + } + + usort($files, static function (array $a, array $b): int { + if ($a['is_dir'] !== $b['is_dir']) { + return $a['is_dir'] ? -1 : 1; + } + return strnatcasecmp($a['relative'], $b['relative']); + }); + + if ($selected !== '') { + $candidate = realpath($backupRootReal . DIRECTORY_SEPARATOR . $selected); + if ($candidate !== false && is_file($candidate) && isInsideRoot($backupRootReal, $candidate)) { + $selectedPath = $candidate; + } + } + + if (isset($_GET['download']) && $selectedPath !== null) { + header('Content-Type: application/octet-stream'); + header('Content-Disposition: inline; filename="' . basename($selectedPath) . '"'); + header('Content-Length: ' . (string) filesize($selectedPath)); + readfile($selectedPath); + exit; + } +} +?> + + + + + + reMarkable Archiv + + + +
+ +
+ +
Links eine Datei waehlen. PDFs werden direkt angezeigt.
+ + +

+ + + + +
Keine PDF-Datei. Bitte ueber den Link oben herunterladen/oeffnen.
+ + +
+
+ + diff --git a/remarkable/web/setup.php b/remarkable/web/setup.php new file mode 100755 index 0000000..0a064bc --- /dev/null +++ b/remarkable/web/setup.php @@ -0,0 +1,118 @@ +&1', $out, $code); + return ['ok' => $code === 0, 'out' => implode("\n", $out)]; +} + +// Token einrichten +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['code'])) { + $code = trim($_POST['code']); + $code = preg_replace('/[^a-zA-Z0-9]/', '', $code); + if (strlen($code) === 8) { + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $proc = proc_open(escapeshellarg($RMAPI) . ' ls /', $descriptors, $pipes); + if (is_resource($proc)) { + fwrite($pipes[0], $code . "\n"); + fclose($pipes[0]); + $out = stream_get_contents($pipes[1]); + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($proc); + $flashOk = $exitCode === 0; + $flash = $flashOk ? 'Token erfolgreich eingerichtet!' : 'Fehler: Ungültiger Code oder Verbindungsproblem.'; + } + } else { + $flash = 'Code muss genau 8 Zeichen lang sein.'; + $flashOk = false; + } +} + +$status = rmapiStatus($RMAPI); +?> + + + + + +reMarkable Setup + + + + +
+

📄 reMarkable Setup

+

Verbindung zur reMarkable Cloud einrichten

+ +
+ +
+ + +
+ + + +
+ + + +
+

+ Code abrufen unter:
+ + my.remarkable.com/device/desktop/connect + +

+ +
+ + + +
+

+ Neuen Code abrufen unter:
+ + my.remarkable.com/device/desktop/connect + +

+ + + ← Zurück zur Übersicht +
+ + diff --git a/remarkable/web/sync.php b/remarkable/web/sync.php new file mode 100755 index 0000000..ed06e7e --- /dev/null +++ b/remarkable/web/sync.php @@ -0,0 +1,64 @@ +&1', 'r'); + if ($handle === false) return 1; + while (!feof($handle)) { + $chunk = fread($handle, 256); + if ($chunk !== false && $chunk !== '') { + echo $chunk; + flush(); + } + } + return pclose($handle); +} + +echo "=== Backup ===\n"; +flush(); +$exit = stream("SNAPSHOT_KEEP={$SNAPSHOT_KEEP} {$BACKUP_SCRIPT} {$BASE_DIR} /"); + +if ($exit !== 0) { + echo "\n[FEHLER] Backup fehlgeschlagen (exit {$exit})\n"; + exit; +} + +// Web-gelöschte Dateien wieder entfernen (kommen durch Sync zurück) +$excludeFile = $BASE_DIR . '/.web_deleted'; +if (file_exists($excludeFile)) { + $lines = file($excludeFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $removed = 0; + foreach (array_unique($lines) as $p) { + $p = '/' . ltrim($p, '/'); + $local = $BASE_DIR . '/current' . $p; + if (is_dir($local)) { + shell_exec('rm -rf ' . escapeshellarg($local)); + $removed++; + } else { + foreach ([$local, $local.'.pdf', $local.'.rmdoc', $local.'.thumb.jpg'] as $f) { + if (is_file($f) && @unlink($f)) $removed++; + } + } + } + if ($removed) echo "\n[Web-Löschung] $removed Element(e) erneut entfernt.\n"; +} + +echo "\n=== PDF-Konvertierung ===\n"; +flush(); +stream("{$CONVERT_SCRIPT} {$BASE_DIR}/current"); + +echo "\n=== Fertig ===\n";