feat(web): Archiv im Regelformular, Export/Import; Doku 0.7.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e59932ab75
commit
660bb70c5d
+15
-1
@@ -8,6 +8,19 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.7.0] – 2026-10-12
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- **Archiv je Regel:** zusätzliche Kopie in ein Archiv (Anbindung SMB/NFS/SFTP oder
|
||||||
|
lokaler Pfad), Ablage „wie in der Quelle“ oder „nach Datum“ (`Jahr/Monat/…`).
|
||||||
|
Reihenfolge: erst Archiv, dann Ziel, **erst dann wird die Quelle gelöscht**.
|
||||||
|
Scheitert das Ziel nach dem Archiv, verhindert eine Buchführung
|
||||||
|
(`archived_files`) eine zweite Archivkopie. Im Archiv wird nie überschrieben.
|
||||||
|
Nicht erreichbares Archiv → Regel wartet. Platzprüfung auch fürs Archiv,
|
||||||
|
Probelauf zeigt das Archivziel. Migration 0004.
|
||||||
|
- Export/Import enthalten die Archiv-Einstellungen; eine als Archiv benutzte
|
||||||
|
Anbindung lässt sich nicht löschen.
|
||||||
|
|
||||||
## [0.6.0] – 2026-10-11
|
## [0.6.0] – 2026-10-11
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
@@ -246,7 +259,8 @@ Erste Version.
|
|||||||
- Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den
|
- Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den
|
||||||
Mount nicht mehr stillschweigend ab, sondern wird von der Datenbank abgelehnt.
|
Mount nicht mehr stillschweigend ab, sondern wird von der Datenbank abgelehnt.
|
||||||
|
|
||||||
[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.6.0...HEAD
|
[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.7.0...HEAD
|
||||||
|
[0.7.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.6.0...v0.7.0
|
||||||
[0.6.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.5.0...v0.6.0
|
[0.6.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.5.0...v0.6.0
|
||||||
[0.5.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.4.0...v0.5.0
|
[0.5.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.4.0...v0.5.0
|
||||||
[0.4.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.3.0...v0.4.0
|
[0.4.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.3.0...v0.4.0
|
||||||
|
|||||||
@@ -20,10 +20,32 @@ Zeiten in Ortszeit (Europe/Berlin).
|
|||||||
| 2026-10-11 | 00:31 | 00:50 | 0:19 | – | Pausieren, Wartungsmodus, E-Mail (0.4.0) |
|
| 2026-10-11 | 00:31 | 00:50 | 0:19 | – | Pausieren, Wartungsmodus, E-Mail (0.4.0) |
|
||||||
| 2026-10-11 | 00:54 | 01:16 | 0:22 | – | SFTP-Quelle, Zugangsdaten, Anmeldung (0.5.0) |
|
| 2026-10-11 | 00:54 | 01:16 | 0:22 | – | SFTP-Quelle, Zugangsdaten, Anmeldung (0.5.0) |
|
||||||
| 2026-10-11 | 08:43 | 08:55 | 0:12 | – | Anmeldung je Netz (0.6.0) |
|
| 2026-10-11 | 08:43 | 08:55 | 0:12 | – | Anmeldung je Netz (0.6.0) |
|
||||||
| **Summe** | | | **3:29** | | |
|
| 2026-10-12 | 00:14 | 00:34 | 0:20 | – | Archiv je Regel (0.7.0) |
|
||||||
|
| **Summe** | | | **3:49** | | |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-10-12 · 00:14–00:34 · Archiv je Regel (0.7.0)
|
||||||
|
|
||||||
|
**Erledigt**
|
||||||
|
- Archivfelder an der Regel, Tabelle `archived_files` (Migration 0004).
|
||||||
|
- Engine: Archiv zuerst, dann Ziel, dann Quelle löschen; Archiv immer „umbenennen“.
|
||||||
|
- Runner: Ziel- und Archivaufbau über eine gemeinsame Fabrik (`_target`).
|
||||||
|
- 14 Tests (350 gesamt); Mutationsprobe 4/4 erkannt (Buchführung, Archiv
|
||||||
|
übersprungen, Kollisionsregel, Platzprüfung).
|
||||||
|
|
||||||
|
**Entscheidungen**
|
||||||
|
- Archiv *vor* Ziel: Paperless holt Dateien sofort ab – andersherum würde ein
|
||||||
|
gescheitertes Archiv beim Neuversuch zur doppelten Lieferung führen.
|
||||||
|
- Archiv überschreibt nie (unabhängig von der Kollisionsregel des Ziels).
|
||||||
|
- Ablage „nach Datum“ nach Verarbeitungsdatum (nicht Datei-mtime) – eindeutig
|
||||||
|
nachvollziehbar, wann eine Datei durchgelaufen ist.
|
||||||
|
|
||||||
|
**Stolperstein**
|
||||||
|
- Downgrade von 0004 scheiterte an den CHECK-Regeln der neuen Auswahlfelder
|
||||||
|
(verweisen nach dem Entfernen der Spalten ins Leere) – nur dank der Migrations-
|
||||||
|
tests mit Daten aufgefallen; Downgrade entfernt sie jetzt zuerst.
|
||||||
|
|
||||||
## 2026-10-11 · 08:43–08:55 · Anmeldung je Netz (0.6.0)
|
## 2026-10-11 · 08:43–08:55 · Anmeldung je Netz (0.6.0)
|
||||||
|
|
||||||
**Anlass** – Wunsch: Anmeldung abschalten, und zwar konfigurierbar, aus welchen Netzen
|
**Anlass** – Wunsch: Anmeldung abschalten, und zwar konfigurierbar, aus welchen Netzen
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ Legende: ✅ erledigt · 🔨 in Arbeit · 📋 geplant · 💡 Idee (noch nicht
|
|||||||
|
|
||||||
| Version | Thema |
|
| Version | Thema |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| 0.7.0 | Archiv je Regel: Kopie ins Ziel und ins Archiv, Quelle erst danach löschen |
|
||||||
| 0.6.0 | Anmeldung je Netz: ohne Passwort / mit Passwort / abgewiesen, Schutz vor Aussperren |
|
| 0.6.0 | Anmeldung je Netz: ohne Passwort / mit Passwort / abgewiesen, Schutz vor Aussperren |
|
||||||
| 0.5.0 | SFTP als Quelle; gespeicherte Zugangsdaten für SMB/SFTP-Passwörter (Seite „Zugangsdaten“); Anmeldung abschaltbar (vertrauenswürdige Netze / ganz) |
|
| 0.5.0 | SFTP als Quelle; gespeicherte Zugangsdaten für SMB/SFTP-Passwörter (Seite „Zugangsdaten“); Anmeldung abschaltbar (vertrauenswürdige Netze / ganz) |
|
||||||
| 0.4.0 | Automatisch pausieren/fortsetzen bei nicht erreichbaren Anbindungen; Wartungsmodus; E-Mail-Benachrichtigung; eine Meldung pro ausgefallener Anbindung |
|
| 0.4.0 | Automatisch pausieren/fortsetzen bei nicht erreichbaren Anbindungen; Wartungsmodus; E-Mail-Benachrichtigung; eine Meldung pro ausgefallener Anbindung |
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
services:
|
services:
|
||||||
netmover:
|
netmover:
|
||||||
build: .
|
build: .
|
||||||
image: netmover:0.6.0
|
image: netmover:0.7.0
|
||||||
container_name: netmover
|
container_name: netmover
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
|
|||||||
+31
-2
@@ -8,7 +8,7 @@ Diese Anleitung erklärt die Bedienung. Installation, Updates und technische Det
|
|||||||
stehen im [README](../README.md).
|
stehen im [README](../README.md).
|
||||||
|
|
||||||
**Inhalt:** [Begriffe](#begriffe) · [Erste Anmeldung](#erste-anmeldung) ·
|
**Inhalt:** [Begriffe](#begriffe) · [Erste Anmeldung](#erste-anmeldung) ·
|
||||||
[Zugangsdaten](#zugangsdaten) · [Anbindungen](#anbindungen) · [Regeln](#regeln) ·
|
[Zugangsdaten](#zugangsdaten) · [Anbindungen](#anbindungen) · [Regeln](#regeln) · [Archiv](#archiv) ·
|
||||||
[Probelauf](#probelauf) · [Status](#status) · [Wartungsmodus](#wartungsmodus) · [Protokoll](#protokoll) ·
|
[Probelauf](#probelauf) · [Status](#status) · [Wartungsmodus](#wartungsmodus) · [Protokoll](#protokoll) ·
|
||||||
[Einstellungen](#einstellungen) · [Anmeldung](#anmeldung) · [Uptime Kuma](#uptime-kuma) · [Beispiele](#beispiele) · [Häufige Fragen](#fragen)
|
[Einstellungen](#einstellungen) · [Anmeldung](#anmeldung) · [Uptime Kuma](#uptime-kuma) · [Beispiele](#beispiele) · [Häufige Fragen](#fragen)
|
||||||
|
|
||||||
@@ -160,6 +160,34 @@ Gespeicherte Zugangsdaten lassen sich nur löschen, wenn sie keine Anbindung meh
|
|||||||
| **Aktion** | **verschieben** (Quelle wird nach erfolgreicher Übertragung gelöscht) oder **kopieren** (Quelle bleibt; jede Dateiversion wird nur einmal kopiert). |
|
| **Aktion** | **verschieben** (Quelle wird nach erfolgreicher Übertragung gelöscht) oder **kopieren** (Quelle bleibt; jede Dateiversion wird nur einmal kopiert). |
|
||||||
| **Wenn Datei existiert** | **umbenennen** (`scan.pdf` → `scan-1.pdf`, `scan-2.pdf` …), **überspringen** oder **überschreiben**. |
|
| **Wenn Datei existiert** | **umbenennen** (`scan.pdf` → `scan-1.pdf`, `scan-2.pdf` …), **überspringen** oder **überschreiben**. |
|
||||||
|
|
||||||
|
### Archiv {#archiv}
|
||||||
|
|
||||||
|
Optional bekommt jede Datei zusätzlich eine **Kopie ins Archiv** – z. B. Paperless als
|
||||||
|
Ziel und ein Archivordner auf dem NAS. **Die Quelle wird erst gelöscht, wenn die Datei
|
||||||
|
im Ziel UND im Archiv angekommen ist.**
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| **Archiv** | kein Archiv / eine Anbindung (SMB, NFS, SFTP) / ein lokaler Pfad |
|
||||||
|
| **Archivordner** | Anbindung: relativer Unterordner (wird angelegt). Lokal: absoluter Pfad, muss existieren. |
|
||||||
|
| **Ablage** | **wie in der Quelle** oder **nach Datum** – `2026/10/scan001.pdf` (Monat der Verarbeitung) |
|
||||||
|
|
||||||
|
So läuft es pro Datei:
|
||||||
|
|
||||||
|
1. **Zuerst ins Archiv** – mit denselben Sicherungen wie beim Ziel.
|
||||||
|
2. **Dann ins Ziel.**
|
||||||
|
3. **Dann erst** wird die Quelle gelöscht (bei „verschieben“).
|
||||||
|
|
||||||
|
Das Archiv kommt zuerst, weil ein Ziel wie Paperless Dateien sofort abholt: Scheitert
|
||||||
|
das Archiv, wurde noch nichts geliefert, und die Datei bleibt in der Quelle. Scheitert
|
||||||
|
danach das Ziel, merkt sich NetMover, dass die Datei schon archiviert ist – der nächste
|
||||||
|
Versuch legt keine zweite Archivkopie an.
|
||||||
|
|
||||||
|
Im Archiv wird **nie überschrieben**: Gleiche Namen werden immer umbenannt
|
||||||
|
(`scan-1.pdf`), egal was die Regel fürs Ziel vorsieht. Ist das Archiv nicht erreichbar,
|
||||||
|
wartet die Regel wie bei Quelle und Ziel. Im Kopiermodus bekommen Ziel und Archiv je
|
||||||
|
eine Kopie, die Quelle bleibt.
|
||||||
|
|
||||||
### Sicherheit und Grenzen {#regel-sicherheit}
|
### Sicherheit und Grenzen {#regel-sicherheit}
|
||||||
|
|
||||||
| Feld | Bedeutung | Empfehlung |
|
| Feld | Bedeutung | Empfehlung |
|
||||||
@@ -378,7 +406,8 @@ deaktiviert, Zugangsdaten müssen neu eingegeben werden.
|
|||||||
3. Anbindung **Paperless** (SFTP, Pfad `/opt/paperless_data/consume`, Schlüssel
|
3. Anbindung **Paperless** (SFTP, Pfad `/opt/paperless_data/consume`, Schlüssel
|
||||||
wählen, Benutzer = der Paperless-Benutzer), testen.
|
wählen, Benutzer = der Paperless-Benutzer), testen.
|
||||||
4. Regel **Scans → Paperless**: Quelle NAS, Filter `*.pdf`, Ziel Paperless,
|
4. Regel **Scans → Paperless**: Quelle NAS, Filter `*.pdf`, Ziel Paperless,
|
||||||
verschieben, umbenennen, 30 s. **Probelauf**, dann aktivieren.
|
verschieben, umbenennen, 30 s. Optional **Archiv**: Anbindung NAS, Ordner
|
||||||
|
`Archiv/Scans`, Ablage nach Datum. **Probelauf**, dann aktivieren.
|
||||||
|
|
||||||
### Jede Person in einen eigenen Ordner {#beispiel-unterordner}
|
### Jede Person in einen eigenen Ordner {#beispiel-unterordner}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben."""
|
"""NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben."""
|
||||||
|
|
||||||
__version__ = "0.6.0"
|
__version__ = "0.7.0"
|
||||||
|
|||||||
+52
-1
@@ -18,6 +18,8 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from netmover.crypto import SecretBox, generate_ssh_keypair
|
from netmover.crypto import SecretBox, generate_ssh_keypair
|
||||||
from netmover.models import (
|
from netmover.models import (
|
||||||
|
ArchiveKind,
|
||||||
|
ArchiveLayout,
|
||||||
CollisionPolicy,
|
CollisionPolicy,
|
||||||
Credential,
|
Credential,
|
||||||
CredentialKind,
|
CredentialKind,
|
||||||
@@ -424,7 +426,11 @@ def delete_mount(s: Session, mounts: MountManager, mount_id: int) -> None:
|
|||||||
if m is None:
|
if m is None:
|
||||||
return
|
return
|
||||||
used = s.scalars(
|
used = s.scalars(
|
||||||
select(Rule.name).where((Rule.src_mount_id == mount_id) | (Rule.dst_mount_id == mount_id))
|
select(Rule.name).where(
|
||||||
|
(Rule.src_mount_id == mount_id)
|
||||||
|
| (Rule.dst_mount_id == mount_id)
|
||||||
|
| (Rule.archive_mount_id == mount_id)
|
||||||
|
)
|
||||||
).all()
|
).all()
|
||||||
if used:
|
if used:
|
||||||
raise AdminError(f"Wird noch von Regeln benutzt: {', '.join(used)}")
|
raise AdminError(f"Wird noch von Regeln benutzt: {', '.join(used)}")
|
||||||
@@ -465,6 +471,10 @@ class RuleInput:
|
|||||||
dry_run: bool = False
|
dry_run: bool = False
|
||||||
quarantine_path: str = ""
|
quarantine_path: str = ""
|
||||||
quarantine_after: int = 3
|
quarantine_after: int = 3
|
||||||
|
archive_kind: ArchiveKind = ArchiveKind.NONE
|
||||||
|
archive_mount_id: int | None = None
|
||||||
|
archive_path: str = ""
|
||||||
|
archive_layout: ArchiveLayout = ArchiveLayout.FLAT
|
||||||
extra: dict[str, str] = field(default_factory=dict)
|
extra: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@@ -492,6 +502,10 @@ def rule_input_from_model(r: Rule) -> RuleInput:
|
|||||||
dry_run=r.dry_run,
|
dry_run=r.dry_run,
|
||||||
quarantine_path=r.quarantine_path or "",
|
quarantine_path=r.quarantine_path or "",
|
||||||
quarantine_after=r.quarantine_after,
|
quarantine_after=r.quarantine_after,
|
||||||
|
archive_kind=r.archive_kind,
|
||||||
|
archive_mount_id=r.archive_mount_id,
|
||||||
|
archive_path=r.archive_path,
|
||||||
|
archive_layout=r.archive_layout,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -585,6 +599,39 @@ def parse_rule_form(s: Session, form: Mapping[str, str]) -> tuple[RuleInput, Err
|
|||||||
d.quarantine_path = safe_relpath("quarantine_path", d.quarantine_path)
|
d.quarantine_path = safe_relpath("quarantine_path", d.quarantine_path)
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
errors["quarantine_path"] = str(exc)
|
errors["quarantine_path"] = str(exc)
|
||||||
|
|
||||||
|
# Archiv
|
||||||
|
try:
|
||||||
|
d.archive_kind = ArchiveKind(form.get("archive_kind") or "none")
|
||||||
|
d.archive_layout = ArchiveLayout(form.get("archive_layout") or "flat")
|
||||||
|
except ValueError:
|
||||||
|
errors["archive_kind"] = "Ungültige Auswahl."
|
||||||
|
d.archive_path = (form.get("archive_path") or "").strip()
|
||||||
|
if d.archive_kind is ArchiveKind.MOUNT:
|
||||||
|
d.archive_mount_id = _int(form, "archive_mount_id", errors)
|
||||||
|
if d.archive_mount_id is None or s.get(Mount, d.archive_mount_id) is None:
|
||||||
|
errors["archive_mount_id"] = "Archiv-Anbindung wählen."
|
||||||
|
try:
|
||||||
|
d.archive_path = safe_relpath("archive_path", d.archive_path)
|
||||||
|
except ValidationError as exc:
|
||||||
|
errors["archive_path"] = str(exc)
|
||||||
|
if (
|
||||||
|
"archive_path" not in errors
|
||||||
|
and d.archive_mount_id == d.dst_mount_id
|
||||||
|
and d.dst_kind == "mount"
|
||||||
|
and d.archive_path == d.dst_path
|
||||||
|
):
|
||||||
|
errors["archive_path"] = "Archiv und Ziel sind derselbe Ordner."
|
||||||
|
elif d.archive_kind is ArchiveKind.LOCAL:
|
||||||
|
d.archive_mount_id = None
|
||||||
|
try:
|
||||||
|
d.archive_path = safe_abspath("archive_path", d.archive_path)
|
||||||
|
except ValidationError as exc:
|
||||||
|
errors["archive_path"] = str(exc)
|
||||||
|
if d.dst_kind == "local" and d.archive_path == d.dst_path:
|
||||||
|
errors["archive_path"] = "Archiv und Ziel sind derselbe Ordner."
|
||||||
|
else:
|
||||||
|
d.archive_mount_id, d.archive_path = None, ""
|
||||||
return d, errors
|
return d, errors
|
||||||
|
|
||||||
|
|
||||||
@@ -617,6 +664,10 @@ def save_rule(s: Session, d: RuleInput, rule_id: int | None = None) -> tuple[Rul
|
|||||||
"on_collision",
|
"on_collision",
|
||||||
"dry_run",
|
"dry_run",
|
||||||
"quarantine_after",
|
"quarantine_after",
|
||||||
|
"archive_kind",
|
||||||
|
"archive_mount_id",
|
||||||
|
"archive_path",
|
||||||
|
"archive_layout",
|
||||||
):
|
):
|
||||||
setattr(r, f, getattr(d, f))
|
setattr(r, f, getattr(d, f))
|
||||||
r.set_owner = d.set_owner or None
|
r.set_owner = d.set_owner or None
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from netmover import __version__
|
from netmover import __version__
|
||||||
from netmover.models import (
|
from netmover.models import (
|
||||||
|
ArchiveKind,
|
||||||
|
ArchiveLayout,
|
||||||
CollisionPolicy,
|
CollisionPolicy,
|
||||||
Mount,
|
Mount,
|
||||||
MountState,
|
MountState,
|
||||||
@@ -72,6 +74,9 @@ RULE_FIELDS = (
|
|||||||
"dry_run",
|
"dry_run",
|
||||||
"quarantine_path",
|
"quarantine_path",
|
||||||
"quarantine_after",
|
"quarantine_after",
|
||||||
|
"archive_kind",
|
||||||
|
"archive_path",
|
||||||
|
"archive_layout",
|
||||||
)
|
)
|
||||||
SETTING_FIELDS = (
|
SETTING_FIELDS = (
|
||||||
"retention_days",
|
"retention_days",
|
||||||
@@ -99,6 +104,7 @@ def export_config(s: Session, settings: AppSettings) -> dict[str, Any]:
|
|||||||
**{f: _plain(getattr(r, f)) for f in RULE_FIELDS},
|
**{f: _plain(getattr(r, f)) for f in RULE_FIELDS},
|
||||||
"src_mount": names[r.src_mount_id],
|
"src_mount": names[r.src_mount_id],
|
||||||
"dst_mount": names.get(r.dst_mount_id) if r.dst_mount_id else None,
|
"dst_mount": names.get(r.dst_mount_id) if r.dst_mount_id else None,
|
||||||
|
"archive_mount": names.get(r.archive_mount_id) if r.archive_mount_id else None,
|
||||||
}
|
}
|
||||||
for r in s.scalars(select(Rule).order_by(Rule.name))
|
for r in s.scalars(select(Rule).order_by(Rule.name))
|
||||||
],
|
],
|
||||||
@@ -165,13 +171,16 @@ def import_config(s: Session, mounts: MountManager, data: dict[str, Any]) -> Imp
|
|||||||
try:
|
try:
|
||||||
src = ids[raw["src_mount"]]
|
src = ids[raw["src_mount"]]
|
||||||
dst = ids[raw["dst_mount"]] if raw.get("dst_mount") else None
|
dst = ids[raw["dst_mount"]] if raw.get("dst_mount") else None
|
||||||
r = Rule(src_mount_id=src, dst_mount_id=dst)
|
arch = ids[raw["archive_mount"]] if raw.get("archive_mount") else None
|
||||||
|
r = Rule(src_mount_id=src, dst_mount_id=dst, archive_mount_id=arch)
|
||||||
for f in RULE_FIELDS:
|
for f in RULE_FIELDS:
|
||||||
if f in raw:
|
if f in raw:
|
||||||
setattr(r, f, raw[f])
|
setattr(r, f, raw[f])
|
||||||
r.pattern_type = PatternType(r.pattern_type)
|
r.pattern_type = PatternType(r.pattern_type)
|
||||||
r.action = RuleAction(r.action)
|
r.action = RuleAction(r.action)
|
||||||
r.on_collision = CollisionPolicy(r.on_collision)
|
r.on_collision = CollisionPolicy(r.on_collision)
|
||||||
|
r.archive_kind = ArchiveKind(r.archive_kind or "none")
|
||||||
|
r.archive_layout = ArchiveLayout(r.archive_layout or "flat")
|
||||||
r.src_subpath = safe_relpath("src_subpath", r.src_subpath or "")
|
r.src_subpath = safe_relpath("src_subpath", r.src_subpath or "")
|
||||||
if int(r.interval_seconds) < 10:
|
if int(r.interval_seconds) < 10:
|
||||||
raise ValueError("Intervall < 10 s")
|
raise ValueError("Intervall < 10 s")
|
||||||
|
|||||||
+12
-1
@@ -422,13 +422,24 @@ class RuleView:
|
|||||||
target: str
|
target: str
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_target(r: Rule) -> str | None:
|
||||||
|
if r.archive_kind.value == "none":
|
||||||
|
return None
|
||||||
|
if r.archive_kind.value == "local" or r.archive_mount is None:
|
||||||
|
place = r.archive_path
|
||||||
|
else:
|
||||||
|
place = f"{r.archive_mount.name}:/{r.archive_path}"
|
||||||
|
return place + (" (nach Datum)" if r.archive_layout.value == "date" else "")
|
||||||
|
|
||||||
|
|
||||||
def _rule_target(r: Rule) -> str:
|
def _rule_target(r: Rule) -> str:
|
||||||
src = f"{r.src_mount.name}:/{r.src_subpath}" if r.src_subpath else f"{r.src_mount.name}:/"
|
src = f"{r.src_mount.name}:/{r.src_subpath}" if r.src_subpath else f"{r.src_mount.name}:/"
|
||||||
if r.dst_mount is None:
|
if r.dst_mount is None:
|
||||||
dst = r.dst_path
|
dst = r.dst_path
|
||||||
else:
|
else:
|
||||||
dst = f"{r.dst_mount.name}:/{r.dst_path}" if r.dst_path else f"{r.dst_mount.name}:/"
|
dst = f"{r.dst_mount.name}:/{r.dst_path}" if r.dst_path else f"{r.dst_mount.name}:/"
|
||||||
return f"{src} → {dst}"
|
archive = _archive_target(r)
|
||||||
|
return f"{src} → {dst}" + (f" + Archiv {archive}" if archive else "")
|
||||||
|
|
||||||
|
|
||||||
async def _status_context(request: Request) -> dict[str, Any]:
|
async def _status_context(request: Request) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -30,6 +30,13 @@
|
|||||||
{{ select('on_collision', 'Wenn Datei existiert', [('rename', 'umbenennen (scan-1.pdf)'), ('skip', 'überspringen'), ('overwrite', 'überschreiben')], d.on_collision.value, errors=errors) }}
|
{{ select('on_collision', 'Wenn Datei existiert', [('rename', 'umbenennen (scan-1.pdf)'), ('skip', 'überspringen'), ('overwrite', 'überschreiben')], d.on_collision.value, errors=errors) }}
|
||||||
</div></fieldset>
|
</div></fieldset>
|
||||||
|
|
||||||
|
<fieldset><legend>Archiv <a class="legend-help" href="/help#archiv" title="Hilfe">?</a></legend><div class="fields">
|
||||||
|
{{ select('archive_kind', 'Archiv', [('none', 'kein Archiv'), ('mount', 'Anbindung'), ('local', 'Lokaler Pfad')], d.archive_kind.value, errors=errors, hint='Zusätzliche Kopie. Die Quelle wird erst gelöscht, wenn Ziel UND Archiv sie haben.') }}
|
||||||
|
<div data-show-for="archive_kind:mount">{{ select('archive_mount_id', 'Archiv-Anbindung', dst_options, d.archive_mount_id or '', errors=errors) }}</div>
|
||||||
|
<div data-show-for="archive_kind:mount local">{{ field('archive_path', 'Archivordner', d.archive_path, errors=errors, hint='Anbindung: relativer Unterordner (wird angelegt). Lokal: absoluter Pfad, muss existieren.') }}</div>
|
||||||
|
<div data-show-for="archive_kind:mount local">{{ select('archive_layout', 'Ablage', [('flat', 'wie in der Quelle'), ('date', 'nach Datum (Jahr/Monat)')], d.archive_layout.value, errors=errors, hint='Im Archiv wird nie überschrieben – gleiche Namen werden umbenannt.') }}</div>
|
||||||
|
</div></fieldset>
|
||||||
|
|
||||||
<fieldset><legend>Sicherheit und Grenzen <a class="legend-help" href="/help#regel-sicherheit" title="Hilfe">?</a></legend><div class="fields">
|
<fieldset><legend>Sicherheit und Grenzen <a class="legend-help" href="/help#regel-sicherheit" title="Hilfe">?</a></legend><div class="fields">
|
||||||
{{ field('stability_seconds', 'Stabilitätsfenster (s)', d.stability_seconds, type='number', errors=errors, attrs='min="0"', hint='Größe und Zeit müssen so lange gleich bleiben') }}
|
{{ field('stability_seconds', 'Stabilitätsfenster (s)', d.stability_seconds, type='number', errors=errors, attrs='min="0"', hint='Größe und Zeit müssen so lange gleich bleiben') }}
|
||||||
{{ field('min_age_seconds', 'Mindestalter (s)', d.min_age_seconds, type='number', errors=errors, attrs='min="0"') }}
|
{{ field('min_age_seconds', 'Mindestalter (s)', d.min_age_seconds, type='number', errors=errors, attrs='min="0"') }}
|
||||||
|
|||||||
Reference in New Issue
Block a user