Initial release with Gitea updater
This commit is contained in:
commit
46c2474985
36 files changed
+3674
No files matched your search
+11
@@ -0,0 +1,11 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.mypy_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
release-build/
|
||||||
|
release-dist/
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Änderungsprotokoll
|
||||||
|
|
||||||
|
## 1.0.0 – 2026-09-23
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- Aufgabenbasierte Einweg- und bidirektionale Ordnersynchronisation
|
||||||
|
- Echtzeit-, Intervall- und manuelle Auslösung
|
||||||
|
- Konfliktkopien, Löschregeln und optionale Unterauswahl
|
||||||
|
- Erkennung sperrender Windows-Prozesse mit automatischem Retry
|
||||||
|
- Aufgabenbezogener Tray-Status und deutsche Dark-Mode-Oberfläche
|
||||||
|
- Sicherer Gitea-Release-Updater mit SHA-256-Prüfung
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# SyncVito
|
||||||
|
|
||||||
|
SyncVito ist ein Windows-Desktopwerkzeug zum Synchronisieren lokaler Ordner mit NAS- oder anderen Dateisystempfaden. Die Oberfläche ist vollständig deutsch, läuft im Windows-Infobereich weiter und speichert Aufgaben sowie Einstellungen lokal als JSON.
|
||||||
|
|
||||||
|
## Funktionsumfang
|
||||||
|
|
||||||
|
- Beliebig viele Sync-Aufgaben mit Quell- und Zielordner
|
||||||
|
- Einweg-Synchronisation oder bidirektionaler Abgleich
|
||||||
|
- Manuelle, zeitgesteuerte oder durch `watchdog` ausgelöste Echtzeitläufe
|
||||||
|
- Optionale Unterauswahl einzelner Dateien und Ordner
|
||||||
|
- Löschungen spiegeln oder Dateien grundsätzlich behalten
|
||||||
|
- Konfliktwahl: neuerer Zeitstempel gewinnt oder `_conflict`-Sicherung
|
||||||
|
- Erkennung sperrender Windows-Prozesse über den Restart Manager
|
||||||
|
- Automatischer Retry für gesperrte Dateien und nicht erreichbare NAS-Pfade
|
||||||
|
- Eine zentrale Fortschrittsanzeige, Aktivitätsprotokoll und Status im Tray
|
||||||
|
- Aufgabenbezogener Laufzeitstatus mit Worst-Case-Ableitung: grün bei gesunden aktiven Aufgaben, rot bei Pause/Inaktivität und Ausrufezeichen bei Fehlern, NAS-Ausfall oder wartenden Dateisperren
|
||||||
|
- Persistente Darstellungseinstellungen und optionaler Windows-Autostart
|
||||||
|
- UAC-Manifest mit `requireAdministrator`
|
||||||
|
- Automatische oder manuelle Updateprüfung über öffentliche Gitea-Releases
|
||||||
|
- SHA-256-geprüfter Download und sicherer Austausch der EXE nach dem Beenden
|
||||||
|
|
||||||
|
Projektseite: [gitea.davito.synology.me/Davito/SyncVito](https://gitea.davito.synology.me/Davito/SyncVito)
|
||||||
|
|
||||||
|
## Entwicklung starten
|
||||||
|
|
||||||
|
Voraussetzung ist Python 3.11 oder neuer unter Windows.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Konfiguration wird unter `%LOCALAPPDATA%\SyncVito\config.json` gespeichert. Der technische Abgleichzustand für bidirektionale Löschungen liegt daneben in `sync-state.json`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Eigenständige EXE bauen
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\build.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Ergebnisse liegen anschließend unter `dist\SyncVito.exe` und `dist\SyncVito.exe.sha256`. Die EXE ist eine One-File-Anwendung ohne benötigte Python-Installation und fordert beim Start über das eingebettete Manifest Administratorrechte an.
|
||||||
|
|
||||||
|
Hinweis: Eine nicht signierte lokale EXE kann beim ersten Start einen Windows-SmartScreen-Hinweis auslösen. Für die Verteilung in einer Organisation empfiehlt sich eine reguläre Codesignatur.
|
||||||
|
|
||||||
|
## Updates und Releases
|
||||||
|
|
||||||
|
SyncVito fragt höchstens einmal innerhalb von 24 Stunden die stabilen Releases des öffentlichen Gitea-Repositories ab. Die automatische Prüfung lässt sich in den Einstellungen abschalten; eine manuelle Prüfung ist dort und im Tray-Menü jederzeit möglich.
|
||||||
|
|
||||||
|
Ein installierbares Release benötigt exakt diese Anhänge:
|
||||||
|
|
||||||
|
- `SyncVito.exe`
|
||||||
|
- `SyncVito.exe.sha256`
|
||||||
|
|
||||||
|
Für ein Release muss die Version in `src/syncvito/version.py` angepasst und der Quellstand committed sein. Anschließend wird ein Gitea-Token mit Repository-Schreibrecht ausschließlich lokal gesetzt:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:GITEA_TOKEN = "<lokales-token>"
|
||||||
|
.\publish-release.ps1 -Version 1.0.0 -Notes "Erstes stabiles Release"
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript baut und testet die Anwendung, erzeugt Tag und Release und lädt beide Artefakte hoch. Das Token wird weder in der Anwendung noch im Repository gespeichert.
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/syncvito/
|
||||||
|
├── app.py Anwendungsstart und Einzelinstanzschutz
|
||||||
|
├── main_window.py Hauptfenster, Tray und UI-Zustand
|
||||||
|
├── dialogs.py Aufgaben- und Unterauswahl-Dialoge
|
||||||
|
├── coordinator.py Hintergrundläufe, Zeitplan und Retry
|
||||||
|
├── sync_engine.py Dateisystem-Abgleich
|
||||||
|
├── file_locks.py Windows-Sperrprozess-Erkennung
|
||||||
|
├── realtime.py watchdog-Integration
|
||||||
|
├── updater.py Gitea-Protokoll, Download und Integritätsprüfung
|
||||||
|
├── update_controller.py Hintergrund-Worker für Updates
|
||||||
|
├── update_dialog.py deutscher Versionsdialog
|
||||||
|
├── config_store.py atomare JSON-Konfiguration
|
||||||
|
├── state_store.py bidirektionaler Abgleichzustand
|
||||||
|
├── models.py Domänenmodelle und Validierung
|
||||||
|
├── theme.py Dark-Mode-Designsystem
|
||||||
|
└── icons.py skalierbare Statussymbole
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sicherheitshinweise
|
||||||
|
|
||||||
|
Beim Modus „Löschungen spiegeln“ entfernt SyncVito Dateien auf der Gegenseite, nachdem eine Löschung gegenüber dem letzten erfolgreichen Lauf eindeutig erkannt wurde. Vor produktivem Einsatz empfiehlt sich ein Test mit einem kleinen Ordnerpaar und eine vorhandene Datensicherung. Symbolische Links werden bewusst nicht verfolgt.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||||
|
<assemblyIdentity version="1.0.0.0" processorArchitecture="*" name="SyncVito" type="win32"/>
|
||||||
|
<description>SyncVito Datei- und NAS-Synchronisation</description>
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges>
|
||||||
|
<requestedExecutionLevel level="requireAdministrator" uiAccess="false"/>
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||||
|
<supportedOS Id="{4f476546-937d-4c0f-b5bf-4e24cda8b0a6}"/>
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||||
|
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
from PyInstaller.utils.hooks import collect_submodules
|
||||||
|
|
||||||
|
hidden_imports = collect_submodules("watchdog.observers")
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
["main.py"],
|
||||||
|
pathex=["src"],
|
||||||
|
binaries=[],
|
||||||
|
datas=[],
|
||||||
|
hiddenimports=hidden_imports,
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
noarchive=False,
|
||||||
|
optimize=1,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name="SyncVito",
|
||||||
|
icon="assets/SyncVito.ico",
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
console=False,
|
||||||
|
manifest="SyncVito.manifest",
|
||||||
|
uac_admin=True,
|
||||||
|
)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 264 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
Set-Location -LiteralPath $ProjectRoot
|
||||||
|
|
||||||
|
if (Get-Process -Name "SyncVito" -ErrorAction SilentlyContinue) {
|
||||||
|
throw "SyncVito läuft noch. Bitte die Anwendung über das Tray-Menü beenden und den Build erneut starten."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Abhängigkeiten werden geprüft …"
|
||||||
|
python -m pip install --disable-pip-version-check -r requirements.txt
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Die Abhängigkeiten konnten nicht installiert werden." }
|
||||||
|
|
||||||
|
Write-Host "Tests werden ausgeführt …"
|
||||||
|
python -m pytest
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Die Tests sind fehlgeschlagen." }
|
||||||
|
|
||||||
|
Write-Host "Programmsymbol wird erzeugt …"
|
||||||
|
python tools\generate_icon.py
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Das Programmsymbol konnte nicht erzeugt werden." }
|
||||||
|
|
||||||
|
Write-Host "SyncVito.exe wird erstellt …"
|
||||||
|
python -m PyInstaller --noconfirm --clean SyncVito.spec
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Der PyInstaller-Build ist fehlgeschlagen." }
|
||||||
|
|
||||||
|
$ExecutablePath = Join-Path $ProjectRoot "dist\SyncVito.exe"
|
||||||
|
$ChecksumPath = "$ExecutablePath.sha256"
|
||||||
|
$Checksum = (Get-FileHash -LiteralPath $ExecutablePath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
Set-Content -LiteralPath $ChecksumPath -Value "$Checksum SyncVito.exe" -Encoding Ascii
|
||||||
|
|
||||||
|
Write-Host "Fertig: $ExecutablePath"
|
||||||
|
Write-Host "Prüfsumme: $ChecksumPath"
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
# Design System Master File
|
||||||
|
|
||||||
|
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
|
||||||
|
> If that file exists, its rules **override** this Master file.
|
||||||
|
> If not, strictly follow the rules below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Project:** SyncVito
|
||||||
|
**Generated:** 2026-09-23 17:52:06
|
||||||
|
**Category:** General
|
||||||
|
**Design Dials:** Variance 5/10 (Balanced / Modern) | Motion 3/10 (Subtle) | Density 7/10 (Standard)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Global Rules
|
||||||
|
|
||||||
|
### Color Palette
|
||||||
|
|
||||||
|
| Role | Hex | CSS Variable |
|
||||||
|
|------|-----|--------------|
|
||||||
|
| Primary | `#2563EB` | `--color-primary` |
|
||||||
|
| On Primary | `#FFFFFF` | `--color-on-primary` |
|
||||||
|
| Secondary | `#3B82F6` | `--color-secondary` |
|
||||||
|
| On Secondary | `#000000` | `--color-on-secondary` |
|
||||||
|
| Accent/CTA | `#EA580C` | `--color-accent` |
|
||||||
|
| On Accent/CTA | `#000000` | `--color-on-accent` |
|
||||||
|
| Background | `#F8FAFC` | `--color-background` |
|
||||||
|
| Foreground | `#1E293B` | `--color-foreground` |
|
||||||
|
| Card | `#FFFFFF` | `--color-card` |
|
||||||
|
| Card Foreground | `#1E293B` | `--color-card-foreground` |
|
||||||
|
| Muted | `#E9EFF8` | `--color-muted` |
|
||||||
|
| Muted Foreground | `#475569` | `--color-muted-foreground` |
|
||||||
|
| Border | `#E2E8F0` | `--color-border` |
|
||||||
|
| Destructive | `#DC2626` | `--color-destructive` |
|
||||||
|
| On Destructive | `#FFFFFF` | `--color-on-destructive` |
|
||||||
|
| Ring | `#2563EB` | `--color-ring` |
|
||||||
|
|
||||||
|
**Color Notes:** Trust blue + orange CTA contrast [Accent adjusted from #F97316]
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
- **Heading Font:** Inter
|
||||||
|
- **Body Font:** Inter
|
||||||
|
- **Mood:** dark, cinematic, technical, precision, clean, premium, developer, professional, high-end utility
|
||||||
|
- **Google Fonts:** [Inter + Inter](https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap)
|
||||||
|
|
||||||
|
**CSS Import:**
|
||||||
|
```css
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Spacing Variables
|
||||||
|
|
||||||
|
*Density: 7/10 — Standard*
|
||||||
|
|
||||||
|
| Token | Value | Usage |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| `--space-xs` | `4px` / `0.25rem` | Tight gaps |
|
||||||
|
| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |
|
||||||
|
| `--space-md` | `16px` / `1rem` | Standard padding |
|
||||||
|
| `--space-lg` | `24px` / `1.5rem` | Section padding |
|
||||||
|
| `--space-xl` | `32px` / `2rem` | Large gaps |
|
||||||
|
| `--space-2xl` | `48px` / `3rem` | Section margins |
|
||||||
|
| `--space-3xl` | `64px` / `4rem` | Hero padding |
|
||||||
|
|
||||||
|
### Shadow Depths
|
||||||
|
|
||||||
|
| Level | Value | Usage |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
|
||||||
|
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
|
||||||
|
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
|
||||||
|
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Specs
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Primary Button */
|
||||||
|
.btn-primary {
|
||||||
|
background: #EA580C;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 200ms ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Secondary Button */
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: #2563EB;
|
||||||
|
border: 2px solid #2563EB;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 200ms ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cards
|
||||||
|
|
||||||
|
```css
|
||||||
|
.card {
|
||||||
|
background: #F8FAFC;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transition: all 200ms ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inputs
|
||||||
|
|
||||||
|
```css
|
||||||
|
.input {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #E2E8F0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: border-color 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: #2563EB;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px #2563EB20;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modals
|
||||||
|
|
||||||
|
```css
|
||||||
|
.modal-overlay {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Style Guidelines
|
||||||
|
|
||||||
|
**Style:** Minimalism & Swiss Style
|
||||||
|
|
||||||
|
**Keywords:** Clean, simple, spacious, functional, white space, high contrast, geometric, sans-serif, grid-based, essential
|
||||||
|
|
||||||
|
**Best For:** Enterprise apps, dashboards, documentation sites, SaaS platforms, professional tools
|
||||||
|
|
||||||
|
**Key Effects:** Subtle hover (200-250ms), smooth transitions, sharp shadows if any, clear type hierarchy, fast loading
|
||||||
|
|
||||||
|
### Page Pattern
|
||||||
|
|
||||||
|
**Pattern Name:** Hero + Features + CTA
|
||||||
|
|
||||||
|
- **Conversion Strategy:** Deep CTA placement. For CTA label text, verify at least 4.5:1 against the button fill; use 7:1 only when the product explicitly targets AAA normal-text contrast. Keep focus and component boundaries independently visible. Disable hero parallax under reduced motion and render its static final state.
|
||||||
|
- **CTA Placement:** Hero (sticky) + Bottom
|
||||||
|
- **Section Order:** Hero with headline/image > Value prop > Key features (3-5) > CTA section > Footer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Motion
|
||||||
|
|
||||||
|
**Scroll Reveal** (Subtle) — Trigger: scroll (viewport enter) | Duration: 300-400ms | Easing: `power1.out`
|
||||||
|
|
||||||
|
```js
|
||||||
|
gsap.from(el, { opacity: 0, y: 12, duration: 0.35, ease: 'power1.out', scrollTrigger: { trigger: el, start: 'top 90%', toggleActions: 'play none none reverse' } });
|
||||||
|
```
|
||||||
|
|
||||||
|
**Framework notes:** Requires the ScrollTrigger plugin registered once via gsap.registerPlugin(ScrollTrigger); Use matchMedia('(prefers-reduced-motion: reduce)') to skip non-essential motion and render the final state immediately
|
||||||
|
|
||||||
|
- ✅ Keep the y offset small (8-16px) so it reads as a fade, not a slide
|
||||||
|
- ❌ Don't reveal below-the-fold content needed for SEO/crawlers as invisible-by-default without a no-JS fallback
|
||||||
|
- ⚡ toggleActions 'play none none reverse' avoids re-triggering on every scroll direction change
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-Patterns (Do NOT Use)
|
||||||
|
|
||||||
|
|
||||||
|
### Additional Forbidden Patterns
|
||||||
|
|
||||||
|
- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
|
||||||
|
- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
|
||||||
|
- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
|
||||||
|
- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
|
||||||
|
- ❌ **Instant state changes** — Always use transitions (150-300ms)
|
||||||
|
- ❌ **Invisible focus states** — Focus states must be visible for a11y
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-Delivery Checklist
|
||||||
|
|
||||||
|
Before delivering any UI code, verify:
|
||||||
|
|
||||||
|
- [ ] No emojis used as icons (use SVG instead)
|
||||||
|
- [ ] All icons from consistent icon set (Heroicons/Lucide)
|
||||||
|
- [ ] `cursor-pointer` on all clickable elements
|
||||||
|
- [ ] Hover states with smooth transitions (150-300ms)
|
||||||
|
- [ ] Light mode: text contrast 4.5:1 minimum
|
||||||
|
- [ ] Focus states visible for keyboard navigation
|
||||||
|
- [ ] `prefers-reduced-motion` respected
|
||||||
|
- [ ] Responsive: 375px, 768px, 1024px, 1440px
|
||||||
|
- [ ] No content hidden behind fixed navbars
|
||||||
|
- [ ] No horizontal scroll on mobile
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""Entwicklungsstart ohne vorherige Paketinstallation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
|
||||||
|
|
||||||
|
from syncvito.app import main # noqa: E402
|
||||||
|
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidatePattern("^\d+\.\d+\.\d+$")]
|
||||||
|
[string]$Version,
|
||||||
|
|
||||||
|
[string]$Notes = "Stabiles SyncVito-Release"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
Set-Location -LiteralPath $ProjectRoot
|
||||||
|
|
||||||
|
if (-not $env:GITEA_TOKEN) {
|
||||||
|
throw "GITEA_TOKEN ist nicht gesetzt. Das Token nur lokal als Umgebungsvariable hinterlegen."
|
||||||
|
}
|
||||||
|
|
||||||
|
$ConfiguredVersion = python -c "import sys; sys.path.insert(0, 'src'); from syncvito.version import __version__; print(__version__)"
|
||||||
|
if ($ConfiguredVersion.Trim() -ne $Version) {
|
||||||
|
throw "Die angeforderte Version $Version stimmt nicht mit syncvito.version ($ConfiguredVersion) überein."
|
||||||
|
}
|
||||||
|
|
||||||
|
& (Join-Path $ProjectRoot "build.ps1")
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Der Build ist fehlgeschlagen."
|
||||||
|
}
|
||||||
|
|
||||||
|
$Tag = "v$Version"
|
||||||
|
git rev-parse --verify $Tag 2>$null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
git tag -a $Tag -m "SyncVito $Version"
|
||||||
|
}
|
||||||
|
git push origin main
|
||||||
|
git push origin $Tag
|
||||||
|
|
||||||
|
$Headers = @{ Authorization = "token $env:GITEA_TOKEN" }
|
||||||
|
$ApiBase = "https://gitea.davito.synology.me/api/v1/repos/Davito/SyncVito"
|
||||||
|
$Payload = @{
|
||||||
|
tag_name = $Tag
|
||||||
|
target_commitish = "main"
|
||||||
|
name = "SyncVito $Version"
|
||||||
|
body = $Notes
|
||||||
|
draft = $false
|
||||||
|
prerelease = $false
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
$Release = Invoke-RestMethod -Uri "$ApiBase/releases" -Method Post -Headers $Headers -ContentType "application/json" -Body $Payload
|
||||||
|
|
||||||
|
Add-Type -AssemblyName System.Net.Http
|
||||||
|
$Client = [System.Net.Http.HttpClient]::new()
|
||||||
|
$Client.DefaultRequestHeaders.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new("token", $env:GITEA_TOKEN)
|
||||||
|
try {
|
||||||
|
foreach ($FilePath in @("dist\SyncVito.exe", "dist\SyncVito.exe.sha256")) {
|
||||||
|
$AbsolutePath = Join-Path $ProjectRoot $FilePath
|
||||||
|
$FileName = Split-Path -Leaf $AbsolutePath
|
||||||
|
$Form = [System.Net.Http.MultipartFormDataContent]::new()
|
||||||
|
try {
|
||||||
|
$Bytes = [System.IO.File]::ReadAllBytes($AbsolutePath)
|
||||||
|
$Content = [System.Net.Http.ByteArrayContent]::new($Bytes)
|
||||||
|
$Content.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::new("application/octet-stream")
|
||||||
|
$Form.Add($Content, "attachment", $FileName)
|
||||||
|
$UploadUrl = "$ApiBase/releases/$($Release.id)/assets?name=$([uri]::EscapeDataString($FileName))"
|
||||||
|
$Response = $Client.PostAsync($UploadUrl, $Form).GetAwaiter().GetResult()
|
||||||
|
if (-not $Response.IsSuccessStatusCode) {
|
||||||
|
throw "Upload von $FileName fehlgeschlagen: $($Response.StatusCode) $($Response.Content.ReadAsStringAsync().Result)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$Form.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$Client.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Release $Tag wurde veröffentlicht: https://gitea.davito.synology.me/Davito/SyncVito/releases"
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=75", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "syncvito"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"PyQt6>=6.7,<7",
|
||||||
|
"watchdog>=5,<7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"mypy>=1.11",
|
||||||
|
"pyinstaller>=6.10",
|
||||||
|
"pytest>=8.3",
|
||||||
|
"ruff>=0.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
syncvito = "syncvito.app:main"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
package-dir = {"" = "src"}
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 120
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "W", "F", "I", "B", "C4", "UP", "SIM"]
|
||||||
|
ignore = ["E501"]
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
quote-style = "double"
|
||||||
|
indent-style = "space"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.11"
|
||||||
|
strict = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
disallow_incomplete_defs = true
|
||||||
|
exclude = ["build", "dist"]
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = ["watchdog.*"]
|
||||||
|
ignore_missing_imports = true
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
PyQt6>=6.7,<7
|
||||||
|
watchdog>=5,<7
|
||||||
|
pyinstaller>=6.10
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""SyncVito – zuverlässige Ordner-Synchronisation für Windows."""
|
||||||
|
|
||||||
|
from syncvito.models import (
|
||||||
|
AppConfig,
|
||||||
|
AppSettings,
|
||||||
|
ConflictMode,
|
||||||
|
DeleteMode,
|
||||||
|
SyncDirection,
|
||||||
|
SyncTask,
|
||||||
|
TaskRuntimeState,
|
||||||
|
TriggerMode,
|
||||||
|
)
|
||||||
|
from syncvito.version import __version__
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"__version__",
|
||||||
|
"AppConfig",
|
||||||
|
"AppSettings",
|
||||||
|
"ConflictMode",
|
||||||
|
"DeleteMode",
|
||||||
|
"SyncDirection",
|
||||||
|
"SyncTask",
|
||||||
|
"TaskRuntimeState",
|
||||||
|
"TriggerMode",
|
||||||
|
]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Ausführung mit ``python -m syncvito``."""
|
||||||
|
|
||||||
|
from syncvito.app import main
|
||||||
|
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Anwendungsstart und Prozesslebenszyklus."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QLockFile, QStandardPaths
|
||||||
|
from PyQt6.QtWidgets import QApplication, QMessageBox, QSystemTrayIcon
|
||||||
|
|
||||||
|
from syncvito.config_store import ConfigError, ConfigStore
|
||||||
|
from syncvito.icons import app_icon
|
||||||
|
from syncvito.main_window import MainWindow
|
||||||
|
from syncvito.models import AppConfig
|
||||||
|
from syncvito.theme import apply_theme
|
||||||
|
|
||||||
|
__all__ = ["main"]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Startet SyncVito als einzelne Windows-Instanz."""
|
||||||
|
application = QApplication(sys.argv)
|
||||||
|
application.setApplicationName("SyncVito")
|
||||||
|
application.setOrganizationName("SyncVito")
|
||||||
|
application.setQuitOnLastWindowClosed(False)
|
||||||
|
application.setWindowIcon(app_icon())
|
||||||
|
|
||||||
|
lock_path = Path(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.TempLocation)) / "syncvito.lock"
|
||||||
|
lock = QLockFile(str(lock_path))
|
||||||
|
lock.setStaleLockTime(0)
|
||||||
|
if not lock.tryLock(100):
|
||||||
|
QMessageBox.information(None, "SyncVito läuft bereits", "SyncVito ist bereits im Infobereich aktiv.")
|
||||||
|
return 0
|
||||||
|
if not QSystemTrayIcon.isSystemTrayAvailable():
|
||||||
|
QMessageBox.critical(None, "Infobereich nicht verfügbar", "SyncVito benötigt den Windows-Infobereich.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
store = ConfigStore()
|
||||||
|
try:
|
||||||
|
config = store.load()
|
||||||
|
except ConfigError as exc:
|
||||||
|
QMessageBox.warning(None, "Konfiguration zurückgesetzt", str(exc))
|
||||||
|
config = AppConfig()
|
||||||
|
apply_theme(application, config.settings)
|
||||||
|
window = MainWindow(config, store)
|
||||||
|
if "--minimized" not in sys.argv:
|
||||||
|
window.show()
|
||||||
|
exit_code = application.exec()
|
||||||
|
window.coordinator.stop()
|
||||||
|
lock.unlock()
|
||||||
|
return exit_code
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Verwaltung des benutzerspezifischen Windows-Autostarts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from contextlib import suppress
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
__all__ = ["set_autostart"]
|
||||||
|
|
||||||
|
|
||||||
|
def set_autostart(enabled: bool) -> None:
|
||||||
|
"""Setzt oder entfernt SyncVito unter HKCU\\...\\Run."""
|
||||||
|
if os.name != "nt":
|
||||||
|
return
|
||||||
|
import winreg
|
||||||
|
|
||||||
|
key_path = r"Software\Microsoft\Windows\CurrentVersion\Run"
|
||||||
|
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE) as key:
|
||||||
|
if enabled:
|
||||||
|
executable = Path(sys.executable)
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
command = f'"{executable}" --minimized'
|
||||||
|
else:
|
||||||
|
entry = Path(__file__).resolve().parents[2] / "main.py"
|
||||||
|
command = f'"{executable}" "{entry}" --minimized'
|
||||||
|
winreg.SetValueEx(key, "SyncVito", 0, winreg.REG_SZ, command)
|
||||||
|
else:
|
||||||
|
with suppress(FileNotFoundError):
|
||||||
|
winreg.DeleteValue(key, "SyncVito")
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Atomare Speicherung der Anwendungskonfiguration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from contextlib import suppress
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from syncvito.models import AppConfig
|
||||||
|
|
||||||
|
__all__ = ["ConfigError", "ConfigStore", "default_data_directory"]
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(RuntimeError):
|
||||||
|
"""Fehler beim Laden oder Speichern der Konfiguration."""
|
||||||
|
|
||||||
|
|
||||||
|
def default_data_directory() -> Path:
|
||||||
|
"""Liefert das lokale, benutzerspezifische Datenverzeichnis."""
|
||||||
|
root = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
||||||
|
return root / "SyncVito"
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigStore:
|
||||||
|
"""Lädt und speichert JSON-Daten mit atomarem Dateiaustausch."""
|
||||||
|
|
||||||
|
def __init__(self, path: Path | None = None) -> None:
|
||||||
|
self.path = path or default_data_directory() / "config.json"
|
||||||
|
|
||||||
|
def load(self) -> AppConfig:
|
||||||
|
"""Lädt die Konfiguration; beschädigte Dateien werden gesichert."""
|
||||||
|
if not self.path.exists():
|
||||||
|
return AppConfig()
|
||||||
|
try:
|
||||||
|
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError("Das JSON-Wurzelelement ist kein Objekt.")
|
||||||
|
return AppConfig.from_dict(raw)
|
||||||
|
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
recovery_path = self.path.with_name(f"config.defekt-{timestamp}.json")
|
||||||
|
try:
|
||||||
|
self.path.replace(recovery_path)
|
||||||
|
except OSError:
|
||||||
|
recovery_path = self.path
|
||||||
|
raise ConfigError(f"Die Konfiguration war nicht lesbar. Sicherung: {recovery_path}") from exc
|
||||||
|
|
||||||
|
def save(self, config: AppConfig) -> None:
|
||||||
|
"""Speichert die Konfiguration atomar und UTF-8-kodiert."""
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = self.path.with_suffix(".json.tmp")
|
||||||
|
try:
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(config.to_dict(), ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(temporary, self.path)
|
||||||
|
except OSError as exc:
|
||||||
|
with suppress(OSError):
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
raise ConfigError(f"Konfiguration konnte nicht gespeichert werden: {exc}") from exc
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
"""Serielle Hintergrundausführung, Zeitplanung und Retry-Steuerung."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
from collections import deque
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QObject, QRunnable, QThreadPool, QTimer, pyqtSignal
|
||||||
|
|
||||||
|
from syncvito.config_store import ConfigStore
|
||||||
|
from syncvito.file_locks import is_file_locked
|
||||||
|
from syncvito.models import AppConfig, SyncTask, TaskRuntimeState, TriggerMode
|
||||||
|
from syncvito.realtime import RealtimeMonitor
|
||||||
|
from syncvito.sync_engine import SyncEngine, SyncResult, UnavailablePathError
|
||||||
|
|
||||||
|
__all__ = ["SyncCoordinator", "derive_overall_status"]
|
||||||
|
|
||||||
|
|
||||||
|
def derive_overall_status(
|
||||||
|
states: list[TaskRuntimeState], globally_paused: bool, running: bool = False
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Leitet den Tray-Zustand deterministisch aus allen Aufgaben ab."""
|
||||||
|
if TaskRuntimeState.ERROR in states:
|
||||||
|
return "error", "Fehler"
|
||||||
|
if TaskRuntimeState.WAITING_FOR_RELEASE in states:
|
||||||
|
return "error", "Wartet auf Dateifreigabe"
|
||||||
|
if globally_paused:
|
||||||
|
return "paused", "Pausiert"
|
||||||
|
if TaskRuntimeState.ACTIVE not in states:
|
||||||
|
return "paused", "Inaktiv"
|
||||||
|
return "active", "Synchronisiert" if running else "Bereit"
|
||||||
|
|
||||||
|
|
||||||
|
class _WorkerSignals(QObject):
|
||||||
|
progress = pyqtSignal(int, str)
|
||||||
|
finished = pyqtSignal(str, object)
|
||||||
|
failed = pyqtSignal(str, str, bool)
|
||||||
|
|
||||||
|
|
||||||
|
class _SyncWorker(QRunnable):
|
||||||
|
def __init__(self, task: SyncTask, engine: SyncEngine) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.task = task
|
||||||
|
self.engine = engine
|
||||||
|
self.signals = _WorkerSignals()
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
result = self.engine.synchronize(self.task, self.signals.progress.emit)
|
||||||
|
except UnavailablePathError as exc:
|
||||||
|
self.signals.failed.emit(self.task.id, str(exc), True)
|
||||||
|
except Exception as exc: # noqa: BLE001 - Worker-Grenze muss UI-Absturz verhindern.
|
||||||
|
detail = f"{exc}\n{traceback.format_exc(limit=4)}"
|
||||||
|
self.signals.failed.emit(self.task.id, detail, False)
|
||||||
|
else:
|
||||||
|
self.signals.finished.emit(self.task.id, result)
|
||||||
|
|
||||||
|
|
||||||
|
class _RealtimeBridge(QObject):
|
||||||
|
changed = pyqtSignal(str)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncCoordinator(QObject):
|
||||||
|
"""Koordiniert genau einen sichtbaren Synchronisationslauf zur Zeit."""
|
||||||
|
|
||||||
|
progress_changed = pyqtSignal(int, str)
|
||||||
|
log_created = pyqtSignal(str, str, str)
|
||||||
|
overall_status_changed = pyqtSignal(str, str)
|
||||||
|
task_state_changed = pyqtSignal(str, object)
|
||||||
|
lock_changed = pyqtSignal(str)
|
||||||
|
idle_changed = pyqtSignal(bool)
|
||||||
|
|
||||||
|
def __init__(self, config: AppConfig, config_store: ConfigStore, parent: QObject | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.config = config
|
||||||
|
self.config_store = config_store
|
||||||
|
self.engine = SyncEngine()
|
||||||
|
self.pool = QThreadPool.globalInstance()
|
||||||
|
self.pool.setMaxThreadCount(max(2, self.pool.maxThreadCount()))
|
||||||
|
self._queue: deque[str] = deque()
|
||||||
|
self._running_task_id: str | None = None
|
||||||
|
self._retry_tasks: dict[str, list[Path]] = {}
|
||||||
|
self._lock_messages: dict[str, str] = {}
|
||||||
|
self._task_states: dict[str, TaskRuntimeState] = {
|
||||||
|
task.id: TaskRuntimeState.ACTIVE if task.enabled else TaskRuntimeState.PAUSED for task in config.tasks
|
||||||
|
}
|
||||||
|
self._last_realtime_event: dict[str, datetime] = {}
|
||||||
|
self._bridge = _RealtimeBridge()
|
||||||
|
self._bridge.changed.connect(self._on_realtime_event)
|
||||||
|
self.monitor = RealtimeMonitor(self._bridge.changed.emit)
|
||||||
|
|
||||||
|
self._schedule_timer = QTimer(self)
|
||||||
|
self._schedule_timer.setInterval(30_000)
|
||||||
|
self._schedule_timer.timeout.connect(self._check_intervals)
|
||||||
|
self._schedule_timer.start()
|
||||||
|
self._retry_timer = QTimer(self)
|
||||||
|
self._retry_timer.setInterval(10_000)
|
||||||
|
self._retry_timer.timeout.connect(self._check_retries)
|
||||||
|
self._retry_timer.start()
|
||||||
|
self.rebuild_monitors()
|
||||||
|
|
||||||
|
def task_state(self, task_id: str) -> TaskRuntimeState:
|
||||||
|
"""Liefert den aktuellen Engine-Zustand einer Aufgabe."""
|
||||||
|
return self._task_states.get(task_id, TaskRuntimeState.PAUSED)
|
||||||
|
|
||||||
|
def current_overall_status(self) -> tuple[str, str]:
|
||||||
|
"""Liefert den aktuell abgeleiteten Tray-Zustand."""
|
||||||
|
return derive_overall_status(list(self._task_states.values()), self.config.settings.paused, self.is_running)
|
||||||
|
|
||||||
|
def refresh_task_states(self) -> None:
|
||||||
|
"""Gleicht Laufzeitzustände nach Änderungen an der Aufgabenliste ab."""
|
||||||
|
known_ids = {task.id for task in self.config.tasks}
|
||||||
|
self._task_states = {task_id: state for task_id, state in self._task_states.items() if task_id in known_ids}
|
||||||
|
self._retry_tasks = {task_id: paths for task_id, paths in self._retry_tasks.items() if task_id in known_ids}
|
||||||
|
self._lock_messages = {
|
||||||
|
task_id: message for task_id, message in self._lock_messages.items() if task_id in known_ids
|
||||||
|
}
|
||||||
|
for task in self.config.tasks:
|
||||||
|
desired = TaskRuntimeState.ACTIVE if task.enabled else TaskRuntimeState.PAUSED
|
||||||
|
current = self._task_states.get(task.id)
|
||||||
|
if current is None or current is TaskRuntimeState.PAUSED or not task.enabled:
|
||||||
|
self._set_task_state(task.id, desired)
|
||||||
|
if not task.enabled:
|
||||||
|
self._retry_tasks.pop(task.id, None)
|
||||||
|
self._lock_messages.pop(task.id, None)
|
||||||
|
enabled_ids = {task.id for task in self.config.tasks if task.enabled}
|
||||||
|
self._queue = deque(task_id for task_id in self._queue if task_id in enabled_ids)
|
||||||
|
self._emit_overall_status()
|
||||||
|
self._emit_lock_summary()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
"""Zeigt an, ob momentan ein Worker aktiv ist."""
|
||||||
|
return self._running_task_id is not None
|
||||||
|
|
||||||
|
def enqueue(self, task_id: str) -> None:
|
||||||
|
"""Stellt eine Aufgabe dedupliziert in die serielle Warteschlange."""
|
||||||
|
if self.config.settings.paused:
|
||||||
|
self.log_created.emit("Synchronisation", "Hinweis", "SyncVito ist pausiert.")
|
||||||
|
return
|
||||||
|
task = self._task(task_id)
|
||||||
|
if task is None or not task.enabled:
|
||||||
|
return
|
||||||
|
if task_id != self._running_task_id and task_id not in self._queue:
|
||||||
|
self._queue.append(task_id)
|
||||||
|
self._start_next()
|
||||||
|
|
||||||
|
def enqueue_all(self) -> None:
|
||||||
|
"""Stellt alle aktiven Aufgaben zur manuellen Ausführung an."""
|
||||||
|
for task in self.config.tasks:
|
||||||
|
if task.enabled:
|
||||||
|
self.enqueue(task.id)
|
||||||
|
|
||||||
|
def set_paused(self, paused: bool) -> None:
|
||||||
|
"""Ändert den globalen Pausenzustand."""
|
||||||
|
self.config.settings.paused = paused
|
||||||
|
self.config_store.save(self.config)
|
||||||
|
if paused:
|
||||||
|
self.log_created.emit("Status", "Hinweis", "Alle automatischen Auslöser sind pausiert.")
|
||||||
|
else:
|
||||||
|
self.log_created.emit("Status", "Erfolg", "Synchronisation fortgesetzt.")
|
||||||
|
self._start_next()
|
||||||
|
self._emit_overall_status()
|
||||||
|
|
||||||
|
def rebuild_monitors(self) -> None:
|
||||||
|
"""Aktualisiert alle Dateisystemüberwachungen."""
|
||||||
|
errors = self.monitor.rebuild(self.config.tasks)
|
||||||
|
for task_id, message in errors:
|
||||||
|
self._retry_tasks[task_id] = []
|
||||||
|
self._set_task_state(task_id, TaskRuntimeState.ERROR)
|
||||||
|
self.log_created.emit("Echtzeit", "Fehler", message)
|
||||||
|
self._emit_overall_status()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stoppt die Dateisystemüberwachung beim Beenden."""
|
||||||
|
self.monitor.stop()
|
||||||
|
|
||||||
|
def _start_next(self) -> None:
|
||||||
|
if self._running_task_id is not None or self.config.settings.paused or not self._queue:
|
||||||
|
return
|
||||||
|
task_id = self._queue.popleft()
|
||||||
|
task = self._task(task_id)
|
||||||
|
if task is None or not task.enabled:
|
||||||
|
self._start_next()
|
||||||
|
return
|
||||||
|
self._running_task_id = task_id
|
||||||
|
self.idle_changed.emit(False)
|
||||||
|
self._emit_overall_status()
|
||||||
|
self.log_created.emit(task.name, "Start", "Synchronisation gestartet.")
|
||||||
|
worker = _SyncWorker(task, self.engine)
|
||||||
|
worker.signals.progress.connect(self.progress_changed)
|
||||||
|
worker.signals.finished.connect(self._on_finished)
|
||||||
|
worker.signals.failed.connect(self._on_failed)
|
||||||
|
self.pool.start(worker)
|
||||||
|
|
||||||
|
def _on_finished(self, task_id: str, result: SyncResult) -> None:
|
||||||
|
task = self._task(task_id)
|
||||||
|
if task is None:
|
||||||
|
self._complete_current()
|
||||||
|
return
|
||||||
|
task.last_run = datetime.now().isoformat(timespec="seconds")
|
||||||
|
self.config_store.save(self.config)
|
||||||
|
if result.blocked:
|
||||||
|
paths = [blocked.path for blocked in result.blocked]
|
||||||
|
self._retry_tasks[task_id] = paths
|
||||||
|
descriptions: list[str] = []
|
||||||
|
for blocked in result.blocked:
|
||||||
|
names = ", ".join(f"{process.name} (PID {process.pid})" for process in blocked.processes)
|
||||||
|
descriptions.append(f"{blocked.path.name}: {names or 'sperrender Prozess nicht ermittelbar'}")
|
||||||
|
message = "Zurückgestellt – " + "; ".join(descriptions)
|
||||||
|
self._lock_messages[task_id] = message
|
||||||
|
self._emit_lock_summary()
|
||||||
|
self.log_created.emit(task.name, "Blockiert", message)
|
||||||
|
self._set_task_state(task_id, TaskRuntimeState.WAITING_FOR_RELEASE)
|
||||||
|
elif result.errors:
|
||||||
|
self._retry_tasks[task_id] = []
|
||||||
|
self._lock_messages.pop(task_id, None)
|
||||||
|
self._emit_lock_summary()
|
||||||
|
self.log_created.emit(task.name, "Fehler", " | ".join(result.errors[:5]))
|
||||||
|
self._set_task_state(task_id, TaskRuntimeState.ERROR)
|
||||||
|
else:
|
||||||
|
self._retry_tasks.pop(task_id, None)
|
||||||
|
self._lock_messages.pop(task_id, None)
|
||||||
|
self._emit_lock_summary()
|
||||||
|
summary = f"{result.copied} kopiert, {result.deleted} gelöscht, {result.conflicts} Konfliktkopien"
|
||||||
|
self.log_created.emit(task.name, "Erfolg", summary)
|
||||||
|
self._set_task_state(task_id, TaskRuntimeState.ACTIVE)
|
||||||
|
self._complete_current()
|
||||||
|
|
||||||
|
def _on_failed(self, task_id: str, detail: str, inaccessible: bool) -> None:
|
||||||
|
task = self._task(task_id)
|
||||||
|
name = task.name if task else "Aufgabe"
|
||||||
|
self._retry_tasks[task_id] = []
|
||||||
|
self._lock_messages.pop(task_id, None)
|
||||||
|
self._emit_lock_summary()
|
||||||
|
self._set_task_state(task_id, TaskRuntimeState.ERROR)
|
||||||
|
self.log_created.emit(name, "Fehler", detail)
|
||||||
|
self._complete_current()
|
||||||
|
|
||||||
|
def _complete_current(self) -> None:
|
||||||
|
self._running_task_id = None
|
||||||
|
self.progress_changed.emit(100, "Abgeschlossen")
|
||||||
|
if not self._queue:
|
||||||
|
self.idle_changed.emit(True)
|
||||||
|
self._emit_overall_status()
|
||||||
|
QTimer.singleShot(200, self._start_next)
|
||||||
|
|
||||||
|
def _check_intervals(self) -> None:
|
||||||
|
if self.config.settings.paused:
|
||||||
|
return
|
||||||
|
now = datetime.now()
|
||||||
|
for task in self.config.tasks:
|
||||||
|
if not task.enabled or task.trigger is not TriggerMode.INTERVAL:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
last_run = datetime.fromisoformat(task.last_run) if task.last_run else datetime.min
|
||||||
|
except ValueError:
|
||||||
|
last_run = datetime.min
|
||||||
|
if now - last_run >= timedelta(minutes=task.interval_minutes):
|
||||||
|
self.enqueue(task.id)
|
||||||
|
|
||||||
|
def _on_realtime_event(self, task_id: str) -> None:
|
||||||
|
now = datetime.now()
|
||||||
|
last = self._last_realtime_event.get(task_id)
|
||||||
|
self._last_realtime_event[task_id] = now
|
||||||
|
if last and now - last < timedelta(seconds=2):
|
||||||
|
return
|
||||||
|
QTimer.singleShot(1200, lambda: self.enqueue(task_id))
|
||||||
|
|
||||||
|
def _check_retries(self) -> None:
|
||||||
|
if self.config.settings.paused or self.is_running:
|
||||||
|
return
|
||||||
|
for task_id, paths in list(self._retry_tasks.items()):
|
||||||
|
if paths and any(is_file_locked(path) for path in paths):
|
||||||
|
continue
|
||||||
|
self.log_created.emit(
|
||||||
|
"Automatischer Versuch", "Hinweis", "Blockierte oder nicht erreichbare Datei wird erneut geprüft."
|
||||||
|
)
|
||||||
|
self.enqueue(task_id)
|
||||||
|
break
|
||||||
|
|
||||||
|
def _task(self, task_id: str) -> SyncTask | None:
|
||||||
|
return next((task for task in self.config.tasks if task.id == task_id), None)
|
||||||
|
|
||||||
|
def _set_task_state(self, task_id: str, state: TaskRuntimeState) -> None:
|
||||||
|
if self._task_states.get(task_id) == state:
|
||||||
|
return
|
||||||
|
self._task_states[task_id] = state
|
||||||
|
self.task_state_changed.emit(task_id, state)
|
||||||
|
self._emit_overall_status()
|
||||||
|
|
||||||
|
def _emit_overall_status(self) -> None:
|
||||||
|
status, text = self.current_overall_status()
|
||||||
|
self.overall_status_changed.emit(status, text)
|
||||||
|
|
||||||
|
def _emit_lock_summary(self) -> None:
|
||||||
|
self.lock_changed.emit("\n".join(self._lock_messages.values()))
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
"""Dialoge zur Aufgabenbearbeitung und Unterauswahl."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QDir
|
||||||
|
from PyQt6.QtGui import QFileSystemModel
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QAbstractItemView,
|
||||||
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QFileDialog,
|
||||||
|
QFormLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QListWidget,
|
||||||
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
|
QSpinBox,
|
||||||
|
QTabWidget,
|
||||||
|
QTreeView,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask, TriggerMode
|
||||||
|
|
||||||
|
__all__ = ["TaskDialog"]
|
||||||
|
|
||||||
|
|
||||||
|
class _PathField(QWidget):
|
||||||
|
def __init__(self, title: str, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.edit = QLineEdit()
|
||||||
|
self.edit.setPlaceholderText(title)
|
||||||
|
self.button = QPushButton("Auswählen …")
|
||||||
|
self.button.clicked.connect(self._browse)
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(8)
|
||||||
|
layout.addWidget(self.edit, 1)
|
||||||
|
layout.addWidget(self.button)
|
||||||
|
|
||||||
|
def _browse(self) -> None:
|
||||||
|
selected = QFileDialog.getExistingDirectory(self, "Ordner auswählen", self.edit.text())
|
||||||
|
if selected:
|
||||||
|
self.edit.setText(QDir.toNativeSeparators(selected))
|
||||||
|
|
||||||
|
|
||||||
|
class _SelectionDialog(QDialog):
|
||||||
|
def __init__(self, root: Path, selected: list[str], parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Dateien und Ordner auswählen")
|
||||||
|
self.resize(820, 560)
|
||||||
|
self.root = root
|
||||||
|
self.model = QFileSystemModel(self)
|
||||||
|
self.model.setRootPath(str(root))
|
||||||
|
self.model.setFilter(QDir.Filter.AllEntries | QDir.Filter.NoDotAndDotDot)
|
||||||
|
self.tree = QTreeView()
|
||||||
|
self.tree.setModel(self.model)
|
||||||
|
self.tree.setRootIndex(self.model.index(str(root)))
|
||||||
|
self.tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||||
|
self.tree.setAlternatingRowColors(True)
|
||||||
|
self.tree.setSortingEnabled(True)
|
||||||
|
for column in (1, 2, 3):
|
||||||
|
self.tree.hideColumn(column)
|
||||||
|
|
||||||
|
self.chosen = QListWidget()
|
||||||
|
self.chosen.addItems(selected)
|
||||||
|
add_button = QPushButton("Markierte übernehmen")
|
||||||
|
remove_button = QPushButton("Aus Liste entfernen")
|
||||||
|
add_button.clicked.connect(self._add_marked)
|
||||||
|
remove_button.clicked.connect(self._remove_chosen)
|
||||||
|
|
||||||
|
hint = QLabel(
|
||||||
|
"Ohne Unterauswahl wird der gesamte Quellordner synchronisiert. "
|
||||||
|
"Ordnerauswahlen schließen deren gesamten Inhalt ein."
|
||||||
|
)
|
||||||
|
hint.setWordWrap(True)
|
||||||
|
hint.setProperty("muted", True)
|
||||||
|
|
||||||
|
columns = QHBoxLayout()
|
||||||
|
tree_column = QVBoxLayout()
|
||||||
|
tree_column.addWidget(QLabel("Quellordner"))
|
||||||
|
tree_column.addWidget(self.tree, 1)
|
||||||
|
tree_column.addWidget(add_button)
|
||||||
|
chosen_column = QVBoxLayout()
|
||||||
|
chosen_column.addWidget(QLabel("Aktive Unterauswahl"))
|
||||||
|
chosen_column.addWidget(self.chosen, 1)
|
||||||
|
chosen_column.addWidget(remove_button)
|
||||||
|
columns.addLayout(tree_column, 3)
|
||||||
|
columns.addLayout(chosen_column, 2)
|
||||||
|
|
||||||
|
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||||
|
buttons.accepted.connect(self.accept)
|
||||||
|
buttons.rejected.connect(self.reject)
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(20, 20, 20, 20)
|
||||||
|
layout.setSpacing(12)
|
||||||
|
layout.addWidget(hint)
|
||||||
|
layout.addLayout(columns, 1)
|
||||||
|
layout.addWidget(buttons)
|
||||||
|
|
||||||
|
def selected_paths(self) -> list[str]:
|
||||||
|
"""Liefert die gespeicherten relativen Pfade."""
|
||||||
|
return sorted({self.chosen.item(index).text() for index in range(self.chosen.count())})
|
||||||
|
|
||||||
|
def _add_marked(self) -> None:
|
||||||
|
existing = set(self.selected_paths())
|
||||||
|
for index in self.tree.selectionModel().selectedRows(0):
|
||||||
|
path = Path(self.model.filePath(index))
|
||||||
|
try:
|
||||||
|
relative = str(path.relative_to(self.root))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if relative not in existing and relative != ".":
|
||||||
|
self.chosen.addItem(relative)
|
||||||
|
existing.add(relative)
|
||||||
|
|
||||||
|
def _remove_chosen(self) -> None:
|
||||||
|
for item in self.chosen.selectedItems():
|
||||||
|
self.chosen.takeItem(self.chosen.row(item))
|
||||||
|
|
||||||
|
|
||||||
|
class TaskDialog(QDialog):
|
||||||
|
"""Vollständiger Editor für eine Synchronisationsaufgabe."""
|
||||||
|
|
||||||
|
def __init__(self, task: SyncTask | None = None, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._original = task
|
||||||
|
self._selected_paths = list(task.selected_paths) if task else []
|
||||||
|
self.setWindowTitle("Aufgabe bearbeiten" if task else "Neue Aufgabe")
|
||||||
|
self.setMinimumWidth(690)
|
||||||
|
self.resize(720, 610)
|
||||||
|
|
||||||
|
self.name_edit = QLineEdit(task.name if task else "")
|
||||||
|
self.name_edit.setPlaceholderText("z. B. Projektarchiv")
|
||||||
|
self.source_field = _PathField("Lokalen Quellordner wählen")
|
||||||
|
self.target_field = _PathField("NAS- oder Zielordner wählen")
|
||||||
|
if task:
|
||||||
|
self.source_field.edit.setText(task.source)
|
||||||
|
self.target_field.edit.setText(task.target)
|
||||||
|
|
||||||
|
self.direction_combo = QComboBox()
|
||||||
|
self.direction_combo.addItem("Einweg – Quelle → Ziel", SyncDirection.ONE_WAY)
|
||||||
|
self.direction_combo.addItem("Bidirektional – beide Seiten", SyncDirection.BIDIRECTIONAL)
|
||||||
|
self.trigger_combo = QComboBox()
|
||||||
|
self.trigger_combo.addItem("Manuell", TriggerMode.MANUAL)
|
||||||
|
self.trigger_combo.addItem("Echtzeit überwachen", TriggerMode.REALTIME)
|
||||||
|
self.trigger_combo.addItem("Zeitgesteuert", TriggerMode.INTERVAL)
|
||||||
|
self.interval_spin = QSpinBox()
|
||||||
|
self.interval_spin.setRange(1, 10080)
|
||||||
|
self.interval_spin.setSuffix(" Minuten")
|
||||||
|
self.interval_spin.setValue(task.interval_minutes if task else 30)
|
||||||
|
self.delete_combo = QComboBox()
|
||||||
|
self.delete_combo.addItem("Nie automatisch löschen", DeleteMode.KEEP)
|
||||||
|
self.delete_combo.addItem("Löschungen spiegeln", DeleteMode.MIRROR)
|
||||||
|
self.conflict_combo = QComboBox()
|
||||||
|
self.conflict_combo.addItem("Neuerer Zeitstempel gewinnt", ConflictMode.NEWER_WINS)
|
||||||
|
self.conflict_combo.addItem("Unterlegene Version als _conflict-Kopie behalten", ConflictMode.CONFLICT_COPY)
|
||||||
|
self.enabled_check = QCheckBox("Aufgabe ist aktiv")
|
||||||
|
self.enabled_check.setChecked(task.enabled if task else True)
|
||||||
|
|
||||||
|
if task:
|
||||||
|
self._set_combo(self.direction_combo, task.direction)
|
||||||
|
self._set_combo(self.trigger_combo, task.trigger)
|
||||||
|
self._set_combo(self.delete_combo, task.delete_mode)
|
||||||
|
self._set_combo(self.conflict_combo, task.conflict_mode)
|
||||||
|
|
||||||
|
self.selection_label = QLabel()
|
||||||
|
self.selection_label.setProperty("muted", True)
|
||||||
|
selection_button = QPushButton("Unterauswahl festlegen …")
|
||||||
|
selection_button.clicked.connect(self._choose_selection)
|
||||||
|
selection_row = QHBoxLayout()
|
||||||
|
selection_row.addWidget(self.selection_label, 1)
|
||||||
|
selection_row.addWidget(selection_button)
|
||||||
|
|
||||||
|
base_page = QWidget()
|
||||||
|
base_form = QFormLayout(base_page)
|
||||||
|
base_form.setContentsMargins(18, 18, 18, 18)
|
||||||
|
base_form.setHorizontalSpacing(18)
|
||||||
|
base_form.setVerticalSpacing(14)
|
||||||
|
base_form.addRow("Name", self.name_edit)
|
||||||
|
base_form.addRow("Quellordner", self.source_field)
|
||||||
|
base_form.addRow("Zielordner", self.target_field)
|
||||||
|
base_form.addRow("Richtung", self.direction_combo)
|
||||||
|
base_form.addRow("Auslösung", self.trigger_combo)
|
||||||
|
base_form.addRow("Intervall", self.interval_spin)
|
||||||
|
base_form.addRow("Umfang", selection_row)
|
||||||
|
base_form.addRow("", self.enabled_check)
|
||||||
|
|
||||||
|
behavior_page = QWidget()
|
||||||
|
behavior_layout = QFormLayout(behavior_page)
|
||||||
|
behavior_layout.setContentsMargins(18, 18, 18, 18)
|
||||||
|
behavior_layout.setHorizontalSpacing(18)
|
||||||
|
behavior_layout.setVerticalSpacing(14)
|
||||||
|
behavior_layout.addRow("Löschverhalten", self.delete_combo)
|
||||||
|
behavior_layout.addRow("Dateikonflikte", self.conflict_combo)
|
||||||
|
warning = QLabel(
|
||||||
|
"Beim Spiegeln von Löschungen wird der letzte erfolgreiche Abgleich als Referenz verwendet. "
|
||||||
|
"So werden neu hinzugefügte Dateien nicht versehentlich als Löschung interpretiert."
|
||||||
|
)
|
||||||
|
warning.setWordWrap(True)
|
||||||
|
warning.setProperty("muted", True)
|
||||||
|
behavior_layout.addRow("", warning)
|
||||||
|
|
||||||
|
tabs = QTabWidget()
|
||||||
|
tabs.addTab(base_page, "Ordner und Zeitplan")
|
||||||
|
tabs.addTab(behavior_page, "Konflikte und Löschungen")
|
||||||
|
|
||||||
|
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
|
||||||
|
buttons.button(QDialogButtonBox.StandardButton.Save).setText("Speichern")
|
||||||
|
buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("Abbrechen")
|
||||||
|
buttons.accepted.connect(self._validate_and_accept)
|
||||||
|
buttons.rejected.connect(self.reject)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(20, 20, 20, 20)
|
||||||
|
layout.addWidget(tabs, 1)
|
||||||
|
layout.addWidget(buttons)
|
||||||
|
self.trigger_combo.currentIndexChanged.connect(self._update_visibility)
|
||||||
|
self.direction_combo.currentIndexChanged.connect(self._update_visibility)
|
||||||
|
self.source_field.edit.textChanged.connect(self._update_selection_label)
|
||||||
|
self._update_visibility()
|
||||||
|
self._update_selection_label()
|
||||||
|
|
||||||
|
def task(self) -> SyncTask:
|
||||||
|
"""Erzeugt das validierte Modell aus den Formulardaten."""
|
||||||
|
task = SyncTask(
|
||||||
|
id=self._original.id if self._original else SyncTask("", "", "").id,
|
||||||
|
name=self.name_edit.text(),
|
||||||
|
source=self.source_field.edit.text(),
|
||||||
|
target=self.target_field.edit.text(),
|
||||||
|
direction=self.direction_combo.currentData(),
|
||||||
|
trigger=self.trigger_combo.currentData(),
|
||||||
|
delete_mode=self.delete_combo.currentData(),
|
||||||
|
conflict_mode=self.conflict_combo.currentData(),
|
||||||
|
interval_minutes=self.interval_spin.value(),
|
||||||
|
selected_paths=self._selected_paths,
|
||||||
|
enabled=self.enabled_check.isChecked(),
|
||||||
|
last_run=self._original.last_run if self._original else None,
|
||||||
|
)
|
||||||
|
task.validate()
|
||||||
|
return task
|
||||||
|
|
||||||
|
def _choose_selection(self) -> None:
|
||||||
|
root = Path(self.source_field.edit.text())
|
||||||
|
if not root.is_dir():
|
||||||
|
QMessageBox.warning(self, "Quellordner fehlt", "Bitte zuerst einen erreichbaren Quellordner auswählen.")
|
||||||
|
return
|
||||||
|
dialog = _SelectionDialog(root, self._selected_paths, self)
|
||||||
|
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||||
|
self._selected_paths = dialog.selected_paths()
|
||||||
|
self._update_selection_label()
|
||||||
|
|
||||||
|
def _validate_and_accept(self) -> None:
|
||||||
|
try:
|
||||||
|
self.task()
|
||||||
|
except ValueError as exc:
|
||||||
|
QMessageBox.warning(self, "Eingaben prüfen", str(exc))
|
||||||
|
return
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
def _update_visibility(self) -> None:
|
||||||
|
self.interval_spin.setEnabled(self.trigger_combo.currentData() is TriggerMode.INTERVAL)
|
||||||
|
self.conflict_combo.setEnabled(self.direction_combo.currentData() is SyncDirection.BIDIRECTIONAL)
|
||||||
|
|
||||||
|
def _update_selection_label(self) -> None:
|
||||||
|
if self._selected_paths:
|
||||||
|
self.selection_label.setText(f"{len(self._selected_paths)} Einträge ausgewählt")
|
||||||
|
else:
|
||||||
|
self.selection_label.setText("Gesamter Quellordner")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _set_combo(combo: QComboBox, value: object) -> None:
|
||||||
|
index = combo.findData(value)
|
||||||
|
if index >= 0:
|
||||||
|
combo.setCurrentIndex(index)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Ermittlung von Prozessen, die Dateien unter Windows sperren."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import os
|
||||||
|
from ctypes import wintypes
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
__all__ = ["LockingProcess", "find_locking_processes", "is_file_locked"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LockingProcess:
|
||||||
|
"""Ein vom Windows Restart Manager gemeldeter Prozess."""
|
||||||
|
|
||||||
|
pid: int
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
def is_file_locked(path: Path) -> bool:
|
||||||
|
"""Prüft eine vorhandene Datei durch exklusives Öffnen ohne Schreibzugriff."""
|
||||||
|
if not path.exists() or os.name != "nt":
|
||||||
|
return False
|
||||||
|
handle = ctypes.windll.kernel32.CreateFileW(
|
||||||
|
str(path),
|
||||||
|
0x80000000,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
3,
|
||||||
|
0x80,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
invalid_handle = ctypes.c_void_p(-1).value
|
||||||
|
if handle == invalid_handle:
|
||||||
|
return ctypes.get_last_error() in {5, 32, 33}
|
||||||
|
ctypes.windll.kernel32.CloseHandle(handle)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def find_locking_processes(path: Path) -> list[LockingProcess]:
|
||||||
|
"""Liefert sperrende Prozesse über die Windows-Restart-Manager-API."""
|
||||||
|
if os.name != "nt" or not path.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return _restart_manager_processes(path)
|
||||||
|
except (OSError, AttributeError, ctypes.ArgumentError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _restart_manager_processes(path: Path) -> list[LockingProcess]:
|
||||||
|
rstrtmgr = ctypes.WinDLL("Rstrtmgr")
|
||||||
|
session_handle = wintypes.DWORD()
|
||||||
|
session_key = ctypes.create_unicode_buffer(33)
|
||||||
|
if rstrtmgr.RmStartSession(ctypes.byref(session_handle), 0, session_key) != 0:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
resources = (wintypes.LPCWSTR * 1)(str(path))
|
||||||
|
if rstrtmgr.RmRegisterResources(session_handle, 1, resources, 0, None, 0, None) != 0:
|
||||||
|
return []
|
||||||
|
needed = wintypes.UINT(0)
|
||||||
|
count = wintypes.UINT(0)
|
||||||
|
reboot_reasons = wintypes.DWORD(0)
|
||||||
|
result = rstrtmgr.RmGetList(
|
||||||
|
session_handle,
|
||||||
|
ctypes.byref(needed),
|
||||||
|
ctypes.byref(count),
|
||||||
|
None,
|
||||||
|
ctypes.byref(reboot_reasons),
|
||||||
|
)
|
||||||
|
if result not in {0, 234} or needed.value == 0:
|
||||||
|
return []
|
||||||
|
process_info_type = _rm_process_info_type()
|
||||||
|
process_array = (process_info_type * needed.value)()
|
||||||
|
count.value = needed.value
|
||||||
|
result = rstrtmgr.RmGetList(
|
||||||
|
session_handle,
|
||||||
|
ctypes.byref(needed),
|
||||||
|
ctypes.byref(count),
|
||||||
|
process_array,
|
||||||
|
ctypes.byref(reboot_reasons),
|
||||||
|
)
|
||||||
|
if result != 0:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
LockingProcess(
|
||||||
|
pid=int(item.Process.dwProcessId), name=item.strAppName or f"Prozess {item.Process.dwProcessId}"
|
||||||
|
)
|
||||||
|
for item in process_array[: count.value]
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
rstrtmgr.RmEndSession(session_handle)
|
||||||
|
|
||||||
|
|
||||||
|
def _rm_process_info_type() -> type[ctypes.Structure]:
|
||||||
|
class FileTime(ctypes.Structure):
|
||||||
|
_fields_ = [("dwLowDateTime", wintypes.DWORD), ("dwHighDateTime", wintypes.DWORD)]
|
||||||
|
|
||||||
|
class UniqueProcess(ctypes.Structure):
|
||||||
|
_fields_ = [("dwProcessId", wintypes.DWORD), ("ProcessStartTime", FileTime)]
|
||||||
|
|
||||||
|
class ProcessInfo(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("Process", UniqueProcess),
|
||||||
|
("strAppName", wintypes.WCHAR * 256),
|
||||||
|
("strServiceShortName", wintypes.WCHAR * 64),
|
||||||
|
("ApplicationType", wintypes.UINT),
|
||||||
|
("AppStatus", wintypes.ULONG),
|
||||||
|
("TSSessionId", wintypes.DWORD),
|
||||||
|
("bRestartable", wintypes.BOOL),
|
||||||
|
]
|
||||||
|
|
||||||
|
return ProcessInfo
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Skalierbare, programmatisch gezeichnete Status- und App-Symbole."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QPointF, QRectF, Qt
|
||||||
|
from PyQt6.QtGui import QColor, QFont, QIcon, QPainter, QPainterPath, QPen, QPixmap
|
||||||
|
|
||||||
|
__all__ = ["app_icon", "status_icon"]
|
||||||
|
|
||||||
|
|
||||||
|
def app_icon(size: int = 64) -> QIcon:
|
||||||
|
"""Erzeugt das SyncVito-Symbol ohne plattformabhängige Emoji-Glyphen."""
|
||||||
|
pixmap = QPixmap(size, size)
|
||||||
|
pixmap.fill(Qt.GlobalColor.transparent)
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QColor("#2878FF"))
|
||||||
|
painter.drawRoundedRect(QRectF(2, 2, size - 4, size - 4), size * 0.22, size * 0.22)
|
||||||
|
pen = QPen(QColor("#FFFFFF"), max(2, size * 0.07), Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)
|
||||||
|
painter.setPen(pen)
|
||||||
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
path = QPainterPath(QPointF(size * 0.25, size * 0.38))
|
||||||
|
path.cubicTo(size * 0.38, size * 0.18, size * 0.68, size * 0.20, size * 0.74, size * 0.39)
|
||||||
|
path.lineTo(size * 0.67, size * 0.34)
|
||||||
|
painter.drawPath(path)
|
||||||
|
path = QPainterPath(QPointF(size * 0.75, size * 0.62))
|
||||||
|
path.cubicTo(size * 0.62, size * 0.82, size * 0.32, size * 0.80, size * 0.26, size * 0.61)
|
||||||
|
path.lineTo(size * 0.33, size * 0.66)
|
||||||
|
painter.drawPath(path)
|
||||||
|
painter.end()
|
||||||
|
return QIcon(pixmap)
|
||||||
|
|
||||||
|
|
||||||
|
def status_icon(status: str, size: int = 64) -> QIcon:
|
||||||
|
"""Erzeugt ein farbiges Tray-Symbol für aktiv, pausiert oder Fehler."""
|
||||||
|
colors = {"active": "#35C77A", "paused": "#E0525D", "error": "#F0B84B"}
|
||||||
|
pixmap = app_icon(size).pixmap(size, size)
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
diameter = size * 0.34
|
||||||
|
badge = QRectF(size - diameter - 1, size - diameter - 1, diameter, diameter)
|
||||||
|
painter.setPen(QPen(QColor("#0B0F14"), max(2, size * 0.04)))
|
||||||
|
painter.setBrush(QColor(colors.get(status, colors["active"])))
|
||||||
|
painter.drawEllipse(badge)
|
||||||
|
if status == "error":
|
||||||
|
painter.setPen(QColor("#111820"))
|
||||||
|
font = QFont("Segoe UI", max(8, round(size * 0.20)), QFont.Weight.Bold)
|
||||||
|
painter.setFont(font)
|
||||||
|
painter.drawText(badge, Qt.AlignmentFlag.AlignCenter, "!")
|
||||||
|
painter.end()
|
||||||
|
return QIcon(pixmap)
|
||||||
@@ -0,0 +1,653 @@
|
|||||||
|
"""Hauptfenster mit Aufgaben-, Protokoll- und Einstellungsansicht."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QEvent, Qt, QTimer, pyqtSignal
|
||||||
|
from PyQt6.QtGui import QAction, QCloseEvent, QFontDatabase
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QAbstractItemView,
|
||||||
|
QApplication,
|
||||||
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
|
QFrame,
|
||||||
|
QHBoxLayout,
|
||||||
|
QHeaderView,
|
||||||
|
QLabel,
|
||||||
|
QMainWindow,
|
||||||
|
QMenu,
|
||||||
|
QMessageBox,
|
||||||
|
QProgressBar,
|
||||||
|
QPushButton,
|
||||||
|
QSizePolicy,
|
||||||
|
QSlider,
|
||||||
|
QSpinBox,
|
||||||
|
QStackedWidget,
|
||||||
|
QSystemTrayIcon,
|
||||||
|
QTableWidget,
|
||||||
|
QTableWidgetItem,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from syncvito.autostart import set_autostart
|
||||||
|
from syncvito.config_store import ConfigError, ConfigStore
|
||||||
|
from syncvito.coordinator import SyncCoordinator
|
||||||
|
from syncvito.dialogs import TaskDialog
|
||||||
|
from syncvito.icons import app_icon, status_icon
|
||||||
|
from syncvito.models import AppConfig, SyncDirection, SyncTask, TaskRuntimeState, TriggerMode
|
||||||
|
from syncvito.state_store import StateStore
|
||||||
|
from syncvito.theme import apply_theme
|
||||||
|
from syncvito.update_controller import UpdateController
|
||||||
|
from syncvito.update_dialog import UpdateDialog
|
||||||
|
from syncvito.updater import UpdateInfo
|
||||||
|
from syncvito.version import __version__
|
||||||
|
|
||||||
|
__all__ = ["MainWindow"]
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
"""Zentrale SyncVito-Benutzeroberfläche."""
|
||||||
|
|
||||||
|
quit_requested = pyqtSignal()
|
||||||
|
|
||||||
|
def __init__(self, config: AppConfig, config_store: ConfigStore) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.config = config
|
||||||
|
self.config_store = config_store
|
||||||
|
self._allow_close = False
|
||||||
|
self.setWindowTitle("SyncVito")
|
||||||
|
self.setWindowIcon(app_icon())
|
||||||
|
self.setMinimumSize(980, 680)
|
||||||
|
self.resize(1220, 800)
|
||||||
|
|
||||||
|
self.coordinator = SyncCoordinator(config, config_store, self)
|
||||||
|
self.updater = UpdateController(__version__, self)
|
||||||
|
self._build_ui()
|
||||||
|
self._build_tray()
|
||||||
|
self._connect_coordinator()
|
||||||
|
self._refresh_tasks()
|
||||||
|
self._set_global_status(*self.coordinator.current_overall_status())
|
||||||
|
QTimer.singleShot(3000, self._automatic_update_check)
|
||||||
|
|
||||||
|
def show_from_tray(self) -> None:
|
||||||
|
"""Stellt das Fenster aus dem Tray wieder her."""
|
||||||
|
self.showNormal()
|
||||||
|
self.activateWindow()
|
||||||
|
self.raise_()
|
||||||
|
|
||||||
|
def request_quit(self) -> None:
|
||||||
|
"""Beendet Observer und Anwendung explizit über das Tray-Menü."""
|
||||||
|
self._allow_close = True
|
||||||
|
self.coordinator.stop()
|
||||||
|
self.tray.hide()
|
||||||
|
QApplication.instance().quit()
|
||||||
|
|
||||||
|
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt API
|
||||||
|
if self._allow_close:
|
||||||
|
event.accept()
|
||||||
|
return
|
||||||
|
event.ignore()
|
||||||
|
self.hide()
|
||||||
|
self.tray.showMessage(
|
||||||
|
"SyncVito läuft weiter",
|
||||||
|
"Das Fenster wurde in den Infobereich verschoben.",
|
||||||
|
QSystemTrayIcon.MessageIcon.Information,
|
||||||
|
2500,
|
||||||
|
)
|
||||||
|
|
||||||
|
def changeEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API
|
||||||
|
super().changeEvent(event)
|
||||||
|
if event.type() == QEvent.Type.WindowStateChange and self.isMinimized():
|
||||||
|
QApplication.processEvents()
|
||||||
|
self.hide()
|
||||||
|
|
||||||
|
def _build_ui(self) -> None:
|
||||||
|
root = QWidget()
|
||||||
|
self.setCentralWidget(root)
|
||||||
|
root_layout = QHBoxLayout(root)
|
||||||
|
root_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
root_layout.setSpacing(0)
|
||||||
|
root_layout.addWidget(self._build_sidebar())
|
||||||
|
|
||||||
|
right = QWidget()
|
||||||
|
right_layout = QVBoxLayout(right)
|
||||||
|
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
right_layout.setSpacing(0)
|
||||||
|
right_layout.addWidget(self._build_header())
|
||||||
|
self.stack = QStackedWidget()
|
||||||
|
self.tasks_page = self._build_tasks_page()
|
||||||
|
self.log_page = self._build_log_page()
|
||||||
|
self.settings_page = self._build_settings_page()
|
||||||
|
self.stack.addWidget(self.tasks_page)
|
||||||
|
self.stack.addWidget(self.log_page)
|
||||||
|
self.stack.addWidget(self.settings_page)
|
||||||
|
right_layout.addWidget(self.stack, 1)
|
||||||
|
right_layout.addWidget(self._build_progress_dock())
|
||||||
|
root_layout.addWidget(right, 1)
|
||||||
|
|
||||||
|
def _build_sidebar(self) -> QWidget:
|
||||||
|
sidebar = QWidget()
|
||||||
|
sidebar.setObjectName("Sidebar")
|
||||||
|
sidebar.setFixedWidth(226)
|
||||||
|
layout = QVBoxLayout(sidebar)
|
||||||
|
layout.setContentsMargins(18, 22, 18, 18)
|
||||||
|
layout.setSpacing(8)
|
||||||
|
|
||||||
|
eyebrow = QLabel("DATEISYNCHRONISATION")
|
||||||
|
eyebrow.setObjectName("Eyebrow")
|
||||||
|
brand = QLabel("SyncVito")
|
||||||
|
brand.setObjectName("Brand")
|
||||||
|
layout.addWidget(eyebrow)
|
||||||
|
layout.addWidget(brand)
|
||||||
|
layout.addSpacing(28)
|
||||||
|
|
||||||
|
self.nav_buttons: list[QPushButton] = []
|
||||||
|
for title, index in (("Aufgaben", 0), ("Protokoll", 1), ("Einstellungen", 2)):
|
||||||
|
button = QPushButton(title)
|
||||||
|
button.setCheckable(True)
|
||||||
|
button.setProperty("nav", True)
|
||||||
|
button.setMinimumHeight(42)
|
||||||
|
button.clicked.connect(lambda checked=False, page=index: self._select_page(page))
|
||||||
|
layout.addWidget(button)
|
||||||
|
self.nav_buttons.append(button)
|
||||||
|
self.nav_buttons[0].setChecked(True)
|
||||||
|
layout.addStretch(1)
|
||||||
|
|
||||||
|
version = QLabel("Version 1.0.0\nLokale Konfiguration")
|
||||||
|
version.setProperty("muted", True)
|
||||||
|
layout.addWidget(version)
|
||||||
|
return sidebar
|
||||||
|
|
||||||
|
def _build_header(self) -> QWidget:
|
||||||
|
header = QWidget()
|
||||||
|
header.setObjectName("Header")
|
||||||
|
header.setFixedHeight(82)
|
||||||
|
layout = QHBoxLayout(header)
|
||||||
|
layout.setContentsMargins(28, 0, 28, 0)
|
||||||
|
title_area = QVBoxLayout()
|
||||||
|
title_area.setSpacing(2)
|
||||||
|
self.page_title = QLabel("Synchronisationsaufgaben")
|
||||||
|
self.page_title.setObjectName("PageTitle")
|
||||||
|
self.page_subtitle = QLabel("Lokale Ordner und NAS-Ziele zuverlässig im gleichen Stand halten")
|
||||||
|
self.page_subtitle.setObjectName("PageSubtitle")
|
||||||
|
title_area.addWidget(self.page_title)
|
||||||
|
title_area.addWidget(self.page_subtitle)
|
||||||
|
layout.addLayout(title_area)
|
||||||
|
layout.addStretch(1)
|
||||||
|
self.status_label = QLabel("Bereit")
|
||||||
|
self.status_label.setObjectName("StatusPill")
|
||||||
|
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.status_label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
||||||
|
layout.addWidget(self.status_label, 0, Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
self.sync_all_button = QPushButton("Alle jetzt synchronisieren")
|
||||||
|
self.sync_all_button.setProperty("primary", True)
|
||||||
|
self.sync_all_button.clicked.connect(self.coordinator.enqueue_all)
|
||||||
|
layout.addWidget(self.sync_all_button, 0, Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
return header
|
||||||
|
|
||||||
|
def _build_tasks_page(self) -> QWidget:
|
||||||
|
page = QWidget()
|
||||||
|
layout = QVBoxLayout(page)
|
||||||
|
layout.setContentsMargins(28, 24, 28, 24)
|
||||||
|
layout.setSpacing(14)
|
||||||
|
toolbar = QHBoxLayout()
|
||||||
|
add_button = QPushButton("Neue Aufgabe")
|
||||||
|
add_button.setProperty("primary", True)
|
||||||
|
edit_button = QPushButton("Bearbeiten")
|
||||||
|
delete_button = QPushButton("Löschen")
|
||||||
|
delete_button.setProperty("danger", True)
|
||||||
|
self.sync_selected_button = QPushButton("Auswahl synchronisieren")
|
||||||
|
add_button.clicked.connect(self._add_task)
|
||||||
|
edit_button.clicked.connect(self._edit_task)
|
||||||
|
delete_button.clicked.connect(self._delete_task)
|
||||||
|
self.sync_selected_button.clicked.connect(self._sync_selected)
|
||||||
|
toolbar.addWidget(add_button)
|
||||||
|
toolbar.addWidget(edit_button)
|
||||||
|
toolbar.addWidget(delete_button)
|
||||||
|
toolbar.addStretch(1)
|
||||||
|
toolbar.addWidget(self.sync_selected_button)
|
||||||
|
layout.addLayout(toolbar)
|
||||||
|
|
||||||
|
self.task_table = QTableWidget(0, 5)
|
||||||
|
self.task_table.setHorizontalHeaderLabels(["Aufgabe", "Ordnerpaar", "Richtung", "Auslösung", "Status"])
|
||||||
|
self.task_table.setAlternatingRowColors(True)
|
||||||
|
self.task_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||||
|
self.task_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||||
|
self.task_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||||
|
self.task_table.verticalHeader().setVisible(False)
|
||||||
|
self.task_table.verticalHeader().setDefaultSectionSize(58)
|
||||||
|
header = self.task_table.horizontalHeader()
|
||||||
|
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||||
|
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
self.task_table.doubleClicked.connect(self._edit_task)
|
||||||
|
layout.addWidget(self.task_table, 1)
|
||||||
|
|
||||||
|
self.lock_banner = QLabel()
|
||||||
|
self.lock_banner.setObjectName("LockBanner")
|
||||||
|
self.lock_banner.setWordWrap(True)
|
||||||
|
self.lock_banner.hide()
|
||||||
|
layout.addWidget(self.lock_banner)
|
||||||
|
return page
|
||||||
|
|
||||||
|
def _build_log_page(self) -> QWidget:
|
||||||
|
page = QWidget()
|
||||||
|
layout = QVBoxLayout(page)
|
||||||
|
layout.setContentsMargins(28, 24, 28, 24)
|
||||||
|
layout.setSpacing(12)
|
||||||
|
self.log_table = QTableWidget(0, 4)
|
||||||
|
self.log_table.setHorizontalHeaderLabels(["Zeitpunkt", "Aktion", "Ergebnis", "Details"])
|
||||||
|
self.log_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||||
|
self.log_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||||
|
self.log_table.setAlternatingRowColors(True)
|
||||||
|
self.log_table.verticalHeader().setVisible(False)
|
||||||
|
header = self.log_table.horizontalHeader()
|
||||||
|
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
|
||||||
|
clear_button = QPushButton("Ansicht leeren")
|
||||||
|
clear_button.clicked.connect(lambda: self.log_table.setRowCount(0))
|
||||||
|
button_row = QHBoxLayout()
|
||||||
|
button_row.addStretch(1)
|
||||||
|
button_row.addWidget(clear_button)
|
||||||
|
layout.addLayout(button_row)
|
||||||
|
layout.addWidget(self.log_table, 1)
|
||||||
|
return page
|
||||||
|
|
||||||
|
def _build_settings_page(self) -> QWidget:
|
||||||
|
page = QWidget()
|
||||||
|
outer = QVBoxLayout(page)
|
||||||
|
outer.setContentsMargins(28, 24, 28, 24)
|
||||||
|
outer.setSpacing(16)
|
||||||
|
card = QFrame()
|
||||||
|
card.setProperty("card", True)
|
||||||
|
layout = QVBoxLayout(card)
|
||||||
|
layout.setContentsMargins(22, 22, 22, 22)
|
||||||
|
layout.setSpacing(16)
|
||||||
|
heading = QLabel("Darstellung und Systemstart")
|
||||||
|
heading.setObjectName("PageTitle")
|
||||||
|
layout.addWidget(heading)
|
||||||
|
|
||||||
|
self.autostart_check = QCheckBox("SyncVito mit Windows starten")
|
||||||
|
self.autostart_check.setChecked(self.config.settings.autostart)
|
||||||
|
layout.addWidget(self.autostart_check)
|
||||||
|
layout.addSpacing(6)
|
||||||
|
|
||||||
|
font_row = QHBoxLayout()
|
||||||
|
font_row.addWidget(QLabel("Schriftart"))
|
||||||
|
self.font_combo = QComboBox()
|
||||||
|
self.font_combo.addItems(QFontDatabase.families())
|
||||||
|
font_index = self.font_combo.findText(self.config.settings.font_family)
|
||||||
|
self.font_combo.setCurrentIndex(max(0, font_index))
|
||||||
|
font_row.addWidget(self.font_combo, 1)
|
||||||
|
font_row.addWidget(QLabel("Größe"))
|
||||||
|
self.font_size_spin = QSpinBox()
|
||||||
|
self.font_size_spin.setRange(8, 18)
|
||||||
|
self.font_size_spin.setValue(self.config.settings.font_size)
|
||||||
|
font_row.addWidget(self.font_size_spin)
|
||||||
|
font_row.addWidget(QLabel("Schnitt"))
|
||||||
|
self.font_weight_combo = QComboBox()
|
||||||
|
self.font_weight_combo.addItem("Regulär", "regular")
|
||||||
|
self.font_weight_combo.addItem("Fett", "bold")
|
||||||
|
self.font_weight_combo.setCurrentIndex(1 if self.config.settings.font_weight == "bold" else 0)
|
||||||
|
font_row.addWidget(self.font_weight_combo)
|
||||||
|
layout.addLayout(font_row)
|
||||||
|
|
||||||
|
scale_row = QHBoxLayout()
|
||||||
|
scale_row.addWidget(QLabel("UI-Skalierung"))
|
||||||
|
self.scale_slider = QSlider(Qt.Orientation.Horizontal)
|
||||||
|
self.scale_slider.setRange(80, 150)
|
||||||
|
self.scale_slider.setTickInterval(10)
|
||||||
|
self.scale_slider.setValue(self.config.settings.ui_scale)
|
||||||
|
self.scale_value = QLabel(f"{self.config.settings.ui_scale} %")
|
||||||
|
self.scale_value.setMinimumWidth(52)
|
||||||
|
self.scale_slider.valueChanged.connect(lambda value: self.scale_value.setText(f"{value} %"))
|
||||||
|
scale_row.addWidget(self.scale_slider, 1)
|
||||||
|
scale_row.addWidget(self.scale_value)
|
||||||
|
layout.addLayout(scale_row)
|
||||||
|
|
||||||
|
hint = QLabel("Änderungen an Schrift und Skalierung werden sofort angewendet und lokal gespeichert.")
|
||||||
|
hint.setProperty("muted", True)
|
||||||
|
layout.addWidget(hint)
|
||||||
|
save_button = QPushButton("Einstellungen übernehmen")
|
||||||
|
save_button.setProperty("primary", True)
|
||||||
|
save_button.clicked.connect(self._save_settings)
|
||||||
|
action_row = QHBoxLayout()
|
||||||
|
action_row.addStretch(1)
|
||||||
|
action_row.addWidget(save_button)
|
||||||
|
layout.addLayout(action_row)
|
||||||
|
outer.addWidget(card)
|
||||||
|
|
||||||
|
update_card = QFrame()
|
||||||
|
update_card.setProperty("card", True)
|
||||||
|
update_layout = QVBoxLayout(update_card)
|
||||||
|
update_layout.setContentsMargins(22, 22, 22, 22)
|
||||||
|
update_layout.setSpacing(12)
|
||||||
|
update_heading = QLabel("Programmupdates")
|
||||||
|
update_heading.setObjectName("PageTitle")
|
||||||
|
update_layout.addWidget(update_heading)
|
||||||
|
version_label = QLabel(f"Installierte Version: {__version__}")
|
||||||
|
version_label.setProperty("muted", True)
|
||||||
|
update_layout.addWidget(version_label)
|
||||||
|
self.update_check_box = QCheckBox("Beim Start automatisch nach Updates suchen")
|
||||||
|
self.update_check_box.setChecked(self.config.settings.check_updates_on_start)
|
||||||
|
update_layout.addWidget(self.update_check_box)
|
||||||
|
update_hint = QLabel(
|
||||||
|
"Updates werden ausschließlich aus den öffentlichen Releases des SyncVito-Gitea-Repositories geladen "
|
||||||
|
"und vor der Installation per SHA-256 geprüft."
|
||||||
|
)
|
||||||
|
update_hint.setWordWrap(True)
|
||||||
|
update_hint.setProperty("muted", True)
|
||||||
|
update_layout.addWidget(update_hint)
|
||||||
|
self.update_button = QPushButton("Nach Updates suchen")
|
||||||
|
self.update_button.clicked.connect(lambda: self._check_updates(manual=True))
|
||||||
|
update_actions = QHBoxLayout()
|
||||||
|
update_actions.addStretch(1)
|
||||||
|
update_actions.addWidget(self.update_button)
|
||||||
|
update_layout.addLayout(update_actions)
|
||||||
|
outer.addWidget(update_card)
|
||||||
|
outer.addStretch(1)
|
||||||
|
return page
|
||||||
|
|
||||||
|
def _build_progress_dock(self) -> QWidget:
|
||||||
|
dock = QWidget()
|
||||||
|
dock.setObjectName("ProgressDock")
|
||||||
|
dock.setFixedHeight(66)
|
||||||
|
layout = QHBoxLayout(dock)
|
||||||
|
layout.setContentsMargins(28, 12, 28, 12)
|
||||||
|
self.progress_text = QLabel("Bereit")
|
||||||
|
self.progress_text.setMinimumWidth(250)
|
||||||
|
self.progress_text.setProperty("muted", True)
|
||||||
|
self.progress_bar = QProgressBar()
|
||||||
|
self.progress_bar.setRange(0, 100)
|
||||||
|
self.progress_bar.setValue(0)
|
||||||
|
self.progress_bar.setFormat("%p %")
|
||||||
|
layout.addWidget(self.progress_text)
|
||||||
|
layout.addWidget(self.progress_bar, 1)
|
||||||
|
return dock
|
||||||
|
|
||||||
|
def _build_tray(self) -> None:
|
||||||
|
self.tray = QSystemTrayIcon(status_icon("active"), self)
|
||||||
|
self.tray.setToolTip("SyncVito – Bereit")
|
||||||
|
menu = QMenu()
|
||||||
|
open_action = QAction("Öffnen", self)
|
||||||
|
self.tray_sync_action = QAction("Sync jetzt", self)
|
||||||
|
self.tray_update_action = QAction("Nach Updates suchen", self)
|
||||||
|
self.pause_action = QAction("Pausieren", self)
|
||||||
|
quit_action = QAction("Beenden", self)
|
||||||
|
open_action.triggered.connect(self.show_from_tray)
|
||||||
|
self.tray_sync_action.triggered.connect(self.coordinator.enqueue_all)
|
||||||
|
self.tray_update_action.triggered.connect(lambda: self._check_updates(manual=True))
|
||||||
|
self.pause_action.triggered.connect(self._toggle_pause)
|
||||||
|
quit_action.triggered.connect(self.request_quit)
|
||||||
|
menu.addAction(open_action)
|
||||||
|
menu.addAction(self.tray_sync_action)
|
||||||
|
menu.addAction(self.pause_action)
|
||||||
|
menu.addAction(self.tray_update_action)
|
||||||
|
menu.addSeparator()
|
||||||
|
menu.addAction(quit_action)
|
||||||
|
self.tray.setContextMenu(menu)
|
||||||
|
self.tray.activated.connect(
|
||||||
|
lambda reason: self.show_from_tray() if reason == QSystemTrayIcon.ActivationReason.DoubleClick else None
|
||||||
|
)
|
||||||
|
self.tray.show()
|
||||||
|
self._update_pause_action()
|
||||||
|
|
||||||
|
def _connect_coordinator(self) -> None:
|
||||||
|
self.coordinator.progress_changed.connect(self._update_progress)
|
||||||
|
self.coordinator.log_created.connect(self._append_log)
|
||||||
|
self.coordinator.overall_status_changed.connect(self._set_global_status)
|
||||||
|
self.coordinator.task_state_changed.connect(self._set_task_state)
|
||||||
|
self.coordinator.lock_changed.connect(self._set_lock_message)
|
||||||
|
self.coordinator.idle_changed.connect(lambda _idle: self._refresh_action_availability())
|
||||||
|
self.updater.update_available.connect(self._on_update_available)
|
||||||
|
self.updater.no_update.connect(self._on_no_update)
|
||||||
|
self.updater.error.connect(self._on_update_error)
|
||||||
|
self.updater.progress.connect(self._update_progress)
|
||||||
|
self.updater.install_ready.connect(self._install_update)
|
||||||
|
self.updater.busy_changed.connect(lambda _busy: self._refresh_action_availability())
|
||||||
|
|
||||||
|
def _select_page(self, index: int) -> None:
|
||||||
|
titles = [
|
||||||
|
("Synchronisationsaufgaben", "Lokale Ordner und NAS-Ziele zuverlässig im gleichen Stand halten"),
|
||||||
|
("Aktivitätsprotokoll", "Erfolge, Konflikte und Fehler transparent nachvollziehen"),
|
||||||
|
("Einstellungen", "Darstellung und Windows-Integration anpassen"),
|
||||||
|
]
|
||||||
|
self.stack.setCurrentIndex(index)
|
||||||
|
self.page_title.setText(titles[index][0])
|
||||||
|
self.page_subtitle.setText(titles[index][1])
|
||||||
|
for button_index, button in enumerate(self.nav_buttons):
|
||||||
|
button.setChecked(button_index == index)
|
||||||
|
|
||||||
|
def _refresh_tasks(self) -> None:
|
||||||
|
self.task_table.setRowCount(len(self.config.tasks))
|
||||||
|
for row, task in enumerate(self.config.tasks):
|
||||||
|
name = QTableWidgetItem(task.name)
|
||||||
|
name.setData(Qt.ItemDataRole.UserRole, task.id)
|
||||||
|
pair = QTableWidgetItem(f"{task.source}\n→ {task.target}")
|
||||||
|
direction = QTableWidgetItem("Einweg" if task.direction is SyncDirection.ONE_WAY else "Bidirektional")
|
||||||
|
trigger_text = {
|
||||||
|
TriggerMode.MANUAL: "Manuell",
|
||||||
|
TriggerMode.REALTIME: "Echtzeit",
|
||||||
|
TriggerMode.INTERVAL: f"Alle {task.interval_minutes} Min.",
|
||||||
|
}[task.trigger]
|
||||||
|
trigger = QTableWidgetItem(trigger_text)
|
||||||
|
status = QTableWidgetItem(self._runtime_state_text(self.coordinator.task_state(task.id)))
|
||||||
|
for column, item in enumerate((name, pair, direction, trigger, status)):
|
||||||
|
item.setToolTip(item.text())
|
||||||
|
self.task_table.setItem(row, column, item)
|
||||||
|
|
||||||
|
def _selected_task(self) -> SyncTask | None:
|
||||||
|
row = self.task_table.currentRow()
|
||||||
|
if row < 0:
|
||||||
|
return None
|
||||||
|
item = self.task_table.item(row, 0)
|
||||||
|
task_id = item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
return next((task for task in self.config.tasks if task.id == task_id), None)
|
||||||
|
|
||||||
|
def _add_task(self) -> None:
|
||||||
|
dialog = TaskDialog(parent=self)
|
||||||
|
if dialog.exec() == TaskDialog.DialogCode.Accepted:
|
||||||
|
self.config.tasks.append(dialog.task())
|
||||||
|
self._persist_tasks("Aufgabe angelegt")
|
||||||
|
|
||||||
|
def _edit_task(self) -> None:
|
||||||
|
task = self._selected_task()
|
||||||
|
if task is None:
|
||||||
|
QMessageBox.information(self, "Keine Auswahl", "Bitte zuerst eine Aufgabe auswählen.")
|
||||||
|
return
|
||||||
|
dialog = TaskDialog(task, self)
|
||||||
|
if dialog.exec() == TaskDialog.DialogCode.Accepted:
|
||||||
|
updated = dialog.task()
|
||||||
|
index = self.config.tasks.index(task)
|
||||||
|
self.config.tasks[index] = updated
|
||||||
|
self._persist_tasks("Aufgabe aktualisiert")
|
||||||
|
|
||||||
|
def _delete_task(self) -> None:
|
||||||
|
task = self._selected_task()
|
||||||
|
if task is None:
|
||||||
|
QMessageBox.information(self, "Keine Auswahl", "Bitte zuerst eine Aufgabe auswählen.")
|
||||||
|
return
|
||||||
|
answer = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Aufgabe löschen",
|
||||||
|
f"Soll „{task.name}“ wirklich gelöscht werden? Dateien werden dabei nicht entfernt.",
|
||||||
|
)
|
||||||
|
if answer != QMessageBox.StandardButton.Yes:
|
||||||
|
return
|
||||||
|
self.config.tasks.remove(task)
|
||||||
|
StateStore().remove(task.id)
|
||||||
|
self._persist_tasks("Aufgabe gelöscht")
|
||||||
|
|
||||||
|
def _sync_selected(self) -> None:
|
||||||
|
task = self._selected_task()
|
||||||
|
if task is None:
|
||||||
|
QMessageBox.information(self, "Keine Auswahl", "Bitte zuerst eine Aufgabe auswählen.")
|
||||||
|
return
|
||||||
|
self.coordinator.enqueue(task.id)
|
||||||
|
|
||||||
|
def _persist_tasks(self, message: str) -> None:
|
||||||
|
try:
|
||||||
|
self.config_store.save(self.config)
|
||||||
|
self.coordinator.refresh_task_states()
|
||||||
|
self.coordinator.rebuild_monitors()
|
||||||
|
except ConfigError as exc:
|
||||||
|
QMessageBox.critical(self, "Speicherfehler", str(exc))
|
||||||
|
return
|
||||||
|
self._refresh_tasks()
|
||||||
|
self._append_log("Aufgaben", "Erfolg", message)
|
||||||
|
|
||||||
|
def _save_settings(self) -> None:
|
||||||
|
settings = self.config.settings
|
||||||
|
old_autostart = settings.autostart
|
||||||
|
settings.autostart = self.autostart_check.isChecked()
|
||||||
|
settings.font_family = self.font_combo.currentText()
|
||||||
|
settings.font_size = self.font_size_spin.value()
|
||||||
|
settings.font_weight = self.font_weight_combo.currentData()
|
||||||
|
settings.ui_scale = self.scale_slider.value()
|
||||||
|
settings.check_updates_on_start = self.update_check_box.isChecked()
|
||||||
|
settings.validate()
|
||||||
|
try:
|
||||||
|
if old_autostart != settings.autostart:
|
||||||
|
set_autostart(settings.autostart)
|
||||||
|
self.config_store.save(self.config)
|
||||||
|
except (ConfigError, OSError) as exc:
|
||||||
|
QMessageBox.critical(self, "Einstellungen konnten nicht gespeichert werden", str(exc))
|
||||||
|
settings.autostart = old_autostart
|
||||||
|
return
|
||||||
|
apply_theme(QApplication.instance(), settings)
|
||||||
|
self._append_log("Einstellungen", "Erfolg", "Darstellung und Systemstart wurden übernommen.")
|
||||||
|
|
||||||
|
def _automatic_update_check(self) -> None:
|
||||||
|
settings = self.config.settings
|
||||||
|
if not settings.check_updates_on_start or self.coordinator.is_running:
|
||||||
|
return
|
||||||
|
if settings.last_update_check:
|
||||||
|
try:
|
||||||
|
last_check = datetime.fromisoformat(settings.last_update_check)
|
||||||
|
except ValueError:
|
||||||
|
last_check = datetime.min
|
||||||
|
if datetime.now() - last_check < timedelta(hours=24):
|
||||||
|
return
|
||||||
|
self._check_updates(manual=False)
|
||||||
|
|
||||||
|
def _check_updates(self, manual: bool) -> None:
|
||||||
|
if self.coordinator.is_running:
|
||||||
|
if manual:
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"Synchronisation läuft",
|
||||||
|
"Bitte die laufende Synchronisation abschließen lassen und die Updateprüfung erneut starten.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self._update_progress(0, "Update wird geprüft …")
|
||||||
|
self.updater.check(manual)
|
||||||
|
|
||||||
|
def _on_update_available(self, info: UpdateInfo, manual: bool) -> None:
|
||||||
|
self._mark_update_checked()
|
||||||
|
self._update_progress(100, f"Version {info.version} ist verfügbar")
|
||||||
|
self._append_log("Update", "Hinweis", f"Version {info.version} ist verfügbar.")
|
||||||
|
dialog = UpdateDialog(__version__, info, getattr(sys, "frozen", False), self)
|
||||||
|
if dialog.exec() == UpdateDialog.DialogCode.Accepted:
|
||||||
|
self.updater.download(info)
|
||||||
|
|
||||||
|
def _on_no_update(self, manual: bool) -> None:
|
||||||
|
self._mark_update_checked()
|
||||||
|
self._update_progress(100, "SyncVito ist aktuell")
|
||||||
|
self._append_log("Update", "Erfolg", "Die installierte Version ist aktuell.")
|
||||||
|
if manual:
|
||||||
|
QMessageBox.information(self, "Keine Updates", "Du verwendest bereits die aktuelle SyncVito-Version.")
|
||||||
|
|
||||||
|
def _on_update_error(self, message: str, manual: bool) -> None:
|
||||||
|
self._update_progress(0, "Updateprüfung fehlgeschlagen")
|
||||||
|
self._append_log("Update", "Fehler", message)
|
||||||
|
if manual:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"Update nicht möglich",
|
||||||
|
f"{message}\n\nBitte die Netzwerkverbindung prüfen und anschließend erneut versuchen.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _install_update(self, path: Path, info: UpdateInfo) -> None:
|
||||||
|
try:
|
||||||
|
self.updater.service.launch_installer(path)
|
||||||
|
except Exception as exc: # noqa: BLE001 - UI-Grenze verhindert einen Anwendungsabbruch.
|
||||||
|
self._on_update_error(str(exc), True)
|
||||||
|
return
|
||||||
|
self._append_log("Update", "Erfolg", f"Version {info.version} wird installiert.")
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"Update wird installiert",
|
||||||
|
"SyncVito wird jetzt geschlossen, aktualisiert und automatisch neu gestartet.",
|
||||||
|
)
|
||||||
|
self.request_quit()
|
||||||
|
|
||||||
|
def _mark_update_checked(self) -> None:
|
||||||
|
self.config.settings.last_update_check = datetime.now().isoformat(timespec="seconds")
|
||||||
|
try:
|
||||||
|
self.config_store.save(self.config)
|
||||||
|
except ConfigError as exc:
|
||||||
|
self._append_log("Update", "Fehler", str(exc))
|
||||||
|
|
||||||
|
def _refresh_action_availability(self) -> None:
|
||||||
|
update_idle = not self.updater.busy
|
||||||
|
sync_idle = not self.coordinator.is_running
|
||||||
|
self.sync_all_button.setEnabled(update_idle and sync_idle)
|
||||||
|
self.sync_selected_button.setEnabled(update_idle and sync_idle)
|
||||||
|
self.tray_sync_action.setEnabled(update_idle and sync_idle)
|
||||||
|
self.update_button.setEnabled(update_idle and sync_idle)
|
||||||
|
self.tray_update_action.setEnabled(update_idle and sync_idle)
|
||||||
|
|
||||||
|
def _toggle_pause(self) -> None:
|
||||||
|
self.coordinator.set_paused(not self.config.settings.paused)
|
||||||
|
self._update_pause_action()
|
||||||
|
|
||||||
|
def _update_pause_action(self) -> None:
|
||||||
|
self.pause_action.setText("Fortsetzen" if self.config.settings.paused else "Pausieren")
|
||||||
|
|
||||||
|
def _update_progress(self, value: int, detail: str) -> None:
|
||||||
|
self.progress_bar.setValue(value)
|
||||||
|
self.progress_text.setText(detail)
|
||||||
|
|
||||||
|
def _append_log(self, action: str, level: str, detail: str) -> None:
|
||||||
|
row = self.log_table.rowCount()
|
||||||
|
self.log_table.insertRow(row)
|
||||||
|
values = (datetime.now().strftime("%d.%m.%Y %H:%M:%S"), action, level, detail)
|
||||||
|
for column, value in enumerate(values):
|
||||||
|
item = QTableWidgetItem(value)
|
||||||
|
item.setToolTip(value)
|
||||||
|
self.log_table.setItem(row, column, item)
|
||||||
|
if self.log_table.rowCount() > 1000:
|
||||||
|
self.log_table.removeRow(0)
|
||||||
|
self.log_table.scrollToBottom()
|
||||||
|
|
||||||
|
def _set_global_status(self, status: str, text: str) -> None:
|
||||||
|
colors = {"active": "#35C77A", "paused": "#E0525D", "error": "#F0B84B"}
|
||||||
|
self.status_label.setText(text)
|
||||||
|
self.status_label.setStyleSheet(f"color: {colors.get(status, '#E8EDF4')};")
|
||||||
|
self.tray.setIcon(status_icon(status))
|
||||||
|
self.tray.setToolTip(f"SyncVito – {text}")
|
||||||
|
self._update_pause_action()
|
||||||
|
|
||||||
|
def _set_task_state(self, task_id: str, state: TaskRuntimeState) -> None:
|
||||||
|
self._refresh_tasks()
|
||||||
|
|
||||||
|
def _set_lock_message(self, message: str) -> None:
|
||||||
|
self.lock_banner.setText(message)
|
||||||
|
self.lock_banner.setVisible(bool(message))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _runtime_state_text(state: TaskRuntimeState) -> str:
|
||||||
|
return {
|
||||||
|
TaskRuntimeState.ACTIVE: "Aktiv",
|
||||||
|
TaskRuntimeState.PAUSED: "Pausiert",
|
||||||
|
TaskRuntimeState.WAITING_FOR_RELEASE: "Wartet auf Freigabe",
|
||||||
|
TaskRuntimeState.ERROR: "Fehler",
|
||||||
|
}[state]
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""Domänenmodelle und validierte Konfigurationswerte."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from enum import StrEnum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AppConfig",
|
||||||
|
"AppSettings",
|
||||||
|
"ConflictMode",
|
||||||
|
"DeleteMode",
|
||||||
|
"SyncDirection",
|
||||||
|
"SyncTask",
|
||||||
|
"TaskRuntimeState",
|
||||||
|
"TriggerMode",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SyncDirection(StrEnum):
|
||||||
|
"""Unterstützte Synchronisationsrichtungen."""
|
||||||
|
|
||||||
|
ONE_WAY = "one_way"
|
||||||
|
BIDIRECTIONAL = "bidirectional"
|
||||||
|
|
||||||
|
|
||||||
|
class TriggerMode(StrEnum):
|
||||||
|
"""Unterstützte Auslöser für eine Aufgabe."""
|
||||||
|
|
||||||
|
REALTIME = "realtime"
|
||||||
|
INTERVAL = "interval"
|
||||||
|
MANUAL = "manual"
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteMode(StrEnum):
|
||||||
|
"""Verhalten bei erkannten Löschungen."""
|
||||||
|
|
||||||
|
MIRROR = "mirror"
|
||||||
|
KEEP = "keep"
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictMode(StrEnum):
|
||||||
|
"""Verhalten bei beidseitig geänderten Dateien."""
|
||||||
|
|
||||||
|
NEWER_WINS = "newer_wins"
|
||||||
|
CONFLICT_COPY = "conflict_copy"
|
||||||
|
|
||||||
|
|
||||||
|
class TaskRuntimeState(StrEnum):
|
||||||
|
"""Nicht persistierter, tatsächlicher Laufzeitzustand einer Aufgabe."""
|
||||||
|
|
||||||
|
ACTIVE = "active"
|
||||||
|
PAUSED = "paused"
|
||||||
|
WAITING_FOR_RELEASE = "waiting_for_release"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class SyncTask:
|
||||||
|
"""Persistierbare Konfiguration eines Ordnerpaars."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
source: str
|
||||||
|
target: str
|
||||||
|
id: str = field(default_factory=lambda: str(uuid4()))
|
||||||
|
direction: SyncDirection = SyncDirection.ONE_WAY
|
||||||
|
trigger: TriggerMode = TriggerMode.MANUAL
|
||||||
|
delete_mode: DeleteMode = DeleteMode.KEEP
|
||||||
|
conflict_mode: ConflictMode = ConflictMode.NEWER_WINS
|
||||||
|
interval_minutes: int = 30
|
||||||
|
selected_paths: list[str] = field(default_factory=list)
|
||||||
|
enabled: bool = True
|
||||||
|
last_run: str | None = None
|
||||||
|
|
||||||
|
def validate(self) -> None:
|
||||||
|
"""Validiert sicherheitsrelevante Aufgabeneigenschaften frühzeitig."""
|
||||||
|
if not self.name.strip():
|
||||||
|
raise ValueError("Bitte einen Namen für die Aufgabe eingeben.")
|
||||||
|
if not self.source.strip() or not self.target.strip():
|
||||||
|
raise ValueError("Quell- und Zielordner müssen angegeben werden.")
|
||||||
|
source = Path(self.source).expanduser().resolve(strict=False)
|
||||||
|
target = Path(self.target).expanduser().resolve(strict=False)
|
||||||
|
source_norm = os.path.normcase(str(source))
|
||||||
|
target_norm = os.path.normcase(str(target))
|
||||||
|
if source_norm == target_norm:
|
||||||
|
raise ValueError("Quell- und Zielordner dürfen nicht identisch sein.")
|
||||||
|
if _is_relative_to(source, target) or _is_relative_to(target, source):
|
||||||
|
raise ValueError("Quell- und Zielordner dürfen nicht ineinander liegen.")
|
||||||
|
if not 1 <= self.interval_minutes <= 10080:
|
||||||
|
raise ValueError("Das Intervall muss zwischen 1 Minute und 7 Tagen liegen.")
|
||||||
|
self.name = self.name.strip()
|
||||||
|
self.source = str(source)
|
||||||
|
self.target = str(target)
|
||||||
|
self.selected_paths = sorted({_normalize_relative_path(item) for item in self.selected_paths if item.strip()})
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Serialisiert die Aufgabe in ein JSON-kompatibles Dictionary."""
|
||||||
|
data = asdict(self)
|
||||||
|
data["direction"] = self.direction.value
|
||||||
|
data["trigger"] = self.trigger.value
|
||||||
|
data["delete_mode"] = self.delete_mode.value
|
||||||
|
data["conflict_mode"] = self.conflict_mode.value
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> SyncTask:
|
||||||
|
"""Erzeugt eine Aufgabe aus persistierten Daten."""
|
||||||
|
task = cls(
|
||||||
|
id=str(data.get("id") or uuid4()),
|
||||||
|
name=str(data.get("name", "Unbenannte Aufgabe")),
|
||||||
|
source=str(data.get("source", "")),
|
||||||
|
target=str(data.get("target", "")),
|
||||||
|
direction=SyncDirection(data.get("direction", SyncDirection.ONE_WAY.value)),
|
||||||
|
trigger=TriggerMode(data.get("trigger", TriggerMode.MANUAL.value)),
|
||||||
|
delete_mode=DeleteMode(data.get("delete_mode", DeleteMode.KEEP.value)),
|
||||||
|
conflict_mode=ConflictMode(data.get("conflict_mode", ConflictMode.NEWER_WINS.value)),
|
||||||
|
interval_minutes=int(data.get("interval_minutes", 30)),
|
||||||
|
selected_paths=[str(item) for item in data.get("selected_paths", [])],
|
||||||
|
enabled=bool(data.get("enabled", True)),
|
||||||
|
last_run=data.get("last_run"),
|
||||||
|
)
|
||||||
|
task.validate()
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AppSettings:
|
||||||
|
"""Globale, persistierbare Anwendungseinstellungen."""
|
||||||
|
|
||||||
|
autostart: bool = False
|
||||||
|
font_family: str = "Segoe UI Variable"
|
||||||
|
font_size: int = 10
|
||||||
|
font_weight: str = "regular"
|
||||||
|
ui_scale: int = 100
|
||||||
|
paused: bool = False
|
||||||
|
check_updates_on_start: bool = True
|
||||||
|
last_update_check: str | None = None
|
||||||
|
|
||||||
|
def validate(self) -> None:
|
||||||
|
"""Begrenzt Darstellungswerte auf brauchbare Bereiche."""
|
||||||
|
self.font_size = max(8, min(18, int(self.font_size)))
|
||||||
|
self.ui_scale = max(80, min(150, int(self.ui_scale)))
|
||||||
|
if self.font_weight not in {"regular", "bold"}:
|
||||||
|
self.font_weight = "regular"
|
||||||
|
if not self.font_family.strip():
|
||||||
|
self.font_family = "Segoe UI Variable"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Serialisiert die Einstellungen."""
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> AppSettings:
|
||||||
|
"""Erzeugt Einstellungen aus einem Dictionary."""
|
||||||
|
settings = cls(
|
||||||
|
autostart=bool(data.get("autostart", False)),
|
||||||
|
font_family=str(data.get("font_family", "Segoe UI Variable")),
|
||||||
|
font_size=int(data.get("font_size", 10)),
|
||||||
|
font_weight=str(data.get("font_weight", "regular")),
|
||||||
|
ui_scale=int(data.get("ui_scale", 100)),
|
||||||
|
paused=bool(data.get("paused", False)),
|
||||||
|
check_updates_on_start=bool(data.get("check_updates_on_start", True)),
|
||||||
|
last_update_check=data.get("last_update_check"),
|
||||||
|
)
|
||||||
|
settings.validate()
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AppConfig:
|
||||||
|
"""Wurzelobjekt der lokalen JSON-Konfiguration."""
|
||||||
|
|
||||||
|
tasks: list[SyncTask] = field(default_factory=list)
|
||||||
|
settings: AppSettings = field(default_factory=AppSettings)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Serialisiert die vollständige Konfiguration."""
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"tasks": [task.to_dict() for task in self.tasks],
|
||||||
|
"settings": self.settings.to_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> AppConfig:
|
||||||
|
"""Erzeugt eine vollständige Konfiguration aus JSON-Daten."""
|
||||||
|
return cls(
|
||||||
|
tasks=[SyncTask.from_dict(item) for item in data.get("tasks", [])],
|
||||||
|
settings=AppSettings.from_dict(data.get("settings", {})),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_relative_path(value: str) -> str:
|
||||||
|
normalized = value.replace("/", os.sep).replace("\\", os.sep).strip(os.sep)
|
||||||
|
path = Path(normalized)
|
||||||
|
if path.is_absolute() or ".." in path.parts:
|
||||||
|
raise ValueError(f"Ungültige Unterauswahl: {value}")
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_relative_to(path: Path, base: Path) -> bool:
|
||||||
|
try:
|
||||||
|
path.relative_to(base)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Watchdog-Adapter für Echtzeit-Aufgaben."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
||||||
|
from watchdog.observers import Observer
|
||||||
|
|
||||||
|
from syncvito.models import SyncDirection, SyncTask, TriggerMode
|
||||||
|
|
||||||
|
__all__ = ["RealtimeMonitor"]
|
||||||
|
|
||||||
|
|
||||||
|
class _TaskEventHandler(FileSystemEventHandler):
|
||||||
|
def __init__(self, task_id: str, callback: Callable[[str], None]) -> None:
|
||||||
|
self.task_id = task_id
|
||||||
|
self.callback = callback
|
||||||
|
|
||||||
|
def on_any_event(self, event: FileSystemEvent) -> None:
|
||||||
|
if not event.is_directory or event.event_type in {"created", "deleted", "moved", "modified"}:
|
||||||
|
self.callback(self.task_id)
|
||||||
|
|
||||||
|
|
||||||
|
class RealtimeMonitor:
|
||||||
|
"""Verwaltet einen Watchdog-Observer für alle Echtzeit-Aufgaben."""
|
||||||
|
|
||||||
|
def __init__(self, callback: Callable[[str], None]) -> None:
|
||||||
|
self.callback = callback
|
||||||
|
self._observer: Observer | None = None
|
||||||
|
|
||||||
|
def rebuild(self, tasks: list[SyncTask]) -> list[tuple[str, str]]:
|
||||||
|
"""Startet die Überwachung passend zur aktuellen Aufgabenliste neu."""
|
||||||
|
self.stop()
|
||||||
|
observer = Observer()
|
||||||
|
errors: list[tuple[str, str]] = []
|
||||||
|
scheduled = False
|
||||||
|
for task in tasks:
|
||||||
|
if not task.enabled or task.trigger is not TriggerMode.REALTIME:
|
||||||
|
continue
|
||||||
|
handler = _TaskEventHandler(task.id, self.callback)
|
||||||
|
paths = [Path(task.source)]
|
||||||
|
if task.direction is SyncDirection.BIDIRECTIONAL:
|
||||||
|
paths.append(Path(task.target))
|
||||||
|
for path in paths:
|
||||||
|
if not path.is_dir():
|
||||||
|
errors.append((task.id, f"Echtzeitüberwachung nicht möglich: {path}"))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
observer.schedule(handler, str(path), recursive=True)
|
||||||
|
scheduled = True
|
||||||
|
except OSError as exc:
|
||||||
|
errors.append((task.id, f"Überwachung fehlgeschlagen ({path}): {exc}"))
|
||||||
|
if scheduled:
|
||||||
|
observer.start()
|
||||||
|
self._observer = observer
|
||||||
|
return errors
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Beendet den laufenden Observer sauber."""
|
||||||
|
if self._observer is not None:
|
||||||
|
self._observer.stop()
|
||||||
|
self._observer.join(timeout=3)
|
||||||
|
self._observer = None
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Persistenter Abgleichzustand für sichere bidirektionale Löschungen."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from syncvito.config_store import default_data_directory
|
||||||
|
|
||||||
|
__all__ = ["StateStore"]
|
||||||
|
|
||||||
|
|
||||||
|
class StateStore:
|
||||||
|
"""Speichert Dateisignaturen getrennt von der Benutzerkonfiguration."""
|
||||||
|
|
||||||
|
def __init__(self, path: Path | None = None) -> None:
|
||||||
|
self.path = path or default_data_directory() / "sync-state.json"
|
||||||
|
self._data: dict[str, dict[str, dict[str, str | None]]] = {}
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def get(self, task_id: str) -> dict[str, dict[str, str | None]]:
|
||||||
|
"""Liefert den zuletzt erfolgreichen Zustand einer Aufgabe."""
|
||||||
|
return dict(self._data.get(task_id, {}))
|
||||||
|
|
||||||
|
def set(self, task_id: str, state: dict[str, dict[str, str | None]]) -> None:
|
||||||
|
"""Aktualisiert und persistiert den Zustand einer Aufgabe."""
|
||||||
|
self._data[task_id] = state
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def remove(self, task_id: str) -> None:
|
||||||
|
"""Entfernt den Zustand einer gelöschten Aufgabe."""
|
||||||
|
self._data.pop(task_id, None)
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
if not self.path.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
raw: Any = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
self._data = raw
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
self._data = {}
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = self.path.with_suffix(".json.tmp")
|
||||||
|
temporary.write_text(json.dumps(self._data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
os.replace(temporary, self.path)
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"""Dateisystem-Synchronisation ohne Abhängigkeit von der Benutzeroberfläche."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from collections.abc import Callable
|
||||||
|
from contextlib import suppress
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from syncvito.file_locks import LockingProcess, find_locking_processes
|
||||||
|
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask
|
||||||
|
from syncvito.state_store import StateStore
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BlockedFile",
|
||||||
|
"ProgressCallback",
|
||||||
|
"SyncEngine",
|
||||||
|
"SyncResult",
|
||||||
|
"UnavailablePathError",
|
||||||
|
]
|
||||||
|
|
||||||
|
ProgressCallback = Callable[[int, str], None]
|
||||||
|
|
||||||
|
|
||||||
|
class UnavailablePathError(RuntimeError):
|
||||||
|
"""Ein für die Synchronisation benötigter Pfad ist nicht erreichbar."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FileRecord:
|
||||||
|
"""Kompakte Dateisystemmetadaten für Vergleiche."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
size: int
|
||||||
|
mtime_ns: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def signature(self) -> str:
|
||||||
|
"""Liefert eine persistierbare Signatur."""
|
||||||
|
return f"{self.size}:{self.mtime_ns}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class BlockedFile:
|
||||||
|
"""Eine zurückgestellte Datei einschließlich bekannter Sperrprozesse."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
processes: tuple[LockingProcess, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class SyncResult:
|
||||||
|
"""Zusammenfassung eines vollständigen Aufgabenlaufs."""
|
||||||
|
|
||||||
|
copied: int = 0
|
||||||
|
deleted: int = 0
|
||||||
|
conflicts: int = 0
|
||||||
|
skipped: int = 0
|
||||||
|
blocked: list[BlockedFile] = field(default_factory=list)
|
||||||
|
errors: list[str] = field(default_factory=list)
|
||||||
|
inaccessible: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def successful(self) -> bool:
|
||||||
|
"""Zeigt an, ob der Lauf ohne Fehler und Sperren abgeschlossen wurde."""
|
||||||
|
return not self.errors and not self.blocked and not self.inaccessible
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _Operation:
|
||||||
|
kind: str
|
||||||
|
source: Path | None
|
||||||
|
target: Path
|
||||||
|
relative: str
|
||||||
|
conflict_copy: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SyncEngine:
|
||||||
|
"""Plant und verarbeitet Einweg- sowie bidirektionale Aufgaben."""
|
||||||
|
|
||||||
|
def __init__(self, state_store: StateStore | None = None) -> None:
|
||||||
|
self.state_store = state_store or StateStore()
|
||||||
|
|
||||||
|
def synchronize(self, task: SyncTask, progress: ProgressCallback | None = None) -> SyncResult:
|
||||||
|
"""Führt eine Aufgabe aus und isoliert Fehler auf einzelne Dateien."""
|
||||||
|
task.validate()
|
||||||
|
source_root = Path(task.source)
|
||||||
|
target_root = Path(task.target)
|
||||||
|
self._validate_roots(source_root, target_root)
|
||||||
|
source_files, source_dirs = self._inventory(source_root, task.selected_paths)
|
||||||
|
target_files, target_dirs = self._inventory(target_root, task.selected_paths)
|
||||||
|
previous = self.state_store.get(task.id)
|
||||||
|
|
||||||
|
if task.direction is SyncDirection.ONE_WAY:
|
||||||
|
operations = self._plan_one_way(
|
||||||
|
task, source_root, target_root, source_files, target_files, source_dirs, target_dirs
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
operations = self._plan_bidirectional(
|
||||||
|
task,
|
||||||
|
source_root,
|
||||||
|
target_root,
|
||||||
|
source_files,
|
||||||
|
target_files,
|
||||||
|
source_dirs,
|
||||||
|
target_dirs,
|
||||||
|
previous,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = SyncResult()
|
||||||
|
total = max(1, len(operations))
|
||||||
|
if progress:
|
||||||
|
progress(0, "Änderungen werden vorbereitet …")
|
||||||
|
for index, operation in enumerate(operations, start=1):
|
||||||
|
try:
|
||||||
|
self._execute(operation, result)
|
||||||
|
except PermissionError as exc:
|
||||||
|
processes = tuple(find_locking_processes(operation.target))
|
||||||
|
result.blocked.append(BlockedFile(operation.target, processes))
|
||||||
|
result.errors.append(f"Zugriff verweigert: {operation.target} ({exc})")
|
||||||
|
except (OSError, shutil.Error) as exc:
|
||||||
|
result.errors.append(f"{operation.target}: {exc}")
|
||||||
|
if progress:
|
||||||
|
detail = f"{operation.kind}: {operation.relative}"
|
||||||
|
progress(round(index / total * 100), detail)
|
||||||
|
|
||||||
|
if not operations and progress:
|
||||||
|
progress(100, "Keine Änderungen gefunden")
|
||||||
|
|
||||||
|
if task.direction is SyncDirection.BIDIRECTIONAL and result.successful:
|
||||||
|
refreshed_source, _ = self._inventory(source_root, task.selected_paths)
|
||||||
|
refreshed_target, _ = self._inventory(target_root, task.selected_paths)
|
||||||
|
self.state_store.set(task.id, self._state_from_inventories(refreshed_source, refreshed_target))
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_roots(source_root: Path, target_root: Path) -> None:
|
||||||
|
if not source_root.is_dir():
|
||||||
|
raise UnavailablePathError(f"Quellordner nicht erreichbar: {source_root}")
|
||||||
|
if not target_root.exists():
|
||||||
|
try:
|
||||||
|
target_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise UnavailablePathError(f"Zielordner nicht erreichbar: {target_root}") from exc
|
||||||
|
if not target_root.is_dir():
|
||||||
|
raise UnavailablePathError(f"Ziel ist kein Ordner: {target_root}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _inventory(root: Path, selected_paths: list[str]) -> tuple[dict[str, FileRecord], set[str]]:
|
||||||
|
files: dict[str, FileRecord] = {}
|
||||||
|
directories: set[str] = set()
|
||||||
|
selections = tuple(Path(item).parts for item in selected_paths)
|
||||||
|
try:
|
||||||
|
for current, dir_names, file_names in os.walk(root):
|
||||||
|
current_path = Path(current)
|
||||||
|
relative_dir = current_path.relative_to(root)
|
||||||
|
dir_names[:] = [name for name in dir_names if not (current_path / name).is_symlink()]
|
||||||
|
for directory_name in dir_names:
|
||||||
|
relative = relative_dir / directory_name
|
||||||
|
if _is_selected(relative.parts, selections):
|
||||||
|
directories.add(str(relative))
|
||||||
|
for file_name in file_names:
|
||||||
|
path = current_path / file_name
|
||||||
|
if path.is_symlink():
|
||||||
|
continue
|
||||||
|
relative = path.relative_to(root)
|
||||||
|
if not _is_selected(relative.parts, selections):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
file_stat = path.stat()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
files[str(relative)] = FileRecord(path, file_stat.st_size, file_stat.st_mtime_ns)
|
||||||
|
except OSError as exc:
|
||||||
|
raise UnavailablePathError(f"Ordner kann nicht gelesen werden: {root}") from exc
|
||||||
|
return files, directories
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _plan_one_way(
|
||||||
|
task: SyncTask,
|
||||||
|
source_root: Path,
|
||||||
|
target_root: Path,
|
||||||
|
source_files: dict[str, FileRecord],
|
||||||
|
target_files: dict[str, FileRecord],
|
||||||
|
source_dirs: set[str],
|
||||||
|
target_dirs: set[str],
|
||||||
|
) -> list[_Operation]:
|
||||||
|
operations: list[_Operation] = []
|
||||||
|
for relative in sorted(source_dirs - target_dirs):
|
||||||
|
operations.append(_Operation("Ordner anlegen", None, target_root / relative, relative))
|
||||||
|
for relative, source in sorted(source_files.items()):
|
||||||
|
target = target_files.get(relative)
|
||||||
|
if target is None or target.signature != source.signature:
|
||||||
|
operations.append(_Operation("Kopieren", source.path, target_root / relative, relative))
|
||||||
|
if task.delete_mode is DeleteMode.MIRROR:
|
||||||
|
for relative in sorted(target_files.keys() - source_files.keys()):
|
||||||
|
operations.append(_Operation("Löschen", None, target_root / relative, relative))
|
||||||
|
for relative in sorted(target_dirs - source_dirs, key=lambda item: len(Path(item).parts), reverse=True):
|
||||||
|
operations.append(_Operation("Ordner löschen", None, target_root / relative, relative))
|
||||||
|
return operations
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _plan_bidirectional(
|
||||||
|
task: SyncTask,
|
||||||
|
source_root: Path,
|
||||||
|
target_root: Path,
|
||||||
|
source_files: dict[str, FileRecord],
|
||||||
|
target_files: dict[str, FileRecord],
|
||||||
|
source_dirs: set[str],
|
||||||
|
target_dirs: set[str],
|
||||||
|
previous: dict[str, dict[str, str | None]],
|
||||||
|
) -> list[_Operation]:
|
||||||
|
operations = [
|
||||||
|
_Operation("Ordner anlegen", None, target_root / relative, relative)
|
||||||
|
for relative in sorted(source_dirs - target_dirs)
|
||||||
|
]
|
||||||
|
operations.extend(
|
||||||
|
_Operation("Ordner anlegen", None, source_root / relative, relative)
|
||||||
|
for relative in sorted(target_dirs - source_dirs)
|
||||||
|
)
|
||||||
|
all_paths = sorted(source_files.keys() | target_files.keys() | previous.keys())
|
||||||
|
for relative in all_paths:
|
||||||
|
source = source_files.get(relative)
|
||||||
|
target = target_files.get(relative)
|
||||||
|
old = previous.get(relative, {})
|
||||||
|
old_source = old.get("source")
|
||||||
|
old_target = old.get("target")
|
||||||
|
if source and target:
|
||||||
|
if source.signature == target.signature:
|
||||||
|
continue
|
||||||
|
winner, destination = _newer_pair(source, target)
|
||||||
|
conflict = task.conflict_mode is ConflictMode.CONFLICT_COPY
|
||||||
|
operations.append(
|
||||||
|
_Operation(
|
||||||
|
"Konflikt lösen" if conflict else "Aktualisieren",
|
||||||
|
winner.path,
|
||||||
|
destination.path,
|
||||||
|
relative,
|
||||||
|
conflict,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif source:
|
||||||
|
target_was_deleted = old_target is not None and source.signature == old_source
|
||||||
|
if task.delete_mode is DeleteMode.MIRROR and target_was_deleted:
|
||||||
|
operations.append(_Operation("Löschung spiegeln", None, source.path, relative))
|
||||||
|
else:
|
||||||
|
operations.append(_Operation("Kopieren", source.path, target_root / relative, relative))
|
||||||
|
elif target:
|
||||||
|
source_was_deleted = old_source is not None and target.signature == old_target
|
||||||
|
if task.delete_mode is DeleteMode.MIRROR and source_was_deleted:
|
||||||
|
operations.append(_Operation("Löschung spiegeln", None, target.path, relative))
|
||||||
|
else:
|
||||||
|
operations.append(_Operation("Kopieren", target.path, source_root / relative, relative))
|
||||||
|
return operations
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _execute(operation: _Operation, result: SyncResult) -> None:
|
||||||
|
if operation.kind == "Ordner anlegen":
|
||||||
|
operation.target.mkdir(parents=True, exist_ok=True)
|
||||||
|
return
|
||||||
|
if operation.kind in {"Löschen", "Löschung spiegeln"}:
|
||||||
|
operation.target.unlink(missing_ok=True)
|
||||||
|
result.deleted += 1
|
||||||
|
return
|
||||||
|
if operation.kind == "Ordner löschen":
|
||||||
|
try:
|
||||||
|
operation.target.rmdir()
|
||||||
|
result.deleted += 1
|
||||||
|
except OSError:
|
||||||
|
result.skipped += 1
|
||||||
|
return
|
||||||
|
if operation.source is None:
|
||||||
|
return
|
||||||
|
operation.target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if operation.conflict_copy and operation.target.exists():
|
||||||
|
conflict_path = _conflict_path(operation.target)
|
||||||
|
shutil.copy2(operation.target, conflict_path)
|
||||||
|
result.conflicts += 1
|
||||||
|
_atomic_copy(operation.source, operation.target)
|
||||||
|
result.copied += 1
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _state_from_inventories(
|
||||||
|
source_files: dict[str, FileRecord], target_files: dict[str, FileRecord]
|
||||||
|
) -> dict[str, dict[str, str | None]]:
|
||||||
|
return {
|
||||||
|
relative: {
|
||||||
|
"source": source_files[relative].signature if relative in source_files else None,
|
||||||
|
"target": target_files[relative].signature if relative in target_files else None,
|
||||||
|
}
|
||||||
|
for relative in source_files.keys() | target_files.keys()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_copy(source: Path, target: Path) -> None:
|
||||||
|
temporary = target.with_name(f".{target.name}.syncvito.tmp")
|
||||||
|
try:
|
||||||
|
shutil.copy2(source, temporary)
|
||||||
|
os.replace(temporary, target)
|
||||||
|
except BaseException:
|
||||||
|
with suppress(OSError):
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _conflict_path(path: Path) -> Path:
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
candidate = path.with_name(f"{path.stem}_conflict_{timestamp}{path.suffix}")
|
||||||
|
counter = 2
|
||||||
|
while candidate.exists():
|
||||||
|
candidate = path.with_name(f"{path.stem}_conflict_{timestamp}_{counter}{path.suffix}")
|
||||||
|
counter += 1
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _newer_pair(left: FileRecord, right: FileRecord) -> tuple[FileRecord, FileRecord]:
|
||||||
|
if left.mtime_ns >= right.mtime_ns:
|
||||||
|
return left, right
|
||||||
|
return right, left
|
||||||
|
|
||||||
|
|
||||||
|
def _is_selected(parts: tuple[str, ...], selections: tuple[tuple[str, ...], ...]) -> bool:
|
||||||
|
if not selections:
|
||||||
|
return True
|
||||||
|
return any(parts[: len(selection)] == selection or selection[: len(parts)] == parts for selection in selections)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Zentrales dunkles Designsystem der Qt-Oberfläche."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt6.QtGui import QFont
|
||||||
|
from PyQt6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from syncvito.models import AppSettings
|
||||||
|
|
||||||
|
__all__ = ["apply_theme"]
|
||||||
|
|
||||||
|
|
||||||
|
def apply_theme(application: QApplication, settings: AppSettings) -> None:
|
||||||
|
"""Wendet Typografie und semantische Farbtoken global an."""
|
||||||
|
scale = settings.ui_scale / 100
|
||||||
|
font = QFont(settings.font_family, max(8, round(settings.font_size * scale)))
|
||||||
|
font.setWeight(QFont.Weight.Bold if settings.font_weight == "bold" else QFont.Weight.Normal)
|
||||||
|
application.setFont(font)
|
||||||
|
application.setStyleSheet(_stylesheet(scale))
|
||||||
|
|
||||||
|
|
||||||
|
def _stylesheet(scale: float) -> str:
|
||||||
|
radius = round(8 * scale)
|
||||||
|
padding = round(8 * scale)
|
||||||
|
return f"""
|
||||||
|
* {{ outline: none; }}
|
||||||
|
QMainWindow, QDialog {{ background: #0B0F14; color: #E8EDF4; }}
|
||||||
|
QWidget {{ color: #E8EDF4; selection-background-color: #2878FF; selection-color: #FFFFFF; }}
|
||||||
|
QWidget#Sidebar {{ background: #10161F; border-right: 1px solid #263241; }}
|
||||||
|
QWidget#Header {{ background: #0B0F14; border-bottom: 1px solid #202B38; }}
|
||||||
|
QWidget#ProgressDock {{ background: #10161F; border-top: 1px solid #263241; }}
|
||||||
|
QFrame[card="true"] {{ background: #121923; border: 1px solid #263241; border-radius: {radius}px; }}
|
||||||
|
QLabel#Brand {{ color: #FFFFFF; font-size: {round(18 * scale)}px; font-weight: 700; }}
|
||||||
|
QLabel#Eyebrow {{ color: #6F8299; font-size: {round(10 * scale)}px; font-weight: 600; }}
|
||||||
|
QLabel#PageTitle {{ color: #F4F7FB; font-size: {round(23 * scale)}px; font-weight: 700; }}
|
||||||
|
QLabel#PageSubtitle, QLabel[muted="true"] {{ color: #9CACBF; }}
|
||||||
|
QLabel#StatusPill {{ background: #162131; border: 1px solid #304158; border-radius: {round(13 * scale)}px;
|
||||||
|
min-height: {round(18 * scale)}px; max-height: {round(18 * scale)}px;
|
||||||
|
padding: {round(3 * scale)}px {round(10 * scale)}px; font-weight: 600; }}
|
||||||
|
QLabel#LockBanner {{ background: #261C10; color: #FFD18A; border: 1px solid #6B4C21;
|
||||||
|
border-radius: {radius}px; padding: {padding}px {round(12 * scale)}px; }}
|
||||||
|
QPushButton {{ background: #192332; border: 1px solid #34455B; border-radius: {radius}px;
|
||||||
|
padding: {padding}px {round(14 * scale)}px; min-height: {round(20 * scale)}px; font-weight: 600; }}
|
||||||
|
QPushButton:hover {{ background: #223044; border-color: #506781; }}
|
||||||
|
QPushButton:pressed {{ background: #131B27; }}
|
||||||
|
QPushButton:focus {{ border: 2px solid #5B9CFF; padding: {padding - 1}px {round(13 * scale)}px; }}
|
||||||
|
QPushButton:disabled {{ color: #627188; background: #121922; border-color: #283342; }}
|
||||||
|
QPushButton[primary="true"] {{ background: #2878FF; border-color: #448AFF; color: #FFFFFF; }}
|
||||||
|
QPushButton[primary="true"]:hover {{ background: #3C86FF; }}
|
||||||
|
QPushButton[danger="true"] {{ color: #FFB6B6; border-color: #653A42; background: #24171B; }}
|
||||||
|
QPushButton[nav="true"] {{ text-align: left; border-color: transparent; background: transparent;
|
||||||
|
padding: {round(10 * scale)}px {round(12 * scale)}px; color: #9CACBF; }}
|
||||||
|
QPushButton[nav="true"]:hover {{ background: #17212E; color: #E8EDF4; }}
|
||||||
|
QPushButton[nav="true"]:checked {{ background: #1B2A3D; color: #FFFFFF; border-color: #2A568B; }}
|
||||||
|
QLineEdit, QComboBox, QSpinBox, QListWidget, QTreeView, QTableWidget {{
|
||||||
|
background: #0E141D; border: 1px solid #304052; border-radius: {radius}px;
|
||||||
|
padding: {round(7 * scale)}px; color: #E8EDF4;
|
||||||
|
}}
|
||||||
|
QTextBrowser {{ background: #0E141D; border: 1px solid #304052; border-radius: {radius}px;
|
||||||
|
padding: {round(8 * scale)}px; color: #DDE5EF; }}
|
||||||
|
QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QListWidget:focus, QTreeView:focus, QTableWidget:focus {{
|
||||||
|
border: 2px solid #5B9CFF;
|
||||||
|
}}
|
||||||
|
QComboBox::drop-down {{ border: none; width: {round(26 * scale)}px; }}
|
||||||
|
QComboBox QAbstractItemView {{ background: #151E2A; border: 1px solid #34455B; selection-background-color: #2878FF; }}
|
||||||
|
QHeaderView::section {{ background: #131B26; color: #91A2B7; border: none; border-bottom: 1px solid #2A3747;
|
||||||
|
padding: {round(9 * scale)}px; font-weight: 600; }}
|
||||||
|
QTableWidget {{ gridline-color: #202B38; alternate-background-color: #101721; }}
|
||||||
|
QTableWidget::item {{ padding: {round(7 * scale)}px; border-bottom: 1px solid #202B38; }}
|
||||||
|
QTableWidget::item:selected {{ background: #1E4E86; }}
|
||||||
|
QProgressBar {{ background: #182230; border: 1px solid #304052; border-radius: {round(7 * scale)}px;
|
||||||
|
text-align: center; color: #FFFFFF; min-height: {round(18 * scale)}px; font-weight: 700; }}
|
||||||
|
QProgressBar::chunk {{ background: #2878FF; border-radius: {round(6 * scale)}px; }}
|
||||||
|
QTabWidget::pane {{ border: 1px solid #263241; border-radius: {radius}px; top: -1px; }}
|
||||||
|
QTabBar::tab {{ background: #111923; padding: {padding}px {round(14 * scale)}px; color: #96A7BA; }}
|
||||||
|
QTabBar::tab:selected {{ background: #1A2635; color: #FFFFFF; }}
|
||||||
|
QCheckBox {{ spacing: {round(8 * scale)}px; }}
|
||||||
|
QCheckBox::indicator {{ width: {round(16 * scale)}px; height: {round(16 * scale)}px;
|
||||||
|
border: 1px solid #50647C; border-radius: {round(4 * scale)}px; background: #0D131B; }}
|
||||||
|
QCheckBox::indicator:checked {{ background: #2878FF; border-color: #72A8FF; }}
|
||||||
|
QScrollBar:vertical {{ background: #0E141D; width: {round(11 * scale)}px; margin: 0; }}
|
||||||
|
QScrollBar::handle:vertical {{ background: #35465A; border-radius: {round(5 * scale)}px; min-height: 24px; }}
|
||||||
|
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||||||
|
QToolTip {{ background: #1B2635; color: #FFFFFF; border: 1px solid #4B6078; padding: 5px; }}
|
||||||
|
"""
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Qt-Worker für nicht blockierende Updateprüfungen und Downloads."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QObject, QRunnable, QThreadPool, pyqtSignal
|
||||||
|
|
||||||
|
from syncvito.updater import GiteaUpdateService, UpdateError, UpdateInfo
|
||||||
|
|
||||||
|
__all__ = ["UpdateController"]
|
||||||
|
|
||||||
|
|
||||||
|
class _CheckSignals(QObject):
|
||||||
|
finished = pyqtSignal(object, bool)
|
||||||
|
failed = pyqtSignal(str, bool)
|
||||||
|
|
||||||
|
|
||||||
|
class _CheckWorker(QRunnable):
|
||||||
|
def __init__(self, service: GiteaUpdateService, manual: bool) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.service = service
|
||||||
|
self.manual = manual
|
||||||
|
self.signals = _CheckSignals()
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
info = self.service.check()
|
||||||
|
except UpdateError as exc:
|
||||||
|
self.signals.failed.emit(str(exc), self.manual)
|
||||||
|
else:
|
||||||
|
self.signals.finished.emit(info, self.manual)
|
||||||
|
|
||||||
|
|
||||||
|
class _DownloadSignals(QObject):
|
||||||
|
progress = pyqtSignal(int, str)
|
||||||
|
finished = pyqtSignal(object, object)
|
||||||
|
failed = pyqtSignal(str, bool)
|
||||||
|
|
||||||
|
|
||||||
|
class _DownloadWorker(QRunnable):
|
||||||
|
def __init__(self, service: GiteaUpdateService, info: UpdateInfo) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.service = service
|
||||||
|
self.info = info
|
||||||
|
self.signals = _DownloadSignals()
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
path = self.service.download(self.info, self.signals.progress.emit)
|
||||||
|
except UpdateError as exc:
|
||||||
|
self.signals.failed.emit(str(exc), True)
|
||||||
|
else:
|
||||||
|
self.signals.finished.emit(path, self.info)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateController(QObject):
|
||||||
|
"""Serialisiert Updateaktionen und überträgt Ergebnisse in den UI-Thread."""
|
||||||
|
|
||||||
|
update_available = pyqtSignal(object, bool)
|
||||||
|
no_update = pyqtSignal(bool)
|
||||||
|
error = pyqtSignal(str, bool)
|
||||||
|
progress = pyqtSignal(int, str)
|
||||||
|
install_ready = pyqtSignal(object, object)
|
||||||
|
busy_changed = pyqtSignal(bool)
|
||||||
|
|
||||||
|
def __init__(self, current_version: str, parent: QObject | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.service = GiteaUpdateService(current_version)
|
||||||
|
self.pool = QThreadPool.globalInstance()
|
||||||
|
self._busy = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def busy(self) -> bool:
|
||||||
|
"""Zeigt an, ob eine Updateaktion läuft."""
|
||||||
|
return self._busy
|
||||||
|
|
||||||
|
def check(self, manual: bool = False) -> None:
|
||||||
|
"""Startet eine Hintergrundprüfung, sofern keine Aktion läuft."""
|
||||||
|
if self._busy:
|
||||||
|
return
|
||||||
|
self._set_busy(True)
|
||||||
|
worker = _CheckWorker(self.service, manual)
|
||||||
|
worker.signals.finished.connect(self._check_finished)
|
||||||
|
worker.signals.failed.connect(self._failed)
|
||||||
|
self.pool.start(worker)
|
||||||
|
|
||||||
|
def download(self, info: UpdateInfo) -> None:
|
||||||
|
"""Lädt ein zuvor validiertes Release im Hintergrund."""
|
||||||
|
if self._busy:
|
||||||
|
return
|
||||||
|
self._set_busy(True)
|
||||||
|
worker = _DownloadWorker(self.service, info)
|
||||||
|
worker.signals.progress.connect(self.progress)
|
||||||
|
worker.signals.finished.connect(self._download_finished)
|
||||||
|
worker.signals.failed.connect(self._failed)
|
||||||
|
self.pool.start(worker)
|
||||||
|
|
||||||
|
def _check_finished(self, info: UpdateInfo | None, manual: bool) -> None:
|
||||||
|
self._set_busy(False)
|
||||||
|
if info is None:
|
||||||
|
self.no_update.emit(manual)
|
||||||
|
else:
|
||||||
|
self.update_available.emit(info, manual)
|
||||||
|
|
||||||
|
def _download_finished(self, path: object, info: object) -> None:
|
||||||
|
self._set_busy(False)
|
||||||
|
self.install_ready.emit(path, info)
|
||||||
|
|
||||||
|
def _failed(self, message: str, manual: bool) -> None:
|
||||||
|
self._set_busy(False)
|
||||||
|
self.error.emit(message, manual)
|
||||||
|
|
||||||
|
def _set_busy(self, busy: bool) -> None:
|
||||||
|
self._busy = busy
|
||||||
|
self.busy_changed.emit(busy)
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Kompakter Dialog für verfügbare Programmupdates."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QFrame,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QTextBrowser,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from syncvito.updater import UpdateInfo
|
||||||
|
|
||||||
|
__all__ = ["UpdateDialog"]
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateDialog(QDialog):
|
||||||
|
"""Zeigt Versionsdaten und fordert eine explizite Installation an."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
current_version: str,
|
||||||
|
update: UpdateInfo,
|
||||||
|
install_supported: bool,
|
||||||
|
parent: QWidget | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("SyncVito-Update")
|
||||||
|
self.setMinimumWidth(590)
|
||||||
|
self.resize(640, 470)
|
||||||
|
|
||||||
|
eyebrow = QLabel("NEUE VERSION VERFÜGBAR")
|
||||||
|
eyebrow.setObjectName("Eyebrow")
|
||||||
|
title = QLabel(update.title)
|
||||||
|
title.setObjectName("PageTitle")
|
||||||
|
version_text = QLabel(f"Installiert: {current_version} → Verfügbar: {update.version}")
|
||||||
|
version_text.setProperty("muted", True)
|
||||||
|
|
||||||
|
notes_frame = QFrame()
|
||||||
|
notes_frame.setProperty("card", True)
|
||||||
|
notes_layout = QVBoxLayout(notes_frame)
|
||||||
|
notes_layout.setContentsMargins(16, 16, 16, 16)
|
||||||
|
notes_layout.addWidget(QLabel("Versionshinweise"))
|
||||||
|
notes = QTextBrowser()
|
||||||
|
notes.setPlainText(update.notes)
|
||||||
|
notes.setOpenExternalLinks(False)
|
||||||
|
notes_layout.addWidget(notes, 1)
|
||||||
|
|
||||||
|
notice = QLabel(
|
||||||
|
"SyncVito lädt die neue EXE herunter, prüft ihre SHA-256-Summe und startet anschließend neu. "
|
||||||
|
"Aufgaben und Einstellungen bleiben erhalten."
|
||||||
|
if install_supported
|
||||||
|
else "Die Updateinstallation ist nur in der gebauten Windows-EXE verfügbar."
|
||||||
|
)
|
||||||
|
notice.setWordWrap(True)
|
||||||
|
notice.setProperty("muted", True)
|
||||||
|
|
||||||
|
buttons = QDialogButtonBox()
|
||||||
|
later_button = buttons.addButton("Später", QDialogButtonBox.ButtonRole.RejectRole)
|
||||||
|
install_text = "Herunterladen und installieren" if install_supported else "Schließen"
|
||||||
|
install_button = buttons.addButton(install_text, QDialogButtonBox.ButtonRole.AcceptRole)
|
||||||
|
install_button.setProperty("primary", True)
|
||||||
|
later_button.clicked.connect(self.reject)
|
||||||
|
install_button.clicked.connect(self.accept if install_supported else self.reject)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(22, 22, 22, 22)
|
||||||
|
layout.setSpacing(12)
|
||||||
|
header = QHBoxLayout()
|
||||||
|
header_text = QVBoxLayout()
|
||||||
|
header_text.addWidget(eyebrow)
|
||||||
|
header_text.addWidget(title)
|
||||||
|
header_text.addWidget(version_text)
|
||||||
|
header.addLayout(header_text)
|
||||||
|
header.addStretch(1)
|
||||||
|
layout.addLayout(header)
|
||||||
|
layout.addWidget(notes_frame, 1)
|
||||||
|
layout.addWidget(notice)
|
||||||
|
layout.addWidget(buttons)
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
"""Gitea-Release-Abfrage, geprüfter Download und sicherer EXE-Austausch."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from syncvito.config_store import default_data_directory
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"GiteaUpdateService",
|
||||||
|
"UpdateError",
|
||||||
|
"UpdateFormatError",
|
||||||
|
"UpdateInfo",
|
||||||
|
"UpdateInstallError",
|
||||||
|
"UpdateIntegrityError",
|
||||||
|
"UpdateNetworkError",
|
||||||
|
"parse_version",
|
||||||
|
]
|
||||||
|
|
||||||
|
GITEA_BASE_URL = "https://gitea.davito.synology.me"
|
||||||
|
GITEA_OWNER = "Davito"
|
||||||
|
GITEA_REPOSITORY = "SyncVito"
|
||||||
|
APPLICATION_ASSET = "SyncVito.exe"
|
||||||
|
CHECKSUM_ASSET = "SyncVito.exe.sha256"
|
||||||
|
MAX_DOWNLOAD_BYTES = 250 * 1024 * 1024
|
||||||
|
USER_AGENT = "SyncVito-Updater/1.0"
|
||||||
|
ProgressCallback = Callable[[int, str], None]
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateError(RuntimeError):
|
||||||
|
"""Basisklasse für erwartete Updatefehler."""
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateNetworkError(UpdateError):
|
||||||
|
"""Gitea oder ein Release-Artefakt war nicht erreichbar."""
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateFormatError(UpdateError):
|
||||||
|
"""Release-Metadaten waren unvollständig oder ungültig."""
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateIntegrityError(UpdateError):
|
||||||
|
"""Die heruntergeladene Datei bestand die Integritätsprüfung nicht."""
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateInstallError(UpdateError):
|
||||||
|
"""Der externe Austauschprozess konnte nicht gestartet werden."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class UpdateInfo:
|
||||||
|
"""Validierte Metadaten eines installierbaren Gitea-Releases."""
|
||||||
|
|
||||||
|
version: str
|
||||||
|
title: str
|
||||||
|
notes: str
|
||||||
|
release_url: str
|
||||||
|
download_url: str
|
||||||
|
checksum_url: str
|
||||||
|
size: int
|
||||||
|
|
||||||
|
|
||||||
|
def parse_version(value: str) -> tuple[int, int, int]:
|
||||||
|
"""Konvertiert stabile SemVer-Tags wie ``v1.2.3`` in Vergleichswerte."""
|
||||||
|
match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", value.strip())
|
||||||
|
if match is None:
|
||||||
|
raise ValueError(f"Ungültige stabile Versionsnummer: {value}")
|
||||||
|
return tuple(int(part) for part in match.groups()) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaUpdateService:
|
||||||
|
"""Kapselt das öffentliche Updateprotokoll für SyncVito."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
current_version: str,
|
||||||
|
base_url: str = GITEA_BASE_URL,
|
||||||
|
owner: str = GITEA_OWNER,
|
||||||
|
repository: str = GITEA_REPOSITORY,
|
||||||
|
update_directory: Path | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.current_version = current_version
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.owner = owner
|
||||||
|
self.repository = repository
|
||||||
|
self.update_directory = update_directory or default_data_directory() / "updates"
|
||||||
|
if not self.base_url.startswith("https://"):
|
||||||
|
raise ValueError("Die Updatequelle muss HTTPS verwenden.")
|
||||||
|
parse_version(current_version)
|
||||||
|
|
||||||
|
def check(self) -> UpdateInfo | None:
|
||||||
|
"""Liefert das neueste geeignete Release, sofern es neuer ist."""
|
||||||
|
endpoint = f"{self.base_url}/api/v1/repos/{self.owner}/{self.repository}/releases?limit=20"
|
||||||
|
payload = self._read_json(endpoint)
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
raise UpdateFormatError("Die Gitea-Antwort enthält keine Release-Liste.")
|
||||||
|
candidates: list[tuple[tuple[int, int, int], dict[str, Any]]] = []
|
||||||
|
for release in payload:
|
||||||
|
if not isinstance(release, dict) or release.get("draft") or release.get("prerelease"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
version = parse_version(str(release.get("tag_name", "")))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
candidates.append((version, release))
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
version, release = max(candidates, key=lambda item: item[0])
|
||||||
|
if version <= parse_version(self.current_version):
|
||||||
|
return None
|
||||||
|
return self._parse_release(release, ".".join(str(part) for part in version))
|
||||||
|
|
||||||
|
def download(self, info: UpdateInfo, progress: ProgressCallback | None = None) -> Path:
|
||||||
|
"""Lädt die neue EXE und verifiziert sie gegen die Release-Prüfsumme."""
|
||||||
|
self.update_directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
expected_hash = self._read_checksum(info.checksum_url)
|
||||||
|
destination = self.update_directory / f"SyncVito-{info.version}.exe"
|
||||||
|
temporary = destination.with_suffix(".exe.part")
|
||||||
|
request = urllib.request.Request(info.download_url, headers={"User-Agent": USER_AGENT})
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
received = 0
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=30) as response, temporary.open("wb") as output:
|
||||||
|
announced = int(response.headers.get("Content-Length", info.size or 0))
|
||||||
|
if announced > MAX_DOWNLOAD_BYTES:
|
||||||
|
raise UpdateFormatError("Das Update ist unerwartet groß und wurde abgebrochen.")
|
||||||
|
while chunk := response.read(1024 * 256):
|
||||||
|
received += len(chunk)
|
||||||
|
if received > MAX_DOWNLOAD_BYTES:
|
||||||
|
raise UpdateFormatError("Das Update überschreitet die zulässige Größe.")
|
||||||
|
output.write(chunk)
|
||||||
|
digest.update(chunk)
|
||||||
|
if progress and announced > 0:
|
||||||
|
progress(min(99, round(received / announced * 100)), "Update wird heruntergeladen …")
|
||||||
|
if digest.hexdigest().lower() != expected_hash:
|
||||||
|
raise UpdateIntegrityError("Die SHA-256-Prüfsumme des Updates stimmt nicht überein.")
|
||||||
|
os.replace(temporary, destination)
|
||||||
|
except UpdateError:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
except (OSError, TimeoutError, urllib.error.URLError) as exc:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
raise UpdateNetworkError(f"Das Update konnte nicht heruntergeladen werden: {exc}") from exc
|
||||||
|
if progress:
|
||||||
|
progress(100, "Update geprüft und bereit")
|
||||||
|
self._remove_old_downloads(destination)
|
||||||
|
return destination
|
||||||
|
|
||||||
|
def launch_installer(self, downloaded_executable: Path) -> None:
|
||||||
|
"""Startet einen versteckten Helfer, der die beendete EXE atomar ersetzt."""
|
||||||
|
if os.name != "nt" or not getattr(sys, "frozen", False):
|
||||||
|
raise UpdateInstallError("Updates können nur aus der installierten Windows-EXE heraus angewendet werden.")
|
||||||
|
current_executable = Path(sys.executable).resolve()
|
||||||
|
downloaded = downloaded_executable.resolve()
|
||||||
|
if not downloaded.is_file() or downloaded.parent != self.update_directory.resolve():
|
||||||
|
raise UpdateInstallError("Die geprüfte Update-Datei wurde nicht gefunden.")
|
||||||
|
script = self.update_directory / "install-update.ps1"
|
||||||
|
log_path = self.update_directory / "update-install.log"
|
||||||
|
script.write_text(_installer_script(), encoding="utf-8-sig")
|
||||||
|
command = [
|
||||||
|
"powershell.exe",
|
||||||
|
"-NoProfile",
|
||||||
|
"-NonInteractive",
|
||||||
|
"-ExecutionPolicy",
|
||||||
|
"Bypass",
|
||||||
|
"-WindowStyle",
|
||||||
|
"Hidden",
|
||||||
|
"-File",
|
||||||
|
str(script),
|
||||||
|
"-ProcessId",
|
||||||
|
str(os.getpid()),
|
||||||
|
"-CurrentExecutable",
|
||||||
|
str(current_executable),
|
||||||
|
"-DownloadedExecutable",
|
||||||
|
str(downloaded),
|
||||||
|
"-LogPath",
|
||||||
|
str(log_path),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
subprocess.Popen(
|
||||||
|
command,
|
||||||
|
close_fds=True,
|
||||||
|
creationflags=subprocess.CREATE_NO_WINDOW | subprocess.DETACHED_PROCESS,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
raise UpdateInstallError(f"Der Update-Helfer konnte nicht gestartet werden: {exc}") from exc
|
||||||
|
|
||||||
|
def _parse_release(self, release: dict[str, Any], version: str) -> UpdateInfo:
|
||||||
|
assets = release.get("assets")
|
||||||
|
if not isinstance(assets, list):
|
||||||
|
raise UpdateFormatError(f"Release {version} enthält keine Dateianhänge.")
|
||||||
|
by_name = {str(asset.get("name")): asset for asset in assets if isinstance(asset, dict)}
|
||||||
|
executable = by_name.get(APPLICATION_ASSET)
|
||||||
|
checksum = by_name.get(CHECKSUM_ASSET)
|
||||||
|
if executable is None or checksum is None:
|
||||||
|
raise UpdateFormatError(f"Release {version} benötigt {APPLICATION_ASSET} und {CHECKSUM_ASSET}.")
|
||||||
|
download_url = str(executable.get("browser_download_url", ""))
|
||||||
|
checksum_url = str(checksum.get("browser_download_url", ""))
|
||||||
|
if not download_url.startswith("https://") or not checksum_url.startswith("https://"):
|
||||||
|
raise UpdateFormatError("Die Release-Dateien besitzen keine sicheren Downloadadressen.")
|
||||||
|
return UpdateInfo(
|
||||||
|
version=version,
|
||||||
|
title=str(release.get("name") or f"SyncVito {version}"),
|
||||||
|
notes=str(release.get("body") or "Keine Versionshinweise vorhanden."),
|
||||||
|
release_url=str(release.get("html_url") or self.base_url),
|
||||||
|
download_url=download_url,
|
||||||
|
checksum_url=checksum_url,
|
||||||
|
size=int(executable.get("size") or 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_json(url: str) -> Any:
|
||||||
|
request = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": USER_AGENT})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=12) as response:
|
||||||
|
return json.load(response)
|
||||||
|
except (TimeoutError, urllib.error.URLError, OSError) as exc:
|
||||||
|
raise UpdateNetworkError(f"Der Gitea-Server ist nicht erreichbar: {exc}") from exc
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||||
|
raise UpdateFormatError("Gitea hat ungültige Release-Daten geliefert.") from exc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_checksum(url: str) -> str:
|
||||||
|
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=12) as response:
|
||||||
|
content = response.read(4096).decode("ascii", errors="strict")
|
||||||
|
except (TimeoutError, urllib.error.URLError, OSError, UnicodeDecodeError) as exc:
|
||||||
|
raise UpdateNetworkError(f"Die Update-Prüfsumme konnte nicht geladen werden: {exc}") from exc
|
||||||
|
match = re.search(r"\b([0-9a-fA-F]{64})\b", content)
|
||||||
|
if match is None:
|
||||||
|
raise UpdateFormatError("Die Release-Prüfsumme ist ungültig.")
|
||||||
|
return match.group(1).lower()
|
||||||
|
|
||||||
|
def _remove_old_downloads(self, keep: Path) -> None:
|
||||||
|
for candidate in self.update_directory.glob("SyncVito-*.exe"):
|
||||||
|
if candidate != keep:
|
||||||
|
try:
|
||||||
|
candidate.unlink()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
def _installer_script() -> str:
|
||||||
|
return r"""param(
|
||||||
|
[Parameter(Mandatory=$true)][int]$ProcessId,
|
||||||
|
[Parameter(Mandatory=$true)][string]$CurrentExecutable,
|
||||||
|
[Parameter(Mandatory=$true)][string]$DownloadedExecutable,
|
||||||
|
[Parameter(Mandatory=$true)][string]$LogPath
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$BackupExecutable = "$CurrentExecutable.old"
|
||||||
|
try {
|
||||||
|
Wait-Process -Id $ProcessId -Timeout 90 -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Milliseconds 800
|
||||||
|
if (Test-Path -LiteralPath $BackupExecutable) {
|
||||||
|
Remove-Item -LiteralPath $BackupExecutable -Force
|
||||||
|
}
|
||||||
|
Move-Item -LiteralPath $CurrentExecutable -Destination $BackupExecutable -Force
|
||||||
|
Move-Item -LiteralPath $DownloadedExecutable -Destination $CurrentExecutable -Force
|
||||||
|
Start-Process -FilePath $CurrentExecutable
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
Remove-Item -LiteralPath $BackupExecutable -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue
|
||||||
|
} catch {
|
||||||
|
$_ | Out-File -LiteralPath $LogPath -Encoding utf8
|
||||||
|
if (-not (Test-Path -LiteralPath $CurrentExecutable) -and (Test-Path -LiteralPath $BackupExecutable)) {
|
||||||
|
Move-Item -LiteralPath $BackupExecutable -Destination $CurrentExecutable -Force
|
||||||
|
}
|
||||||
|
if (Test-Path -LiteralPath $CurrentExecutable) {
|
||||||
|
Start-Process -FilePath $CurrentExecutable
|
||||||
|
}
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
"""
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Zentrale, maschinenlesbare Anwendungsversion."""
|
||||||
|
|
||||||
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from syncvito.config_store import ConfigStore
|
||||||
|
from syncvito.models import AppConfig, AppSettings, SyncDirection, SyncTask
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_roundtrip(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "config.json"
|
||||||
|
config = AppConfig(
|
||||||
|
tasks=[
|
||||||
|
SyncTask(
|
||||||
|
name="Archiv",
|
||||||
|
source=str(tmp_path / "quelle"),
|
||||||
|
target=str(tmp_path / "ziel"),
|
||||||
|
direction=SyncDirection.BIDIRECTIONAL,
|
||||||
|
selected_paths=["Dokumente"],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
settings=AppSettings(font_size=12, ui_scale=110, check_updates_on_start=False),
|
||||||
|
)
|
||||||
|
ConfigStore(path).save(config)
|
||||||
|
|
||||||
|
loaded = ConfigStore(path).load()
|
||||||
|
|
||||||
|
assert loaded.settings.font_size == 12
|
||||||
|
assert loaded.settings.ui_scale == 110
|
||||||
|
assert loaded.settings.check_updates_on_start is False
|
||||||
|
assert loaded.tasks[0].name == "Archiv"
|
||||||
|
assert loaded.tasks[0].direction is SyncDirection.BIDIRECTIONAL
|
||||||
|
assert loaded.tasks[0].selected_paths == ["Dokumente"]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from syncvito.coordinator import derive_overall_status
|
||||||
|
from syncvito.models import TaskRuntimeState
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_active_task_is_red() -> None:
|
||||||
|
assert derive_overall_status([], globally_paused=False) == ("paused", "Inaktiv")
|
||||||
|
assert derive_overall_status([TaskRuntimeState.PAUSED], globally_paused=False) == ("paused", "Inaktiv")
|
||||||
|
|
||||||
|
|
||||||
|
def test_healthy_active_tasks_are_green() -> None:
|
||||||
|
states = [TaskRuntimeState.ACTIVE, TaskRuntimeState.PAUSED]
|
||||||
|
assert derive_overall_status(states, globally_paused=False) == ("active", "Bereit")
|
||||||
|
|
||||||
|
|
||||||
|
def test_waiting_lock_has_priority_over_active_task() -> None:
|
||||||
|
states = [TaskRuntimeState.ACTIVE, TaskRuntimeState.WAITING_FOR_RELEASE]
|
||||||
|
assert derive_overall_status(states, globally_paused=False) == ("error", "Wartet auf Dateifreigabe")
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_has_highest_priority() -> None:
|
||||||
|
states = [TaskRuntimeState.WAITING_FOR_RELEASE, TaskRuntimeState.ERROR]
|
||||||
|
assert derive_overall_status(states, globally_paused=False) == ("error", "Fehler")
|
||||||
|
|
||||||
|
|
||||||
|
def test_global_pause_is_red_without_errors() -> None:
|
||||||
|
assert derive_overall_status([TaskRuntimeState.ACTIVE], globally_paused=True) == ("paused", "Pausiert")
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask
|
||||||
|
from syncvito.state_store import StateStore
|
||||||
|
from syncvito.sync_engine import SyncEngine
|
||||||
|
|
||||||
|
|
||||||
|
def _engine(tmp_path: Path) -> SyncEngine:
|
||||||
|
return SyncEngine(StateStore(tmp_path / "state.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_way_copy_and_mirror_delete(tmp_path: Path) -> None:
|
||||||
|
source = tmp_path / "source"
|
||||||
|
target = tmp_path / "target"
|
||||||
|
source.mkdir()
|
||||||
|
target.mkdir()
|
||||||
|
(source / "neu.txt").write_text("aktuell", encoding="utf-8")
|
||||||
|
(target / "alt.txt").write_text("veraltet", encoding="utf-8")
|
||||||
|
task = SyncTask(
|
||||||
|
name="Einweg",
|
||||||
|
source=str(source),
|
||||||
|
target=str(target),
|
||||||
|
delete_mode=DeleteMode.MIRROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _engine(tmp_path).synchronize(task)
|
||||||
|
|
||||||
|
assert result.successful
|
||||||
|
assert (target / "neu.txt").read_text(encoding="utf-8") == "aktuell"
|
||||||
|
assert not (target / "alt.txt").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_selection_limits_synced_files(tmp_path: Path) -> None:
|
||||||
|
source = tmp_path / "source"
|
||||||
|
target = tmp_path / "target"
|
||||||
|
(source / "auswahl").mkdir(parents=True)
|
||||||
|
target.mkdir()
|
||||||
|
(source / "auswahl" / "ja.txt").write_text("ja", encoding="utf-8")
|
||||||
|
(source / "nein.txt").write_text("nein", encoding="utf-8")
|
||||||
|
task = SyncTask(
|
||||||
|
name="Auswahl",
|
||||||
|
source=str(source),
|
||||||
|
target=str(target),
|
||||||
|
selected_paths=["auswahl"],
|
||||||
|
)
|
||||||
|
|
||||||
|
_engine(tmp_path).synchronize(task)
|
||||||
|
|
||||||
|
assert (target / "auswahl" / "ja.txt").exists()
|
||||||
|
assert not (target / "nein.txt").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bidirectional_newer_file_wins(tmp_path: Path) -> None:
|
||||||
|
source = tmp_path / "source"
|
||||||
|
target = tmp_path / "target"
|
||||||
|
source.mkdir()
|
||||||
|
target.mkdir()
|
||||||
|
left = source / "datei.txt"
|
||||||
|
right = target / "datei.txt"
|
||||||
|
left.write_text("links", encoding="utf-8")
|
||||||
|
right.write_text("rechts", encoding="utf-8")
|
||||||
|
os.utime(left, ns=(2_000_000_000, 2_000_000_000))
|
||||||
|
os.utime(right, ns=(1_000_000_000, 1_000_000_000))
|
||||||
|
task = SyncTask(
|
||||||
|
name="Beide",
|
||||||
|
source=str(source),
|
||||||
|
target=str(target),
|
||||||
|
direction=SyncDirection.BIDIRECTIONAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _engine(tmp_path).synchronize(task)
|
||||||
|
|
||||||
|
assert result.successful
|
||||||
|
assert right.read_text(encoding="utf-8") == "links"
|
||||||
|
|
||||||
|
|
||||||
|
def test_conflict_copy_preserves_replaced_version(tmp_path: Path) -> None:
|
||||||
|
source = tmp_path / "source"
|
||||||
|
target = tmp_path / "target"
|
||||||
|
source.mkdir()
|
||||||
|
target.mkdir()
|
||||||
|
left = source / "plan.txt"
|
||||||
|
right = target / "plan.txt"
|
||||||
|
left.write_text("neu", encoding="utf-8")
|
||||||
|
right.write_text("alt", encoding="utf-8")
|
||||||
|
os.utime(left, ns=(3_000_000_000, 3_000_000_000))
|
||||||
|
os.utime(right, ns=(1_000_000_000, 1_000_000_000))
|
||||||
|
task = SyncTask(
|
||||||
|
name="Konflikt",
|
||||||
|
source=str(source),
|
||||||
|
target=str(target),
|
||||||
|
direction=SyncDirection.BIDIRECTIONAL,
|
||||||
|
conflict_mode=ConflictMode.CONFLICT_COPY,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _engine(tmp_path).synchronize(task)
|
||||||
|
|
||||||
|
conflict_files = list(target.glob("plan_conflict_*.txt"))
|
||||||
|
assert result.successful
|
||||||
|
assert result.conflicts == 1
|
||||||
|
assert right.read_text(encoding="utf-8") == "neu"
|
||||||
|
assert conflict_files[0].read_text(encoding="utf-8") == "alt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bidirectional_mirrors_known_deletion(tmp_path: Path) -> None:
|
||||||
|
source = tmp_path / "source"
|
||||||
|
target = tmp_path / "target"
|
||||||
|
source.mkdir()
|
||||||
|
target.mkdir()
|
||||||
|
(source / "entfernen.txt").write_text("inhalt", encoding="utf-8")
|
||||||
|
task = SyncTask(
|
||||||
|
name="Löschung",
|
||||||
|
source=str(source),
|
||||||
|
target=str(target),
|
||||||
|
direction=SyncDirection.BIDIRECTIONAL,
|
||||||
|
delete_mode=DeleteMode.MIRROR,
|
||||||
|
)
|
||||||
|
engine = _engine(tmp_path)
|
||||||
|
engine.synchronize(task)
|
||||||
|
(target / "entfernen.txt").unlink()
|
||||||
|
|
||||||
|
result = engine.synchronize(task)
|
||||||
|
|
||||||
|
assert result.successful
|
||||||
|
assert not (source / "entfernen.txt").exists()
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from syncvito.updater import GiteaUpdateService, UpdateFormatError, parse_version
|
||||||
|
|
||||||
|
|
||||||
|
def _release(version: str, *, draft: bool = False, prerelease: bool = False) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"tag_name": f"v{version}",
|
||||||
|
"name": f"SyncVito {version}",
|
||||||
|
"body": "Änderungen",
|
||||||
|
"html_url": f"https://gitea.example/releases/v{version}",
|
||||||
|
"draft": draft,
|
||||||
|
"prerelease": prerelease,
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"name": "SyncVito.exe",
|
||||||
|
"browser_download_url": "https://gitea.example/download/SyncVito.exe",
|
||||||
|
"size": 1234,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SyncVito.exe.sha256",
|
||||||
|
"browser_download_url": "https://gitea.example/download/SyncVito.exe.sha256",
|
||||||
|
"size": 80,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_version_accepts_stable_tags() -> None:
|
||||||
|
assert parse_version("v1.2.3") == (1, 2, 3)
|
||||||
|
assert parse_version("10.20.30") == (10, 20, 30)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_version_rejects_prerelease() -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
parse_version("1.2.3-beta")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_selects_newest_stable_release(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
service = GiteaUpdateService("1.0.0", base_url="https://gitea.example", update_directory=tmp_path)
|
||||||
|
releases = [_release("1.1.0"), _release("2.0.0", prerelease=True), _release("1.3.0")]
|
||||||
|
monkeypatch.setattr(service, "_read_json", lambda _url: releases)
|
||||||
|
|
||||||
|
update = service.check()
|
||||||
|
|
||||||
|
assert update is not None
|
||||||
|
assert update.version == "1.3.0"
|
||||||
|
assert update.download_url.endswith("SyncVito.exe")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_returns_none_for_current_version(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
service = GiteaUpdateService("1.3.0", base_url="https://gitea.example", update_directory=tmp_path)
|
||||||
|
monkeypatch.setattr(service, "_read_json", lambda _url: [_release("1.3.0"), _release("1.2.0")])
|
||||||
|
|
||||||
|
assert service.check() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_requires_checksum(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
service = GiteaUpdateService("1.0.0", base_url="https://gitea.example", update_directory=tmp_path)
|
||||||
|
release = _release("1.1.0")
|
||||||
|
release["assets"] = [release["assets"][0]] # type: ignore[index]
|
||||||
|
monkeypatch.setattr(service, "_read_json", lambda _url: [release])
|
||||||
|
|
||||||
|
with pytest.raises(UpdateFormatError, match="Prüfsumme|SyncVito.exe.sha256"):
|
||||||
|
service.check()
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Erzeugt das native Windows-Programmsymbol aus dem Qt-Vektordrawing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT / "src"))
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||||
|
|
||||||
|
from syncvito.icons import app_icon # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Schreibt eine hochauflösende ICO-Datei in den Assets-Ordner."""
|
||||||
|
application = QApplication.instance() or QApplication([])
|
||||||
|
output = PROJECT_ROOT / "assets" / "SyncVito.ico"
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
pixmap = app_icon(256).pixmap(256, 256)
|
||||||
|
if not pixmap.save(str(output), "ICO"):
|
||||||
|
raise RuntimeError(f"Programmsymbol konnte nicht geschrieben werden: {output}")
|
||||||
|
application.quit()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in new issue
Block a user