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:
51
remarkable/Dockerfile
Executable file
51
remarkable/Dockerfile
Executable 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
128
remarkable/README.md
Executable 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
22
remarkable/docker-compose.yml
Executable 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
32
remarkable/entrypoint.sh
Executable 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
|
||||||
113
remarkable/scripts/backup-official-cloud.sh
Executable file
113
remarkable/scripts/backup-official-cloud.sh
Executable 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}"
|
||||||
169
remarkable/scripts/convert-to-pdf.sh
Executable file
169
remarkable/scripts/convert-to-pdf.sh
Executable 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 =="
|
||||||
142
remarkable/scripts/merge-annotations.py
Executable file
142
remarkable/scripts/merge-annotations.py
Executable 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
7
remarkable/web/Dockerfile
Executable 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
26
remarkable/web/auth.php
Executable 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
852
remarkable/web/index.php
Executable 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
157
remarkable/web/index2.php
Executable 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
118
remarkable/web/setup.php
Executable 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
64
remarkable/web/sync.php
Executable 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";
|
||||||
Reference in New Issue
Block a user