Add reMarkable backup system with web UI

- Docker container with rmapi cloud backup, PDF conversion pipeline
- Web UI: file browser, multi-select, delete/move, thumbnail preview
- Sync: backup from reMarkable cloud, rmdoc→PDF conversion via rmc+Inkscape
- Excluded-files mechanism to prevent deleted items from returning after sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Borgal
2026-03-24 23:43:36 +01:00
parent 747a35e172
commit b63131c0c5
13 changed files with 1881 additions and 0 deletions

51
remarkable/Dockerfile Executable file
View File

@@ -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"]

128
remarkable/README.md Executable file
View File

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

22
remarkable/docker-compose.yml Executable file
View File

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

32
remarkable/entrypoint.sh Executable file
View File

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

View File

@@ -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}"

View File

@@ -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 =="

View File

@@ -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'<rect\b[^>]*/?>',
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[^>]*>(.*)</svg>', 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('<?xml version="1.0" encoding="UTF-8"?>\n')
fh.write('<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"')
fh.write(' width="' + str(pdf_w_pt) + 'pt"')
fh.write(' height="' + str(pdf_h_pt) + 'pt"')
fh.write(' viewBox="0 0 ' + str(png_w) + ' ' + str(png_h) + '">\n')
fh.write(' <image xlink:href="file://' + bg_png + '" href="file://' + bg_png + '"')
fh.write(' width="' + str(png_w) + '" height="' + str(png_h) + '"/>\n')
fh.write(' <g transform="scale(' + str(pts_to_px) + ',' + str(pts_to_px) + ')')
fh.write(' translate(' + str(-vb_x + rm_margin_left) + ',' + str(-vb_y) + ')">\n')
fh.write(' ' + inner + '\n')
fh.write(' </g>\n')
fh.write('</svg>')
# 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)

7
remarkable/web/Dockerfile Executable file
View File

@@ -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"]

26
remarkable/web/auth.php Executable file
View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
$user = getenv('RM_USER') ?: 'admin';
$pass = getenv('RM_PASS') ?: '';
if ($pass === '') return; // kein Passwort gesetzt → kein Schutz
// PHP Built-in Server liefert keine PHP_AUTH_* Variablen — Header manuell parsen
$authUser = $_SERVER['PHP_AUTH_USER'] ?? '';
$authPass = $_SERVER['PHP_AUTH_PW'] ?? '';
if ($authUser === '' && isset($_SERVER['HTTP_AUTHORIZATION'])) {
$decoded = base64_decode(substr($_SERVER['HTTP_AUTHORIZATION'], 6));
[$authUser, $authPass] = explode(':', $decoded, 2) + ['', ''];
}
$ok = $authUser === $user
&& hash_equals(hash('sha256', $pass), hash('sha256', $authPass));
if (!$ok) {
header('WWW-Authenticate: Basic realm="reMarkable"');
header('HTTP/1.1 401 Unauthorized');
echo 'Zugriff verweigert.';
exit;
}

852
remarkable/web/index.php Executable file
View File

@@ -0,0 +1,852 @@
<?php
declare(strict_types=1);
require __DIR__ . '/auth.php';
$RMAPI = getenv('RMAPI_BIN') ?: '/usr/local/bin/rmapi';
$BACKUP = rtrim(getenv('RM_BACKUP_ROOT') ?: '/data/current', '/');
$TMP = '/tmp/rmweb_cache';
@mkdir($TMP, 0700, true);
function h(string $v): string {
return htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function safePath(string $p): string {
if ($p === '' || $p === '/') return '/';
$parts = array_filter(explode('/', $p), fn($s) => $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'));
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>reMarkable</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='18' fill='%23000'/%3E%3Ctext y='.9em' font-size='72' font-family='Georgia,serif' font-weight='bold' fill='white' x='50%25' text-anchor='middle'%3Erm%3C/text%3E%3C/svg%3E">
<style>
[x-cloak] { display: none; }
.thumb-img { width:100%; height:100%; object-fit:cover; }
.item-card:has(.sel-cb:checked) .sel-wrap { display:block !important; }
</style>
</head>
<body class="bg-zinc-50 text-zinc-900 min-h-screen overflow-x-hidden" style="font-family:-apple-system,BlinkMacSystemFont,'Inter','Segoe UI',sans-serif;font-size:14px">
<!-- ── Header ─────────────────────────────────────────────────────── -->
<header class="bg-white border-b border-zinc-200 sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-5 flex items-center gap-3 h-14">
<div class="flex items-center gap-2 font-semibold text-[15px] shrink-0 text-zinc-800">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-zinc-400"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
reMarkable
</div>
<div class="w-px h-5 bg-zinc-200 shrink-0 hidden sm:block"></div>
<nav class="hidden sm:flex items-center gap-0.5 flex-1 min-w-0 overflow-hidden text-[13px]">
<?php foreach ($breadcrumbs as $i => $bc):
$isLast = $i === count($breadcrumbs) - 1;
$isFirst = $i === 0;
// Auf Mobile: nur erstes + letztes Element anzeigen, Rest ausblenden
$hideClass = (!$isFirst && !$isLast) ? 'hidden sm:flex' : 'flex';
?>
<?php if ($i > 0): ?><span class="text-zinc-300 px-0.5 text-xs <?= (!$isLast) ? 'hidden sm:inline' : '' ?>">/</span><?php endif; ?>
<div class="<?= $hideClass ?> items-center">
<?php if (!$isLast): ?>
<a href="?path=<?= rawurlencode($bc['path']) ?><?= $showRmdoc ? '&rmdoc=1' : '' ?><?= $showTrash ? '&trash=1' : '' ?>"
class="text-zinc-500 hover:text-zinc-900 hover:bg-zinc-100 px-2 py-1 rounded-md transition-colors whitespace-nowrap"><?= h($bc['label']) ?></a>
<?php else: ?>
<span class="font-medium text-zinc-900 px-2 py-1 whitespace-nowrap"><?= h($bc['label']) ?></span>
<?php endif; ?>
</div>
<?php endforeach; ?>
</nav>
<div class="flex items-center gap-2 shrink-0">
<a href="setup.php" class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-[12px] font-medium rounded-lg border border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-50 transition-colors" title="Setup">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></a>
<a href="?path=%2Ftrash" title="Papierkorb"
class="inline-flex items-center px-2 py-1.5 rounded-lg border border-zinc-200 bg-white text-zinc-400 hover:text-zinc-600 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
</a>
<button onclick="startSync()" class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-[12px] font-medium rounded-lg border border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-50 transition-colors" title="Sync">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
<span class="hidden sm:inline">Sync</span>
</button>
<button onclick="openModal('mkdirModal')" class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-[12px] font-medium rounded-lg border border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-50 transition-colors" title="Neuer Ordner">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span class="hidden sm:inline">Ordner</span>
</button>
<button onclick="openModal('uploadModal')" class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-[12px] font-medium rounded-lg bg-zinc-900 text-white hover:bg-zinc-700 transition-colors" title="Upload">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="16 16 12 12 8 16"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"/></svg>
<span class="hidden sm:inline">Upload</span>
</button>
</div>
</div>
</header>
<!-- ── Main ───────────────────────────────────────────────────────── -->
<main class="max-w-7xl mx-auto px-3 sm:px-5 py-4 sm:py-6">
<?php if ($flash !== ''): ?>
<div class="mb-5 flex items-center gap-2 px-4 py-2.5 rounded-xl text-[13px] <?= $flashOk ? 'bg-emerald-50 border border-emerald-200 text-emerald-800' : 'bg-red-50 border border-red-200 text-red-800' ?>">
<?= $flashOk ? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>' : '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>' ?>
<?= h($flash) ?>
</div>
<?php endif; ?>
<!-- Toolbar -->
<div class="flex items-center gap-2 mb-6">
<div class="flex-1 flex items-center gap-2">
<?php if ($path !== '/'): ?>
<a href="?path=<?= rawurlencode($parentPath) ?><?= $showRmdoc ? '&rmdoc=1' : '' ?><?= $showTrash ? '&trash=1' : '' ?>"
class="inline-flex items-center gap-1 px-3 py-1.5 text-[12px] font-medium rounded-lg border border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-50 transition-colors">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="15 18 9 12 15 6"/></svg>
Zurück
</a>
<?php endif; ?>
</div>
<span class="text-[12px] text-zinc-400"><?= count($items) ?> Einträge</span>
<a href="?path=<?= rawurlencode($path) ?><?= $showRmdoc ? '' : '&rmdoc=1' ?>"
class="inline-flex items-center gap-2 px-2.5 py-1.5 text-[12px] rounded-lg border transition-colors <?= $showRmdoc ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-zinc-200 bg-white text-zinc-500 hover:border-zinc-300' ?>">
<span class="inline-block w-7 h-4 rounded-full relative transition-colors <?= $showRmdoc ? 'bg-blue-500' : 'bg-zinc-300' ?>">
<span class="absolute top-0.5 w-3 h-3 bg-white rounded-full shadow transition-all <?= $showRmdoc ? 'left-3.5' : 'left-0.5' ?>"></span>
</span>
rmdoc
</a>
</div>
<?php if (empty($items)): ?>
<div class="text-center py-24 text-zinc-400">
<svg class="mx-auto mb-4 text-zinc-300" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<p class="text-base font-medium text-zinc-500">Keine Dokumente vorhanden</p>
<p class="text-sm mt-1">Backup noch nicht ausgeführt oder Ordner ist leer</p>
</div>
<?php else: ?>
<!-- ── Ordner ──────────────────────────────────────────────────── -->
<?php if (!empty($folders)): ?>
<div class="mb-7">
<p class="text-[11px] font-semibold uppercase tracking-widest text-zinc-400 mb-3">Ordner</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
<?php foreach ($folders as $item):
$itemPath = safePath(rtrim($path, '/') . '/' . $item['name']); ?>
<div class="group relative flex items-center gap-3 px-4 py-3 bg-white rounded-xl border border-zinc-200 hover:border-zinc-300 hover:shadow-sm cursor-pointer transition-all item-card"
onclick="location.href='?path=<?= rawurlencode($itemPath) ?><?= $showRmdoc ? '&rmdoc=1' : '' ?>'">
<div class="sel-wrap hidden group-hover:block shrink-0" onclick="event.stopPropagation()">
<input type="checkbox" class="sel-cb w-4 h-4 rounded accent-zinc-800" data-path="<?= h($itemPath) ?>" data-folder="1"
onchange="toggleItem(<?= h(json_encode($itemPath)) ?>, this)">
</div>
<div class="w-9 h-9 rounded-xl bg-amber-50 flex items-center justify-center shrink-0">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h3.172a2 2 0 0 1 1.414.586l1.414 1.414A2 2 0 0 0 12.414 8H19a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" fill="#e8d5b7" stroke="#c4a46b" stroke-width="1.5"/></svg>
</div>
<span class="text-[13px] font-medium text-zinc-700 truncate flex-1 min-w-0"><?= h($item['name']) ?></span>
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" onclick="event.stopPropagation()">
<button title="Umbenennen"
onclick="openRename(<?= h(json_encode($itemPath)) ?>,<?= h(json_encode($item['name'])) ?>)"
class="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 hover:bg-zinc-100 transition-colors">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button title="Löschen"
onclick="confirmDelete(<?= h(json_encode($itemPath)) ?>,<?= h(json_encode($item['name'])) ?>, true)"
class="p-1.5 rounded-lg text-zinc-400 hover:text-red-600 hover:bg-red-50 transition-colors">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
</button>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if (!empty($folders) && !empty($files)): ?>
<div class="border-t border-zinc-200 mb-7"></div>
<?php endif; ?>
<!-- ── Dokumente ───────────────────────────────────────────────── -->
<?php if (!empty($files)): ?>
<div>
<p class="text-[11px] font-semibold uppercase tracking-widest text-zinc-400 mb-3">Dokumente</p>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2 sm:gap-3">
<?php foreach ($files as $item):
$itemPath = safePath(rtrim($path, '/') . '/' . $item['name']);
$displayName = preg_replace('/\.(pdf|rmdoc)$/i', '', $item['name']);
$thumbLocal = $BACKUP . preg_replace('/\.(pdf|rmdoc)$/i', '.thumb.jpg', $item['name'] === basename($itemPath) ? $itemPath : $itemPath);
$thumbUrl = preg_replace('/\.(pdf|rmdoc)$/i', '.thumb.jpg', $itemPath);
$hasThumb = file_exists($BACKUP . $thumbUrl);
?>
<div class="group relative bg-white rounded-xl border border-zinc-200 hover:border-zinc-300 hover:shadow-md cursor-pointer transition-all overflow-hidden flex flex-col item-card"
onclick="openViewer(<?= h(json_encode($itemPath)) ?>,<?= h(json_encode($displayName)) ?>)">
<!-- Vorschau -->
<div class="relative bg-zinc-100 overflow-hidden" style="aspect-ratio:3/4">
<div class="sel-wrap hidden group-hover:block absolute top-1.5 left-1.5 z-20" onclick="event.stopPropagation()">
<input type="checkbox" class="sel-cb w-4 h-4 rounded accent-zinc-800" data-path="<?= h($itemPath) ?>"
onchange="toggleItem(<?= h(json_encode($itemPath)) ?>, this)">
</div>
<?php if ($hasThumb): ?>
<img src="?action=view&target=<?= rawurlencode($thumbUrl) ?>" alt="" class="thumb-img">
<?php else: ?>
<div class="w-full h-full flex items-center justify-center">
<?php if ($item['is_rmdoc']): ?>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="1.5"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
<?php else: ?>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Hover-Overlay mit Aktionen -->
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all flex items-start justify-end p-1.5 opacity-0 group-hover:opacity-100">
<div class="flex gap-1" onclick="event.stopPropagation()">
<a href="?action=view&target=<?= rawurlencode($itemPath) ?>" target="_blank"
class="p-1.5 rounded-lg bg-white/90 text-zinc-600 hover:text-zinc-900 shadow-sm transition-colors" title="In neuem Tab öffnen">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</a>
<button onclick="openRename(<?= h(json_encode($itemPath)) ?>,<?= h(json_encode($item['name'])) ?>)"
class="p-1.5 rounded-lg bg-white/90 text-zinc-600 hover:text-zinc-900 shadow-sm transition-colors" title="Umbenennen">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button onclick="confirmDelete(<?= h(json_encode($itemPath)) ?>,<?= h(json_encode($item['name'])) ?>)"
class="p-1.5 rounded-lg bg-white/90 text-red-500 hover:text-red-700 shadow-sm transition-colors" title="Löschen">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
</button>
</div>
</div>
</div>
<!-- Name -->
<div class="px-2.5 py-2">
<p class="text-[12px] font-medium text-zinc-700 truncate leading-tight"><?= h($displayName) ?></p>
<p class="text-[11px] text-zinc-400 mt-0.5">
<?= $item['is_rmdoc'] ? 'rmdoc' : 'PDF' ?>
<?php if ($item['size'] > 0): ?> · <?= formatSize($item['size']) ?><?php endif; ?>
</p>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
</main>
<!-- ── Modals ─────────────────────────────────────────────────────── -->
<div id="mkdirModal" class="modal-bg fixed inset-0 bg-black/40 hidden items-center justify-center z-50 p-4" style="backdrop-filter:blur(2px)">
<div class="bg-white rounded-2xl p-6 w-full max-w-sm shadow-2xl border border-zinc-200">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-base">Neuer Ordner</h3>
<button onclick="closeModal('mkdirModal')" class="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 hover:bg-zinc-100 transition-colors">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<form method="post" action="?path=<?= rawurlencode($path) ?>">
<input type="hidden" name="action" value="mkdir">
<input type="hidden" name="path" value="<?= h($path) ?>">
<label class="block text-[12px] font-medium text-zinc-500 mb-1.5">Ordnername</label>
<input type="text" name="name" placeholder="Mein Ordner" autofocus required
class="w-full px-3 py-2 border border-zinc-300 rounded-lg text-[13px] focus:outline-none focus:border-zinc-500 focus:ring-2 focus:ring-zinc-200 mb-4">
<div class="flex gap-2 justify-end">
<button type="button" onclick="closeModal('mkdirModal')" class="px-3 py-2 text-[13px] font-medium rounded-lg border border-zinc-200 text-zinc-600 hover:bg-zinc-50 transition-colors">Abbrechen</button>
<button type="submit" class="px-3 py-2 text-[13px] font-medium rounded-lg bg-zinc-900 text-white hover:bg-zinc-700 transition-colors">Erstellen</button>
</div>
</form>
</div>
</div>
<div id="uploadModal" class="modal-bg fixed inset-0 bg-black/40 hidden items-center justify-center z-50 p-4" style="backdrop-filter:blur(2px)">
<div class="bg-white rounded-2xl p-6 w-full max-w-sm shadow-2xl border border-zinc-200">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-base">Dokument hochladen</h3>
<button onclick="closeModal('uploadModal')" class="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 hover:bg-zinc-100 transition-colors">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<form method="post" enctype="multipart/form-data" action="?path=<?= rawurlencode($path) ?>">
<input type="hidden" name="action" value="upload">
<input type="hidden" name="path" value="<?= h($path) ?>">
<label class="block text-[12px] font-medium text-zinc-500 mb-1.5">PDF oder EPUB</label>
<input type="file" name="file" accept=".pdf,.epub" required
class="w-full text-[13px] text-zinc-600 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-[12px] file:font-medium file:bg-zinc-100 file:text-zinc-700 hover:file:bg-zinc-200 mb-4">
<div class="flex gap-2 justify-end">
<button type="button" onclick="closeModal('uploadModal')" class="px-3 py-2 text-[13px] font-medium rounded-lg border border-zinc-200 text-zinc-600 hover:bg-zinc-50 transition-colors">Abbrechen</button>
<button type="submit" class="px-3 py-2 text-[13px] font-medium rounded-lg bg-zinc-900 text-white hover:bg-zinc-700 transition-colors">Hochladen</button>
</div>
</form>
</div>
</div>
<div id="deleteModal" class="modal-bg fixed inset-0 bg-black/40 hidden items-center justify-center z-50 p-4" style="backdrop-filter:blur(2px)">
<div class="bg-white rounded-2xl p-6 w-full max-w-sm shadow-2xl border border-zinc-200">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-base">Löschen bestätigen</h3>
<button onclick="closeModal('deleteModal')" class="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 hover:bg-zinc-100 transition-colors">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<p id="deleteLabel" class="text-[13px] text-zinc-500 mb-3"></p>
<div id="deleteFolderWarn" class="hidden mb-4 flex items-start gap-2 bg-red-50 border border-red-200 rounded-lg px-3 py-2.5">
<svg class="shrink-0 mt-0.5" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#dc2626" stroke-width="2" stroke-linecap="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span class="text-[12px] text-red-700">Achtung: Der gesamte Inhalt des Ordners wird ebenfalls unwiderruflich gelöscht!</span>
</div>
<form method="post" action="?path=<?= rawurlencode($path) ?>" onsubmit="showLoading('Wird gelöscht…')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="path" value="<?= h($path) ?>">
<input type="hidden" name="target" id="deleteTarget">
<div class="flex gap-2 justify-end">
<button type="button" onclick="closeModal('deleteModal')" class="px-3 py-2 text-[13px] font-medium rounded-lg border border-zinc-200 text-zinc-600 hover:bg-zinc-50 transition-colors">Abbrechen</button>
<button type="submit" class="px-3 py-2 text-[13px] font-medium rounded-lg bg-red-600 text-white hover:bg-red-700 transition-colors">Löschen</button>
</div>
</form>
</div>
</div>
<div id="renameModal" class="modal-bg fixed inset-0 bg-black/40 hidden items-center justify-center z-50 p-4" style="backdrop-filter:blur(2px)">
<div class="bg-white rounded-2xl p-6 w-full max-w-sm shadow-2xl border border-zinc-200">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-base">Umbenennen / Verschieben</h3>
<button onclick="closeModal('renameModal')" class="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 hover:bg-zinc-100 transition-colors">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<form method="post" action="?path=<?= rawurlencode($path) ?>">
<input type="hidden" name="action" value="move">
<input type="hidden" name="path" value="<?= h($path) ?>">
<input type="hidden" name="src" id="renameSrc">
<label class="block text-[12px] font-medium text-zinc-500 mb-1.5">Neuer Pfad</label>
<input type="text" name="dst" id="renameDst" placeholder="/Ordner/Dateiname" required
class="w-full px-3 py-2 border border-zinc-300 rounded-lg text-[13px] focus:outline-none focus:border-zinc-500 focus:ring-2 focus:ring-zinc-200 mb-4">
<div class="flex gap-2 justify-end">
<button type="button" onclick="closeModal('renameModal')" class="px-3 py-2 text-[13px] font-medium rounded-lg border border-zinc-200 text-zinc-600 hover:bg-zinc-50 transition-colors">Abbrechen</button>
<button type="submit" class="px-3 py-2 text-[13px] font-medium rounded-lg bg-zinc-900 text-white hover:bg-zinc-700 transition-colors">Speichern</button>
</div>
</form>
</div>
</div>
<div id="viewerModal" class="modal-bg fixed inset-0 bg-black/50 hidden items-end sm:items-center justify-center z-50 p-0 sm:p-4" style="backdrop-filter:blur(2px)">
<div class="bg-white rounded-t-2xl sm:rounded-2xl w-full sm:max-w-4xl shadow-2xl border border-zinc-200 flex flex-col overflow-hidden" style="height:92vh;max-height:92vh">
<div class="flex items-center justify-between px-4 py-3 border-b border-zinc-200 shrink-0">
<span id="viewerTitle" class="font-medium text-[14px] truncate min-w-0 mr-4"></span>
<div class="flex gap-2 shrink-0">
<a id="viewerOpen" href="#" target="_blank" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-lg border border-zinc-200 text-zinc-600 hover:bg-zinc-50 transition-colors">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Neues Tab
</a>
<button onclick="closeModal('viewerModal')" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-lg border border-zinc-200 text-zinc-600 hover:bg-zinc-50 transition-colors">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
Schließen
</button>
</div>
</div>
<iframe id="viewerFrame" src="" class="flex-1 border-none w-full"></iframe>
</div>
</div>
<div id="syncModal" class="modal-bg fixed inset-0 bg-black/40 hidden items-center justify-center z-50 p-4" style="backdrop-filter:blur(2px)">
<div class="bg-white rounded-2xl p-6 w-full max-w-lg shadow-2xl border border-zinc-200">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-base">Sync</h3>
<button id="syncClose" onclick="closeModal('syncModal')" class="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 hover:bg-zinc-100 transition-colors">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<pre id="syncOutput" class="bg-zinc-950 text-zinc-300 p-4 rounded-xl text-[12px] leading-relaxed max-h-80 overflow-y-auto whitespace-pre-wrap break-all font-mono">Warte auf Start...</pre>
<div id="syncDone" class="mt-4 hidden">
<button onclick="location.reload()" class="w-full py-2 text-[13px] font-medium rounded-lg bg-zinc-900 text-white hover:bg-zinc-700 transition-colors">Seite neu laden</button>
</div>
</div>
</div>
<!-- Loading Overlay -->
<div id="loadingOverlay" class="hidden fixed inset-0 bg-white/70 backdrop-blur-sm z-[60] flex items-center justify-center">
<div class="bg-white rounded-2xl shadow-2xl border border-zinc-200 px-8 py-6 flex flex-col items-center gap-3">
<svg class="animate-spin text-zinc-400" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
<p id="loadingText" class="text-[13px] font-medium text-zinc-600">Wird verarbeitet…</p>
</div>
</div>
<!-- Mehrfachauswahl: Löschen bestätigen -->
<div id="bulkDeleteModal" class="modal-bg fixed inset-0 bg-black/40 hidden items-center justify-center z-50 p-4" style="backdrop-filter:blur(2px)">
<div class="bg-white rounded-2xl p-6 w-full max-w-sm shadow-2xl border border-zinc-200">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-base">Auswahl löschen</h3>
<button onclick="closeModal('bulkDeleteModal')" class="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 hover:bg-zinc-100 transition-colors">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<p id="bulkDeleteLabel" class="text-[13px] text-zinc-500 mb-3"></p>
<div id="bulkDeleteFolderWarn" class="hidden mb-4 flex items-start gap-2 bg-red-50 border border-red-200 rounded-lg px-3 py-2.5">
<svg class="shrink-0 mt-0.5" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#dc2626" stroke-width="2" stroke-linecap="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span class="text-[12px] text-red-700">Achtung: Ausgewählte Ordner werden mit ihrem gesamten Inhalt gelöscht!</span>
</div>
<div class="flex gap-2 justify-end">
<button onclick="closeModal('bulkDeleteModal')" class="px-3 py-2 text-[13px] font-medium rounded-lg border border-zinc-200 text-zinc-600 hover:bg-zinc-50 transition-colors">Abbrechen</button>
<button onclick="submitBulkDelete()" class="px-3 py-2 text-[13px] font-medium rounded-lg bg-red-600 text-white hover:bg-red-700 transition-colors">Unwiderruflich löschen</button>
</div>
</div>
</div>
<!-- Mehrfachauswahl: Verschieben -->
<div id="bulkMoveModal" class="modal-bg fixed inset-0 bg-black/40 hidden items-center justify-center z-50 p-4" style="backdrop-filter:blur(2px)">
<div class="bg-white rounded-2xl w-full max-w-sm shadow-2xl border border-zinc-200 flex flex-col" style="max-height:80vh">
<div class="flex items-center justify-between px-5 pt-5 pb-3 shrink-0">
<h3 class="font-semibold text-base">Zielordner wählen</h3>
<button onclick="closeModal('bulkMoveModal')" class="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 hover:bg-zinc-100 transition-colors">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<!-- Breadcrumb -->
<div id="folderBreadcrumb" class="flex items-center gap-1 px-5 pb-2 text-[12px] text-zinc-500 shrink-0 flex-wrap"></div>
<!-- Ordner-Liste -->
<div id="folderList" class="flex-1 overflow-y-auto border-t border-b border-zinc-100 divide-y divide-zinc-50 min-h-[120px]"></div>
<!-- Aktuelle Auswahl + Buttons -->
<div class="px-5 py-4 shrink-0">
<p class="text-[11px] text-zinc-400 mb-1">Ziel:</p>
<p id="folderSelected" class="text-[13px] font-medium text-zinc-700 mb-3 truncate">/</p>
<div class="flex gap-2 justify-end">
<button onclick="closeModal('bulkMoveModal')" class="px-3 py-2 text-[13px] font-medium rounded-lg border border-zinc-200 text-zinc-600 hover:bg-zinc-50 transition-colors">Abbrechen</button>
<button onclick="submitBulkMove()" class="px-3 py-2 text-[13px] font-medium rounded-lg bg-zinc-900 text-white hover:bg-zinc-700 transition-colors">Hierher verschieben</button>
</div>
</div>
</div>
</div>
<!-- Mehrfachauswahl-Bar -->
<div id="selBar" class="hidden fixed bottom-6 left-1/2 -translate-x-1/2 bg-zinc-900 text-white rounded-2xl shadow-2xl px-4 py-3 flex items-center gap-3 z-40">
<label class="flex items-center gap-2 text-[13px] cursor-pointer select-none">
<input type="checkbox" id="selAll" class="w-4 h-4 rounded" onchange="toggleAll(this)">
<span id="selCount">0 ausgewählt</span>
</label>
<button onclick="bulkMove()" class="flex items-center gap-1.5 px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 rounded-lg text-[12px] font-medium transition-colors">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><polyline points="12 5 19 12 12 19"/></svg>
Verschieben
</button>
<button onclick="bulkDelete()" class="flex items-center gap-1.5 px-3 py-1.5 bg-red-500 hover:bg-red-600 rounded-lg text-[12px] font-medium transition-colors">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
Löschen
</button>
<button onclick="cancelSelection()" class="p-1.5 rounded-lg hover:bg-zinc-700 transition-colors" title="Abbrechen">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<script>
function openModal(id) {
const el = document.getElementById(id);
el.classList.remove('hidden');
el.classList.add('flex');
}
function closeModal(id) {
const el = document.getElementById(id);
el.classList.add('hidden');
el.classList.remove('flex');
if (id === 'viewerModal') document.getElementById('viewerFrame').src = '';
}
document.querySelectorAll('.modal-bg').forEach(el => {
el.addEventListener('click', e => { if (e.target === el) closeModal(el.id); });
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') document.querySelectorAll('.modal-bg.flex').forEach(el => closeModal(el.id));
});
function confirmDelete(path, name, isFolder) {
document.getElementById('deleteTarget').value = path;
document.getElementById('deleteLabel').textContent = '"' + name + '" wird unwiderruflich gelöscht.';
const warn = document.getElementById('deleteFolderWarn');
if (isFolder) { warn.classList.remove('hidden'); } else { warn.classList.add('hidden'); }
openModal('deleteModal');
}
function openRename(path, name) {
document.getElementById('renameSrc').value = path;
document.getElementById('renameDst').value = path;
openModal('renameModal');
}
function openViewer(path, title) {
const url = '?action=view&target=' + encodeURIComponent(path);
document.getElementById('viewerTitle').textContent = title || path.split('/').pop();
document.getElementById('viewerFrame').src = url;
document.getElementById('viewerOpen').href = url;
openModal('viewerModal');
}
const selected = new Set();
function toggleItem(path, cb) {
if (cb.checked) selected.add(path); else selected.delete(path);
updateSelBar();
}
function toggleAll(cb) {
document.querySelectorAll('.sel-cb').forEach(c => {
c.checked = cb.checked;
if (cb.checked) selected.add(c.dataset.path); else selected.delete(c.dataset.path);
});
updateSelBar();
}
function updateSelBar() {
const n = selected.size;
document.getElementById('selBar').classList.toggle('hidden', n === 0);
document.getElementById('selCount').textContent = n + ' ausgewählt';
}
function cancelSelection() {
selected.clear();
document.querySelectorAll('.sel-cb').forEach(c => c.checked = false);
document.getElementById('selAll').checked = false;
updateSelBar();
}
function showLoading(text) {
document.getElementById('loadingText').textContent = text || 'Wird verarbeitet…';
const ol = document.getElementById('loadingOverlay');
ol.classList.remove('hidden');
ol.classList.add('flex');
}
function buildMultiForm(action) {
const form = document.createElement('form');
form.method = 'post';
form.action = '?path=<?= rawurlencode($path) ?>';
const ai = document.createElement('input'); ai.type='hidden'; ai.name='action'; ai.value=action;
form.appendChild(ai);
selected.forEach(p => {
const i = document.createElement('input'); i.type='hidden'; i.name='targets[]'; i.value=p;
form.appendChild(i);
});
return form;
}
function bulkDelete() {
if (!selected.size) return;
document.getElementById('bulkDeleteLabel').textContent = selected.size + ' Element(e) werden unwiderruflich gelöscht.';
const hasFolder = [...document.querySelectorAll('.sel-cb:checked')].some(cb => cb.dataset.folder === '1');
const warn = document.getElementById('bulkDeleteFolderWarn');
if (hasFolder) { warn.classList.remove('hidden'); } else { warn.classList.add('hidden'); }
openModal('bulkDeleteModal');
}
function submitBulkDelete() {
closeModal('bulkDeleteModal');
showLoading('Wird gelöscht…');
const form = buildMultiForm('delete_multi');
document.body.appendChild(form);
form.submit();
}
let folderBrowsePath = '/';
function folderBrowse(path) {
folderBrowsePath = path;
document.getElementById('folderSelected').textContent = path;
const list = document.getElementById('folderList');
list.innerHTML = '<div class="px-5 py-3 text-[12px] text-zinc-400">Lädt…</div>';
// Breadcrumb aufbauen
const bc = document.getElementById('folderBreadcrumb');
bc.innerHTML = '';
const parts = path === '/' ? [] : path.split('/').filter(Boolean);
const addCrumb = (label, p) => {
if (bc.children.length) { const s=document.createElement('span'); s.textContent='/'; s.className='text-zinc-300'; bc.appendChild(s); }
const a=document.createElement('button'); a.textContent=label; a.className='hover:text-zinc-900 transition-colors';
a.onclick=()=>folderBrowse(p); bc.appendChild(a);
};
addCrumb('reMarkable', '/');
let acc='';
parts.forEach(p => { acc+='/'+p; addCrumb(p, acc); });
fetch('?action=folders&path='+encodeURIComponent(path))
.then(r=>r.json())
.then(data => {
if (!data.folders.length) {
list.innerHTML='<div class="px-5 py-4 text-[12px] text-zinc-400 text-center">Keine Unterordner</div>';
return;
}
list.innerHTML='';
data.folders.forEach(name => {
const fp = (path==='/'?'':path)+'/'+name;
const row=document.createElement('div');
row.className='flex items-center gap-3 px-5 py-2.5 hover:bg-zinc-50 cursor-pointer transition-colors';
row.innerHTML='<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h3.172a2 2 0 0 1 1.414.586l1.414 1.414A2 2 0 0 0 12.414 8H19a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" fill="#e8d5b7" stroke="#c4a46b" stroke-width="1.5"/></svg>'
+'<span class="text-[13px] text-zinc-700 flex-1">'+name+'</span>'
+'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#a1a1aa" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>';
row.onclick=()=>folderBrowse(fp);
list.appendChild(row);
});
});
}
function bulkMove() {
if (!selected.size) return;
folderBrowse('<?= h($path) ?>');
openModal('bulkMoveModal');
}
function submitBulkMove() {
const dst = folderBrowsePath;
closeModal('bulkMoveModal');
showLoading('Wird verschoben…');
const form = buildMultiForm('move_multi');
const di = document.createElement('input'); di.type='hidden'; di.name='destination'; di.value=dst;
form.appendChild(di);
document.body.appendChild(form);
form.submit();
}
function startSync() {
const output = document.getElementById('syncOutput');
const done = document.getElementById('syncDone');
const close = document.getElementById('syncClose');
output.textContent = '';
done.classList.add('hidden');
close.disabled = true;
openModal('syncModal');
fetch('sync.php')
.then(res => {
const reader = res.body.getReader();
const dec = new TextDecoder();
function read() {
reader.read().then(({ done: d, value }) => {
if (d) { done.classList.remove('hidden'); close.disabled = false; return; }
output.textContent += dec.decode(value);
output.scrollTop = output.scrollHeight;
read();
});
}
read();
})
.catch(e => { output.textContent += '\n[Fehler: ' + e + ']'; close.disabled = false; });
}
</script>
</body>
</html>

157
remarkable/web/index2.php Executable file
View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
require __DIR__ . '/auth.php';
$backupRoot = getenv('RM_BACKUP_ROOT') ?: '/www/remarkable/notizen/snapshots/latest';
$backupRootReal = realpath($backupRoot);
function h(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function isInsideRoot(string $root, string $path): bool
{
$rootPrefix = rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
return str_starts_with($path, $rootPrefix) || $path === $root;
}
$query = isset($_GET['q']) ? trim((string)$_GET['q']) : '';
$selected = isset($_GET['file']) ? (string)$_GET['file'] : '';
$error = '';
if ($backupRootReal === false || !is_dir($backupRootReal)) {
$error = 'Backup-Pfad nicht gefunden: ' . $backupRoot;
}
$files = [];
$selectedPath = null;
if ($error === '') {
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(
$backupRootReal,
FilesystemIterator::SKIP_DOTS
),
RecursiveIteratorIterator::SELF_FIRST
);
$qLower = mb_strtolower($query);
foreach ($iterator as $entry) {
$absolutePath = $entry->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;
}
}
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>reMarkable Archiv</title>
<style>
body { margin: 0; font-family: Arial, sans-serif; background: #f6f7f9; color: #222; }
.layout { display: grid; grid-template-columns: 430px 1fr; min-height: 100vh; }
.sidebar { border-right: 1px solid #ddd; background: #fff; padding: 14px; overflow: auto; }
.content { padding: 14px; overflow: auto; }
.muted { color: #666; font-size: 12px; }
.item { display: block; padding: 8px 10px; margin: 4px 0; border: 1px solid #eee; border-radius: 8px; text-decoration: none; color: #222; background: #fafafa; }
.item:hover { background: #f0f4ff; border-color: #c9d7ff; }
.dir { font-weight: 600; }
.meta { color: #777; font-size: 12px; margin-top: 4px; }
form { margin-bottom: 12px; }
input[type="text"] { width: 75%; padding: 8px; border: 1px solid #ccc; border-radius: 6px; }
button { padding: 8px 10px; border-radius: 6px; border: 1px solid #bbb; background: #fff; cursor: pointer; }
iframe { width: 100%; height: calc(100vh - 110px); border: 1px solid #ccc; border-radius: 8px; background: #fff; }
.notice { padding: 10px; border: 1px solid #f0c36d; background: #fff8e1; border-radius: 8px; }
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<h2 style="margin: 0 0 8px 0;">reMarkable Archiv</h2>
<div class="muted">Quelle: <?= h($backupRootReal ?: $backupRoot) ?></div>
<form method="get">
<input type="text" name="q" value="<?= h($query) ?>" placeholder="Suche nach Dateinamen oder Ordner...">
<button type="submit">Suchen</button>
</form>
<?php if ($error !== ''): ?>
<div class="notice"><?= h($error) ?></div>
<?php else: ?>
<?php if (count($files) === 0): ?>
<div class="notice">Keine Treffer.</div>
<?php else: ?>
<?php foreach ($files as $entry): ?>
<?php if ($entry['is_dir']): ?>
<div class="item dir">📁 <?= h($entry['relative']) ?></div>
<?php else: ?>
<a class="item" href="?q=<?= rawurlencode($query) ?>&file=<?= rawurlencode($entry['relative']) ?>">
📄 <?= h($entry['relative']) ?>
<div class="meta">
<?= number_format((int) $entry['size'] / 1024, 1, ',', '.') ?> KB ·
<?= h(date('Y-m-d H:i:s', (int) $entry['mtime'])) ?>
<?= $entry['is_pdf'] ? ' · PDF' : '' ?>
</div>
</a>
<?php endif; ?>
<?php endforeach; ?>
<?php endif; ?>
<?php endif; ?>
</aside>
<main class="content">
<?php if ($selectedPath === null): ?>
<div class="notice">Links eine Datei waehlen. PDFs werden direkt angezeigt.</div>
<?php else: ?>
<?php $rel = ltrim(substr($selectedPath, strlen($backupRootReal)), DIRECTORY_SEPARATOR); ?>
<h3 style="margin-top: 0;"><?= h(str_replace(DIRECTORY_SEPARATOR, '/', $rel)) ?></h3>
<div class="muted" style="margin-bottom: 10px;">
<a href="?q=<?= rawurlencode($query) ?>&file=<?= rawurlencode(str_replace(DIRECTORY_SEPARATOR, '/', $rel)) ?>&download=1">Datei oeffnen/herunterladen</a>
</div>
<?php if (str_ends_with(strtolower($selectedPath), '.pdf')): ?>
<iframe src="?q=<?= rawurlencode($query) ?>&file=<?= rawurlencode(str_replace(DIRECTORY_SEPARATOR, '/', $rel)) ?>&download=1"></iframe>
<?php else: ?>
<div class="notice">Keine PDF-Datei. Bitte ueber den Link oben herunterladen/oeffnen.</div>
<?php endif; ?>
<?php endif; ?>
</main>
</div>
</body>
</html>

118
remarkable/web/setup.php Executable file
View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
require __DIR__ . '/auth.php';
$RMAPI = '/usr/local/bin/rmapi';
$flash = '';
$flashOk = true;
function h(string $v): string {
return htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
// rmapi-Status prüfen
function rmapiStatus(string $bin): array {
exec($bin . ' ls / 2>&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);
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>reMarkable Setup</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='18' fill='%23000'/%3E%3Ctext y='.9em' font-size='72' font-family='Georgia,serif' font-weight='bold' fill='white' x='50%25' text-anchor='middle'%3Erm%3C/text%3E%3C/svg%3E">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f4f4f4;color:#222;min-height:100vh;display:flex;align-items:center;justify-content:center}
.card{background:#fff;border-radius:12px;padding:32px;width:440px;max-width:95vw;box-shadow:0 4px 24px rgba(0,0,0,.1)}
h1{font-size:20px;font-weight:700;margin-bottom:8px}
.subtitle{font-size:14px;color:#666;margin-bottom:24px}
.status{padding:12px 16px;border-radius:8px;font-size:14px;margin-bottom:20px;display:flex;align-items:center;gap:10px}
.status.ok{background:#dcfce7;border:1px solid #86efac;color:#166534}
.status.err{background:#fee2e2;border:1px solid #fca5a5;color:#991b1b}
.flash{padding:10px 16px;border-radius:8px;font-size:14px;margin-bottom:16px}
.flash.ok{background:#dcfce7;border:1px solid #86efac;color:#166534}
.flash.err{background:#fee2e2;border:1px solid #fca5a5;color:#991b1b}
label{display:block;font-size:13px;font-weight:500;margin-bottom:6px;color:#444}
input[type=text]{width:100%;padding:10px 12px;border:1px solid #ccc;border-radius:8px;font-size:16px;letter-spacing:4px;text-align:center;margin-bottom:12px}
.btn{width:100%;padding:10px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;border:none;background:#2563eb;color:#fff}
.btn:hover{background:#1d4ed8}
.hint{font-size:13px;color:#666;margin-top:16px;line-height:1.6}
.hint a{color:#2563eb}
.back{display:block;text-align:center;margin-top:20px;font-size:13px;color:#2563eb;text-decoration:none}
</style>
</head>
<body>
<div class="card">
<h1>📄 reMarkable Setup</h1>
<p class="subtitle">Verbindung zur reMarkable Cloud einrichten</p>
<div class="status <?= $status['ok'] ? 'ok' : 'err' ?>">
<?= $status['ok'] ? '✓ Verbunden mit reMarkable Cloud' : '✗ Nicht verbunden — Token fehlt oder abgelaufen' ?>
</div>
<?php if ($flash !== ''): ?>
<div class="flash <?= $flashOk ? 'ok' : 'err' ?>"><?= h($flash) ?></div>
<?php endif; ?>
<?php if (!$status['ok']): ?>
<form method="post">
<label for="code">One-Time-Code</label>
<input type="text" id="code" name="code" maxlength="8" placeholder="xxxxxxxx" autocomplete="off" autofocus>
<button type="submit" class="btn">Verbinden</button>
</form>
<p class="hint">
Code abrufen unter:<br>
<a href="https://my.remarkable.com/device/desktop/connect" target="_blank">
my.remarkable.com/device/desktop/connect
</a>
</p>
<?php else: ?>
<form method="post">
<label for="code">Token erneuern (neuer One-Time-Code)</label>
<input type="text" id="code" name="code" maxlength="8" placeholder="xxxxxxxx" autocomplete="off">
<button type="submit" class="btn btn-secondary" style="background:#6b7280">Token erneuern</button>
</form>
<p class="hint">
Neuen Code abrufen unter:<br>
<a href="https://my.remarkable.com/device/desktop/connect" target="_blank">
my.remarkable.com/device/desktop/connect
</a>
</p>
<?php endif; ?>
<a href="/" class="back">← Zurück zur Übersicht</a>
</div>
</body>
</html>

64
remarkable/web/sync.php Executable file
View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
require __DIR__ . '/auth.php';
$BASE_DIR = rtrim(getenv('BASE_DIR') ?: '/data', '/');
$BACKUP_SCRIPT = '/usr/local/bin/remarkable-backup.sh';
$CONVERT_SCRIPT= '/usr/local/bin/remarkable-convert.sh';
$SNAPSHOT_KEEP = getenv('SNAPSHOT_KEEP') ?: '60';
// Läuft bereits ein Sync?
$LOCK = $BASE_DIR . '/.backup.lock';
header('Content-Type: text/plain; charset=utf-8');
header('X-Accel-Buffering: no');
header('Cache-Control: no-cache');
if (ob_get_level()) ob_end_clean();
function stream(string $cmd): int {
$handle = popen($cmd . ' 2>&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";