Compare commits

..

35 Commits

Author SHA1 Message Date
Stefan Hacker dd40c55f7d fix(cloud-files): Pin loest Hydration aus, Icon-Refresh via SHChangeNotify
CfSetPinState aendert nur das Pin-Flag - ohne expliziten Call
passiert am Disk-Inhalt nichts und das Explorer-Icon bleibt
unveraendert. Darum klickte "Immer offline verfuegbar" scheinbar
ins Leere.

- Bei Pin: CfHydratePlaceholder triggert FETCH_DATA und laedt die
  Datei komplett herunter
- Bei Unpin: CfDehydratePlaceholder (war schon da)
- Nach jeder Zustandsaenderung SHChangeNotify(SHCNE_UPDATEITEM)
  damit das Overlay-Icon sofort neu gezeichnet wird, ohne dass
  der User F5 druecken muss
- Log bekommt zusaetzlich hydrate_err fuer Debugging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 23:09:28 +02:00
Stefan Hacker 78615d8897 fix(cloud-files): Existierende normale Dateien vor Placeholder-Erstellung loeschen
Wenn der Client vorher aktiv war und dann deaktiviert wurde (oder
hart beendet), wandelt CfUnregisterSyncRoot alle Platzhalter in
normale Dateien um. Beim erneuten Aktivieren versuchte
populate_placeholders einen neuen Platzhalter anzulegen, was aber
mit ERROR_FILE_EXISTS scheiterte - der Fehler wurde zudem nur per
eprintln geloggt und verschluckt.

Ergebnis: die Datei blieb eine ganz normale Datei (kein Platzhalter,
kein Wolken-Icon). Spaeter fragt CfDehydratePlaceholder dann mit
HRESULT 0x80070178 "Die Datei ist keine Clouddatei", und "Speicher
freigeben" funktioniert nicht.

Jetzt prueft populate_placeholders vor jedem Create, ob die Datei
schon existiert und KEIN Platzhalter ist. Wenn ja: loeschen,
dann neu als Platzhalter anlegen. Erfolge und Fehler gehen beide
ins .minicloud-cloudfiles.log, damit man das Ergebnis prueft.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 22:56:58 +02:00
Stefan Hacker 3c340f9653 fix(cloud-files): Pin/Unpin tatsaechlich wirksam machen + CLI-Logging
set_pin_state hatte drei Probleme:
- FILE_READ_ATTRIBUTES: CfSetPinState braucht WRITE_ATTRIBUTES
- Kein OPEN_REPARSE_POINT: das Oeffnen selbst hat evtl. die
  Hydration getriggert, bevor wir unpinnen konnten
- Kein CfDehydratePlaceholder: Pin-Wechsel auf UNPINNED aendert
  nur das Flag, der Disk-Space wird nicht freigegeben

Jetzt:
- WRITE_ATTRIBUTES + OPEN_REPARSE_POINT beim Handle-Oeffnen
- Bei Unpin zusaetzlich CfDehydratePlaceholder, damit "Speicher
  freigeben" auch wirklich Platz freiraeumt
- Ergebnis + Fehler werden nach <parent>\.minicloud-cloudfiles.log
  geschrieben, damit wir sehen was passiert

handle_cli_shortcuts loggt nun nach %LOCALAPPDATA%\MiniCloud Sync\
cli.log, weil Explorer die stdout/stderr eines gestarteten Prozesses
verwirft. Ohne das Log kann man die vom Kontextmenue gestarteten
Aktionen nicht debuggen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 17:29:25 +02:00
Stefan Hacker 85dae4377f fix(cloud-files): AppliesTo-Syntax fuer Kontextmenue reparieren
Alter AppliesTo-Wert hatte:
- verdoppelte Backslashes (Windows AQS will einfache)
- einen verirrten Schluss-Backslash in der Quote, was die Query
  aufgebrochen hat

Neu:
- Saubere AQS-Syntax: System.ItemPathDisplay:~< "C:\\..." mit
  einfachen Backslashes (winreg schreibt REG_SZ 1:1)
- Registrierung unter AllFilesystemObjects statt *, damit auch
  Ordner den Menueeintrag erhalten
- Default-Wert (MUIVerb zusaetzlich) gesetzt, weil manche Windows-
  Versionen den Default fuer den Anzeigename nutzen
- uninstall entfernt beide Registry-Stellen (alte und neue)

Hinweis fuer Windows 11: klassische Shell-Verben stehen standard-
maessig nur unter "Weitere Optionen anzeigen" (Shift+F10). Fuer
das Haupt-Menue braeuchte man IExplorerCommand via COM-Extension.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 16:54:46 +02:00
Stefan Hacker 88c9617ae7 feat(client): Sync-Pfade und lokalen Dateibrowser bei aktivem Cloud-Files ausblenden
Wenn der Windows-Client mit Cloud-Files (OneDrive-Style) laeuft,
macht der klassische Sync-Pfade-Abschnitt samt lokalem .cloud-
Dateibrowser keinen Sinn mehr - Cloud-Files erzeugt Platzhalter
direkt im Explorer und bietet das gleiche On-Demand-Verhalten
mit nativer Shell-Integration.

Server-Dateien bleiben sichtbar (nuetzlich als Remote-Browser
unabhaengig vom Mount).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 11:47:43 +02:00
Stefan Hacker 78cfbf1ad3 feat(cloud-files): Geteilte Ordner + Rechtsklick-Menue
Backend:
- /api/sync/tree liefert jetzt {tree, shared} - shared enthaelt alle
  Dateien die MIT dem Benutzer geteilt wurden (FilePermission), nur
  Top-Level-Shares, mit Owner-Name im Anzeigenamen
- updated_at zusaetzlich als modified_at im Response fuer Client-
  Kompatibilitaet

Client:
- fetch_remote_entries merged Shared-Subtree unter virtuellem Ordner
  "Geteilt mit mir" (synthetische ID -1) in den Mount-Point
- modified_at faellt auf updated_at zurueck, falls nicht vorhanden

Kontextmenue:
- Neue HKCU-Registry-Eintraege fuer "Immer offline verfuegbar" und
  "Speicher freigeben", AppliesTo filtert auf Mount-Pfad, sodass die
  Verben nur bei Dateien unterhalb des Sync-Ordners erscheinen
- Aufruf der eigenen .exe mit --pin / --unpin <file>
- handle_cli_shortcuts fuehrt die Aktion aus und beendet sofort,
  ohne die UI/Tray/Single-Instance-Logik anzustossen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 11:15:04 +02:00
Stefan Hacker 4026defe79 feat(cloud-files): Explorer-Sidebar-Integration fuer Windows
Registriert den Sync-Ordner als Shell-Namespace-Extension unter
HKEY_CURRENT_USER (kein Admin noetig), sodass er mit eigenem Icon
in der linken Leiste des Datei-Explorers erscheint - wie bei
OneDrive oder Dropbox.

- Neues Modul cloud_files::shell_integration mit install/uninstall
- Registry-Eintraege unter HKCU\Software\Classes\CLSID\{GUID} und
  HKCU\...\Explorer\Desktop\NameSpace\{GUID}
- Nutzt die laufende .exe als Icon-Quelle (fallback: imageres.dll)
- SHChangeNotify(SHCNE_ASSOCCHANGED) damit Explorer sofort aktualisiert
- install/uninstall werden aus register_sync_root/unregister aufgerufen
- winreg-Crate fuer sauberen Registry-Zugriff

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 15:47:05 +02:00
Stefan Hacker 2937082ba2 fix(cloud-files): Sauberes Re-Register + FETCH_PLACEHOLDERS-Stub + mehr Log
- CfUnregisterSyncRoot VOR CfRegisterSyncRoot, damit alte Policies
  (z.B. PARTIAL) nicht durch UPDATE-Flag kleben bleiben
- FETCH_PLACEHOLDERS-Stub registriert, der mit leerer Antwort und
  DISABLE_ON_DEMAND_POPULATION-Flag antwortet. Safety-Net falls
  Windows trotz FULL-Policy doch danach fragt
- log_msg an kritischen Stellen (register, connect, populate), damit
  wir beim naechsten Timeout sehen wo es haengt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:29:11 +02:00
Stefan Hacker e55ce106d4 fix(cloud-files): Population-Policy FULL statt PARTIAL
Mit PARTIAL erwartet Windows einen FETCH_PLACEHOLDERS-Callback
fuer die Ordnerenumeration. Den haben wir nicht registriert, also
lief der Explorer beim Oeffnen des Mount-Ordners in Timeout.

FULL bedeutet: wir fuellen alle Platzhalter selbst vor (machen wir
schon in populate_placeholders) und Windows fragt nicht nach.
Hydration bleibt PARTIAL - Datei-Inhalt wird weiter bei Zugriff
per FETCH_DATA geladen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:42:44 +02:00
Stefan Hacker 601e0741b1 fix(cloud-files): Platzhalter nicht als lokale Aenderung hochladen + Logging
Ursache des "voll gesynct"-Problems: der notify-Watcher feuerte auf die
cfapi-Platzhalter, die wir selbst beim Aktivieren angelegt haben. Der
sync_loop hat die dann als lokale Aenderung hochgeladen, was implizit
die Hydration ausgeloest hat. Ergebnis: keine On-Demand-Platzhalter,
sondern voller Sync.

- is_cfapi_placeholder() prueft FILE_ATTRIBUTE_OFFLINE /
  RECALL_ON_DATA_ACCESS / RECALL_ON_OPEN - solche Dateien werden beim
  Upload uebersprungen
- Log-Datei liegt jetzt NEBEN dem Mount (nicht drin), damit sie nicht
  selbst als Cloud-Datei behandelt wird
- FETCH_DATA loggt jetzt auch Success, damit man sieht dass der
  Callback ueberhaupt feuert

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:42:00 +02:00
Stefan Hacker be121190b3 feat(cloud-files): Mount-Pfad persistieren + Force-Cleanup fuer tote Sync-Roots
- cloud_files_mount in AppConfig -> bleibt ueber Neustarts erhalten
- Beim Auto-Login wird Cloud-Files automatisch wieder aktiviert
- Neue Commands cloud_files_get_mount und cloud_files_force_cleanup
- UI zeigt "Aufraeumen"-Button wenn Mount gesetzt aber nicht aktiv,
  damit User einen Ordner der nach hartem Beenden des Clients als
  toter Sync-Root haengt wieder freigeben/loeschen kann

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:32:02 +02:00
Stefan Hacker 6274567219 fix(cloud-files): Timeout-Ursachen im FETCH_DATA-Callback beheben
- HTTP-Client bekommt 60s-Timeout (statt unendlich)
- Bei Send-/Netzwerkfehler wird CfExecute immer mit Failure-Status
  abgeschlossen, damit Explorer nicht ins OS-Timeout laeuft
- Wenn Server kein Range unterstuetzt (200 statt 206), wird aus dem
  Full-Body der angeforderte Bereich herausgeschnitten und die
  tatsaechliche Laenge an CfExecute uebergeben
- Fehler werden in <mount>\.minicloud-cloudfiles.log geschrieben,
  damit man das Problem bei Timeout ueberhaupt sehen kann

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:24:51 +02:00
Stefan Hacker 204dbb6ab5 fix(client): Cloud-Files-Sektion immer sichtbar, Hinweis bei nicht unterstuetzter Plattform
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:06:54 +02:00
Stefan Hacker d9a4ee6a0b feat(client/windows): cfapi-Sync lebendig machen (Loop + Watcher + UI)
Jetzt tatsaechlich funktionsfaehig, nicht mehr nur Dummy:

- Register-Fallback: erst CF_REGISTER_FLAG_NONE, bei "bereits registriert"
  automatisch mit UPDATE erneut versuchen. Klappt damit bei Erstaktivierung
  und bei Client-Neustart.
- Hintergrund-Loop (cloud_files::sync_loop) pollt alle 30s
  /api/sync/changes, legt neue Placeholder an und ersetzt geaenderte.
- Eigener Callback-Watcher (cloud_files::watcher::CallbackWatcher) hoert
  auf den Mount-Ordner und sendet lokale Aenderungen (Create/Modify) an
  den Loop, der sie via POST /api/files/upload hochlaedt.
- Helper create_placeholder_at() vom Windows-Modul exportiert, damit der
  Loop neue Server-Dateien als Placeholder anlegen kann.
- AppState erhaelt cloud_files_loop + cloud_files_watcher Felder; beim
  Disable wird der Loop sauber gestoppt und der Watcher gedroppt.

Frontend (App.vue):
- Neue Sektion "Cloud-Files (OneDrive-Style)" nur sichtbar wenn die
  Plattform es unterstuetzt (cloud_files_supported).
- Ordner-Picker + Aktivieren/Deaktivieren-Button.
- Fehlermeldungen + Sync-Log-Eintraege.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:46:52 +02:00
Stefan Hacker 8f70b047d8 fix(client/windows): CfConnectSyncRoot liefert Key als Return-Value
In windows-rs 0.58 hat CfConnectSyncRoot nur 4 Argumente und liefert
den CF_CONNECTION_KEY direkt zurueck, keinen out-Parameter mehr.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:37:16 +02:00
Stefan Hacker f9bf53803f fix(client/windows): cfapi-Code auf windows-rs 0.58 umgestellt
- Feature Win32_System_CorrelationVector aktiviert (gate fuer
  CF_CALLBACK_INFO / CfExecute / CfConnectSyncRoot / CfCreatePlaceholders
  / CfSetPinState / CF_OPERATION_INFO / CF_CALLBACK_REGISTRATION)
- reqwest "blocking" aktiviert (wird im cfapi-Callback-Thread genutzt)
- Cf*-Funktionen liefern jetzt Result<(), Error> statt HRESULT; alle
  Aufrufe ueber ? / .map_err umgestellt
- CF_SYNC_POLICIES.Hydration/Population sind Wrapper-Structs;
  .Primary-Feld setzen statt direkter Enum-Zuweisung
- LARGE_INTEGER entfernt (Felder sind in 0.58 einfach i64)
- FILETIME-Ticks direkt als i64 schreiben (BasicInfo.*Time)
- FetchData.RequiredFileOffset/Length direkt als i64 verwenden
- CfCreatePlaceholders nimmt Slice + Option<*mut u32>
- CfSetPinState nimmt Option<*mut OVERLAPPED>
- Tauri-Command: MutexGuard vor .await freigeben (Send-Constraint)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:29:18 +02:00
Stefan Hacker de1039fc7d feat(client): Windows Cloud-Files-API als File-Provider (OneDrive-Style)
Neuer Modus neben dem bestehenden Full-Sync: Dateien erscheinen im
Explorer als Platzhalter mit Wolken-Icon und werden erst bei Zugriff
vom Mini-Cloud-Server gestreamt.

Windows (MVP):
- CfRegisterSyncRoot + CfConnectSyncRoot
- CfCreatePlaceholders fuer jede Datei aus /api/sync/tree
- FETCH_DATA-Callback mit Range-basiertem HTTPS-Download + CfExecute
- CfSetPinState fuer manuelles "Immer offline halten"

Linux (Skelett):
- FUSE-Provider hinter Feature-Flag linux_fuse (libfuse3-dev)
- Stub-Funktionen - Implementierung folgt

macOS:
- Platzhalter, erfordert Apple-Signatur - spaeter

Tauri-Commands: cloud_files_supported/enable/disable/pin/unpin.
Cargo.toml: target-spezifische windows-rs Dependency.
Doku: clients/desktop/CLOUD_FILES.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:19:22 +02:00
Stefan Hacker 2610e3b183 ui(files): Upload-Pfeil vor dem Ordner-Icon im Button "Ordner"
Damit ist auf den ersten Blick erkennbar, dass auch der Ordner-Button
einen Upload ausloest (und nicht bloss eine Ordner-Aktion).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:00:36 +02:00
Stefan Hacker 9f6132a400 feat: Auswahl-Dropdowns zeigen "(geteilt von <Name>)" bei Freigaben
Wenn der eigene und ein freigegebener Kalender/Adressbuch/Aufgabenliste
denselben Namen tragen, sind sie in den Anlegen-Dialogen jetzt
unterscheidbar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:53:46 +02:00
Stefan Hacker ed944339c4 feat: Listen/Kalender/Adressbuch-Namen im 3-Punkte-Menue umbenennbar
Stift-Icon neben dem Namen oeffnet Inline-Editor (Eingabefeld + Check/X).
Enter speichert, ESC bricht ab. Nur fuer Eigentuemer sichtbar.
Backend-PUT-Endpunkte sind bereits vorhanden.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:52:12 +02:00
Stefan Hacker 2ef186e262 feat: Liste/Kalender/Adressbuch beim Anlegen waehlbar (nur Schreibrecht)
- ContactsView: Adressbuch-Auswahl im Kontakt-Dialog (versteckt bei nur
  einem beschreibbaren Buch). Neuer-Kontakt-Button disabled wenn keiner.
- TasksView: gleiches fuer Aufgabenlisten.
- CalendarView: writableCalendars (eigene + Schreibfreigaben) ersetzt
  ownCalendars in Event-Dialog und Import-Auswahl. Auswahlfeld nur ab 2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:44:44 +02:00
Stefan Hacker 4d67819cac feat: Vor-/Nachname, geteilte Listen zeigen Eigentuemer
Backend:
- User.first_name / User.last_name (nullable, Auto-Migrate fuegt sie an)
  full_name/display_name als Properties + in to_dict
- TaskList.owner-Relationship ergaenzt (fehlte, daher wurden geteilte
  Listen beim Empfaenger nicht korrekt aufgeloest)
- /auth/me GET + PUT (Profil bearbeiten: Vorname, Nachname, E-Mail)
- /users/search findet jetzt auch nach Vor-/Nachname und liefert
  full_name/display_name mit
- list_tasklists/list_calendars/list_addressbooks liefern
  owner_full_name und owner_display_name

Frontend:
- Sidebars bei Kontakten/Kalender/Aufgaben: "(geteilt von <Voller Name>)"
  mit Fallback auf Username
- User-Search-Popup zeigt vollen Namen neben Username
- SettingsView: Vorname/Nachname/E-Mail bearbeiten

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:34:22 +02:00
Stefan Hacker e4dd555bd1 feat(tasks): Berechtigung bestehender Freigaben nachtraeglich aendern
Stift-Icon neben Freigabe oeffnet Inline-Editor mit Select "Lesen" /
"Lesen+Schreiben" (analog zu Kontakten/Kalender).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:26:59 +02:00
Stefan Hacker a21bf6de1b fix(docker): tzdata-Install entfernt - im python:3.11-slim schon drin
Vermeidet unnoetigen Platzbedarf beim Build (31 Pakete / 192 MB werden
sonst mitgezogen).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:22:42 +02:00
Stefan Hacker 3eb038abd8 feat(tasks): Benutzer-Suche beim Teilen (statt Freitext)
Analog zu Kontakten/Kalender: ab 2 Zeichen werden Vorschlaege per
/users/search eingeblendet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:21:14 +02:00
Stefan Hacker 9bb22eb17b feat: Admin-Sicht System-Zeit + TZ-Liste in README/.env.example
- /api/settings gibt zusaetzlich timezone, timezone_abbr, server_time,
  ntp_server zurueck (alle read-only, aus Config/ENV).
- AdminView zeigt neuen Abschnitt "System-Zeit" mit Zeitzone, aktueller
  Server-Zeit und NTP-Server samt Hinweis "wird in der .env festgelegt".
- .env.example: Liste gaengiger TZ-Werte mit Link zur IANA-Vollliste.
- README.md: neuer Abschnitt "Zeitzone & NTP" mit Werte-Tabelle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:19:40 +02:00
Stefan Hacker dca064427e feat(config): TZ + NTP_SERVER in .env mit sinnvollen Defaults
- .env / .env.example: TZ=Europe/Berlin und NTP_SERVER=ptbtime1.ptb.de
  (offizielle deutsche Zeitreferenz, hohe Verfuegbarkeit)
- app/__init__.py setzt prozessweite Zeitzone frueh via os.environ+tzset
- Leichtgewichtiger SNTP-Client (pure socket, keine deps) prueft den
  Uhr-Offset beim Start im Hintergrund-Thread und warnt bei Abweichung >5s
- Dockerfile installiert tzdata und ENV TZ=Europe/Berlin als Fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:15:57 +02:00
Stefan Hacker ba3e619963 feat: Aufgaben (Tasks) mit CalDAV VTODO-Sync
Neuer Menuepunkt "Aufgaben" unterhalb Kontakte.

Backend:
- TaskList + Task + TaskListShare Models
- REST-API: CRUD, Teilen, my-color, Import/Export (.ics mit VTODO, CSV)
- CalDAV: Task-Listen tauchen als Calendar-Collection mit
  supported-calendar-component-set=VTODO im autodiscovery auf
- PROPFIND/REPORT/GET/PUT/DELETE/PROPPATCH/MKCOL fuer /dav/<user>/tl-<id>/
- SSE-Notifications bei Aenderungen

Frontend:
- TasksView mit Listen-Sidebar, Suche, "Erledigte ausblenden"
- Mehrfachauswahl + Bulk-Loeschen, Status-Toggle per Checkbox
- Editor mit Titel/Beschreibung/Faellig/Prioritaet/Status/Fortschritt
- Teilen, Farbe persoenlich anpassen, Import/Export

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:07:06 +02:00
Stefan Hacker 2ce088e96b feat: Import/Export fuer Kontakte und Kalender + Bulk-Loeschen Kontakte
Kontakte:
- Mehrfachauswahl in der Liste (Checkbox-Spalte) mit Bulk-Loeschen
- Export als Sammel-vCard (.vcf), als ZIP mit Einzel-vCards oder als CSV
- Import aus vCard (mehrere im File moeglich) oder CSV; Match per UID,
  bestehende Kontakte werden aktualisiert

Kalender:
- Export als iCalendar (.ics) oder CSV
- Import aus .ics oder CSV; bestehende Termine via UID aktualisiert

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:23:23 +02:00
Stefan Hacker c6241519a6 feat(calendar): Hinweis bei passwortgeschuetztem iCal-Link
Browser/Kalender-App fragen sonst nach Benutzername+Passwort - der
Benutzername muss leer bleiben.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:13:50 +02:00
Stefan Hacker f6626da114 feat(calendar): Mehrfachauswahl + Bulk-Loeschen in der Listen-Ansicht
Checkbox-Spalte plus Header-Checkbox "Alle". Bulk-Aktion mit
Bestaetigung loescht ausgewaehlte Termine; Read-Only-Eintraege
werden uebersprungen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:11:12 +02:00
Stefan Hacker e96c84b5f7 feat(ui): Browser-Titel "Mini-Cloud - <username>" + Wolken-Favicon
Titel reagiert reaktiv auf Login/Logout. Favicon ist die Wolke aus
der Sidebar (pi-cloud-Style).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:05:51 +02:00
Stefan Hacker 1eba5d0adc revert(contacts): Titel-Feld wieder raus, nur Anrede (Herr/Frau/Divers)
Sync-Probleme durch zusammengesetzten PREFIX vermeiden.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:52:23 +02:00
Stefan Hacker 655b789e06 feat(contacts): Anrede + Titel als getrennte Dropdowns
Anrede: Herr/Frau/Divers (fest), Titel: Dr./Prof./Dipl.-Ing./... (editierbar).
Beim Speichern werden beide zu vCard-PREFIX zusammengesetzt, beim Laden
wieder aufgesplittet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:37:41 +02:00
Stefan Hacker 50df055794 feat(contacts): Anrede als Dropdown (Herr/Frau/Divers/Dr./Prof.)
editable bleibt aktiv, damit eigene Werte weiterhin moeglich sind.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:35:59 +02:00
40 changed files with 4822 additions and 52 deletions
+18
View File
@@ -31,6 +31,24 @@ FRONTEND_URL=https://cloud.example.com
# Max Upload-Groesse in MB
MAX_UPLOAD_SIZE_MB=500
# Zeitzone (prozessweit) - IANA-Format "Region/Stadt".
# Wirkt auf datetime.now(), strftime %Z und Kalender/Task-Zeitstempel.
# Haeufige Werte:
# Europe/Berlin, Europe/Vienna, Europe/Zurich, Europe/Amsterdam,
# Europe/Paris, Europe/London, Europe/Madrid, Europe/Rome,
# Europe/Warsaw, Europe/Prague, Europe/Copenhagen, Europe/Stockholm,
# UTC, America/New_York, America/Los_Angeles, Asia/Tokyo, Australia/Sydney
# Vollstaendige Liste: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
TZ=Europe/Berlin
# NTP-Server zum Pruefen der Uhrzeit beim Start (nicht-invasiver Offset-Check
# - im Container kann die Systemuhr nicht gesetzt werden; bei Abweichung >5s
# erscheint eine Warnung im Log, dann bitte die Host-Uhr synchronisieren).
# Leerlassen um den Check zu deaktivieren.
# Default: Physikalisch-Technische Bundesanstalt (offizielle deutsche Zeit).
# Alternativen: ptbtime2.ptb.de, ptbtime3.ptb.de, de.pool.ntp.org, time.cloudflare.com
NTP_SERVER=ptbtime1.ptb.de
# OnlyOffice Document Server (optional)
# Eigene Subdomain mit HTTPS, z.B. https://office.example.com
# JWT wird automatisch vom JWT_SECRET_KEY oben verwendet
+2
View File
@@ -11,6 +11,7 @@ FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
# tzdata ist im python:3.11-slim bereits enthalten - nur gcc nachinstallieren.
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
@@ -30,6 +31,7 @@ RUN mkdir -p /app/data/files
# Environment
ENV FLASK_ENV=production
ENV TZ=Europe/Berlin
ENV DATABASE_PATH=/app/data/minicloud.db
ENV UPLOAD_PATH=/app/data/files
+30
View File
@@ -191,6 +191,36 @@ docker-compose up --build -d
**Ohne OnlyOffice** (`ONLYOFFICE_URL` leer) werden Office-Dateien in einer einfachen Vorschau angezeigt. **Mit OnlyOffice** erhaelt man einen vollwertigen Editor (wie Google Docs).
### Zeitzone & NTP
In der `.env` stehen zwei Variablen die die Systemzeit betreffen:
```env
TZ=Europe/Berlin
NTP_SERVER=ptbtime1.ptb.de
```
**`TZ`** setzt die prozessweite Zeitzone (wirkt auf Log-Zeitstempel, Kalender/Task-Zeiten, `datetime.now()`). IANA-Format `Region/Stadt`.
Haeufige Werte:
| Region | Beispielwerte |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Deutschland | `Europe/Berlin` |
| DACH/EU | `Europe/Vienna`, `Europe/Zurich`, `Europe/Amsterdam`, `Europe/Paris`, `Europe/London`, `Europe/Madrid`, `Europe/Rome`, `Europe/Warsaw` |
| Nord-EU | `Europe/Copenhagen`, `Europe/Stockholm`, `Europe/Helsinki`, `Europe/Oslo` |
| Sonstige | `UTC`, `America/New_York`, `America/Los_Angeles`, `Asia/Tokyo`, `Australia/Sydney` |
Vollstaendige Liste: <https://en.wikipedia.org/wiki/List_of_tz_database_time_zones>
**`NTP_SERVER`** wird beim Start abgefragt, um die Abweichung der Systemuhr zu pruefen. Bei Drift > 5 s erscheint eine Warnung im Log. **Hinweis:** Im Container wird die Uhr dadurch nicht gesetzt (benoetigt `CAP_SYS_TIME`) - auf dem Host sollte ein NTP-Daemon laufen. Der Check dient nur zur Sichtbarkeit.
Default: `ptbtime1.ptb.de` (offizielle deutsche Zeitreferenz der Physikalisch-Technischen Bundesanstalt, Stratum 1, sehr hohe Verfuegbarkeit).
Alternativen: `ptbtime2.ptb.de`, `ptbtime3.ptb.de`, `de.pool.ntp.org`, `time.cloudflare.com`. Leerlassen um den Check zu deaktivieren.
Aktuelle Werte sind im Admin-Bereich unter **Einstellungen > System** einsehbar.
## Verwendung
### Dateien
+29
View File
@@ -1,4 +1,5 @@
import os
import time
from pathlib import Path
from flask import Flask, Response, redirect, send_from_directory
@@ -8,6 +9,20 @@ from app.config import Config
from app.extensions import db, bcrypt, migrate
def _configure_timezone(tz_name: str) -> None:
"""Prozess-Zeitzone setzen, sodass datetime.now(), strftime %Z etc.
die konfigurierte TZ verwenden. Sichere no-op wenn tzdata fehlt."""
if not tz_name:
return
os.environ['TZ'] = tz_name
tzset = getattr(time, 'tzset', None)
if tzset:
try:
tzset()
except Exception:
pass
def _auto_migrate(db):
"""Add missing columns to existing tables by comparing model definitions
with actual database schema. This handles the case where new columns are
@@ -61,6 +76,9 @@ def _auto_migrate(db):
def create_app(config_class=Config):
# Zeitzone moeglichst frueh setzen - vor allen datetime.now()-Aufrufen
_configure_timezone(getattr(config_class, 'TIMEZONE', None) or os.environ.get('TZ'))
# Check if static frontend build exists (Docker production mode)
static_dir = Path(__file__).resolve().parent.parent / 'static'
if static_dir.exists():
@@ -171,4 +189,15 @@ def create_app(config_class=Config):
from app.services.backup_scheduler import start_backup_scheduler
start_backup_scheduler(app)
# NTP-Offset gegen den konfigurierten Zeitserver pruefen (nicht fatal).
ntp_server = app.config.get('NTP_SERVER') or ''
if ntp_server.strip():
import threading
from app.services.ntp_check import check_and_log
threading.Thread(
target=check_and_log,
args=(ntp_server.strip(), app.logger),
daemon=True,
).start()
return app
+1 -1
View File
@@ -2,4 +2,4 @@ from flask import Blueprint
api_bp = Blueprint('api', __name__, url_prefix='/api')
from app.api import auth, users, files, calendar, contacts, email, office, passwords, backup, client_downloads # noqa: E402, F401
from app.api import auth, users, files, calendar, contacts, tasks, email, office, passwords, backup, client_downloads # noqa: E402, F401
+157
View File
@@ -1,3 +1,6 @@
import csv
import io
import re
import secrets
import uuid
from datetime import datetime, timezone
@@ -106,6 +109,8 @@ def list_calendars():
if share and share.color:
d['color'] = share.color
d['owner_name'] = c.owner.username
d['owner_full_name'] = c.owner.full_name
d['owner_display_name'] = c.owner.display_name
result.append(d)
return jsonify(result), 200
@@ -236,6 +241,158 @@ def list_events(cal_id):
return jsonify([_redact_if_private(e.to_dict(), is_owner) for e in events]), 200
@api_bp.route('/calendars/<int:cal_id>/export', methods=['GET'])
@token_required
def export_calendar(cal_id):
"""Export VEVENTs als .ics oder .csv."""
user = request.current_user
cal, err = _get_calendar_or_err(cal_id, user)
if err:
return err
fmt = (request.args.get('format') or 'ics').lower()
events = CalendarEvent.query.filter_by(calendar_id=cal_id).order_by(CalendarEvent.dtstart).all()
safe_name = re.sub(r'[^A-Za-z0-9._-]+', '_', cal.name or 'kalender') or 'kalender'
if fmt == 'ics':
lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Mini-Cloud//DE', 'CALSCALE:GREGORIAN']
for e in events:
block = (e.ical_data or '').strip()
if not block:
block = _build_vevent(e.uid, e.summary or '', e.dtstart, e.dtend,
e.all_day, e.description or '', e.location or '',
e.recurrence_rule or '',
(e.exdates or '').split(',') if e.exdates else None)
# Make sure block contains BEGIN/END VEVENT
if 'BEGIN:VEVENT' not in block.upper():
continue
lines.append(block.strip())
lines.append('END:VCALENDAR')
body = '\r\n'.join(lines) + '\r\n'
return Response(
body, mimetype='text/calendar; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename="{safe_name}.ics"'},
)
if fmt == 'csv':
out = io.StringIO()
cols = ['summary', 'dtstart', 'dtend', 'all_day', 'location',
'description', 'recurrence_rule', 'uid']
w = csv.writer(out, delimiter=';', quoting=csv.QUOTE_ALL)
w.writerow(cols)
for e in events:
w.writerow([
e.summary or '',
e.dtstart.isoformat() if e.dtstart else '',
e.dtend.isoformat() if e.dtend else '',
'1' if e.all_day else '0',
e.location or '',
(e.description or '').replace('\r\n', ' ').replace('\n', ' '),
e.recurrence_rule or '',
e.uid or '',
])
return Response(
'\ufeff' + out.getvalue(), mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename="{safe_name}.csv"'},
)
return jsonify({'error': 'Unbekanntes Format'}), 400
@api_bp.route('/calendars/<int:cal_id>/import', methods=['POST'])
@token_required
def import_calendar(cal_id):
"""Import .ics oder .csv -> Termine ins Kalender."""
from app.dav.caldav import _parse_vevent, _extract_vevent_block
user = request.current_user
cal, err = _get_calendar_or_err(cal_id, user, need_write=True)
if err:
return err
file = request.files.get('file')
if not file:
return jsonify({'error': 'Keine Datei'}), 400
raw = file.read()
name = (file.filename or '').lower()
try:
text = raw.decode('utf-8-sig')
except UnicodeDecodeError:
text = raw.decode('latin-1', errors='replace')
imported = 0
skipped = 0
def _save(parsed: dict, ical_block: str | None = None):
nonlocal imported, skipped
if not parsed.get('summary') or not parsed.get('dtstart'):
skipped += 1
return
uid = parsed.get('uid') or str(uuid.uuid4())
existing = CalendarEvent.query.filter_by(calendar_id=cal_id, uid=uid).first()
ev = existing or CalendarEvent(calendar_id=cal_id, uid=uid, ical_data='')
ev.summary = parsed.get('summary') or '(ohne Titel)'
ev.description = parsed.get('description')
ev.location = parsed.get('location')
ev.dtstart = parsed.get('dtstart')
ev.dtend = parsed.get('dtend')
ev.all_day = parsed.get('all_day', False)
ev.recurrence_rule = parsed.get('rrule')
ev.exdates = ','.join(parsed.get('exdates', [])) or None
ev.ical_data = (ical_block or '').strip() or _build_vevent(
uid, ev.summary, ev.dtstart, ev.dtend, ev.all_day,
ev.description or '', ev.location or '', ev.recurrence_rule or '',
(ev.exdates or '').split(',') if ev.exdates else None,
)
ev.updated_at = datetime.now(timezone.utc)
if not existing:
db.session.add(ev)
imported += 1
if name.endswith('.csv') or (b';' in raw[:200] and b'BEGIN:VCALENDAR' not in raw[:200]):
reader = csv.DictReader(io.StringIO(text), delimiter=';')
if not reader.fieldnames or len(reader.fieldnames) < 2:
reader = csv.DictReader(io.StringIO(text), delimiter=',')
for row in reader:
row = {k.strip().lower(): (v or '').strip() for k, v in row.items() if k}
try:
dtstart = datetime.fromisoformat(row.get('dtstart') or row.get('start') or '')
except (ValueError, TypeError):
skipped += 1
continue
try:
dtend = datetime.fromisoformat(row.get('dtend') or row.get('end') or '') if (row.get('dtend') or row.get('end')) else None
except ValueError:
dtend = None
parsed = {
'uid': row.get('uid'),
'summary': row.get('summary') or row.get('titel') or row.get('title'),
'description': row.get('description') or row.get('beschreibung'),
'location': row.get('location') or row.get('ort'),
'dtstart': dtstart,
'dtend': dtend,
'all_day': (row.get('all_day') or '').lower() in ('1', 'true', 'ja', 'yes'),
'rrule': row.get('recurrence_rule') or row.get('rrule'),
'exdates': [],
}
_save(parsed)
else:
# iCal: Kalender-Datei mit beliebig vielen VEVENTs
blocks = re.findall(r'BEGIN:VEVENT.*?END:VEVENT', text, flags=re.DOTALL | re.IGNORECASE)
if not blocks:
return jsonify({'error': 'Keine VEVENT-Daten gefunden'}), 400
for block in blocks:
try:
parsed = _parse_vevent(block)
except Exception:
parsed = None
if not parsed:
skipped += 1
continue
_save(parsed, ical_block=block)
db.session.commit()
if imported:
notify_calendar_change(cal.owner_id, cal.id, 'event',
shared_with=_calendar_recipients(cal))
return jsonify({'imported': imported, 'skipped': skipped}), 200
@api_bp.route('/calendars/<int:cal_id>/events', methods=['POST'])
@token_required
def create_event(cal_id):
+160
View File
@@ -1,6 +1,9 @@
import csv
import io
import json
import re
import uuid
import zipfile
from datetime import datetime, timezone
from flask import request, jsonify, Response
@@ -289,6 +292,8 @@ def list_addressbooks():
if share and share.color:
d['color'] = share.color
d['owner_name'] = b.owner.username
d['owner_full_name'] = b.owner.full_name
d['owner_display_name'] = b.owner.display_name
d['contact_count'] = b.contacts.count()
result.append(d)
@@ -404,6 +409,161 @@ def list_contacts(book_id):
return jsonify([c.to_dict() for c in contacts]), 200
@api_bp.route('/addressbooks/<int:book_id>/export', methods=['GET'])
@token_required
def export_addressbook(book_id):
"""Export contacts as a single .vcf, a .zip with one .vcf per contact, or .csv."""
user = request.current_user
book, err = _get_addressbook_or_err(book_id, user)
if err:
return err
fmt = (request.args.get('format') or 'vcf').lower()
contacts = Contact.query.filter_by(address_book_id=book_id).order_by(Contact.display_name).all()
safe_name = re.sub(r'[^A-Za-z0-9._-]+', '_', book.name or 'kontakte') or 'kontakte'
if fmt == 'vcf':
body = '\r\n'.join((c.vcard_data or _build_vcard(c)).strip() for c in contacts) + '\r\n'
return Response(
body, mimetype='text/vcard; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename="{safe_name}.vcf"'},
)
if fmt == 'vcf-zip':
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
seen = {}
for c in contacts:
base = re.sub(r'[^A-Za-z0-9._-]+', '_', c.display_name or c.uid) or c.uid
seen[base] = seen.get(base, 0) + 1
fname = f"{base}.vcf" if seen[base] == 1 else f"{base}_{seen[base]}.vcf"
zf.writestr(fname, (c.vcard_data or _build_vcard(c)).strip() + '\r\n')
buf.seek(0)
return Response(
buf.read(), mimetype='application/zip',
headers={'Content-Disposition': f'attachment; filename="{safe_name}.zip"'},
)
if fmt == 'csv':
out = io.StringIO()
cols = ['display_name', 'prefix', 'first_name', 'middle_name', 'last_name', 'suffix',
'nickname', 'organization', 'department', 'job_title',
'primary_email', 'primary_phone', 'birthday', 'anniversary',
'emails', 'phones', 'addresses', 'websites', 'categories', 'notes']
w = csv.writer(out, delimiter=';', quoting=csv.QUOTE_ALL)
w.writerow(cols)
for c in contacts:
d = c.to_dict()
row = []
for col in cols:
v = d.get(col, '')
if isinstance(v, list):
if v and isinstance(v[0], dict):
v = '; '.join(
(x.get('value') or x.get('street') or '') +
(f" ({x.get('type')})" if x.get('type') else '')
for x in v if isinstance(x, dict)
)
else:
v = ', '.join(str(x) for x in v)
row.append('' if v is None else str(v))
w.writerow(row)
return Response(
'\ufeff' + out.getvalue(), mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename="{safe_name}.csv"'},
)
return jsonify({'error': 'Unbekanntes Format'}), 400
@api_bp.route('/addressbooks/<int:book_id>/import', methods=['POST'])
@token_required
def import_addressbook(book_id):
"""Import vCard (.vcf, single oder mehrere im File) oder CSV."""
user = request.current_user
book, err = _get_addressbook_or_err(book_id, user, need_write=True)
if err:
return err
file = request.files.get('file')
if not file:
return jsonify({'error': 'Keine Datei'}), 400
raw = file.read()
name = (file.filename or '').lower()
try:
text = raw.decode('utf-8-sig')
except UnicodeDecodeError:
text = raw.decode('latin-1', errors='replace')
imported = 0
skipped = 0
def _add_from_parsed(parsed: dict, raw_text: str | None = None) -> bool:
nonlocal imported, skipped
if not parsed.get('display_name') and not parsed.get('first_name') \
and not parsed.get('last_name') and not parsed.get('organization'):
skipped += 1
return False
uid = parsed.get('uid') or str(uuid.uuid4())
existing = Contact.query.filter_by(address_book_id=book_id, uid=uid).first()
contact = existing or Contact(address_book_id=book_id, uid=uid, vcard_data='')
_apply_fields_to_contact(contact, parsed)
contact.vcard_data = (raw_text or '').strip() or _build_vcard(contact)
contact.updated_at = datetime.now(timezone.utc)
if not existing:
db.session.add(contact)
imported += 1
return True
if name.endswith('.csv') or (b',' in raw[:200] and b'BEGIN:VCARD' not in raw[:200]):
# CSV import
reader = csv.DictReader(io.StringIO(text), delimiter=';')
if not reader.fieldnames or len(reader.fieldnames) < 2:
# try comma
reader = csv.DictReader(io.StringIO(text), delimiter=',')
for row in reader:
row = {k.strip().lower(): (v or '').strip() for k, v in row.items() if k}
parsed = {
'display_name': row.get('display_name') or row.get('name')
or row.get('vollname') or row.get('full name'),
'first_name': row.get('first_name') or row.get('vorname'),
'last_name': row.get('last_name') or row.get('nachname'),
'middle_name': row.get('middle_name'),
'prefix': row.get('prefix') or row.get('anrede'),
'suffix': row.get('suffix'),
'nickname': row.get('nickname') or row.get('spitzname'),
'organization': row.get('organization') or row.get('firma') or row.get('company'),
'department': row.get('department') or row.get('abteilung'),
'job_title': row.get('job_title') or row.get('position') or row.get('title'),
'birthday': row.get('birthday') or row.get('geburtstag'),
'notes': row.get('notes') or row.get('notizen'),
'emails': [], 'phones': [], 'addresses': [], 'websites': [], 'categories': [],
}
email = row.get('primary_email') or row.get('email') or row.get('e-mail')
if email:
parsed['emails'].append({'type': 'home', 'value': email})
phone = row.get('primary_phone') or row.get('phone') or row.get('telefon') or row.get('mobil')
if phone:
parsed['phones'].append({'type': 'cell', 'value': phone})
cats = row.get('categories') or row.get('kategorien')
if cats:
parsed['categories'] = [c.strip() for c in cats.split(',') if c.strip()]
_add_from_parsed(parsed)
else:
# vCard - eine oder mehrere im File
parts = re.findall(r'BEGIN:VCARD.*?END:VCARD', text, flags=re.DOTALL | re.IGNORECASE)
if not parts:
return jsonify({'error': 'Keine VCARD-Daten gefunden'}), 400
for vcf in parts:
try:
parsed = parse_vcard(vcf)
except Exception:
skipped += 1
continue
_add_from_parsed(parsed, raw_text=vcf)
db.session.commit()
if imported:
_notify_addressbook(book.owner_id, book.id, 'contact',
shared_with=_book_recipients(book))
return jsonify({'imported': imported, 'skipped': skipped}), 200
@api_bp.route('/addressbooks/<int:book_id>/contacts', methods=['POST'])
@token_required
def create_contact(book_id):
+63 -16
View File
@@ -1254,32 +1254,79 @@ def list_locks():
@api_bp.route('/sync/tree', methods=['GET'])
@token_required
def sync_tree():
"""Returns complete file tree with checksums for sync clients."""
"""Returns complete file tree with checksums for sync clients.
Includes both files owned by the user (under 'tree') and files
shared WITH the user (as a virtual 'Geteilt mit mir' folder under
'shared'). The client merges both.
"""
user = request.current_user
def _entry(f):
entry = {
'id': f.id,
'name': f.name,
'is_folder': f.is_folder,
'size': f.size,
'checksum': f.checksum,
'updated_at': f.updated_at.isoformat() if f.updated_at else None,
'modified_at': f.updated_at.isoformat() if f.updated_at else None,
}
lock = FileLock.get_lock(f.id)
if lock:
entry['locked'] = True
entry['locked_by'] = lock.user.username
return entry
def _build_tree(parent_id):
files = File.query.filter_by(owner_id=user.id, parent_id=parent_id, is_trashed=False)\
.order_by(File.is_folder.desc(), File.name).all()
result = []
for f in files:
entry = {
'id': f.id,
'name': f.name,
'is_folder': f.is_folder,
'size': f.size,
'checksum': f.checksum,
'updated_at': f.updated_at.isoformat() if f.updated_at else None,
}
lock = FileLock.get_lock(f.id)
if lock:
entry['locked'] = True
entry['locked_by'] = lock.user.username
e = _entry(f)
if f.is_folder:
entry['children'] = _build_tree(f.id)
result.append(entry)
e['children'] = _build_tree(f.id)
result.append(e)
return result
return jsonify({'tree': _build_tree(None)}), 200
def _build_shared_children(parent_id):
files = File.query.filter_by(parent_id=parent_id, is_trashed=False)\
.order_by(File.is_folder.desc(), File.name).all()
out = []
for f in files:
e = _entry(f)
if f.is_folder:
e['children'] = _build_shared_children(f.id)
out.append(e)
return out
shared_perms = FilePermission.query.filter_by(user_id=user.id).all()
shared_roots = []
seen = set()
for perm in shared_perms:
f = perm.file
if not f or f.is_trashed or f.id in seen:
continue
seen.add(f.id)
# Nur "Top-Level"-Shares: wenn der Eltern-Ordner NICHT auch geteilt
# ist, ist dieses Item die Wurzel des Shares beim Empfaenger.
parent_shared = any(
p.file_id == f.parent_id for p in shared_perms
) if f.parent_id else False
if parent_shared:
continue
e = _entry(f)
owner = f.owner.display_name if hasattr(f, 'owner') and f.owner else None
if owner:
e['name'] = f'{f.name} (von {owner})'
if f.is_folder:
e['children'] = _build_shared_children(f.id)
shared_roots.append(e)
return jsonify({
'tree': _build_tree(None),
'shared': shared_roots,
}), 200
@api_bp.route('/sync/events', methods=['GET'])
+590
View File
@@ -0,0 +1,590 @@
"""REST API for task lists / tasks (VTODO).
Mirror der calendar.py-Architektur: TaskList = Calendar-aehnliche Sammlung,
Task = VTODO. CalDAV-Anbindung erfolgt in app/dav/caldav.py: TaskLists
erscheinen als Kalender-Collection mit supported-calendar-component-set
auf VTODO und unter URL /dav/<user>/tl-<id>/.
"""
from __future__ import annotations
import re
import uuid
from datetime import datetime, timezone
from flask import request, jsonify, Response
from app.api import api_bp
from app.api.auth import token_required
from app.extensions import db
from app.models.task import TaskList, Task, TaskListShare
from app.models.user import User
from app.services.events import notify_tasklist_change
# ---------------------------------------------------------------------------
# Access helpers
# ---------------------------------------------------------------------------
def _list_recipients(tl: TaskList):
return [s.shared_with_id for s in
TaskListShare.query.filter_by(task_list_id=tl.id).all()]
def _get_list_or_err(list_id, user, need_write=False):
tl = db.session.get(TaskList, list_id)
if not tl:
return None, (jsonify({'error': 'Aufgabenliste nicht gefunden'}), 404)
if tl.owner_id == user.id:
return tl, None
share = TaskListShare.query.filter_by(
task_list_id=list_id, shared_with_id=user.id
).first()
if not share:
return None, (jsonify({'error': 'Zugriff verweigert'}), 403)
if need_write and share.permission != 'readwrite':
return None, (jsonify({'error': 'Schreibzugriff verweigert'}), 403)
return tl, None
# ---------------------------------------------------------------------------
# VTODO build / parse
# ---------------------------------------------------------------------------
def _fmt_dt(dt: datetime | None) -> str | None:
if not dt:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
def build_vtodo(task: Task) -> str:
lines = ['BEGIN:VTODO', f'UID:{task.uid}',
f'DTSTAMP:{_fmt_dt(datetime.now(timezone.utc))}',
f'SUMMARY:{(task.summary or "").replace(chr(10), " ")}']
if task.description:
lines.append(f'DESCRIPTION:{task.description.replace(chr(10), chr(92) + "n")}')
if task.status:
lines.append(f'STATUS:{task.status}')
if task.priority is not None:
lines.append(f'PRIORITY:{task.priority}')
if task.percent_complete is not None:
lines.append(f'PERCENT-COMPLETE:{task.percent_complete}')
if task.due:
lines.append(f'DUE:{_fmt_dt(task.due)}')
if task.dtstart:
lines.append(f'DTSTART:{_fmt_dt(task.dtstart)}')
if task.completed_at:
lines.append(f'COMPLETED:{_fmt_dt(task.completed_at)}')
if task.categories:
lines.append(f'CATEGORIES:{task.categories}')
lines.append('END:VTODO')
return '\r\n'.join(lines)
def _unfold(text: str):
out, current = [], ''
for line in text.replace('\r\n', '\n').split('\n'):
if line.startswith((' ', '\t')) and current:
current += line[1:]
else:
if current:
out.append(current)
current = line
if current:
out.append(current)
return out
def _parse_dt(value: str) -> datetime | None:
value = value.strip()
try:
if value.endswith('Z'):
return datetime.strptime(value, '%Y%m%dT%H%M%SZ').replace(tzinfo=timezone.utc)
if 'T' in value:
return datetime.strptime(value, '%Y%m%dT%H%M%S')
return datetime.strptime(value, '%Y%m%d')
except ValueError:
try:
return datetime.fromisoformat(value)
except ValueError:
return None
def parse_vtodo(raw: str) -> dict | None:
if 'BEGIN:VTODO' not in raw.upper():
return None
result: dict = {}
in_block = False
for line in _unfold(raw):
upper = line.upper()
if upper.startswith('BEGIN:VTODO'):
in_block = True
continue
if upper.startswith('END:VTODO'):
break
if not in_block or ':' not in line:
continue
key, _, value = line.partition(':')
name = key.split(';')[0].upper()
if name == 'UID':
result['uid'] = value.strip()
elif name == 'SUMMARY':
result['summary'] = value.strip()
elif name == 'DESCRIPTION':
result['description'] = value.replace('\\n', '\n').replace('\\,', ',').strip()
elif name == 'STATUS':
result['status'] = value.strip().upper()
elif name == 'PRIORITY':
try:
result['priority'] = int(value.strip())
except ValueError:
pass
elif name == 'PERCENT-COMPLETE':
try:
result['percent_complete'] = int(value.strip())
except ValueError:
pass
elif name == 'DUE':
result['due'] = _parse_dt(value)
elif name == 'DTSTART':
result['dtstart'] = _parse_dt(value)
elif name == 'COMPLETED':
result['completed_at'] = _parse_dt(value)
elif name == 'CATEGORIES':
result['categories'] = value.strip()
return result if result.get('summary') or result.get('uid') else None
def _apply(task: Task, data: dict):
if 'summary' in data:
task.summary = (data.get('summary') or '').strip() or None
if 'description' in data:
task.description = (data.get('description') or '').strip() or None
if 'status' in data:
s = (data.get('status') or '').upper().strip() or None
task.status = s
if s == 'COMPLETED' and not task.completed_at:
task.completed_at = datetime.now(timezone.utc)
task.percent_complete = 100
elif s != 'COMPLETED':
task.completed_at = None
if 'priority' in data:
task.priority = data['priority'] if data['priority'] is not None else None
if 'percent_complete' in data:
task.percent_complete = data['percent_complete']
if 'due' in data:
v = data['due']
task.due = datetime.fromisoformat(v) if v else None
if 'dtstart' in data:
v = data['dtstart']
task.dtstart = datetime.fromisoformat(v) if v else None
if 'completed_at' in data:
v = data['completed_at']
task.completed_at = datetime.fromisoformat(v) if v else None
if 'categories' in data:
cats = data['categories']
if isinstance(cats, list):
task.categories = ','.join(c.strip() for c in cats if c and c.strip()) or None
else:
task.categories = (cats or '').strip() or None
# ---------------------------------------------------------------------------
# REST endpoints - lists
# ---------------------------------------------------------------------------
@api_bp.route('/tasklists', methods=['GET'])
@token_required
def list_tasklists():
user = request.current_user
own = TaskList.query.filter_by(owner_id=user.id).all()
shared = TaskListShare.query.filter_by(shared_with_id=user.id).all()
out = []
for tl in own:
d = tl.to_dict()
d['permission'] = 'owner'
d['task_count'] = tl.tasks.count()
out.append(d)
for s in shared:
tl = s.task_list
if not tl:
continue
d = tl.to_dict()
d['permission'] = s.permission
owner = tl.owner
d['owner_name'] = owner.username if owner else ''
d['owner_full_name'] = owner.full_name if owner else ''
d['owner_display_name'] = owner.display_name if owner else ''
d['task_count'] = tl.tasks.count()
if s.color:
d['color'] = s.color
out.append(d)
return jsonify(out), 200
@api_bp.route('/tasklists', methods=['POST'])
@token_required
def create_tasklist():
user = request.current_user
data = request.get_json() or {}
name = (data.get('name') or '').strip()
if not name:
return jsonify({'error': 'Name erforderlich'}), 400
tl = TaskList(owner_id=user.id, name=name,
color=data.get('color') or '#10b981',
description=(data.get('description') or '').strip() or None)
db.session.add(tl)
db.session.commit()
notify_tasklist_change(user.id, tl.id, 'created')
return jsonify(tl.to_dict()), 201
@api_bp.route('/tasklists/<int:list_id>', methods=['PUT'])
@token_required
def update_tasklist(list_id):
user = request.current_user
tl, err = _get_list_or_err(list_id, user, need_write=True)
if err:
return err
if tl.owner_id != user.id:
return jsonify({'error': 'Nur Eigentuemer kann die Liste umbenennen'}), 403
data = request.get_json() or {}
if 'name' in data:
tl.name = data['name'].strip()
if 'color' in data:
tl.color = data['color']
if 'description' in data:
tl.description = (data['description'] or '').strip() or None
db.session.commit()
notify_tasklist_change(tl.owner_id, tl.id, 'updated', shared_with=_list_recipients(tl))
return jsonify(tl.to_dict()), 200
@api_bp.route('/tasklists/<int:list_id>/my-color', methods=['PUT'])
@token_required
def set_my_tasklist_color(list_id):
user = request.current_user
tl = db.session.get(TaskList, list_id)
if not tl:
return jsonify({'error': 'Nicht gefunden'}), 404
color = (request.get_json() or {}).get('color')
if not color:
return jsonify({'error': 'color erforderlich'}), 400
if tl.owner_id == user.id:
tl.color = color
db.session.commit()
return jsonify({'color': tl.color}), 200
share = TaskListShare.query.filter_by(task_list_id=list_id, shared_with_id=user.id).first()
if not share:
return jsonify({'error': 'Zugriff verweigert'}), 403
share.color = color
db.session.commit()
return jsonify({'color': share.color}), 200
@api_bp.route('/tasklists/<int:list_id>', methods=['DELETE'])
@token_required
def delete_tasklist(list_id):
user = request.current_user
tl = db.session.get(TaskList, list_id)
if not tl or tl.owner_id != user.id:
return jsonify({'error': 'Nur Eigentuemer kann loeschen'}), 403
recipients = _list_recipients(tl)
db.session.delete(tl)
db.session.commit()
notify_tasklist_change(user.id, list_id, 'deleted', shared_with=recipients)
return jsonify({'message': 'Aufgabenliste geloescht'}), 200
# ---------------------------------------------------------------------------
# REST endpoints - tasks
# ---------------------------------------------------------------------------
@api_bp.route('/tasklists/<int:list_id>/tasks', methods=['GET'])
@token_required
def list_tasks(list_id):
user = request.current_user
tl, err = _get_list_or_err(list_id, user)
if err:
return err
show_done = (request.args.get('include_done') or 'true').lower() != 'false'
q = Task.query.filter_by(task_list_id=list_id)
if not show_done:
q = q.filter((Task.status.is_(None)) | (Task.status != 'COMPLETED'))
tasks = q.order_by(Task.due.asc().nullslast(), Task.priority.desc().nullslast(), Task.id).all()
return jsonify([t.to_dict() for t in tasks]), 200
@api_bp.route('/tasklists/<int:list_id>/tasks', methods=['POST'])
@token_required
def create_task(list_id):
user = request.current_user
tl, err = _get_list_or_err(list_id, user, need_write=True)
if err:
return err
data = request.get_json() or {}
if not (data.get('summary') or '').strip():
return jsonify({'error': 'Titel erforderlich'}), 400
task = Task(task_list_id=list_id, uid=str(uuid.uuid4()), ical_data='')
_apply(task, data)
if not task.status:
task.status = 'NEEDS-ACTION'
task.ical_data = build_vtodo(task)
db.session.add(task)
db.session.commit()
notify_tasklist_change(tl.owner_id, tl.id, 'task', shared_with=_list_recipients(tl))
return jsonify(task.to_dict()), 201
@api_bp.route('/tasks/<int:task_id>', methods=['GET'])
@token_required
def get_task(task_id):
user = request.current_user
task = db.session.get(Task, task_id)
if not task:
return jsonify({'error': 'Aufgabe nicht gefunden'}), 404
tl, err = _get_list_or_err(task.task_list_id, user)
if err:
return err
return jsonify(task.to_dict()), 200
@api_bp.route('/tasks/<int:task_id>', methods=['PUT'])
@token_required
def update_task(task_id):
user = request.current_user
task = db.session.get(Task, task_id)
if not task:
return jsonify({'error': 'Aufgabe nicht gefunden'}), 404
tl, err = _get_list_or_err(task.task_list_id, user, need_write=True)
if err:
return err
data = request.get_json() or {}
if 'task_list_id' in data and data['task_list_id'] != task.task_list_id:
new_tl, e2 = _get_list_or_err(data['task_list_id'], user, need_write=True)
if e2:
return e2
task.task_list_id = data['task_list_id']
_apply(task, data)
task.ical_data = build_vtodo(task)
task.updated_at = datetime.now(timezone.utc)
db.session.commit()
notify_tasklist_change(tl.owner_id, tl.id, 'task', shared_with=_list_recipients(tl))
return jsonify(task.to_dict()), 200
@api_bp.route('/tasks/<int:task_id>', methods=['DELETE'])
@token_required
def delete_task(task_id):
user = request.current_user
task = db.session.get(Task, task_id)
if not task:
return jsonify({'error': 'Aufgabe nicht gefunden'}), 404
tl, err = _get_list_or_err(task.task_list_id, user, need_write=True)
if err:
return err
db.session.delete(task)
db.session.commit()
notify_tasklist_change(tl.owner_id, tl.id, 'task', shared_with=_list_recipients(tl))
return jsonify({'message': 'Aufgabe geloescht'}), 200
# ---------------------------------------------------------------------------
# Sharing
# ---------------------------------------------------------------------------
@api_bp.route('/tasklists/<int:list_id>/share', methods=['POST'])
@token_required
def share_tasklist(list_id):
user = request.current_user
tl = db.session.get(TaskList, list_id)
if not tl or tl.owner_id != user.id:
return jsonify({'error': 'Nur Eigentuemer kann teilen'}), 403
data = request.get_json() or {}
username = (data.get('username') or '').strip()
permission = data.get('permission', 'read')
if permission not in ('read', 'readwrite'):
return jsonify({'error': 'Ungueltige Berechtigung'}), 400
target = User.query.filter_by(username=username).first()
if not target:
return jsonify({'error': 'Benutzer nicht gefunden'}), 404
if target.id == user.id:
return jsonify({'error': 'Kann nicht mit sich selbst teilen'}), 400
existing = TaskListShare.query.filter_by(task_list_id=list_id, shared_with_id=target.id).first()
if existing:
existing.permission = permission
else:
db.session.add(TaskListShare(task_list_id=list_id, shared_with_id=target.id,
permission=permission))
db.session.commit()
notify_tasklist_change(tl.owner_id, tl.id, 'share',
shared_with=[target.id, *_list_recipients(tl)])
return jsonify({'message': f'Geteilt mit {username}'}), 200
@api_bp.route('/tasklists/<int:list_id>/shares', methods=['GET'])
@token_required
def list_tasklist_shares(list_id):
user = request.current_user
tl = db.session.get(TaskList, list_id)
if not tl or tl.owner_id != user.id:
return jsonify({'error': 'Nicht gefunden'}), 404
shares = TaskListShare.query.filter_by(task_list_id=list_id).all()
return jsonify([{
'id': s.id, 'user_id': s.shared_with_id,
'username': s.shared_with.username, 'permission': s.permission,
} for s in shares]), 200
@api_bp.route('/tasklists/<int:list_id>/shares/<int:share_id>', methods=['DELETE'])
@token_required
def remove_tasklist_share(list_id, share_id):
user = request.current_user
tl = db.session.get(TaskList, list_id)
if not tl or tl.owner_id != user.id:
return jsonify({'error': 'Nicht gefunden'}), 404
share = db.session.get(TaskListShare, share_id)
if not share or share.task_list_id != list_id:
return jsonify({'error': 'Freigabe nicht gefunden'}), 404
target_id = share.shared_with_id
db.session.delete(share)
db.session.commit()
notify_tasklist_change(tl.owner_id, tl.id, 'share',
shared_with=[target_id, *_list_recipients(tl)])
return jsonify({'message': 'Freigabe entfernt'}), 200
# ---------------------------------------------------------------------------
# Import / Export (.ics with VTODO; CSV)
# ---------------------------------------------------------------------------
@api_bp.route('/tasklists/<int:list_id>/export', methods=['GET'])
@token_required
def export_tasklist(list_id):
import csv
import io
user = request.current_user
tl, err = _get_list_or_err(list_id, user)
if err:
return err
fmt = (request.args.get('format') or 'ics').lower()
tasks = Task.query.filter_by(task_list_id=list_id).all()
safe = re.sub(r'[^A-Za-z0-9._-]+', '_', tl.name or 'aufgaben') or 'aufgaben'
if fmt == 'ics':
lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Mini-Cloud//DE', 'CALSCALE:GREGORIAN']
for t in tasks:
block = (t.ical_data or '').strip() or build_vtodo(t)
lines.append(block)
lines.append('END:VCALENDAR')
return Response(
'\r\n'.join(lines) + '\r\n',
mimetype='text/calendar; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename="{safe}.ics"'},
)
if fmt == 'csv':
out = io.StringIO()
w = csv.writer(out, delimiter=';', quoting=csv.QUOTE_ALL)
w.writerow(['summary', 'status', 'priority', 'percent_complete',
'due', 'dtstart', 'completed_at', 'categories', 'description', 'uid'])
for t in tasks:
w.writerow([
t.summary or '', t.status or '',
t.priority if t.priority is not None else '',
t.percent_complete if t.percent_complete is not None else '',
t.due.isoformat() if t.due else '',
t.dtstart.isoformat() if t.dtstart else '',
t.completed_at.isoformat() if t.completed_at else '',
t.categories or '',
(t.description or '').replace('\r\n', ' ').replace('\n', ' '),
t.uid or '',
])
return Response(
'\ufeff' + out.getvalue(), mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename="{safe}.csv"'},
)
return jsonify({'error': 'Unbekanntes Format'}), 400
@api_bp.route('/tasklists/<int:list_id>/import', methods=['POST'])
@token_required
def import_tasklist(list_id):
import csv
import io
user = request.current_user
tl, err = _get_list_or_err(list_id, user, need_write=True)
if err:
return err
file = request.files.get('file')
if not file:
return jsonify({'error': 'Keine Datei'}), 400
raw = file.read()
try:
text = raw.decode('utf-8-sig')
except UnicodeDecodeError:
text = raw.decode('latin-1', errors='replace')
name = (file.filename or '').lower()
imported, skipped = 0, 0
def _save(parsed: dict, ical_block: str | None = None):
nonlocal imported, skipped
if not parsed.get('summary'):
skipped += 1
return
uid = parsed.get('uid') or str(uuid.uuid4())
existing = Task.query.filter_by(task_list_id=list_id, uid=uid).first()
t = existing or Task(task_list_id=list_id, uid=uid, ical_data='')
t.summary = parsed.get('summary')
t.description = parsed.get('description')
t.status = parsed.get('status') or 'NEEDS-ACTION'
t.priority = parsed.get('priority')
t.percent_complete = parsed.get('percent_complete')
t.due = parsed.get('due')
t.dtstart = parsed.get('dtstart')
t.completed_at = parsed.get('completed_at')
cats = parsed.get('categories')
if isinstance(cats, list):
t.categories = ','.join(cats)
elif isinstance(cats, str):
t.categories = cats or None
t.ical_data = (ical_block or '').strip() or build_vtodo(t)
if not existing:
db.session.add(t)
imported += 1
if name.endswith('.csv') or (b';' in raw[:200] and b'BEGIN:VCALENDAR' not in raw[:200]):
reader = csv.DictReader(__import__('io').StringIO(text), delimiter=';')
if not reader.fieldnames or len(reader.fieldnames) < 2:
reader = csv.DictReader(__import__('io').StringIO(text), delimiter=',')
for row in reader:
row = {k.strip().lower(): (v or '').strip() for k, v in row.items() if k}
try:
due = datetime.fromisoformat(row['due']) if row.get('due') else None
except ValueError:
due = None
_save({
'uid': row.get('uid'),
'summary': row.get('summary') or row.get('titel'),
'description': row.get('description') or row.get('beschreibung'),
'status': (row.get('status') or '').upper() or None,
'priority': int(row['priority']) if row.get('priority', '').isdigit() else None,
'percent_complete': int(row['percent_complete']) if row.get('percent_complete', '').isdigit() else None,
'due': due,
'categories': row.get('categories') or row.get('kategorien'),
})
else:
blocks = re.findall(r'BEGIN:VTODO.*?END:VTODO', text, flags=re.DOTALL | re.IGNORECASE)
if not blocks:
return jsonify({'error': 'Keine VTODO-Daten gefunden'}), 400
for block in blocks:
parsed = parse_vtodo(block)
if not parsed:
skipped += 1
continue
_save(parsed, ical_block=block)
db.session.commit()
if imported:
notify_tasklist_change(tl.owner_id, tl.id, 'task', shared_with=_list_recipients(tl))
return jsonify({'imported': imported, 'skipped': skipped}), 200
+44 -2
View File
@@ -145,6 +145,12 @@ def delete_user(user_id):
@api_bp.route('/settings', methods=['GET'])
@admin_required
def get_settings():
import time as _time
from datetime import datetime as _dt
try:
tzname = _time.strftime('%Z')
except Exception:
tzname = ''
return jsonify({
'public_registration': AppSettings.get_bool('public_registration', default=True),
'system_smtp_host': AppSettings.get('system_smtp_host', ''),
@@ -155,6 +161,11 @@ def get_settings():
'system_email_from': AppSettings.get('system_email_from', ''),
'onlyoffice_url': os.environ.get('ONLYOFFICE_URL', ''),
'onlyoffice_configured': bool(os.environ.get('ONLYOFFICE_URL', '')),
# Read-only system info aus der .env
'timezone': os.environ.get('TZ', 'Europe/Berlin'),
'timezone_abbr': tzname,
'server_time': _dt.now().isoformat(timespec='seconds'),
'ntp_server': os.environ.get('NTP_SERVER', ''),
}), 200
@@ -270,6 +281,31 @@ def create_invite_link():
# --- User search (for sharing dialogs) ---
@api_bp.route('/auth/me', methods=['GET'])
@token_required
def get_me():
return jsonify(request.current_user.to_dict(include_email=True)), 200
@api_bp.route('/auth/me', methods=['PUT'])
@token_required
def update_me():
user = request.current_user
data = request.get_json() or {}
if 'first_name' in data:
user.first_name = (data.get('first_name') or '').strip() or None
if 'last_name' in data:
user.last_name = (data.get('last_name') or '').strip() or None
if 'email' in data:
email = (data.get('email') or '').strip() or None
if email and email != user.email:
if User.query.filter(User.email == email, User.id != user.id).first():
return jsonify({'error': 'E-Mail ist bereits vergeben'}), 409
user.email = email
db.session.commit()
return jsonify(user.to_dict(include_email=True)), 200
@api_bp.route('/users/search', methods=['GET'])
@token_required
def search_users():
@@ -278,13 +314,19 @@ def search_users():
if len(query) < 2:
return jsonify([]), 200
like = f'%{query}%'
users = User.query.filter(
User.username.ilike(f'%{query}%'),
(User.username.ilike(like)) | (User.first_name.ilike(like)) | (User.last_name.ilike(like)),
User.id != request.current_user.id,
User.is_active == True,
).limit(10).all()
return jsonify([{'id': u.id, 'username': u.username} for u in users]), 200
return jsonify([{
'id': u.id,
'username': u.username,
'full_name': u.full_name,
'display_name': u.display_name,
} for u in users]), 200
# --- Change password (non-admin, own account) ---
+5
View File
@@ -40,3 +40,8 @@ class Config:
# CORS
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:3000')
# Zeitzone (prozessweit, wirkt nach time.tzset())
TIMEZONE = os.environ.get('TZ', 'Europe/Berlin')
# NTP-Server fuer Offset-Check beim Start. Leerstring deaktiviert den Check.
NTP_SERVER = os.environ.get('NTP_SERVER', 'ptbtime1.ptb.de')
+31 -4
View File
@@ -284,11 +284,11 @@ def propfind(subpath=''):
multistatus.append(_principal_response(user))
return _xml_response(multistatus)
# /dav/<username>/calendars/ : only calendar collections
# /dav/<username>/calendars/ : Kalender + Aufgabenlisten (DAVx5 erkennt
# VTODO-Listen automatisch an supported-calendar-component-set).
if len(parts) == 2 and parts[1] == 'calendars':
if parts[0] != user.username:
return Response('', 403)
# A plain collection container
container = ET.Element(_qn('d', 'response'))
ET.SubElement(container, _qn('d', 'href')).text = f'/dav/{user.username}/calendars/'
propstat = ET.SubElement(container, _qn('d', 'propstat'))
@@ -301,6 +301,9 @@ def propfind(subpath=''):
if depth != '0':
for cal in _user_calendars(user):
multistatus.append(_calendar_response(user, cal))
from .taskdav import user_lists, list_response
for tl in user_lists(user):
multistatus.append(list_response(user, tl))
return _xml_response(multistatus)
# /dav/<username>/addressbooks/ : only addressbook collections
@@ -322,10 +325,13 @@ def propfind(subpath=''):
multistatus.append(_addressbook_response(user, ab))
return _xml_response(multistatus)
# /dav/<username>/cal-<id>/ : calendar + events
# /dav/<username>/cal-<id>/ : calendar + events (auch tl-N delegieren)
if len(parts) == 2:
if parts[0] != user.username:
return Response('', 403)
if parts[1].startswith('tl-'):
from .taskdav import tl_propfind
return tl_propfind(username=parts[0], tl_part=parts[1])
cal_id = _parse_calendar_path(parts[1])
if cal_id is None:
return Response('Not found', 404)
@@ -338,10 +344,13 @@ def propfind(subpath=''):
multistatus.append(_event_response(user, cal, ev))
return _xml_response(multistatus)
# /dav/<username>/cal-<id>/<uid>.ics : single event
# /dav/<username>/cal-<id>/<uid>.ics : single event (tl-N delegieren)
if len(parts) == 3:
if parts[0] != user.username:
return Response('', 403)
if parts[1].startswith('tl-'):
from .taskdav import tl_task_propfind
return tl_task_propfind(username=parts[0], tl_part=parts[1], filename=parts[2])
cal_id = _parse_calendar_path(parts[1])
cal = _calendar_for(user, cal_id) if cal_id else None
if not cal:
@@ -367,6 +376,9 @@ def report(subpath):
parts = [p for p in subpath.split('/') if p]
if len(parts) < 2 or parts[0] != user.username:
return Response('', 403)
if parts[1].startswith('tl-'):
from .taskdav import tl_report
return tl_report(username=parts[0], tl_part=parts[1])
cal_id = _parse_calendar_path(parts[1])
cal = _calendar_for(user, cal_id) if cal_id else None
if not cal:
@@ -449,6 +461,9 @@ def get_event(username, cal_part, filename):
if cal_part.startswith('ab-'):
from .carddav import ab_get
return ab_get(username=username, ab_part=cal_part, filename=filename)
if cal_part.startswith('tl-'):
from .taskdav import tl_get
return tl_get(username=username, tl_part=cal_part, filename=filename)
user: User = request.dav_user
if username != user.username:
return Response('', 403)
@@ -477,6 +492,9 @@ def put_event(username, cal_part, filename):
if cal_part.startswith('ab-'):
from .carddav import ab_put
return ab_put(username=username, ab_part=cal_part, filename=filename)
if cal_part.startswith('tl-'):
from .taskdav import tl_put
return tl_put(username=username, tl_part=cal_part, filename=filename)
user: User = request.dav_user
if username != user.username:
return Response('', 403)
@@ -536,6 +554,9 @@ def delete_event(username, cal_part, filename):
if cal_part.startswith('ab-'):
from .carddav import ab_delete
return ab_delete(username=username, ab_part=cal_part, filename=filename)
if cal_part.startswith('tl-'):
from .taskdav import tl_delete
return tl_delete(username=username, tl_part=cal_part, filename=filename)
user: User = request.dav_user
if username != user.username:
return Response('', 403)
@@ -561,6 +582,9 @@ def delete_calendar(username, cal_part):
if cal_part.startswith('ab-'):
from .carddav import ab_delete_collection
return ab_delete_collection(username=username, ab_part=cal_part)
if cal_part.startswith('tl-'):
from .taskdav import tl_delete_collection
return tl_delete_collection(username=username, tl_part=cal_part)
user: User = request.dav_user
if username != user.username:
return Response('', 403)
@@ -587,6 +611,9 @@ def delete_calendar(username, cal_part):
@dav_bp.route('/<username>/<cal_part>', methods=['PROPPATCH'])
@basic_auth
def proppatch_calendar(username, cal_part):
if cal_part.startswith('tl-'):
from .taskdav import tl_proppatch
return tl_proppatch(username=username, tl_part=cal_part)
user: User = request.dav_user
if username != user.username:
return Response('', 403)
+368
View File
@@ -0,0 +1,368 @@
"""CalDAV Task-List Handler (VTODO).
TaskLists werden parallel zu Calendars als Calendar-Collection
ausgeliefert, jedoch mit `<supported-calendar-component-set>` = VTODO
(statt VEVENT). DAVx5/OpenTasks erkennen sie dadurch automatisch als
Aufgabenliste.
URL-Schema:
/dav/<user>/tl-<id>/ Collection
/dav/<user>/tl-<id>/<uid>.ics VTODO-Resource
Diese Funktionen werden aus caldav.py heraus aufgerufen, sobald der
URL-Bestandteil mit `tl-` beginnt - parallel zur ab-/CardDAV-Delegation.
"""
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from flask import Response, request
from app.extensions import db
from app.models.task import TaskList, Task
from app.models.user import User
from app.api.tasks import build_vtodo, parse_vtodo, _list_recipients
from app.services.events import notify_tasklist_change
# Re-use XML helpers from caldav.py
def _import_caldav_helpers():
from . import caldav
return caldav
def _qn(prefix, name):
return _import_caldav_helpers()._qn(prefix, name)
def _xml_response(elem):
return _import_caldav_helpers()._xml_response(elem)
def _make_response(href, populate):
return _import_caldav_helpers()._make_response(href, populate)
# ---------------------------------------------------------------------------
# Path / URL helpers
# ---------------------------------------------------------------------------
def parse_tl_path(part: str):
m = re.match(r'tl-(\d+)$', part)
return int(m.group(1)) if m else None
def href_list(username, lid):
return f'/dav/{username}/tl-{lid}/'
def href_task(username, lid, uid):
return f'/dav/{username}/tl-{lid}/{uid}.ics'
def user_lists(user: User):
return TaskList.query.filter_by(owner_id=user.id).all()
def list_for(user: User, lid: int):
tl = db.session.get(TaskList, lid)
if not tl or tl.owner_id != user.id:
return None
return tl
def _ctag(tl: TaskList) -> str:
last = db.session.query(db.func.max(Task.updated_at)).filter_by(task_list_id=tl.id).scalar()
ts = int((last or tl.updated_at or datetime.now(timezone.utc)).timestamp())
return f'"tl{tl.id}-{ts}"'
def _etag(t: Task) -> str:
ts = int((t.updated_at or t.created_at or datetime.now(timezone.utc)).timestamp() * 1000)
return f'"{t.id}-{ts}"'
def _wrap_vcalendar(t: Task) -> str:
block = (t.ical_data or '').strip() or build_vtodo(t)
return '\r\n'.join([
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Mini-Cloud//DE',
'CALSCALE:GREGORIAN', block, 'END:VCALENDAR',
])
# ---------------------------------------------------------------------------
# PROPFIND building blocks
# ---------------------------------------------------------------------------
def list_response(user: User, tl: TaskList) -> ET.Element:
href = href_list(user.username, tl.id)
def populate(prop):
rt = ET.SubElement(prop, _qn('d', 'resourcetype'))
ET.SubElement(rt, _qn('d', 'collection'))
ET.SubElement(rt, _qn('c', 'calendar'))
ET.SubElement(prop, _qn('d', 'displayname')).text = tl.name
ET.SubElement(prop, _qn('c', 'calendar-description')).text = tl.description or ''
supported = ET.SubElement(prop, _qn('c', 'supported-calendar-component-set'))
comp = ET.SubElement(supported, _qn('c', 'comp'))
comp.set('name', 'VTODO')
srs = ET.SubElement(prop, _qn('d', 'supported-report-set'))
for r in ('calendar-query', 'calendar-multiget'):
sup = ET.SubElement(srs, _qn('d', 'supported-report'))
rep = ET.SubElement(sup, _qn('d', 'report'))
ET.SubElement(rep, _qn('c', r))
ET.SubElement(prop, _qn('ic', 'calendar-color')).text = tl.color or '#10b981'
ET.SubElement(prop, _qn('cs', 'getctag')).text = _ctag(tl)
cups = ET.SubElement(prop, _qn('d', 'current-user-privilege-set'))
for priv in ('read', 'write', 'write-properties', 'write-content', 'bind', 'unbind'):
p = ET.SubElement(cups, _qn('d', 'privilege'))
ET.SubElement(p, _qn('d', priv))
return _make_response(href, populate)
def task_response(user: User, tl: TaskList, t: Task, include_data=False) -> ET.Element:
href = href_task(user.username, tl.id, t.uid)
def populate(prop):
ET.SubElement(prop, _qn('d', 'getetag')).text = _etag(t)
ET.SubElement(prop, _qn('d', 'getcontenttype')).text = \
'text/calendar; charset=utf-8; component=VTODO'
ET.SubElement(prop, _qn('d', 'resourcetype'))
if include_data:
ET.SubElement(prop, _qn('c', 'calendar-data')).text = _wrap_vcalendar(t)
return _make_response(href, populate)
# ---------------------------------------------------------------------------
# Handlers (entered from caldav.py when path starts with tl-)
# ---------------------------------------------------------------------------
def tl_propfind(username, tl_part):
user: User = request.dav_user
if username != user.username:
return Response('', 403)
lid = parse_tl_path(tl_part)
tl = list_for(user, lid) if lid else None
if not tl:
return Response('Not found', 404)
depth = request.headers.get('Depth', '0')
multi = ET.Element(_qn('d', 'multistatus'))
multi.append(list_response(user, tl))
if depth != '0':
for t in tl.tasks.all():
multi.append(task_response(user, tl, t))
return _xml_response(multi)
def tl_task_propfind(username, tl_part, filename):
user: User = request.dav_user
if username != user.username:
return Response('', 403)
lid = parse_tl_path(tl_part)
tl = list_for(user, lid) if lid else None
if not tl:
return Response('Not found', 404)
uid = filename.removesuffix('.ics')
t = Task.query.filter_by(task_list_id=tl.id, uid=uid).first()
if not t:
return Response('Not found', 404)
multi = ET.Element(_qn('d', 'multistatus'))
multi.append(task_response(user, tl, t, include_data=True))
return _xml_response(multi)
def tl_report(username, tl_part):
user: User = request.dav_user
if username != user.username:
return Response('', 403)
lid = parse_tl_path(tl_part)
tl = list_for(user, lid) if lid else None
if not tl:
return Response('Not found', 404)
try:
root = ET.fromstring(request.data or b'<x/>')
except ET.ParseError:
return Response('Malformed XML', 400)
wants_data = root.find(f".//{_qn('c', 'calendar-data')}") is not None
multi = ET.Element(_qn('d', 'multistatus'))
if root.tag == _qn('c', 'calendar-multiget'):
hrefs = [h.text for h in root.findall(_qn('d', 'href')) if h.text]
for href in hrefs:
uid = href.rsplit('/', 1)[-1].removesuffix('.ics')
t = Task.query.filter_by(task_list_id=tl.id, uid=uid).first()
if t:
multi.append(task_response(user, tl, t, include_data=True))
return _xml_response(multi)
if root.tag == _qn('c', 'calendar-query'):
for t in tl.tasks.all():
multi.append(task_response(user, tl, t, include_data=wants_data))
return _xml_response(multi)
return _xml_response(multi)
def tl_get(username, tl_part, filename):
user: User = request.dav_user
if username != user.username:
return Response('', 403)
lid = parse_tl_path(tl_part)
tl = list_for(user, lid) if lid else None
if not tl:
return Response('Not found', 404)
uid = filename.removesuffix('.ics')
t = Task.query.filter_by(task_list_id=tl.id, uid=uid).first()
if not t:
return Response('Not found', 404)
return Response(_wrap_vcalendar(t),
mimetype='text/calendar; charset=utf-8',
headers={'ETag': _etag(t)})
def tl_put(username, tl_part, filename):
user: User = request.dav_user
if username != user.username:
return Response('', 403)
lid = parse_tl_path(tl_part)
tl = list_for(user, lid) if lid else None
if not tl:
return Response('Not found', 404)
uid = filename.removesuffix('.ics')
raw = request.get_data(as_text=True) or ''
parsed = parse_vtodo(raw)
if not parsed:
return Response('Cannot parse VTODO', 400)
body_uid = parsed.get('uid') or uid
existing = Task.query.filter_by(task_list_id=tl.id, uid=body_uid).first()
if_match = request.headers.get('If-Match')
if_none_match = request.headers.get('If-None-Match')
if existing and if_none_match == '*':
return Response('', 412)
if if_match and existing and if_match.strip() != _etag(existing):
return Response('', 412)
is_new = existing is None
if is_new:
existing = Task(task_list_id=tl.id, uid=body_uid, ical_data=raw)
db.session.add(existing)
existing.summary = parsed.get('summary') or '(ohne Titel)'
existing.description = parsed.get('description')
existing.status = parsed.get('status') or 'NEEDS-ACTION'
existing.priority = parsed.get('priority')
existing.percent_complete = parsed.get('percent_complete')
existing.due = parsed.get('due')
existing.dtstart = parsed.get('dtstart')
existing.completed_at = parsed.get('completed_at')
cats = parsed.get('categories')
if isinstance(cats, str):
existing.categories = cats or None
elif isinstance(cats, list):
existing.categories = ','.join(cats) or None
# Roh-Block sichern fuer Round-Trip
block = re.search(r'BEGIN:VTODO.*?END:VTODO', raw, flags=re.DOTALL | re.IGNORECASE)
existing.ical_data = (block.group(0).strip() if block else raw.strip()) or build_vtodo(existing)
existing.updated_at = datetime.now(timezone.utc)
db.session.commit()
notify_tasklist_change(tl.owner_id, tl.id, 'task', shared_with=_list_recipients(tl))
return Response('', 201 if is_new else 204, {'ETag': _etag(existing)})
def tl_delete(username, tl_part, filename):
user: User = request.dav_user
if username != user.username:
return Response('', 403)
lid = parse_tl_path(tl_part)
tl = list_for(user, lid) if lid else None
if not tl:
return Response('Not found', 404)
uid = filename.removesuffix('.ics')
t = Task.query.filter_by(task_list_id=tl.id, uid=uid).first()
if not t:
return Response('', 404)
db.session.delete(t)
db.session.commit()
notify_tasklist_change(tl.owner_id, tl.id, 'task', shared_with=_list_recipients(tl))
return Response('', 204)
def tl_delete_collection(username, tl_part):
user: User = request.dav_user
if username != user.username:
return Response('', 403)
lid = parse_tl_path(tl_part)
tl = list_for(user, lid) if lid else None
if not tl:
return Response('', 404)
recipients = _list_recipients(tl)
owner_id = tl.owner_id
list_id = tl.id
db.session.delete(tl)
db.session.commit()
notify_tasklist_change(owner_id, list_id, 'deleted', shared_with=recipients)
return Response('', 204)
def tl_options(username, tl_part):
return Response('', 200, {
'DAV': '1, 2, 3, calendar-access, addressbook',
'Allow': 'OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, MKCALENDAR, PROPPATCH',
})
def tl_proppatch(username, tl_part):
"""Bestaetige Property-Updates damit Clients zufrieden sind. Wir
persistieren Displayname + Color, alles andere wird stillschweigend
akzeptiert."""
user: User = request.dav_user
if username != user.username:
return Response('', 403)
lid = parse_tl_path(tl_part)
tl = list_for(user, lid) if lid else None
if not tl:
return Response('Not found', 404)
try:
root = ET.fromstring(request.data or b'<x/>')
except ET.ParseError:
return Response('Malformed XML', 400)
changed = False
for el in root.iter():
tag = (el.tag.split('}', 1)[1] if '}' in el.tag else el.tag).lower()
if tag == 'displayname' and el.text:
tl.name = el.text
changed = True
elif tag == 'calendar-color' and el.text:
tl.color = el.text[:7]
changed = True
if changed:
db.session.commit()
multi = ET.Element(_qn('d', 'multistatus'))
resp = ET.SubElement(multi, _qn('d', 'response'))
ET.SubElement(resp, _qn('d', 'href')).text = href_list(user.username, tl.id)
ps = ET.SubElement(resp, _qn('d', 'propstat'))
ET.SubElement(ps, _qn('d', 'status')).text = 'HTTP/1.1 200 OK'
return _xml_response(multi)
def tl_mkcol(username, tl_part):
"""Erstelle eine neue TaskList per MKCOL/MKCALENDAR. Der Pfadteil
`tl-N` ist bei MKCOL aber unbekannt - DAVx5 schickt einen frei
gewaehlten Namen wie `mein-task-uuid`. Daher: wir akzeptieren jeden
Pfadteil und legen eine TaskList an."""
user: User = request.dav_user
if username != user.username:
return Response('', 403)
name = 'Neue Aufgabenliste'
try:
body = request.get_data()
if body:
root = ET.fromstring(body)
for el in root.iter():
tag = (el.tag.split('}', 1)[1] if '}' in el.tag else el.tag).lower()
if tag == 'displayname' and el.text:
name = el.text
except ET.ParseError:
pass
tl = TaskList(owner_id=user.id, name=name)
db.session.add(tl)
db.session.commit()
notify_tasklist_change(user.id, tl.id, 'created')
return Response('', 201, {'Location': href_list(user.username, tl.id)})
+2
View File
@@ -2,6 +2,7 @@ from app.models.user import User
from app.models.file import File, FilePermission, ShareLink
from app.models.calendar import Calendar, CalendarEvent, CalendarShare
from app.models.contact import AddressBook, Contact, AddressBookShare
from app.models.task import TaskList, Task, TaskListShare
from app.models.email_account import EmailAccount
from app.models.password_vault import PasswordFolder, PasswordEntry, PasswordShare
from app.models.settings import AppSettings
@@ -13,6 +14,7 @@ __all__ = [
'File', 'FilePermission', 'ShareLink',
'Calendar', 'CalendarEvent', 'CalendarShare',
'AddressBook', 'Contact', 'AddressBookShare',
'TaskList', 'Task', 'TaskListShare',
'EmailAccount',
'PasswordFolder', 'PasswordEntry', 'PasswordShare',
'AppSettings',
+86
View File
@@ -0,0 +1,86 @@
from datetime import datetime, timezone
from app.extensions import db
class TaskList(db.Model):
__tablename__ = 'task_lists'
id = db.Column(db.Integer, primary_key=True)
owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
name = db.Column(db.String(255), nullable=False)
color = db.Column(db.String(7), default='#10b981')
description = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
tasks = db.relationship('Task', backref='task_list', lazy='dynamic',
cascade='all, delete-orphan')
shares = db.relationship('TaskListShare', backref='task_list', lazy='dynamic',
cascade='all, delete-orphan')
def to_dict(self):
return {
'id': self.id,
'owner_id': self.owner_id,
'name': self.name,
'color': self.color,
'description': self.description,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
class Task(db.Model):
__tablename__ = 'tasks'
id = db.Column(db.Integer, primary_key=True)
task_list_id = db.Column(db.Integer, db.ForeignKey('task_lists.id'), nullable=False, index=True)
uid = db.Column(db.String(255), unique=True, nullable=False)
ical_data = db.Column(db.Text, nullable=False, default='') # Full VTODO block
summary = db.Column(db.String(500), nullable=True)
description = db.Column(db.Text, nullable=True)
status = db.Column(db.String(32), nullable=True) # NEEDS-ACTION | IN-PROCESS | COMPLETED | CANCELLED
priority = db.Column(db.Integer, nullable=True) # 0 (keine) - 9
percent_complete = db.Column(db.Integer, nullable=True) # 0..100
due = db.Column(db.DateTime, nullable=True, index=True)
dtstart = db.Column(db.DateTime, nullable=True)
completed_at = db.Column(db.DateTime, nullable=True)
categories = db.Column(db.Text, nullable=True) # kommagetrennt
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
def to_dict(self):
return {
'id': self.id,
'task_list_id': self.task_list_id,
'uid': self.uid,
'summary': self.summary,
'description': self.description,
'status': self.status or 'NEEDS-ACTION',
'priority': self.priority,
'percent_complete': self.percent_complete,
'due': self.due.isoformat() if self.due else None,
'dtstart': self.dtstart.isoformat() if self.dtstart else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
'categories': self.categories.split(',') if self.categories else [],
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
class TaskListShare(db.Model):
__tablename__ = 'task_list_shares'
id = db.Column(db.Integer, primary_key=True)
task_list_id = db.Column(db.Integer, db.ForeignKey('task_lists.id'), nullable=False, index=True)
shared_with_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
permission = db.Column(db.String(20), nullable=False, default='read')
color = db.Column(db.String(7), nullable=True)
shared_with = db.relationship('User', backref='shared_task_lists')
__table_args__ = (
db.UniqueConstraint('task_list_id', 'shared_with_id', name='uq_task_list_share'),
)
+18
View File
@@ -9,6 +9,8 @@ class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
email = db.Column(db.String(255), unique=True, nullable=True)
first_name = db.Column(db.String(100), nullable=True)
last_name = db.Column(db.String(100), nullable=True)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.String(20), default='user', nullable=False) # 'admin' or 'user'
master_key_salt = db.Column(db.LargeBinary, nullable=True) # For password manager
@@ -23,6 +25,7 @@ class User(db.Model):
foreign_keys='File.owner_id')
calendars = db.relationship('Calendar', backref='owner', lazy='dynamic')
address_books = db.relationship('AddressBook', backref='owner', lazy='dynamic')
task_lists = db.relationship('TaskList', backref='owner', lazy='dynamic')
email_accounts = db.relationship('EmailAccount', backref='user', lazy='dynamic',
order_by='EmailAccount.sort_order')
password_folders = db.relationship('PasswordFolder', backref='owner', lazy='dynamic')
@@ -33,10 +36,25 @@ class User(db.Model):
def check_password(self, password):
return bcrypt.check_password_hash(self.password_hash, password)
@property
def full_name(self) -> str:
"""Vor- + Nachname zusammengesetzt, sonst Leerstring."""
parts = [self.first_name or '', self.last_name or '']
return ' '.join(p.strip() for p in parts if p and p.strip())
@property
def display_name(self) -> str:
"""Voller Name falls vorhanden, sonst Username."""
return self.full_name or self.username
def to_dict(self, include_email=False):
data = {
'id': self.id,
'username': self.username,
'first_name': self.first_name or '',
'last_name': self.last_name or '',
'full_name': self.full_name,
'display_name': self.display_name,
'role': self.role,
'is_active': self.is_active,
'storage_quota_mb': self.storage_quota_mb,
+10
View File
@@ -92,3 +92,13 @@ def notify_calendar_change(owner_id: int, calendar_id: int, change: str,
'change': change, # 'event'|'share'|'deleted'
'calendar_id': calendar_id,
})
def notify_tasklist_change(owner_id: int, list_id: int, change: str,
shared_with: Iterable[int] = ()) -> None:
recipients = [owner_id, *shared_with]
broadcaster.publish(recipients, {
'type': 'tasklist',
'change': change, # 'task'|'share'|'deleted'|'created'
'task_list_id': list_id,
})
+56
View File
@@ -0,0 +1,56 @@
"""Leichtgewichtiger SNTP-Client zum Pruefen des Zeit-Offsets.
Im Container koennen wir die Systemzeit nicht wirklich setzen (braucht
CAP_SYS_TIME). Aber wir koennen den Offset ermitteln und loggen, damit
der Admin weiss, ob der Host driftet. Fuer einen harten Sync muss auf
dem Host selbst ein NTP-Daemon laufen.
"""
from __future__ import annotations
import socket
import struct
import time
_NTP_EPOCH_OFFSET = 2208988800 # Sekunden zwischen 1900 und 1970
def query_ntp(server: str, timeout: float = 3.0, port: int = 123) -> float | None:
"""Fragt einen NTP-Server und gibt das Offset (Server - Local) in
Sekunden zurueck, oder None bei Fehler."""
packet = b'\x1b' + 47 * b'\0' # LI=0, VN=3, Mode=3 (client)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(timeout)
try:
t0 = time.time()
sock.sendto(packet, (server, port))
data, _ = sock.recvfrom(1024)
t3 = time.time()
except (socket.gaierror, socket.timeout, OSError):
return None
finally:
sock.close()
if len(data) < 48:
return None
# Transmit timestamp: Offset 40, 8 bytes, fixed point 32.32
secs, frac = struct.unpack('!II', data[40:48])
if secs == 0:
return None
t2 = secs - _NTP_EPOCH_OFFSET + frac / 2**32
# Einfacher Offset (sans roundtrip): (t2 - ((t0 + t3) / 2))
return t2 - (t0 + t3) / 2
def check_and_log(server: str, logger=None) -> float | None:
import logging
log = logger or logging.getLogger('ntp')
offset = query_ntp(server)
if offset is None:
log.warning('NTP-Check: Server %s nicht erreichbar', server)
return None
if abs(offset) > 5.0:
log.warning('NTP-Check: Systemzeit weicht um %.2fs von %s ab -> Host-Uhr synchronisieren!',
offset, server)
else:
log.info('NTP-Check: Offset %.3fs gegen %s (ok)', offset, server)
return offset
+70
View File
@@ -0,0 +1,70 @@
# Native File-Provider-Integration (Platzhalter-Modus)
Zusaetzlich zum klassischen "alles-kopieren"-Sync bietet der Desktop-Client
einen **OneDrive-aehnlichen Platzhalter-Modus**: Dateien erscheinen im
Dateimanager als kleine Metadata-Dateien (Platzhalter) und werden erst
bei Doppelklick vom Server geladen.
## Status
| Plattform | Status | Technologie |
| --------- | --------- | ------------------------------------ |
| Windows | **MVP** | Cloud Files API (`cfapi.dll`) |
| Linux | Skelett | FUSE (libfuse3) - feature `linux_fuse` |
| macOS | Geplant | `NSFileProviderExtension` + Signatur |
## Windows
### Voraussetzungen
- Windows 10 1709 (Build 16299) oder neuer
- Der Client laeuft als regulaerer Benutzerprozess (keine Admin-Rechte noetig)
### Was funktioniert
- `CfRegisterSyncRoot` registriert einen Ordner als Sync-Root, der Explorer
zeigt Wolken-Overlay-Icons an.
- `CfCreatePlaceholders` legt fuer jede Mini-Cloud-Datei einen Platzhalter
mit korrekter Groesse und Aenderungszeit an.
- `FETCH_DATA`-Callback laedt per Range-Request vom Server, sobald der
Explorer Dateidaten anfordert (z.B. beim Oeffnen).
- `CfSetPinState` erlaubt manuelles "Immer offline halten" / "Nur in Cloud".
### Was noch fehlt
- Upload-Callback (`NOTIFY_FILE_CLOSE_COMPLETION`) fuer lokal geaenderte Dateien
- Context-Menue "Ein-/Auschecken" via Shell-Extension
- Delta-Updates (neue/geloeschte Dateien auf dem Server -> lokale Placeholder)
- Konflikt-Aufloesung
### Einschalten
Im Client-UI den Schalter **"Cloud-Files-Modus"** aktivieren (ruft intern
`cloud_files_enable`-Command auf). Alternativ per Kommandozeile beim Build:
```powershell
# Aus clients/desktop/src-tauri:
cargo build --release
```
Windows-Targets brauchen das Windows-SDK (uebersetzt aber sauber mit
cross-compile via `cargo xwin` aus Linux, wenn `build.sh windows` laeuft).
## Linux
FUSE-Provider ist optional und mit einem Feature-Flag versehen, damit
normale Linux-Builds nicht `libfuse3-dev` voraussetzen:
```bash
cargo build --features linux_fuse
```
Overlay-Icons im Dateimanager (Nautilus / Dolphin / Caja) brauchen
zusaetzlich eine native Extension pro DE - folgt in einem spaeteren
Commit.
## macOS
Braucht eine Apple Developer ID + Notarization, da `NSFileProviderExtension`
sonst vom Finder nicht geladen wird. Wird angegangen, sobald ein
Apple-Dev-Zugang verfuegbar ist.
+28 -1
View File
@@ -19,7 +19,7 @@ tauri-plugin-dialog = "2"
tauri-plugin-notification = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json", "multipart", "rustls-tls"], default-features = false }
reqwest = { version = "0.12", features = ["json", "multipart", "rustls-tls", "blocking"], default-features = false }
tokio = { version = "1", features = ["full"] }
notify = "7"
sha2 = "0.10"
@@ -28,3 +28,30 @@ rusqlite = { version = "0.34", features = ["bundled"] }
chrono = { version = "0.4", features = ["serde"] }
base64 = "0.22"
open = "5"
once_cell = "1"
# Plattform-spezifische File-Provider-Integration (OneDrive-artig).
# Nur auf Windows gegen die Cloud Files API (cfapi.dll) linken.
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_Storage_FileSystem",
"Win32_Storage_CloudFilters",
"Win32_System_IO",
"Win32_System_Com",
"Win32_System_CorrelationVector", # gate fuer CF_CALLBACK_INFO / CfExecute / CfConnectSyncRoot
"Win32_UI_Shell",
"Win32_Security",
"Win32_System_Registry",
] }
widestring = "1"
winreg = "0.52"
# Linux: FUSE-basiertes Virtual-Filesystem (optional, cargo build --features linux_fuse)
[target.'cfg(target_os = "linux")'.dependencies]
fuser = { version = "0.15", optional = true }
libc = "0.2"
[features]
default = []
linux_fuse = ["fuser"]
@@ -0,0 +1,25 @@
//! Linux FUSE-basierte File-Provider-Integration (Platzhalter-Modus).
//!
//! Status: Skelett. Funktioniert nur wenn mit `--features linux_fuse`
//! gebaut wird und `libfuse3-dev` installiert ist. Overlay-Icons im
//! Dateimanager (Nautilus/Dolphin) werden spaeter als separate Extension
//! nachgereicht - das FUSE-Filesystem selbst kann die nicht setzen.
#![cfg(all(target_os = "linux", feature = "linux_fuse"))]
use super::RemoteEntry;
use std::path::PathBuf;
pub fn mount(mount_point: &PathBuf) -> Result<(), String> {
std::fs::create_dir_all(mount_point).map_err(|e| e.to_string())?;
// TODO: fuser::Filesystem-Impl mit auf-Abruf-Download
Err("Linux FUSE-Provider: noch nicht implementiert (MVP folgt)".into())
}
pub fn unmount(_mount_point: &PathBuf) -> Result<(), String> {
Err("Linux FUSE-Provider: noch nicht implementiert".into())
}
pub fn populate(_mount_point: &PathBuf, _entries: &[RemoteEntry]) -> Result<(), String> {
Err("Linux FUSE-Provider: noch nicht implementiert".into())
}
@@ -0,0 +1,121 @@
//! Native File-Provider-Integration (Platzhalter-Dateien wie bei OneDrive).
//!
//! Auf Windows realisiert ueber die Cloud Files API (cfapi.dll), auf Linux
//! ueber FUSE (optional, hinter `linux_fuse`-Feature). macOS folgt spaeter
//! ueber NSFileProviderExtension (braucht Apple-Signatur).
//!
//! Der bestehende `sync::engine` bleibt unberuehrt und bietet weiterhin
//! den klassischen "kopiere-alles-lokal"-Modus. Der Cloud-Files-Modus
//! ist sozusagen "files-on-demand": Datei wird erst bei Zugriff geladen.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Ein Eintrag aus dem Mini-Cloud-Syncbaum, so wie er vom Server kommt.
/// Wird von beiden Plattformen genutzt, um Platzhalter / FUSE-Inodes zu
/// erzeugen.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteEntry {
pub id: i64,
pub name: String,
pub parent_id: Option<i64>,
pub is_folder: bool,
pub size: i64,
/// UTC-ISO8601
pub modified_at: String,
/// SHA-256 falls vom Server ausgeliefert, sonst None.
pub checksum: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncState {
/// Datei existiert nur als Platzhalter (online-only).
Cloud,
/// Datei ist vollstaendig lokal vorhanden und aktuell.
InSync,
/// Lokal geaendert, Upload ausstehend.
PendingUpload,
/// Auf dem Server gesperrt (durch anderen Nutzer).
LockedByOther,
/// Durch diesen Client gesperrt.
LockedLocal,
}
#[cfg(windows)]
pub mod windows;
#[cfg(windows)]
pub mod shell_integration;
#[cfg(all(target_os = "linux", feature = "linux_fuse"))]
pub mod linux;
pub mod sync_loop;
pub mod watcher;
/// Registriere den Sync-Root beim Betriebssystem. Ruft je nach Plattform
/// cfapi/CfRegisterSyncRoot bzw. mountet ein FUSE-Dateisystem.
#[allow(unused_variables)]
pub fn register_sync_root(
mount_point: &PathBuf,
provider_name: &str,
account_id: &str,
) -> Result<(), String> {
#[cfg(windows)]
return windows::register_sync_root(mount_point, provider_name, account_id);
#[cfg(all(target_os = "linux", feature = "linux_fuse"))]
return linux::mount(mount_point);
#[cfg(not(any(windows, all(target_os = "linux", feature = "linux_fuse"))))]
Err("File-Provider-Integration fuer diese Plattform noch nicht verfuegbar".into())
}
#[allow(unused_variables)]
pub fn unregister_sync_root(mount_point: &PathBuf) -> Result<(), String> {
#[cfg(windows)]
return windows::unregister_sync_root(mount_point);
#[cfg(all(target_os = "linux", feature = "linux_fuse"))]
return linux::unmount(mount_point);
#[cfg(not(any(windows, all(target_os = "linux", feature = "linux_fuse"))))]
Err("File-Provider-Integration fuer diese Plattform noch nicht verfuegbar".into())
}
/// Erzeuge fuer alle Remote-Eintraege Platzhalter (cloud-only Dateien).
/// Ordner werden als echte Verzeichnisse angelegt, Dateien als
/// Platzhalter mit gespeicherten Metadaten (Groesse, Mtime, ID).
#[allow(unused_variables)]
pub fn populate_placeholders(
mount_point: &PathBuf,
entries: &[RemoteEntry],
) -> Result<(), String> {
#[cfg(windows)]
return windows::populate_placeholders(mount_point, entries);
#[cfg(all(target_os = "linux", feature = "linux_fuse"))]
return linux::populate(mount_point, entries);
#[cfg(not(any(windows, all(target_os = "linux", feature = "linux_fuse"))))]
Err("File-Provider-Integration fuer diese Plattform noch nicht verfuegbar".into())
}
/// Ist File-Provider-Integration auf dieser Plattform grundsaetzlich verfuegbar?
pub fn is_supported() -> bool {
cfg!(windows) || cfg!(all(target_os = "linux", feature = "linux_fuse"))
}
/// Markiere eine lokal bereits vorhandene Datei als "immer offline halten".
#[allow(unused_variables)]
pub fn pin_file(path: &PathBuf) -> Result<(), String> {
#[cfg(windows)]
return windows::set_pin_state(path, true);
#[cfg(not(windows))]
Err("Nur auf Windows unterstuetzt".into())
}
#[allow(unused_variables)]
pub fn unpin_file(path: &PathBuf) -> Result<(), String> {
#[cfg(windows)]
return windows::set_pin_state(path, false);
#[cfg(not(windows))]
Err("Nur auf Windows unterstuetzt".into())
}
@@ -0,0 +1,206 @@
//! Explorer-Sidebar-Integration fuer Windows (ohne Admin-Rechte).
//!
//! Registriert den Sync-Ordner als Shell-Namespace-Extension unter
//! HKEY_CURRENT_USER, sodass er mit eigenem Icon in der Navigation
//! des Datei-Explorers erscheint (wie OneDrive/Dropbox).
//!
//! Anders als die eigentliche Cloud Files API ist das reine Registry-
//! Kosmetik - der Ordner funktioniert auch ohne Sidebar-Eintrag,
//! nur sieht man ihn dann nicht in der linken Leiste.
#![cfg(windows)]
use std::path::Path;
use winreg::enums::*;
use winreg::RegKey;
// Stabile GUID fuer Mini-Cloud - gleiche wie in windows.rs als ProviderId.
const CLSID_GUID: &str = "{4D696E69-436C-6F75-6444-7566667944AB}";
// Standard-CLSID fuer "Generic Shell Folder Implementation".
const SHELL_FOLDER_CLSID: &str = "{0E5AAE11-A475-4c5b-AB00-C66DE400274E}";
/// Registriere den Mount-Ordner in der Explorer-Navigation.
/// `icon_source`: Pfad zu ICO oder EXE mit Icon-Index (z.B. "C:\\app.exe,0")
pub fn install(
display_name: &str,
mount_point: &Path,
icon_source: &str,
) -> Result<(), String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
// 1) CLSID-Eintrag unter Software\Classes\CLSID\{GUID}
let clsid_path = format!("Software\\Classes\\CLSID\\{}", CLSID_GUID);
let (clsid, _) = hkcu
.create_subkey(&clsid_path)
.map_err(|e| format!("create CLSID: {e}"))?;
clsid
.set_value("", &display_name.to_string())
.map_err(|e| format!("set displayname: {e}"))?;
clsid
.set_value("System.IsPinnedToNameSpaceTree", &1u32)
.map_err(|e| format!("set pinned: {e}"))?;
clsid
.set_value("SortOrderIndex", &0x42u32)
.map_err(|e| format!("set sortorder: {e}"))?;
// 2) DefaultIcon
let (icon_key, _) = clsid
.create_subkey("DefaultIcon")
.map_err(|e| format!("create DefaultIcon: {e}"))?;
icon_key
.set_value("", &icon_source.to_string())
.map_err(|e| format!("set icon: {e}"))?;
// 3) InProcServer32 -> shell32.dll (Standard Shell-Folder-Host)
let (inproc, _) = clsid
.create_subkey("InProcServer32")
.map_err(|e| format!("create InProcServer32: {e}"))?;
inproc
.set_value("", &"%SystemRoot%\\system32\\shell32.dll".to_string())
.map_err(|e| format!("set shell32: {e}"))?;
inproc
.set_value("ThreadingModel", &"Both".to_string())
.map_err(|e| format!("set threading: {e}"))?;
// 4) Instance -> zeigt auf generischen Shell-Folder
let (instance, _) = clsid
.create_subkey("Instance")
.map_err(|e| format!("create Instance: {e}"))?;
instance
.set_value("CLSID", &SHELL_FOLDER_CLSID.to_string())
.map_err(|e| format!("set inst clsid: {e}"))?;
let (pb, _) = instance
.create_subkey("InitPropertyBag")
.map_err(|e| format!("create InitPropertyBag: {e}"))?;
pb.set_value("Attributes", &0x11u32)
.map_err(|e| format!("set attrs pb: {e}"))?;
pb.set_value(
"TargetFolderPath",
&mount_point.to_string_lossy().into_owned(),
)
.map_err(|e| format!("set target: {e}"))?;
// 5) ShellFolder-Flags
let (sf, _) = clsid
.create_subkey("ShellFolder")
.map_err(|e| format!("create ShellFolder: {e}"))?;
sf.set_value("FolderValueFlags", &0x28u32)
.map_err(|e| format!("set folderflags: {e}"))?;
sf.set_value("Attributes", &0xF080004Du32)
.map_err(|e| format!("set attrs sf: {e}"))?;
// 6) In die Navigation einhaengen
let ns_path = format!(
"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Desktop\\NameSpace\\{}",
CLSID_GUID
);
let (ns, _) = hkcu
.create_subkey(&ns_path)
.map_err(|e| format!("create NameSpace: {e}"))?;
ns.set_value("", &display_name.to_string())
.map_err(|e| format!("set ns name: {e}"))?;
// 7) Kontext-Menue-Verben (Rechtsklick) fuer Dateien unter dem Mount
install_context_menu(mount_point)?;
// 8) Explorer informieren (SHChangeNotify)
notify_shell();
Ok(())
}
/// Registriert "Immer offline halten" / "Speicher freigeben" als
/// Rechtsklick-Menuepunkte, die nur fuer Dateien unterhalb des Mounts
/// angezeigt werden (AppliesTo-Filter).
fn install_context_menu(mount_point: &Path) -> Result<(), String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let exe = std::env::current_exe()
.map_err(|e| format!("current_exe: {e}"))?
.to_string_lossy()
.into_owned();
// Trailing Backslash wegstrippen, dann eine saubere AQS-Query bauen.
// Registry-Werte sind normale Strings; Backslashes bleiben einfach.
let mount_clean = mount_point
.to_string_lossy()
.trim_end_matches('\\')
.to_string();
// AppliesTo: nur Dateien, deren Pfad mit dem Mount-Ordner beginnt.
let applies_to = format!("System.ItemPathDisplay:~< \"{}\"", mount_clean);
for (verb, label, flag) in [
("MiniCloudPin", "Immer offline verfuegbar", "--pin"),
("MiniCloudUnpin", "Speicher freigeben", "--unpin"),
] {
// Unter AllFilesystemObjects statt * - das greift auch fuer
// Ordner und vermeidet Konflikte mit Dateityp-spezifischen Verben.
let key_path = format!("Software\\Classes\\AllFilesystemObjects\\shell\\{}", verb);
let (k, _) = hkcu
.create_subkey(&key_path)
.map_err(|e| format!("verb {verb}: {e}"))?;
k.set_value("", &label.to_string())
.map_err(|e| format!("default: {e}"))?;
k.set_value("MUIVerb", &label.to_string())
.map_err(|e| format!("MUIVerb: {e}"))?;
k.set_value("AppliesTo", &applies_to)
.map_err(|e| format!("AppliesTo: {e}"))?;
k.set_value("Icon", &exe)
.map_err(|e| format!("Icon: {e}"))?;
let (cmd, _) = k
.create_subkey("command")
.map_err(|e| format!("cmd: {e}"))?;
cmd.set_value("", &format!("\"{}\" {} \"%1\"", exe, flag))
.map_err(|e| format!("cmdline: {e}"))?;
}
Ok(())
}
fn uninstall_context_menu() {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
for verb in ["MiniCloudPin", "MiniCloudUnpin"] {
// alte (falsche) Stelle ebenfalls aufraeumen
let _ = hkcu.delete_subkey_all(format!("Software\\Classes\\*\\shell\\{}", verb));
let _ = hkcu.delete_subkey_all(format!(
"Software\\Classes\\AllFilesystemObjects\\shell\\{}",
verb
));
}
}
/// Entferne die Shell-Integration wieder.
pub fn uninstall() -> Result<(), String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let ns_path = format!(
"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Desktop\\NameSpace\\{}",
CLSID_GUID
);
let _ = hkcu.delete_subkey_all(&ns_path);
let clsid_path = format!("Software\\Classes\\CLSID\\{}", CLSID_GUID);
let _ = hkcu.delete_subkey_all(&clsid_path);
uninstall_context_menu();
notify_shell();
Ok(())
}
/// Teilt Explorer mit, dass sich die Shell-Namespace-Liste geaendert hat.
/// Ohne das sieht man den neuen Eintrag erst nach Explorer-Neustart.
fn notify_shell() {
use windows::Win32::UI::Shell::{SHChangeNotify, SHCNE_ASSOCCHANGED, SHCNF_IDLIST};
unsafe {
SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, None, None);
}
}
/// Standard-Icon-Quelle: die laufende .exe mit Index 0.
pub fn default_icon_source() -> String {
std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(|s| format!("{},0", s)))
.unwrap_or_else(|| "%SystemRoot%\\system32\\imageres.dll,2".to_string())
}
@@ -0,0 +1,221 @@
//! Hintergrund-Synchronisation fuer den Cloud-Files-Modus.
//!
//! Zwei Aufgaben:
//! 1. Lokale Aenderungen im Mount-Point beobachten (notify-Watcher) und
//! geaenderte Dateien hochladen. Neu angelegte Dateien werden als
//! neue Datei beim Server registriert und als Platzhalter markiert.
//! 2. Serverseitige Aenderungen pollen (/api/sync/changes?since=...) und
//! fehlende Platzhalter erzeugen bzw. entfernte loeschen.
//!
//! Der Loop laeuft in einem dedizierten Tokio-Task; ein gespeicherter
//! `Stop`-Channel beendet ihn sauber beim Deaktivieren.
use super::RemoteEntry;
use serde::Deserialize;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
#[derive(Clone)]
pub struct SyncLoopConfig {
pub server_url: String,
pub access_token: String,
pub mount_point: PathBuf,
pub poll_interval_secs: u64,
}
pub struct SyncLoopHandle {
pub stop_flag: Arc<AtomicBool>,
pub tx: mpsc::UnboundedSender<LoopMessage>,
}
pub enum LoopMessage {
LocalChange(PathBuf),
Shutdown,
}
/// Starte den Sync-Loop. Gibt einen Handle zurueck, mit dem man ihn
/// stoppen oder externe Events (z.B. vom Watcher) einspeisen kann.
pub fn start(cfg: SyncLoopConfig) -> SyncLoopHandle {
let stop_flag = Arc::new(AtomicBool::new(false));
let (tx, mut rx) = mpsc::unbounded_channel::<LoopMessage>();
let stop = stop_flag.clone();
let cfg_task = cfg.clone();
tokio::spawn(async move {
let client = reqwest::Client::new();
let mut since: Option<String> = None;
let mut interval = tokio::time::interval(Duration::from_secs(cfg_task.poll_interval_secs));
loop {
if stop.load(Ordering::Relaxed) {
break;
}
tokio::select! {
_ = interval.tick() => {
if let Err(e) = poll_server_changes(&client, &cfg_task, &mut since).await {
eprintln!("[cloud_files] poll error: {e}");
}
}
Some(msg) = rx.recv() => {
match msg {
LoopMessage::Shutdown => break,
LoopMessage::LocalChange(path) => {
if let Err(e) = upload_local_change(&client, &cfg_task, &path).await {
eprintln!("[cloud_files] upload error: {e}");
}
}
}
}
}
}
});
SyncLoopHandle { stop_flag, tx }
}
#[derive(Debug, Deserialize)]
struct ChangesResponse {
#[serde(default)]
created: Vec<RemoteEntry>,
#[serde(default)]
updated: Vec<RemoteEntry>,
#[serde(default)]
deleted: Vec<i64>,
timestamp: Option<String>,
}
async fn poll_server_changes(
client: &reqwest::Client,
cfg: &SyncLoopConfig,
since: &mut Option<String>,
) -> Result<(), String> {
let base = cfg.server_url.trim_end_matches('/');
let mut url = format!("{}/api/sync/changes", base);
if let Some(s) = since.as_deref() {
url.push_str(&format!("?since={}", urlencode(s)));
}
let resp = client
.get(&url)
.bearer_auth(&cfg.access_token)
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
let body: ChangesResponse = resp.json().await.map_err(|e| e.to_string())?;
// Created + Updated: jeweils passendes Verzeichnis sichern, dann
// Platzhalter (neu) anlegen. Bei Updates muss der alte Platzhalter
// erst geloescht werden - Windows erlaubt kein "replace in place".
for e in body.created.iter().chain(body.updated.iter()) {
let rel = build_relative_path(e);
let full = cfg.mount_point.join(&rel);
if e.is_folder {
let _ = std::fs::create_dir_all(&full);
continue;
}
let parent = full.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| cfg.mount_point.clone());
let _ = std::fs::create_dir_all(&parent);
let _ = std::fs::remove_file(&full); // ignoriert falls nicht da
#[cfg(windows)]
{
let identity = e.id.to_string();
if let Err(err) = super::windows::create_placeholder_at(
&parent,
&e.name,
e.size,
&e.modified_at,
identity.as_bytes(),
) {
eprintln!("[cloud_files] placeholder {}: {}", e.name, err);
}
}
}
// Deleted: nur per ID vom Server - wir kennen den Pfad nicht mehr.
// MVP: ignorieren. In Version 2 fuehren wir ein lokales Mapping.
let _ = body.deleted;
if let Some(ts) = body.timestamp {
*since = Some(ts);
}
Ok(())
}
async fn upload_local_change(
client: &reqwest::Client,
cfg: &SyncLoopConfig,
path: &PathBuf,
) -> Result<(), String> {
if !path.is_file() {
return Ok(());
}
// cfapi-Platzhalter oder gerade hydrierende Dateien NICHT hochladen -
// sonst wird jede Wolken-Datei sofort komplett gesynct und wir haben
// keinen On-Demand-Modus mehr.
#[cfg(windows)]
{
if super::windows::is_cfapi_placeholder(path) {
super::windows::log_msg(
&cfg.mount_point,
&format!("skip upload (placeholder): {}", path.display()),
);
return Ok(());
}
}
// Eigene Log-Datei nicht mit hochladen.
if path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with(".minicloud-"))
.unwrap_or(false)
{
return Ok(());
}
// Relativer Pfad im Mount = Ziel-Pfad auf Server
let rel = path
.strip_prefix(&cfg.mount_point)
.map_err(|_| "path outside mount".to_string())?
.to_string_lossy()
.replace('\\', "/");
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
let base = cfg.server_url.trim_end_matches('/');
let url = format!("{}/api/files/upload", base);
let file_name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("unnamed")
.to_string();
let form = reqwest::multipart::Form::new()
.text("path", rel.clone())
.part(
"file",
reqwest::multipart::Part::bytes(bytes).file_name(file_name),
);
let resp = client
.post(&url)
.bearer_auth(&cfg.access_token)
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
Ok(())
}
fn build_relative_path(e: &RemoteEntry) -> PathBuf {
// Achtung: RemoteEntry hat nur parent_id, nicht den kompletten Pfad.
// Fuer diesen einfachen Fall nehmen wir nur den Namen. Bei geschachtelten
// Ordnern muesste man die Hierarchie ueber /api/sync/tree vor-laden -
// das passiert einmal beim Aktivieren; Delta-Updates kommen meistens
// flach (oder in einer gemeinsamen Wurzel).
PathBuf::from(&e.name)
}
fn urlencode(s: &str) -> String {
// Sehr minimalistisch: wir ersetzen nur problematische Zeichen.
s.replace(' ', "%20").replace(':', "%3A").replace('+', "%2B")
}
@@ -0,0 +1,43 @@
//! Leichtgewichtiger Callback-basierter FS-Watcher fuer den Cloud-Files-Modus.
//!
//! Anders als `sync::watcher::FileWatcher` gibt dieser hier einen Closure
//! direkt an notify weiter, sodass wir kein Channel-Pumpen brauchen.
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher, Config};
use std::path::{Path, PathBuf};
pub struct CallbackWatcher {
_watcher: RecommendedWatcher,
}
impl CallbackWatcher {
pub fn new<F>(watch_dir: &Path, mut on_change: F) -> Result<Self, String>
where
F: FnMut(PathBuf, EventKind) + Send + 'static,
{
let mut watcher = RecommendedWatcher::new(
move |res: Result<Event, notify::Error>| {
if let Ok(ev) = res {
for path in ev.paths {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.starts_with('.')
|| name.starts_with('~')
|| name.ends_with(".tmp")
{
continue;
}
on_change(path, ev.kind.clone());
}
}
},
Config::default(),
)
.map_err(|e| format!("Watcher-Fehler: {e}"))?;
watcher
.watch(watch_dir, RecursiveMode::Recursive)
.map_err(|e| format!("Watch-Fehler: {e}"))?;
Ok(Self { _watcher: watcher })
}
}
@@ -0,0 +1,639 @@
//! Windows Cloud Files API Integration.
//!
//! Registriert den Sync-Ordner als Sync-Root, legt Platzhalter-Dateien an
//! und reicht Zugriffe auf Dateidaten als HTTPS-Download durch. Der
//! Explorer zeigt Wolken-/Haken-Overlays automatisch an, solange die
//! Pin-Stati korrekt gesetzt sind.
//!
//! Voraussetzung: Windows 10 1709+ (cfapi.dll). Der Account-Identifier
//! sollte stabil sein (z.B. Hash(Server-URL + Username)).
#![cfg(windows)]
use super::RemoteEntry;
use once_cell::sync::Lazy;
use std::path::{Path, PathBuf};
use std::ptr;
use std::sync::{Arc, Mutex};
use widestring::U16CString;
use windows::core::PCWSTR;
use windows::Win32::Storage::CloudFilters as CF;
use windows::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL;
use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED};
#[derive(Default, Clone)]
pub struct CloudContext {
pub server_url: String,
pub access_token: String,
pub mount_point: PathBuf,
}
static CONTEXT: Lazy<Arc<Mutex<CloudContext>>> =
Lazy::new(|| Arc::new(Mutex::new(CloudContext::default())));
static CONNECTION_KEY: Lazy<Mutex<Option<CF::CF_CONNECTION_KEY>>> =
Lazy::new(|| Mutex::new(None));
pub fn set_context(server_url: String, access_token: String, mount_point: PathBuf) {
let mut ctx = CONTEXT.lock().unwrap();
ctx.server_url = server_url;
ctx.access_token = access_token;
ctx.mount_point = mount_point;
}
fn ctx_snapshot() -> CloudContext {
CONTEXT.lock().unwrap().clone()
}
const PROVIDER_VERSION: &str = "1.0";
// Windows-FILETIME: 100ns-Ticks seit 1601-01-01. Unix-Epoch liegt
// 11_644_473_600 Sekunden danach.
fn unix_to_ft_ticks(unix_secs: i64) -> i64 {
(unix_secs + 11_644_473_600) * 10_000_000
}
// ---------------------------------------------------------------------------
// Sync-Root-Registrierung
// ---------------------------------------------------------------------------
pub fn register_sync_root(
mount_point: &PathBuf,
provider_name: &str,
account_id: &str,
) -> Result<(), String> {
// COM initialisieren (cfapi benoetigt MTA-Apartment)
unsafe {
let _ = CoInitializeEx(Some(ptr::null()), COINIT_MULTITHREADED);
}
std::fs::create_dir_all(mount_point).map_err(|e| format!("mkdir: {e}"))?;
let display = format!("Mini-Cloud - {}", account_id);
let path_wide = U16CString::from_str(mount_point.to_string_lossy().as_ref())
.map_err(|e| format!("path encode: {e}"))?;
let display_wide = U16CString::from_str(&display).map_err(|e| e.to_string())?;
let provider_wide = U16CString::from_str(provider_name).map_err(|e| e.to_string())?;
let version_wide = U16CString::from_str(PROVIDER_VERSION).map_err(|e| e.to_string())?;
let mut info = CF::CF_SYNC_REGISTRATION::default();
info.StructSize = std::mem::size_of::<CF::CF_SYNC_REGISTRATION>() as u32;
info.ProviderName = PCWSTR(provider_wide.as_ptr());
info.ProviderVersion = PCWSTR(version_wide.as_ptr());
// Stabile GUID fuer "Mini-Cloud" (random einmalig generiert).
info.ProviderId = windows::core::GUID::from_u128(0x4D696E69_436C_6F75_6444_7566667944ab);
let mut policies = CF::CF_SYNC_POLICIES::default();
policies.StructSize = std::mem::size_of::<CF::CF_SYNC_POLICIES>() as u32;
policies.HardLink = CF::CF_HARDLINK_POLICY::default();
policies.Hydration = CF::CF_HYDRATION_POLICY::default();
policies.Population = CF::CF_POPULATION_POLICY::default();
policies.InSync = CF::CF_INSYNC_POLICY::default();
// Hydration PARTIAL = Datei-Inhalt kommt bei Zugriff per FETCH_DATA.
// Population FULL = Ordnerinhalt ist komplett vorgefuellt durch uns
// (populate_placeholders). So muss Windows NICHT FETCH_PLACEHOLDERS
// callen, den wir nicht implementieren - sonst timeout beim Oeffnen.
policies.Hydration.Primary = CF::CF_HYDRATION_POLICY_PARTIAL;
policies.Population.Primary = CF::CF_POPULATION_POLICY_FULL;
// Holder fuer displayname, damit wir ihn spaeter ggf. in ein eigenes
// struct einbauen koennen. windows-rs verlangt hier nichts weiter.
let _ = display_wide;
// Erst eine eventuell vorhandene Registrierung wegraeumen. Sonst
// uebernimmt UPDATE nur einen Teil der Policies und alte PARTIAL-
// Population-Einstellungen bleiben aktiv -> Explorer-Timeout.
unsafe {
let _ = CF::CfUnregisterSyncRoot(PCWSTR(path_wide.as_ptr()));
}
log_msg(mount_point, &format!(
"register_sync_root path={} provider={} account={}",
mount_point.display(), provider_name, account_id
));
unsafe {
if let Err(e) = CF::CfRegisterSyncRoot(
PCWSTR(path_wide.as_ptr()),
&info,
&policies,
CF::CF_REGISTER_FLAG_NONE,
) {
log_err(mount_point, &format!("CfRegisterSyncRoot FAILED: {e:?}"));
// Als Fallback mit UPDATE-Flag
CF::CfRegisterSyncRoot(
PCWSTR(path_wide.as_ptr()),
&info,
&policies,
CF::CF_REGISTER_FLAG_UPDATE,
)
.map_err(|e| format!("CfRegisterSyncRoot(UPDATE): {e}"))?;
}
}
log_msg(mount_point, "CfRegisterSyncRoot OK");
connect_callbacks(mount_point)?;
log_msg(mount_point, "callbacks connected");
// Explorer-Sidebar-Eintrag mit Wolken-Icon
let icon = super::shell_integration::default_icon_source();
match super::shell_integration::install(provider_name, mount_point, &icon) {
Ok(()) => log_msg(mount_point, "shell integration installed"),
Err(e) => log_err(mount_point, &format!("shell integration FAILED: {e}")),
}
Ok(())
}
pub fn unregister_sync_root(mount_point: &PathBuf) -> Result<(), String> {
// Shell-Eintrag zuerst entfernen (schlaegt nie fehl).
let _ = super::shell_integration::uninstall();
let _ = disconnect_callbacks();
let path_wide = U16CString::from_str(mount_point.to_string_lossy().as_ref())
.map_err(|e| e.to_string())?;
unsafe {
CF::CfUnregisterSyncRoot(PCWSTR(path_wide.as_ptr()))
.map_err(|e| format!("CfUnregisterSyncRoot: {e}"))?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Callback-Tabelle
// ---------------------------------------------------------------------------
unsafe extern "system" fn on_fetch_data(
info: *const CF::CF_CALLBACK_INFO,
params: *const CF::CF_CALLBACK_PARAMETERS,
) {
let info = &*info;
let params = &*params;
let fetch = &params.Anonymous.FetchData;
// FileIdentity enthaelt unsere Mini-Cloud-File-ID als UTF-8-Bytes.
let identity = std::slice::from_raw_parts(
info.FileIdentity as *const u8,
info.FileIdentityLength as usize,
);
let file_id: i64 = std::str::from_utf8(identity)
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let offset: i64 = fetch.RequiredFileOffset;
let length: u64 = fetch.RequiredLength as u64;
let connection_key = info.ConnectionKey;
let transfer_key = info.TransferKey;
// HTTPS-Download im separaten Thread (Callback darf nicht blockieren).
let ctx = ctx_snapshot();
std::thread::spawn(move || {
log_msg(&ctx.mount_point, &format!(
"FETCH_DATA file_id={file_id} offset={offset} len={length}"
));
match transfer_range(connection_key, transfer_key, file_id, offset, length, &ctx) {
Ok(()) => log_msg(&ctx.mount_point, &format!(
"fetch file_id={file_id} OK"
)),
Err(e) => {
log_err(&ctx.mount_point, &format!(
"fetch file_id={file_id} offset={offset} len={length} FAILED: {e}"
));
// Garantiert Fehler-Completion, damit Windows nicht in Timeout laeuft.
let _ = complete_transfer(connection_key, transfer_key, None, offset, length);
}
}
});
}
pub fn log_msg(mount: &Path, msg: &str) {
use std::io::Write;
// Log-Datei NEBEN den Mount, damit sie nicht selbst als Platzhalter
// behandelt wird.
let log = mount
.parent()
.map(|p| p.join(".minicloud-cloudfiles.log"))
.unwrap_or_else(|| PathBuf::from(".minicloud-cloudfiles.log"));
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log) {
let _ = writeln!(f, "[{}] {}", chrono::Utc::now().to_rfc3339(), msg);
}
}
fn log_err(mount: &Path, msg: &str) {
log_msg(mount, msg);
}
/// True wenn die Datei ein cfapi-Platzhalter ist (noch nicht hydriert)
/// oder gerade vom Cloud-Filter verwaltet wird. Fuer solche Dateien
/// duerfen wir KEINEN Upload ausloesen, sonst verwandelt der Sync-Loop
/// jeden Platzhalter sofort in eine vollstaendig lokale Datei.
pub fn is_cfapi_placeholder(path: &Path) -> bool {
use windows::Win32::Storage::FileSystem::GetFileAttributesW;
let Ok(w) = U16CString::from_str(path.to_string_lossy().as_ref()) else {
return false;
};
let attrs = unsafe { GetFileAttributesW(PCWSTR(w.as_ptr())) };
if attrs == u32::MAX {
return false;
}
// FILE_ATTRIBUTE_OFFLINE (0x1000) oder
// FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS (0x400000) oder
// FILE_ATTRIBUTE_RECALL_ON_OPEN (0x40000)
(attrs & 0x0040_1000) != 0 || (attrs & 0x0004_0000) != 0
}
fn transfer_range(
connection_key: CF::CF_CONNECTION_KEY,
transfer_key: i64,
file_id: i64,
offset: i64,
length: u64,
ctx: &CloudContext,
) -> Result<(), String> {
if ctx.server_url.is_empty() || ctx.access_token.is_empty() {
return Err("CloudContext nicht gesetzt (Server/Token leer)".into());
}
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()
.map_err(|e| format!("client: {e}"))?;
let url = format!(
"{}/api/files/{}/download",
ctx.server_url.trim_end_matches('/'),
file_id
);
let range = format!("bytes={}-{}", offset, offset as u64 + length - 1);
let resp = client
.get(&url)
.bearer_auth(&ctx.access_token)
.header("Range", &range)
.send()
.map_err(|e| format!("send: {e}"))?;
let status = resp.status();
if !status.is_success() && status.as_u16() != 206 {
return Err(format!("HTTP {}", status));
}
let bytes = resp.bytes().map_err(|e: reqwest::Error| e.to_string())?;
// Wenn Server kein Range unterstuetzt und volle Datei liefert,
// aus dem Body den angeforderten Bereich ausschneiden.
let slice: &[u8] = if status.as_u16() == 206 {
&bytes[..]
} else {
let start = offset as usize;
let end = (start + length as usize).min(bytes.len());
if start >= bytes.len() {
&[]
} else {
&bytes[start..end]
}
};
complete_transfer(connection_key, transfer_key, Some(slice), offset, slice.len() as u64)
}
fn complete_transfer(
connection_key: CF::CF_CONNECTION_KEY,
transfer_key: i64,
data: Option<&[u8]>,
offset: i64,
length: u64,
) -> Result<(), String> {
let mut op_info = CF::CF_OPERATION_INFO::default();
op_info.StructSize = std::mem::size_of::<CF::CF_OPERATION_INFO>() as u32;
op_info.Type = CF::CF_OPERATION_TYPE_TRANSFER_DATA;
op_info.ConnectionKey = connection_key;
op_info.TransferKey = transfer_key;
let mut params = CF::CF_OPERATION_PARAMETERS::default();
params.ParamSize = std::mem::size_of::<CF::CF_OPERATION_PARAMETERS>() as u32;
unsafe {
let transfer = &mut params.Anonymous.TransferData;
if let Some(data) = data {
transfer.CompletionStatus = windows::Win32::Foundation::NTSTATUS(0); // STATUS_SUCCESS
transfer.Buffer = data.as_ptr() as _;
transfer.Offset = offset;
transfer.Length = length as i64;
} else {
transfer.CompletionStatus =
windows::Win32::Foundation::NTSTATUS(0xC0000001u32 as i32); // STATUS_UNSUCCESSFUL
}
CF::CfExecute(&op_info, &mut params).map_err(|e| format!("CfExecute: {e}"))?;
}
Ok(())
}
unsafe extern "system" fn on_fetch_placeholders(
info: *const CF::CF_CALLBACK_INFO,
_params: *const CF::CF_CALLBACK_PARAMETERS,
) {
// Safety-Net: wir populieren schon ueber populate_placeholders,
// aber falls Windows trotzdem ruft, geben wir leere Antwort.
let info = &*info;
let mut op_info = CF::CF_OPERATION_INFO::default();
op_info.StructSize = std::mem::size_of::<CF::CF_OPERATION_INFO>() as u32;
op_info.Type = CF::CF_OPERATION_TYPE_TRANSFER_PLACEHOLDERS;
op_info.ConnectionKey = info.ConnectionKey;
op_info.TransferKey = info.TransferKey;
let mut params = CF::CF_OPERATION_PARAMETERS::default();
params.ParamSize = std::mem::size_of::<CF::CF_OPERATION_PARAMETERS>() as u32;
let transfer = &mut params.Anonymous.TransferPlaceholders;
transfer.CompletionStatus = windows::Win32::Foundation::NTSTATUS(0);
transfer.PlaceholderTotalCount = 0;
transfer.PlaceholderArray = std::ptr::null_mut();
transfer.PlaceholderCount = 0;
transfer.EntriesProcessed = 0;
transfer.Flags = CF::CF_OPERATION_TRANSFER_PLACEHOLDERS_FLAG_DISABLE_ON_DEMAND_POPULATION;
let _ = CF::CfExecute(&op_info, &mut params);
}
fn connect_callbacks(mount_point: &Path) -> Result<(), String> {
let callbacks = [
CF::CF_CALLBACK_REGISTRATION {
Type: CF::CF_CALLBACK_TYPE_FETCH_DATA,
Callback: Some(on_fetch_data),
},
CF::CF_CALLBACK_REGISTRATION {
Type: CF::CF_CALLBACK_TYPE_FETCH_PLACEHOLDERS,
Callback: Some(on_fetch_placeholders),
},
// Sentinel: Type = INVALID beendet die Tabelle.
CF::CF_CALLBACK_REGISTRATION {
Type: CF::CF_CALLBACK_TYPE_NONE,
Callback: None,
},
];
let path_wide = U16CString::from_str(mount_point.to_string_lossy().as_ref())
.map_err(|e| e.to_string())?;
let key = unsafe {
CF::CfConnectSyncRoot(
PCWSTR(path_wide.as_ptr()),
callbacks.as_ptr(),
None,
CF::CF_CONNECT_FLAG_REQUIRE_PROCESS_INFO
| CF::CF_CONNECT_FLAG_REQUIRE_FULL_FILE_PATH,
)
.map_err(|e| format!("CfConnectSyncRoot: {e}"))?
};
*CONNECTION_KEY.lock().unwrap() = Some(key);
Ok(())
}
fn disconnect_callbacks() -> Result<(), String> {
if let Some(key) = CONNECTION_KEY.lock().unwrap().take() {
unsafe {
CF::CfDisconnectSyncRoot(key)
.map_err(|e| format!("CfDisconnectSyncRoot: {e}"))?;
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Placeholder-Erzeugung
// ---------------------------------------------------------------------------
pub fn populate_placeholders(
mount_point: &PathBuf,
entries: &[RemoteEntry],
) -> Result<(), String> {
use std::collections::HashMap;
log_msg(mount_point, &format!(
"populate_placeholders: {} Eintraege", entries.len()
));
let by_id: HashMap<i64, &RemoteEntry> = entries.iter().map(|e| (e.id, e)).collect();
fn rel_path<'a>(
entry: &'a RemoteEntry,
by_id: &HashMap<i64, &'a RemoteEntry>,
) -> PathBuf {
let mut parts = vec![entry.name.as_str()];
let mut cur = entry.parent_id;
while let Some(id) = cur {
if let Some(p) = by_id.get(&id) {
parts.push(p.name.as_str());
cur = p.parent_id;
} else {
break;
}
}
parts.reverse();
parts.iter().collect()
}
// Erst Ordner anlegen
for e in entries.iter().filter(|e| e.is_folder) {
let p = mount_point.join(rel_path(e, &by_id));
std::fs::create_dir_all(&p).ok();
}
// Dann Dateien als Platzhalter. Existierende "normale" Dateien
// (z.B. nach vorherigem CfUnregisterSyncRoot) vorher loeschen,
// weil CfCreatePlaceholders sonst mit ERROR_FILE_EXISTS scheitert
// und die Datei nie zum Platzhalter wird -> spaeter koennte man
// sie nicht mehr dehydrieren (0x80070178 "keine Clouddatei").
for e in entries.iter().filter(|e| !e.is_folder) {
let rel = rel_path(e, &by_id);
let full = mount_point.join(&rel);
let parent = rel
.parent()
.map(|p| mount_point.join(p))
.unwrap_or_else(|| mount_point.clone());
let identity = e.id.to_string();
if full.exists() && !is_cfapi_placeholder(&full) {
log_msg(mount_point, &format!(
"deleting non-placeholder {} to recreate",
full.display()
));
if let Err(err) = std::fs::remove_file(&full) {
log_err(mount_point, &format!(
"remove {} failed: {err}", full.display()
));
}
}
match create_placeholder(&parent, &e.name, e.size, &e.modified_at, identity.as_bytes()) {
Ok(()) => log_msg(mount_point, &format!("placeholder created: {}", full.display())),
Err(err) => log_err(mount_point, &format!(
"placeholder {} FAILED: {err}", full.display()
)),
}
}
Ok(())
}
pub fn create_placeholder_at(
parent_dir: &Path,
name: &str,
size: i64,
modified_iso: &str,
file_identity: &[u8],
) -> Result<(), String> {
create_placeholder(parent_dir, name, size, modified_iso, file_identity)
}
fn create_placeholder(
parent_dir: &Path,
name: &str,
size: i64,
modified_iso: &str,
file_identity: &[u8],
) -> Result<(), String> {
let parent_wide = U16CString::from_str(parent_dir.to_string_lossy().as_ref())
.map_err(|e| e.to_string())?;
let name_wide = U16CString::from_str(name).map_err(|e| e.to_string())?;
let mtime_unix = chrono::DateTime::parse_from_rfc3339(modified_iso)
.map(|dt| dt.timestamp())
.unwrap_or(0);
let ft_ticks = unix_to_ft_ticks(mtime_unix);
let mut ph = CF::CF_PLACEHOLDER_CREATE_INFO::default();
ph.RelativeFileName = PCWSTR(name_wide.as_ptr());
ph.FsMetadata.FileSize = size;
ph.FsMetadata.BasicInfo.FileAttributes = FILE_ATTRIBUTE_NORMAL.0;
ph.FsMetadata.BasicInfo.LastWriteTime = ft_ticks;
ph.FsMetadata.BasicInfo.CreationTime = ft_ticks;
ph.FsMetadata.BasicInfo.ChangeTime = ft_ticks;
ph.FsMetadata.BasicInfo.LastAccessTime = ft_ticks;
ph.Flags = CF::CF_PLACEHOLDER_CREATE_FLAG_MARK_IN_SYNC;
ph.FileIdentity = file_identity.as_ptr() as _;
ph.FileIdentityLength = file_identity.len() as u32;
// CfCreatePlaceholders nimmt in windows-rs 0.58 einen Slice und einen
// Option<*mut u32> fuer "wie viele wurden angelegt".
let mut phs = [ph];
let mut count: u32 = 0;
unsafe {
CF::CfCreatePlaceholders(
PCWSTR(parent_wide.as_ptr()),
&mut phs,
CF::CF_CREATE_FLAG_NONE,
Some(&mut count as *mut u32),
)
.map_err(|e| format!("CfCreatePlaceholders: {e}"))?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Pin / Unpin (offline halten)
// ---------------------------------------------------------------------------
pub fn set_pin_state(file: &Path, pinned: bool) -> Result<(), String> {
use windows::Win32::Storage::FileSystem::{
CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT,
FILE_WRITE_ATTRIBUTES, FILE_READ_ATTRIBUTES,
FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_SHARE_DELETE, OPEN_EXISTING,
};
let path_wide = U16CString::from_str(file.to_string_lossy().as_ref())
.map_err(|e| e.to_string())?;
// CfSetPinState / CfDehydratePlaceholder brauchen WRITE_ATTRIBUTES.
// OPEN_REPARSE_POINT verhindert, dass das Oeffnen selbst eine
// Hydration ausloest (sonst waere Unpin bedeutungslos).
let handle = unsafe {
CreateFileW(
PCWSTR(path_wide.as_ptr()),
(FILE_READ_ATTRIBUTES | FILE_WRITE_ATTRIBUTES).0,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
None,
OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
None,
)
}
.map_err(|e| format!("open: {e}"))?;
let state = if pinned {
CF::CF_PIN_STATE_PINNED
} else {
CF::CF_PIN_STATE_UNPINNED
};
let set_res = unsafe {
CF::CfSetPinState(handle, state, CF::CF_SET_PIN_FLAG_NONE, None)
};
// Hydrate bei Pin / Dehydrate bei Unpin. CfSetPinState aendert nur
// das Flag - ohne explizite Hydrate-/Dehydrate-Calls passiert am
// Disk-Inhalt und am Icon nichts Sichtbares.
let (hydrate_err, dehydrate_err) = if set_res.is_ok() {
if pinned {
let r = unsafe {
CF::CfHydratePlaceholder(
handle,
0,
-1,
CF::CF_HYDRATE_FLAG_NONE,
None,
)
};
(r.err().map(|e| format!("{:?}", e)), None)
} else {
let r = unsafe {
CF::CfDehydratePlaceholder(
handle,
0,
-1,
CF::CF_DEHYDRATE_FLAG_NONE,
None,
)
};
(None, r.err().map(|e| format!("{:?}", e)))
}
} else {
(None, None)
};
unsafe {
let _ = windows::Win32::Foundation::CloseHandle(handle);
}
// Explorer Icon-Overlay aktualisieren
notify_file_update(file);
// Log-Verzeichnis ist der Mount-Ordner oder dessen Parent
let log_dir = file
.ancestors()
.find(|p| p.parent().is_some())
.map(|p| p.to_path_buf())
.unwrap_or_else(|| file.to_path_buf());
log_msg(
&log_dir,
&format!(
"set_pin_state file={} pinned={} result={:?} hydrate_err={:?} dehydrate_err={:?}",
file.display(),
pinned,
set_res,
hydrate_err,
dehydrate_err
),
);
set_res.map_err(|e| format!("CfSetPinState: {e}"))?;
Ok(())
}
/// Sagt dem Shell "diese Datei hat sich geaendert" damit das Overlay-
/// Icon (Wolke/Haken) aktualisiert wird, ohne dass der User F5 druecken
/// muss.
fn notify_file_update(file: &Path) {
use windows::Win32::UI::Shell::{SHChangeNotify, SHCNE_UPDATEITEM, SHCNF_PATHW};
let Ok(w) = U16CString::from_str(file.to_string_lossy().as_ref()) else {
return;
};
unsafe {
SHChangeNotify(
SHCNE_UPDATEITEM,
SHCNF_PATHW,
Some(w.as_ptr() as _),
None,
);
}
}
+276
View File
@@ -1,4 +1,5 @@
mod sync;
mod cloud_files;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -26,6 +27,8 @@ struct AppState {
sync_paths: Mutex<Vec<SyncPath>>,
journal: Arc<Journal>,
background_started: AtomicBool,
cloud_files_loop: Mutex<Option<cloud_files::sync_loop::SyncLoopHandle>>,
cloud_files_watcher: Mutex<Option<cloud_files::watcher::CallbackWatcher>>,
}
// --- Auth ---
@@ -884,8 +887,272 @@ fn handle_single_instance() {
// ---------------------------------------------------------------------------
// Native File-Provider-Integration (OneDrive-artige Platzhalter)
// ---------------------------------------------------------------------------
#[tauri::command]
fn cloud_files_supported() -> bool {
cloud_files::is_supported()
}
#[tauri::command]
async fn cloud_files_enable(
state: State<'_, AppState>,
mount_point: String,
) -> Result<(), String> {
let mp = PathBuf::from(&mount_point);
// MutexGuards nur kurz halten, damit der Future Send bleibt.
let (server, token, username) = {
let api_guard = state.api.lock().unwrap();
let api = api_guard.as_ref().ok_or("Nicht eingeloggt")?;
let username = state
.username
.lock()
.unwrap()
.clone()
.unwrap_or_else(|| "user".into());
(api.server_url.clone(), api.access_token.clone(), username)
};
#[cfg(windows)]
{
cloud_files::windows::set_context(server.clone(), token.clone(), mp.clone());
}
cloud_files::register_sync_root(&mp, "Mini-Cloud", &username)?;
// Baum vom Server holen und Platzhalter anlegen
let entries = fetch_remote_entries(&server, &token).await?;
cloud_files::populate_placeholders(&mp, &entries)?;
// Hintergrund-Loop starten: poll Changes + upload lokaler Aenderungen
let cfg = cloud_files::sync_loop::SyncLoopConfig {
server_url: server.clone(),
access_token: token.clone(),
mount_point: mp.clone(),
poll_interval_secs: 30,
};
let handle = cloud_files::sync_loop::start(cfg);
// Filesystem-Watcher mit Callback; leitet geaenderte Dateien
// direkt an den Sync-Loop weiter.
let tx = handle.tx.clone();
let watcher = cloud_files::watcher::CallbackWatcher::new(&mp, move |path, kind| {
use notify::EventKind;
let relevant = matches!(kind, EventKind::Create(_) | EventKind::Modify(_));
if relevant {
let _ = tx.send(cloud_files::sync_loop::LoopMessage::LocalChange(path));
}
})
.map_err(|e| format!("watcher: {e}"))?;
*state.cloud_files_loop.lock().unwrap() = Some(handle);
*state.cloud_files_watcher.lock().unwrap() = Some(watcher);
// Mount-Pfad persistieren, damit er beim Neustart wiederkommt.
let mut cfg = AppConfig::load();
cfg.cloud_files_mount = mount_point.clone();
let _ = cfg.save();
Ok(())
}
#[tauri::command]
async fn cloud_files_disable(
state: State<'_, AppState>,
mount_point: String,
) -> Result<(), String> {
// Loop und Watcher stoppen
if let Some(handle) = state.cloud_files_loop.lock().unwrap().take() {
handle.stop_flag.store(true, std::sync::atomic::Ordering::Relaxed);
let _ = handle.tx.send(cloud_files::sync_loop::LoopMessage::Shutdown);
}
state.cloud_files_watcher.lock().unwrap().take();
let result = cloud_files::unregister_sync_root(&PathBuf::from(&mount_point));
// Auch bei Fehler Mount aus Config loeschen, damit der Client nicht
// endlos versucht, einen toten Pfad wiederherzustellen.
let mut cfg = AppConfig::load();
cfg.cloud_files_mount.clear();
let _ = cfg.save();
result
}
#[tauri::command]
fn cloud_files_get_mount() -> String {
AppConfig::load().cloud_files_mount
}
/// Notfall-Aufraeumen: Ordner als Sync-Root deregistrieren, auch wenn
/// kein Callback-Handle existiert. Nuetzlich wenn der Client hart beendet
/// wurde und ein "toter" Ordner in Windows haengt.
#[tauri::command]
async fn cloud_files_force_cleanup(mount_point: String) -> Result<(), String> {
let mp = PathBuf::from(&mount_point);
let _ = cloud_files::unregister_sync_root(&mp);
let mut cfg = AppConfig::load();
cfg.cloud_files_mount.clear();
let _ = cfg.save();
Ok(())
}
#[tauri::command]
async fn cloud_files_pin(path: String) -> Result<(), String> {
cloud_files::pin_file(&PathBuf::from(path))
}
#[tauri::command]
async fn cloud_files_unpin(path: String) -> Result<(), String> {
cloud_files::unpin_file(&PathBuf::from(path))
}
async fn fetch_remote_entries(
server: &str,
token: &str,
) -> Result<Vec<cloud_files::RemoteEntry>, String> {
let client = reqwest::Client::new();
let url = format!("{}/api/sync/tree", server.trim_end_matches('/'));
let resp = client
.get(&url)
.bearer_auth(token)
.send()
.await
.map_err(|e| format!("tree: {e}"))?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
let tree = json
.get("tree")
.ok_or("Antwort ohne 'tree'")?
.as_array()
.cloned()
.unwrap_or_default();
let shared = json
.get("shared")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
// Rekursiv flach machen (Struktur parent_id beibehalten).
// modified_at akzeptiert beides: das neue "modified_at" oder das
// alte "updated_at" als Fallback.
fn walk(
nodes: &[serde_json::Value],
parent: Option<i64>,
out: &mut Vec<cloud_files::RemoteEntry>,
) {
for n in nodes {
let id = n.get("id").and_then(|x| x.as_i64()).unwrap_or(0);
let name = n
.get("name")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
let is_folder = n.get("is_folder").and_then(|x| x.as_bool()).unwrap_or(false);
let size = n.get("size").and_then(|x| x.as_i64()).unwrap_or(0);
let modified_at = n
.get("modified_at")
.and_then(|x| x.as_str())
.or_else(|| n.get("updated_at").and_then(|x| x.as_str()))
.unwrap_or("")
.to_string();
let checksum = n
.get("checksum")
.and_then(|x| x.as_str())
.map(|s| s.to_string());
out.push(cloud_files::RemoteEntry {
id,
name,
parent_id: parent,
is_folder,
size,
modified_at,
checksum,
});
if let Some(children) = n.get("children").and_then(|x| x.as_array()) {
walk(children, Some(id), out);
}
}
}
let mut flat = Vec::new();
walk(&tree, None, &mut flat);
// Virtueller Ordner "Geteilt mit mir" nur dann, wenn es geteilte
// Dateien gibt. ID -1 ist reserviert dafuer (keine Kollision
// mit echten DB-IDs).
if !shared.is_empty() {
flat.push(cloud_files::RemoteEntry {
id: -1,
name: "Geteilt mit mir".to_string(),
parent_id: None,
is_folder: true,
size: 0,
modified_at: String::new(),
checksum: None,
});
walk(&shared, Some(-1), &mut flat);
}
Ok(flat)
}
/// Short-circuit fuer Shell-Kontextmenue-Aufrufe:
/// `minicloud-sync --pin <file>` oder `--unpin <file>` fuehrt die
/// Aktion direkt aus und beendet. Kein UI, kein Tray.
/// Logs landen in %LOCALAPPDATA%\MiniCloud Sync\cli.log - sonst
/// wuerden wir vom Explorer gestartete Prozesse nie debuggen koennen.
#[cfg(windows)]
fn handle_cli_shortcuts() {
use std::io::Write;
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
return;
}
let cmd = args[1].as_str();
if cmd != "--pin" && cmd != "--unpin" {
return;
}
let path = std::path::PathBuf::from(&args[2]);
let log_path = dirs::data_local_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("MiniCloud Sync")
.join("cli.log");
if let Some(p) = log_path.parent() {
let _ = std::fs::create_dir_all(p);
}
let log = |msg: &str| {
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
let _ = writeln!(f, "[{}] {}", chrono::Utc::now().to_rfc3339(), msg);
}
};
log(&format!("CLI invoked: {} {}", cmd, path.display()));
let result = match cmd {
"--pin" => cloud_files::pin_file(&path),
"--unpin" => cloud_files::unpin_file(&path),
_ => unreachable!(),
};
match &result {
Ok(()) => log(&format!("{cmd} OK: {}", path.display())),
Err(e) => log(&format!("{cmd} FAILED: {e}")),
}
std::process::exit(if result.is_ok() { 0 } else { 1 });
}
#[cfg(not(windows))]
fn handle_cli_shortcuts() {}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
handle_cli_shortcuts();
handle_single_instance();
tauri::Builder::default()
@@ -902,6 +1169,8 @@ pub fn run() {
sync_paths: Mutex::new(Vec::new()),
journal: Arc::new(Journal::open().expect("Journal konnte nicht geoeffnet werden")),
background_started: AtomicBool::new(false),
cloud_files_loop: Mutex::new(None),
cloud_files_watcher: Mutex::new(None),
})
.on_window_event(|window, event| {
// Close button = minimize to tray instead of quit
@@ -1016,6 +1285,13 @@ pub fn run() {
browse_sync_folder,
mark_offline,
unmark_offline,
cloud_files_supported,
cloud_files_enable,
cloud_files_disable,
cloud_files_get_mount,
cloud_files_force_cleanup,
cloud_files_pin,
cloud_files_unpin,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
@@ -13,6 +13,10 @@ pub struct AppConfig {
pub auto_start: bool,
#[serde(default)]
pub start_minimized: bool,
/// Persistierter Mount-Punkt der Cloud-Files-Integration.
/// Leer = nicht aktiv. Wird beim App-Start wieder aktiviert.
#[serde(default)]
pub cloud_files_mount: String,
}
impl AppConfig {
+123 -3
View File
@@ -31,6 +31,75 @@ const newPathLocal = ref("");
const newPathServerFolder = ref("");
const newPathServerId = ref(null);
const newPathMode = ref("virtual");
// Cloud-Files (Windows cfapi / Linux FUSE)
const cloudFilesSupported = ref(false);
const cloudFilesActive = ref(false);
const cloudFilesBusy = ref(false);
const cloudFilesMountPoint = ref("");
const cloudFilesError = ref("");
async function checkCloudFilesSupport() {
try { cloudFilesSupported.value = await invoke("cloud_files_supported"); }
catch { cloudFilesSupported.value = false; }
try {
const saved = await invoke("cloud_files_get_mount");
if (saved) cloudFilesMountPoint.value = saved;
} catch { /* no saved mount */ }
}
async function forceCleanupCloudFiles() {
if (!cloudFilesMountPoint.value) return;
if (!confirm(`Sync-Root unter ${cloudFilesMountPoint.value} zwangsweise aufraeumen?\n\nDanach kann der Ordner ggf. geloescht werden.`)) return;
cloudFilesError.value = "";
cloudFilesBusy.value = true;
try {
await invoke("cloud_files_force_cleanup", { mountPoint: cloudFilesMountPoint.value });
cloudFilesActive.value = false;
cloudFilesMountPoint.value = "";
syncLog.value = [`[${ts()}] Cloud-Files Zwangsbereinigung durchgefuehrt`, ...syncLog.value].slice(0, 200);
} catch (err) {
cloudFilesError.value = String(err);
} finally {
cloudFilesBusy.value = false;
}
}
async function browseCfMount() {
try {
const selected = await dialogOpen({ directory: true, multiple: false,
title: "Cloud-Files-Ordner waehlen" });
if (selected) cloudFilesMountPoint.value = selected;
} catch { /* cancelled */ }
}
async function enableCloudFiles() {
cloudFilesError.value = "";
cloudFilesBusy.value = true;
try {
await invoke("cloud_files_enable", { mountPoint: cloudFilesMountPoint.value });
cloudFilesActive.value = true;
syncLog.value = [`[${ts()}] Cloud-Files aktiviert: ${cloudFilesMountPoint.value}`, ...syncLog.value].slice(0, 200);
} catch (err) {
cloudFilesError.value = String(err);
} finally {
cloudFilesBusy.value = false;
}
}
async function disableCloudFiles() {
cloudFilesError.value = "";
cloudFilesBusy.value = true;
try {
await invoke("cloud_files_disable", { mountPoint: cloudFilesMountPoint.value });
cloudFilesActive.value = false;
syncLog.value = [`[${ts()}] Cloud-Files deaktiviert`, ...syncLog.value].slice(0, 200);
} catch (err) {
cloudFilesError.value = String(err);
} finally {
cloudFilesBusy.value = false;
}
}
const serverFolders = ref([]);
// Local file browser
@@ -289,6 +358,7 @@ function formatSize(b) {
}
onMounted(async () => {
await checkCloudFilesSupport();
// Try auto-login with saved credentials
try {
const saved = await invoke("load_saved_config");
@@ -308,6 +378,15 @@ onMounted(async () => {
if (syncPaths.value.length > 0) {
await startSync();
}
// Cloud-Files automatisch reaktivieren, wenn Mount gespeichert.
if (cloudFilesSupported.value && cloudFilesMountPoint.value) {
try {
await invoke("cloud_files_enable", { mountPoint: cloudFilesMountPoint.value });
cloudFilesActive.value = true;
} catch (e) {
cloudFilesError.value = `Auto-Reaktivierung fehlgeschlagen: ${e}`;
}
}
} catch (err) {
syncStatus.value = "Auto-Login fehlgeschlagen";
// Show login screen with pre-filled fields
@@ -387,8 +466,47 @@ onUnmounted(() => { unlistenStatus?.(); unlistenLog?.(); unlistenError?.(); unli
</div>
<div class="content">
<!-- Sync Paths -->
<!-- Cloud-Files (Windows Cloud Files API, OneDrive-artig) -->
<div class="section">
<div class="section-header">
<h3>Cloud-Files (OneDrive-Style)</h3>
<span v-if="cloudFilesActive" class="status-badge syncing"> aktiv</span>
<span v-else-if="!cloudFilesSupported" class="status-badge error">nicht verfuegbar</span>
</div>
<p class="hint">
Dateien erscheinen als Platzhalter im Explorer mit Wolken-Icon und
werden erst bei Zugriff geladen. Rechtsklick im Explorer &rarr;
"Immer offline halten" oder "Speicher freigeben".
</p>
<p v-if="!cloudFilesSupported" class="hint" style="color:#c62828">
Auf dieser Plattform noch nicht verfuegbar. Aktuell: Windows 10/11.
Linux-FUSE ist in Vorbereitung, macOS folgt mit Apple-Signatur.
</p>
<template v-else>
<div class="cf-row">
<input v-model="cloudFilesMountPoint" placeholder="Ordner waehlen..." />
<button class="btn-secondary" @click="browseCfMount">Durchsuchen</button>
<button v-if="!cloudFilesActive" class="btn-primary"
:disabled="!cloudFilesMountPoint || cloudFilesBusy"
@click="enableCloudFiles">
{{ cloudFilesBusy ? "Aktiviere..." : "Aktivieren" }}
</button>
<button v-else class="btn-secondary" :disabled="cloudFilesBusy"
@click="disableCloudFiles">Deaktivieren</button>
<button v-if="cloudFilesMountPoint && !cloudFilesActive"
class="btn-secondary" :disabled="cloudFilesBusy"
@click="forceCleanupCloudFiles"
title="Toten Sync-Root nach hartem Beenden des Clients aufraeumen">
Aufraeumen
</button>
</div>
<div v-if="cloudFilesError" class="error" style="margin-top:0.5rem">{{ cloudFilesError }}</div>
</template>
</div>
<!-- Sync Paths (Legacy) - auf Windows ausgeblendet sobald Cloud-Files
aktiv ist; Cloud-Files ersetzt diese Ansicht vollstaendig. -->
<div v-if="!cloudFilesActive" class="section">
<div class="section-header">
<h3>Sync-Pfade</h3>
<div class="header-btns">
@@ -454,8 +572,8 @@ onUnmounted(() => { unlistenStatus?.(); unlistenLog?.(); unlistenError?.(); unli
</div>
</div>
<!-- Local File Browser -->
<div v-if="autoSyncActive" class="section" @click="hideContextMenu">
<!-- Local File Browser (Legacy, nur fuer Full-Sync-Modus) -->
<div v-if="autoSyncActive && !cloudFilesActive" class="section" @click="hideContextMenu">
<div class="section-header">
<h3>Lokale Dateien</h3>
<button @click="loadLocalFiles(null)" class="btn-small"></button>
@@ -604,6 +722,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;f
.sp-actions{display:flex;align-items:center;gap:.375rem;flex-shrink:0}
.sp-mode{font-size:.75rem;padding:.2rem .4rem;border-radius:4px;cursor:pointer;background:#f0f0f0}
.sp-mode.Full{background:#e3f2fd;color:#1565c0}.sp-mode.Virtual{background:#f3e5f5;color:#7b1fa2}
.cf-row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap}
.cf-row input{flex:1;min-width:300px}
.file-tree{max-height:250px;overflow-y:auto}
.tree-item{display:flex;align-items:center;gap:.5rem;padding:.3rem 0;border-bottom:1px solid #f5f5f5;font-size:.85rem}
.tree-item.indent{padding-left:1.5rem}.tree-icon{flex-shrink:0}.tree-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>Mini-Cloud</title>
</head>
<body>
<div id="app"></div>
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 337 B

+13
View File
@@ -1,3 +1,16 @@
<template>
<router-view />
</template>
<script setup>
import { watchEffect } from 'vue'
import { useAuthStore } from './stores/auth'
const auth = useAuthStore()
watchEffect(() => {
document.title = auth.user?.username
? `Mini-Cloud - ${auth.user.username}`
: 'Mini-Cloud'
})
</script>
+5
View File
@@ -48,6 +48,11 @@ const routes = [
name: 'Contacts',
component: () => import('../views/ContactsView.vue'),
},
{
path: 'tasks',
name: 'Tasks',
component: () => import('../views/TasksView.vue'),
},
{
path: 'email',
name: 'Email',
+45
View File
@@ -37,6 +37,28 @@
</div>
</div>
<!-- System-Info: Zeitzone & NTP (read-only) -->
<div class="admin-section">
<h3>System-Zeit</h3>
<p class="hint">Wird in der <code>.env</code> festgelegt (Keys <code>TZ</code> und <code>NTP_SERVER</code>).
Aenderungen erfordern einen Neustart des Backends.</p>
<div class="sysinfo">
<div class="sysinfo-row">
<span class="sysinfo-label">Zeitzone:</span>
<code>{{ settings.timezone || '—' }}</code>
<span v-if="settings.timezone_abbr" class="sysinfo-extra">({{ settings.timezone_abbr }})</span>
</div>
<div class="sysinfo-row">
<span class="sysinfo-label">Aktuelle Server-Zeit:</span>
<code>{{ formatServerTime(settings.server_time) }}</code>
</div>
<div class="sysinfo-row">
<span class="sysinfo-label">NTP-Server:</span>
<code>{{ settings.ntp_server || '(deaktiviert)' }}</code>
</div>
</div>
</div>
<!-- System Email -->
<div class="admin-section">
<h3>System-E-Mail (SMTP)</h3>
@@ -551,6 +573,17 @@ const smtpForm = ref({
const smtpPasswordSet = ref(false)
const onlyofficeConfigured = ref(false)
const onlyofficeUrl = ref('')
const settings = ref({ timezone: '', timezone_abbr: '', server_time: '', ntp_server: '' })
function formatServerTime(iso) {
if (!iso) return '—'
try {
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
} catch { return iso }
}
const smtpTesting = ref(false)
// Backup & Restore
@@ -660,6 +693,12 @@ async function loadSettings() {
smtpPasswordSet.value = res.data.system_smtp_password_set
onlyofficeConfigured.value = res.data.onlyoffice_configured
onlyofficeUrl.value = res.data.onlyoffice_url || ''
settings.value = {
timezone: res.data.timezone || '',
timezone_abbr: res.data.timezone_abbr || '',
server_time: res.data.server_time || '',
ntp_server: res.data.ntp_server || '',
}
} catch { /* first load, defaults */ }
}
@@ -1216,6 +1255,12 @@ onMounted(() => {
.field-row { display: flex; gap: 0.75rem; align-items: flex-end; }
.flex-grow { flex: 1; }
.hint { font-size: 0.85rem; color: var(--p-text-muted-color); margin: 0 0 0.75rem; }
.hint code { background: var(--p-surface-100); padding: 0.05rem 0.35rem; border-radius: 3px; font-size: 0.8rem; }
.sysinfo { display: flex; flex-direction: column; gap: 0.4rem; font-size: 0.875rem; }
.sysinfo-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.sysinfo-label { min-width: 180px; color: var(--p-text-muted-color); }
.sysinfo code { background: var(--p-surface-100); padding: 0.15rem 0.5rem; border-radius: 4px; }
.sysinfo-extra { color: var(--p-text-muted-color); font-size: 0.8rem; }
.invite-section { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--p-surface-200); }
.invite-section h4 { margin: 0 0 0.25rem; font-size: 0.95rem; }
.invite-row { display: flex; gap: 0.5rem; align-items: flex-start; }
+5
View File
@@ -22,6 +22,11 @@
<span>Kontakte</span>
</router-link>
<router-link to="/tasks" class="nav-item" active-class="active">
<i class="pi pi-check-square"></i>
<span>Aufgaben</span>
</router-link>
<router-link
v-if="auth.hasEmailAccounts"
to="/email"
+285 -10
View File
@@ -5,7 +5,12 @@
<div class="header-actions">
<SelectButton v-model="viewMode" :options="viewModeOptions" optionLabel="label" optionValue="value" size="small" />
<Button icon="pi pi-plus" label="Neuer Kalender" size="small" outlined @click="showNewCalendar = true" />
<Button icon="pi pi-plus" label="Neuer Termin" size="small" @click="openNewEvent()" />
<Button icon="pi pi-upload" label="Import" size="small" outlined @click="triggerCalImport" />
<input ref="calImportInput" type="file" accept=".ics,.ical,.csv" hidden @change="onCalImportFile" />
<Button icon="pi pi-download" label="Export" size="small" outlined
:disabled="!exportableCalendars.length" @click="showCalExportDialog = true" />
<Button icon="pi pi-plus" label="Neuer Termin" size="small"
:disabled="!writableCalendars.length" @click="openNewEvent()" />
</div>
</div>
@@ -16,7 +21,10 @@
<input type="checkbox" v-model="visibleCalendars[cal.id]" @change="refreshEvents" />
<div class="calendar-color" :style="{ background: cal.color }"></div>
<span class="cal-name">{{ cal.name }}</span>
<span v-if="cal.permission !== 'owner'" class="shared-label">(geteilt)</span>
<span v-if="cal.permission !== 'owner'" class="shared-label"
:title="`Geteilt von ${cal.owner_display_name || cal.owner_name}`">
(geteilt von {{ cal.owner_display_name || cal.owner_name }})
</span>
<Button icon="pi pi-ellipsis-v" text size="small" @click.stop="openCalendarMenu(cal)" />
</div>
</aside>
@@ -45,10 +53,22 @@
optionLabel="label" optionValue="value" placeholder="Alle Kalender"
showClear style="min-width: 180px" />
</div>
<div class="list-meta">{{ filteredListEvents.length }} Termin(e)</div>
<div class="list-meta-row">
<div class="list-meta">{{ filteredListEvents.length }} Termin(e)</div>
<div v-if="selectedListIds.length" class="list-bulk">
<span>{{ selectedListIds.length }} ausgewaehlt</span>
<Button icon="pi pi-trash" :label="`${selectedListIds.length} loeschen`"
severity="danger" size="small" @click="deleteSelectedListEvents" />
<Button label="Auswahl aufheben" size="small" text @click="selectedListIds = []" />
</div>
</div>
<table class="list-table">
<thead>
<tr>
<th class="col-check">
<Checkbox v-model="allListSelected" :binary="true"
@change="toggleAllListSelected" title="Alle auswaehlen" />
</th>
<th @click="toggleListSort('dtstart')" class="sortable">
Datum <i v-if="listSort === 'dtstart'" :class="listSortDir === 'asc' ? 'pi pi-arrow-up' : 'pi pi-arrow-down'"></i>
</th>
@@ -61,7 +81,14 @@
</tr>
</thead>
<tbody>
<tr v-for="ev in filteredListEvents" :key="ev.id" class="list-row" @click="openEditEvent(ev)">
<tr v-for="ev in filteredListEvents" :key="ev.id" class="list-row"
:class="{ selected: selectedListIds.includes(ev.id) }"
@click="openEditEvent(ev)">
<td class="col-check" @click.stop>
<Checkbox :modelValue="selectedListIds.includes(ev.id)" :binary="true"
:disabled="ev._cal?.permission === 'read'"
@update:modelValue="toggleListSelect(ev.id, $event)" />
</td>
<td class="col-date">
<div>{{ formatListDate(ev) }}</div>
<div v-if="!ev.all_day" class="meta-time">{{ formatListTime(ev) }}</div>
@@ -80,7 +107,7 @@
</td>
</tr>
<tr v-if="!filteredListEvents.length">
<td colspan="5" class="empty-row">Keine Termine gefunden.</td>
<td colspan="6" class="empty-row">Keine Termine gefunden.</td>
</tr>
</tbody>
</table>
@@ -88,6 +115,37 @@
</div>
</div>
<!-- Import-Auswahl Dialog -->
<Dialog v-model:visible="showCalImportDialog" header="In welchen Kalender importieren?" modal :style="{ width: '420px' }">
<div class="field">
<label>Kalender</label>
<Select v-model="importTargetCalId" :options="writableCalendarOptions" optionLabel="label" optionValue="id" fluid />
</div>
<p class="hint" style="font-size:0.85rem;color:var(--p-text-muted-color)">Datei: {{ pendingImportFile?.name }}</p>
<template #footer>
<Button label="Abbrechen" text @click="cancelCalImport" />
<Button label="Importieren" icon="pi pi-upload" :disabled="!importTargetCalId" @click="doCalImport" />
</template>
</Dialog>
<!-- Export Dialog -->
<Dialog v-model:visible="showCalExportDialog" header="Kalender exportieren" modal :style="{ width: '420px' }">
<div class="field">
<label>Kalender</label>
<Select v-model="exportCalId" :options="exportableCalendars" optionLabel="name" optionValue="id" fluid />
</div>
<div class="field">
<label>Format</label>
<Select v-model="calExportFormat" :options="calExportFormats" optionLabel="label" optionValue="value" fluid />
</div>
<p class="hint" v-if="calExportFormat === 'ics'" style="font-size:0.85rem;color:var(--p-text-muted-color)">Standard iCalendar-Datei (kompatibel mit jedem Kalender-Programm).</p>
<p class="hint" v-if="calExportFormat === 'csv'" style="font-size:0.85rem;color:var(--p-text-muted-color)">CSV mit Titel, Start, Ende, Ort, Beschreibung, RRULE.</p>
<template #footer>
<Button label="Abbrechen" text @click="showCalExportDialog = false" />
<Button label="Herunterladen" icon="pi pi-download" :disabled="!exportCalId" @click="doCalExport" />
</template>
</Dialog>
<!-- New Calendar Dialog -->
<Dialog v-model:visible="showNewCalendar" header="Neuer Kalender" modal :style="{ width: '400px' }">
<div class="field">
@@ -111,9 +169,9 @@
<label>Titel</label>
<InputText v-model="eventForm.summary" fluid autofocus />
</div>
<div class="field">
<div v-if="writableCalendars.length > 1" class="field">
<label>Kalender</label>
<Select v-model="eventForm.calendar_id" :options="ownCalendars" optionLabel="name" optionValue="id" fluid />
<Select v-model="eventForm.calendar_id" :options="writableCalendarOptions" optionLabel="label" optionValue="id" fluid />
</div>
<div class="field-row">
<div class="field">
@@ -198,7 +256,22 @@
<!-- Calendar Menu -->
<Dialog v-model:visible="showCalMenu" header="Kalender-Optionen" modal :style="{ width: '480px' }">
<div v-if="selectedCal" class="cal-menu-content">
<p><strong>{{ selectedCal.name }}</strong></p>
<div class="rename-row">
<template v-if="!isRenamingCal">
<strong>{{ selectedCal.name }}</strong>
<Button v-if="selectedCal.permission === 'owner'"
icon="pi pi-pencil" text size="small" title="Umbenennen"
@click="startRenameCal" />
</template>
<template v-else>
<InputText v-model="renameCalValue" fluid autofocus
@keyup.enter="saveRenameCal" @keyup.escape="isRenamingCal = false" />
<Button icon="pi pi-check" text size="small" severity="success"
title="Speichern" @click="saveRenameCal" />
<Button icon="pi pi-times" text size="small"
title="Abbrechen" @click="isRenamingCal = false" />
</template>
</div>
<div class="field">
<label>
@@ -222,7 +295,9 @@
<div v-if="shareSearchResults.length" class="user-search-popup">
<div v-for="u in shareSearchResults" :key="u.id" class="user-result"
@click="shareUsername = u.username; shareSearchResults = []">
<i class="pi pi-user"></i> {{ u.username }}
<i class="pi pi-user"></i>
<span>{{ u.username }}</span>
<small v-if="u.full_name" class="user-fullname">{{ u.full_name }}</small>
</div>
</div>
</div>
@@ -258,6 +333,10 @@
<div v-else class="ical-url">
<code>{{ fullIcalUrl }}</code>
<Button icon="pi pi-copy" text size="small" @click="copyIcal" title="Kopieren" />
<div v-if="selectedCal.ical_has_password" class="ical-pw-hint">
<i class="pi pi-info-circle"></i>
Bei der Passwort-Abfrage <strong>Benutzername leer lassen</strong> und nur das Passwort eingeben.
</div>
<div style="margin-top: 0.5rem; display: flex; gap: 0.5rem; align-items: center;">
<span v-if="selectedCal.ical_has_password" class="hint-badge">
<i class="pi pi-lock"></i> Passwortgeschuetzt
@@ -337,6 +416,7 @@ import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import Select from 'primevue/select'
import SelectButton from 'primevue/selectbutton'
import Checkbox from 'primevue/checkbox'
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
@@ -368,6 +448,57 @@ const listTo = ref('')
const listCalFilter = ref(null)
const listSort = ref('dtstart')
const listSortDir = ref('asc')
const selectedListIds = ref([])
const allListSelected = computed({
get: () => {
const writable = filteredListEvents.value.filter(e => e._cal?.permission !== 'read')
return writable.length > 0 && writable.every(e => selectedListIds.value.includes(e.id))
},
set: () => {},
})
function toggleAllListSelected() {
const writableIds = filteredListEvents.value
.filter(e => e._cal?.permission !== 'read').map(e => e.id)
const allSel = writableIds.length > 0 && writableIds.every(id => selectedListIds.value.includes(id))
if (allSel) {
selectedListIds.value = selectedListIds.value.filter(id => !writableIds.includes(id))
} else {
const set = new Set(selectedListIds.value)
writableIds.forEach(id => set.add(id))
selectedListIds.value = [...set]
}
}
function toggleListSelect(id, checked) {
if (checked) {
if (!selectedListIds.value.includes(id)) selectedListIds.value = [...selectedListIds.value, id]
} else {
selectedListIds.value = selectedListIds.value.filter(x => x !== id)
}
}
async function deleteSelectedListEvents() {
const ids = [...selectedListIds.value]
if (!ids.length) return
if (!confirm(`${ids.length} Termin(e) wirklich loeschen?`)) return
let ok = 0, fail = 0
for (const id of ids) {
try {
await apiClient.delete(`/events/${id}`)
ok++
} catch { fail++ }
}
selectedListIds.value = []
toast.add({
severity: fail ? 'warn' : 'success',
summary: `${ok} geloescht${fail ? `, ${fail} fehlgeschlagen` : ''}`,
life: 3000,
})
await loadListEvents()
refreshEvents()
}
const listCalOptions = computed(() => calendars.value.map(c => ({ label: c.name, value: c.id })))
@@ -501,6 +632,107 @@ const currentEditScope = ref(null)
const permOptions = [{ label: 'Lesen', value: 'read' }, { label: 'Lesen+Schreiben', value: 'readwrite' }]
const ownCalendars = computed(() => calendars.value.filter(c => c.permission === 'owner'))
// Beschreibbar = eigener Kalender ODER Freigabe mit Schreibrecht.
const writableCalendars = computed(() =>
calendars.value.filter(c => c.permission === 'owner' || c.permission === 'readwrite')
)
// Mit "(geteilt von <Name>)"-Suffix fuer eindeutige Anzeige in Selects.
const writableCalendarOptions = computed(() => writableCalendars.value.map(c => ({
...c,
label: c.permission === 'owner'
? c.name
: `${c.name} (geteilt von ${c.owner_display_name || c.owner_name})`,
})))
const exportableCalendars = computed(() => calendars.value)
// --- Calendar Import / Export ---
const calImportInput = ref(null)
const showCalImportDialog = ref(false)
const pendingImportFile = ref(null)
const importTargetCalId = ref(null)
const showCalExportDialog = ref(false)
const exportCalId = ref(null)
const calExportFormat = ref('ics')
const calExportFormats = [
{ label: 'iCalendar (.ics)', value: 'ics' },
{ label: 'CSV (.csv)', value: 'csv' },
]
watch(showCalExportDialog, (v) => {
if (v && !exportCalId.value && exportableCalendars.value.length) {
exportCalId.value = exportableCalendars.value[0].id
}
})
function triggerCalImport() {
if (!writableCalendars.value.length) {
toast.add({ severity: 'warn', summary: 'Kein beschreibbarer Kalender', life: 3000 })
return
}
calImportInput.value?.click()
}
function onCalImportFile(ev) {
const file = ev.target.files?.[0]
ev.target.value = ''
if (!file) return
pendingImportFile.value = file
importTargetCalId.value = writableCalendars.value[0]?.id
showCalImportDialog.value = true
}
function cancelCalImport() {
showCalImportDialog.value = false
pendingImportFile.value = null
}
async function doCalImport() {
if (!pendingImportFile.value || !importTargetCalId.value) return
const fd = new FormData()
fd.append('file', pendingImportFile.value)
try {
const res = await apiClient.post(
`/calendars/${importTargetCalId.value}/import`, fd,
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
toast.add({
severity: 'success',
summary: `${res.data.imported} importiert`,
detail: res.data.skipped ? `${res.data.skipped} uebersprungen` : undefined,
life: 4000,
})
showCalImportDialog.value = false
pendingImportFile.value = null
refreshEvents()
} catch (err) {
toast.add({ severity: 'error', summary: 'Import fehlgeschlagen',
detail: err.response?.data?.error || err.message, life: 5000 })
}
}
async function doCalExport() {
if (!exportCalId.value) return
try {
const res = await apiClient.get(
`/calendars/${exportCalId.value}/export`,
{ params: { format: calExportFormat.value }, responseType: 'blob' }
)
const cal = calendars.value.find(c => c.id === exportCalId.value)
const ext = calExportFormat.value === 'csv' ? 'csv' : 'ics'
const blob = new Blob([res.data])
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${cal?.name || 'kalender'}.${ext}`
a.click()
URL.revokeObjectURL(url)
showCalExportDialog.value = false
} catch (err) {
toast.add({ severity: 'error', summary: 'Export fehlgeschlagen',
detail: err.response?.data?.error || err.message, life: 5000 })
}
}
const fullIcalUrl = computed(() =>
selectedCal.value?.ical_token ? `${window.location.origin}/ical/${selectedCal.value.ical_token}` : ''
)
@@ -764,6 +996,10 @@ async function createCalendar() {
}
function openNewEvent(start, end, allDay = false) {
if (!writableCalendars.value.length) {
toast.add({ severity: 'warn', summary: 'Kein beschreibbarer Kalender vorhanden', life: 3000 })
return
}
editingEvent.value = null
const now = start || new Date()
const later = end || new Date(now.getTime() + 3600000)
@@ -771,7 +1007,7 @@ function openNewEvent(start, end, allDay = false) {
summary: '',
description: '',
location: '',
calendar_id: ownCalendars.value[0]?.id,
calendar_id: writableCalendars.value[0].id,
dtstart: toLocalISO(now, allDay),
dtend: toLocalISO(later, allDay),
all_day: allDay,
@@ -856,10 +1092,38 @@ function openCalendarMenu(cal) {
icalPassword.value = ''
shareUsername.value = ''
shareSearchResults.value = []
isRenamingCal.value = false
showCalMenu.value = true
loadShares()
}
const isRenamingCal = ref(false)
const renameCalValue = ref('')
function startRenameCal() {
renameCalValue.value = selectedCal.value?.name || ''
isRenamingCal.value = true
}
async function saveRenameCal() {
const newName = renameCalValue.value.trim()
if (!newName || !selectedCal.value || newName === selectedCal.value.name) {
isRenamingCal.value = false
return
}
try {
await apiClient.put(`/calendars/${selectedCal.value.id}`, { name: newName })
selectedCal.value.name = newName
isRenamingCal.value = false
await loadCalendars()
refreshEvents()
toast.add({ severity: 'success', summary: 'Umbenannt', life: 2000 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler',
detail: err.response?.data?.error || err.message, life: 4000 })
}
}
function onShareSearch() {
clearTimeout(shareSearchTimer)
const q = shareUsername.value.trim()
@@ -1092,6 +1356,9 @@ onUnmounted(() => {
max-height: 160px; overflow-y: auto; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.user-result { padding: 0.5rem 0.75rem; cursor: pointer; font-size: 0.875rem; display: flex; gap: 0.5rem; align-items: center; }
.user-result:hover { background: var(--p-primary-50); }
.user-fullname { color: var(--p-text-muted-color); font-size: 0.75rem; margin-left: auto; }
.rename-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }
.rename-row strong { font-size: 1rem; }
.existing-shares { margin-top: 0.5rem; }
.share-perm-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.875rem; flex-wrap: wrap; }
.share-perm-item.editing { background: var(--p-surface-50); padding: 0.5rem; border-radius: 4px; }
@@ -1117,6 +1384,14 @@ onUnmounted(() => {
.list-view { display: flex; flex-direction: column; gap: 0.75rem; }
.list-filters { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.list-meta { font-size: 0.8rem; color: var(--p-text-muted-color); }
.list-meta-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.list-bulk { display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; }
.col-check { width: 36px; }
.list-row.selected { background: var(--p-primary-50); }
.ical-pw-hint { margin-top: 0.5rem; font-size: 0.8rem; color: var(--p-text-muted-color);
background: var(--p-yellow-50, #fffbeb); border-left: 3px solid var(--p-yellow-400, #facc15);
padding: 0.5rem 0.75rem; border-radius: 4px; }
.ical-pw-hint i { margin-right: 0.4rem; }
.list-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
.list-table th { text-align: left; padding: 0.5rem; border-bottom: 2px solid var(--p-surface-200); font-weight: 600; user-select: none; }
.list-table th.sortable { cursor: pointer; }
+206 -8
View File
@@ -4,7 +4,12 @@
<h2>Kontakte</h2>
<div class="header-actions">
<Button icon="pi pi-book" label="Neues Adressbuch" size="small" outlined @click="showNewBook = true" />
<Button icon="pi pi-user-plus" label="Neuer Kontakt" size="small" @click="openNewContact" />
<Button icon="pi pi-upload" label="Import" size="small" outlined @click="triggerImport" />
<input ref="importInput" type="file" accept=".vcf,.vcard,.csv" hidden @change="onImportFile" />
<Button icon="pi pi-download" label="Export" size="small" outlined
:disabled="!selectedBookId" @click="showExportDialog = true" />
<Button icon="pi pi-user-plus" label="Neuer Kontakt" size="small"
:disabled="!writableBooks.length" @click="openNewContact" />
</div>
</div>
@@ -16,7 +21,10 @@
@click="selectBook(book.id)">
<span class="book-color" :style="{ background: book.color }"></span>
<span class="book-name">{{ book.name }}</span>
<span v-if="book.permission !== 'owner'" class="shared-label">(geteilt)</span>
<span v-if="book.permission !== 'owner'" class="shared-label"
:title="`Geteilt von ${book.owner_display_name || book.owner_name}`">
(geteilt von {{ book.owner_display_name || book.owner_name }})
</span>
<span class="count">{{ book.contact_count }}</span>
<Button icon="pi pi-ellipsis-v" text size="small" class="book-menu"
@click.stop="openBookMenu(book)" />
@@ -28,9 +36,18 @@
<InputText v-model="searchQuery" placeholder="Kontakte suchen..." fluid @input="onSearch" />
</div>
<div v-if="selectedContacts.length" class="bulk-bar">
<span>{{ selectedContacts.length }} ausgewaehlt</span>
<Button icon="pi pi-trash" :label="`${selectedContacts.length} loeschen`"
severity="danger" size="small" @click="bulkDeleteContacts" />
<Button label="Auswahl aufheben" size="small" text @click="selectedContacts = []" />
</div>
<DataTable :value="contacts" :loading="loading" striped-rows
v-model:selection="selectedContacts" dataKey="id"
@row-click="onRowClick" :rowClass="() => 'clickable'">
<template #empty><p class="empty">Keine Kontakte</p></template>
<Column selectionMode="multiple" headerStyle="width:3rem" />
<Column header="Name" sortable sortField="display_name">
<template #body="{ data }">
<div class="contact-row">
@@ -75,7 +92,22 @@
<!-- Book Menu (3-dot) -->
<Dialog v-model:visible="showBookMenu" header="Adressbuch-Optionen" modal :style="{ width: '560px' }">
<div v-if="menuBook" class="book-menu-content">
<p><strong>{{ menuBook.name }}</strong></p>
<div class="rename-row">
<template v-if="!isRenamingBook">
<strong>{{ menuBook.name }}</strong>
<Button v-if="menuBook.permission === 'owner'"
icon="pi pi-pencil" text size="small" title="Umbenennen"
@click="startRenameBook" />
</template>
<template v-else>
<InputText v-model="renameBookValue" fluid autofocus
@keyup.enter="saveRenameBook" @keyup.escape="isRenamingBook = false" />
<Button icon="pi pi-check" text size="small" severity="success"
title="Speichern" @click="saveRenameBook" />
<Button icon="pi pi-times" text size="small"
title="Abbrechen" @click="isRenamingBook = false" />
</template>
</div>
<div class="field">
<label>
@@ -99,7 +131,9 @@
<div v-if="shareSearchResults.length" class="user-search-popup">
<div v-for="u in shareSearchResults" :key="u.id" class="user-result"
@click="shareUsername = u.username; shareSearchResults = []">
<i class="pi pi-user"></i> {{ u.username }}
<i class="pi pi-user"></i>
<span>{{ u.username }}</span>
<small v-if="u.full_name" class="user-fullname">{{ u.full_name }}</small>
</div>
</div>
</div>
@@ -161,6 +195,11 @@
</TabList>
<TabPanels>
<TabPanel value="general">
<div v-if="writableBooks.length > 1" class="field" style="max-width:400px">
<label>Adressbuch</label>
<Select v-model="contactTargetBookId" :options="writableBookOptions"
optionLabel="label" optionValue="id" fluid />
</div>
<div class="photo-row">
<div class="avatar large" :style="{ background: avatarColor(contactForm) }">
<img v-if="contactForm.photo" :src="contactForm.photo" />
@@ -175,9 +214,10 @@
</div>
<div class="field-row">
<div class="field" style="max-width:120px">
<div class="field" style="max-width:130px">
<label>Anrede</label>
<InputText v-model="contactForm.prefix" />
<Select v-model="contactForm.prefix" :options="salutationOptions"
showClear placeholder="" />
</div>
<div class="field">
<label>Vorname</label>
@@ -315,6 +355,21 @@
</template>
</Dialog>
<Dialog v-model:visible="showExportDialog" header="Kontakte exportieren" modal :style="{ width: '420px' }">
<p>Aus Adressbuch <strong>{{ currentBook?.name }}</strong></p>
<div class="field">
<label>Format</label>
<Select v-model="exportFormat" :options="exportFormats" optionLabel="label" optionValue="value" fluid />
</div>
<p class="hint" v-if="exportFormat === 'vcf'">Eine Sammel-Datei im vCard-3.0-Format (alle Kontakte hintereinander).</p>
<p class="hint" v-if="exportFormat === 'vcf-zip'">ZIP mit einer einzelnen .vcf-Datei je Kontakt.</p>
<p class="hint" v-if="exportFormat === 'csv'">CSV mit den wichtigsten Feldern (Name, E-Mail, Telefon, Adresse, ...).</p>
<template #footer>
<Button label="Abbrechen" text @click="showExportDialog = false" />
<Button label="Herunterladen" icon="pi pi-download" @click="doExport" />
</template>
</Dialog>
<Dialog v-model:visible="confirmDeleteContactDialog" header="Kontakt löschen" modal :style="{ width: '400px' }">
<p>Möchtest du <strong>{{ deleteContactTarget?.display_name }}</strong> wirklich löschen?</p>
<template #footer>
@@ -357,6 +412,16 @@ const auth = useAuthStore()
const addressBooks = ref([])
const contacts = ref([])
const selectedBookId = ref(null)
const contactTargetBookId = ref(null)
const writableBooks = computed(() =>
addressBooks.value.filter(b => b.permission === 'owner' || b.permission === 'readwrite')
)
const writableBookOptions = computed(() => writableBooks.value.map(b => ({
...b,
label: b.permission === 'owner'
? b.name
: `${b.name} (geteilt von ${b.owner_display_name || b.owner_name})`,
})))
const loading = ref(false)
const searchQuery = ref('')
let searchTimer = null
@@ -409,10 +474,12 @@ const urlTypes = [
{ label: 'Privat', value: 'home' }, { label: 'Geschäftlich', value: 'work' },
{ label: 'Sonstige', value: 'other' },
]
const salutationOptions = ['Herr', 'Frau', 'Divers']
function emptyContact() {
return {
prefix: '', first_name: '', middle_name: '', last_name: '', suffix: '',
prefix: '',
first_name: '', middle_name: '', last_name: '', suffix: '',
display_name: '', nickname: '',
organization: '', department: '', job_title: '',
emails: [], phones: [], addresses: [], websites: [], impp: [], categories: [],
@@ -483,10 +550,37 @@ function openBookMenu(book) {
shareUsername.value = ''
shareSearchResults.value = []
editingShareId.value = null
isRenamingBook.value = false
showBookMenu.value = true
loadShares()
}
const isRenamingBook = ref(false)
const renameBookValue = ref('')
function startRenameBook() {
renameBookValue.value = menuBook.value?.name || ''
isRenamingBook.value = true
}
async function saveRenameBook() {
const newName = renameBookValue.value.trim()
if (!newName || !menuBook.value || newName === menuBook.value.name) {
isRenamingBook.value = false
return
}
try {
await apiClient.put(`/addressbooks/${menuBook.value.id}`, { name: newName })
menuBook.value.name = newName
isRenamingBook.value = false
await loadBooks()
toast.add({ severity: 'success', summary: 'Umbenannt', life: 2000 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler',
detail: err.response?.data?.error || err.message, life: 4000 })
}
}
async function loadShares() {
if (!menuBook.value || menuBook.value.permission !== 'owner') {
bookShares.value = []
@@ -579,10 +673,17 @@ function copyText(t) {
}
function openNewContact() {
if (!writableBooks.value.length) {
toast.add({ severity: 'warn', summary: 'Kein beschreibbares Adressbuch vorhanden', life: 3000 })
return
}
editingContactId.value = null
Object.assign(contactForm, emptyContact())
categoriesString.value = ''
activeTab.value = 'general'
// Default: aktuell markiertes Buch, falls beschreibbar, sonst erstes beschreibbares
const selectedBook = writableBooks.value.find(b => b.id === selectedBookId.value)
contactTargetBookId.value = selectedBook ? selectedBook.id : writableBooks.value[0].id
showContactDialog.value = true
}
@@ -649,7 +750,12 @@ async function saveContact() {
if (editingContactId.value) {
await apiClient.put(`/contacts/${editingContactId.value}`, payload)
} else {
await apiClient.post(`/addressbooks/${selectedBookId.value}/contacts`, payload)
const target = contactTargetBookId.value || selectedBookId.value
if (!target) {
toast.add({ severity: 'error', summary: 'Bitte Adressbuch waehlen', life: 3000 })
return
}
await apiClient.post(`/addressbooks/${target}/contacts`, payload)
}
showContactDialog.value = false
await loadBooks()
@@ -672,6 +778,93 @@ async function deleteContact() {
await loadContacts()
}
// --- Multi-Select / Bulk-Loeschen ---
const selectedContacts = ref([])
async function bulkDeleteContacts() {
const ids = selectedContacts.value.map(c => c.id)
if (!ids.length) return
if (!confirm(`${ids.length} Kontakt(e) wirklich loeschen?`)) return
let ok = 0, fail = 0
for (const id of ids) {
try { await apiClient.delete(`/contacts/${id}`); ok++ } catch { fail++ }
}
selectedContacts.value = []
toast.add({
severity: fail ? 'warn' : 'success',
summary: `${ok} geloescht${fail ? `, ${fail} fehlgeschlagen` : ''}`,
life: 3000,
})
await loadBooks()
await loadContacts()
}
// --- Import / Export ---
const importInput = ref(null)
const showExportDialog = ref(false)
const exportFormat = ref('vcf')
const exportFormats = [
{ label: 'vCard (Sammeldatei .vcf)', value: 'vcf' },
{ label: 'vCards einzeln (.zip)', value: 'vcf-zip' },
{ label: 'CSV (.csv)', value: 'csv' },
]
const currentBook = computed(() => addressBooks.value.find(b => b.id === selectedBookId.value))
function triggerImport() {
if (!selectedBookId.value) {
toast.add({ severity: 'warn', summary: 'Kein Adressbuch ausgewaehlt', life: 3000 })
return
}
importInput.value?.click()
}
async function onImportFile(ev) {
const file = ev.target.files?.[0]
ev.target.value = ''
if (!file || !selectedBookId.value) return
const fd = new FormData()
fd.append('file', file)
try {
const res = await apiClient.post(
`/addressbooks/${selectedBookId.value}/import`, fd,
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
toast.add({
severity: 'success',
summary: `${res.data.imported} importiert`,
detail: res.data.skipped ? `${res.data.skipped} uebersprungen` : undefined,
life: 4000,
})
await loadBooks()
await loadContacts()
} catch (err) {
toast.add({ severity: 'error', summary: 'Import fehlgeschlagen',
detail: err.response?.data?.error || err.message, life: 5000 })
}
}
async function doExport() {
if (!selectedBookId.value) return
try {
const res = await apiClient.get(
`/addressbooks/${selectedBookId.value}/export`,
{ params: { format: exportFormat.value }, responseType: 'blob' }
)
const ext = exportFormat.value === 'csv' ? 'csv' : (exportFormat.value === 'vcf-zip' ? 'zip' : 'vcf')
const blob = new Blob([res.data])
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${currentBook.value?.name || 'kontakte'}.${ext}`
a.click()
URL.revokeObjectURL(url)
showExportDialog.value = false
} catch (err) {
toast.add({ severity: 'error', summary: 'Export fehlgeschlagen',
detail: err.response?.data?.error || err.message, life: 5000 })
}
}
// Live-Refresh via SSE
let eventSource = null
let reloadTimer = null
@@ -726,6 +919,8 @@ watch(selectedBookId, loadContacts)
.book-item:hover .book-menu { opacity: 1; }
.contacts-main { flex: 1; min-width: 0; }
.search-bar { margin-bottom: 0.75rem; }
.bulk-bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem;
background: var(--p-primary-50); border-radius: 6px; margin-bottom: 0.5rem; font-size: 0.875rem; }
.empty { text-align: center; color: var(--p-text-muted-color); padding: 2rem; }
.contact-row { display: flex; align-items: center; gap: 0.75rem; }
.avatar { width: 36px; height: 36px; border-radius: 50%; background: #888; color: white;
@@ -749,12 +944,15 @@ watch(selectedBookId, loadContacts)
.multi-row { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.4rem; }
.address-card { border: 1px solid var(--p-surface-200); padding: 0.75rem; border-radius: 6px; margin-bottom: 0.75rem; }
.share-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.rename-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }
.rename-row strong { font-size: 1rem; }
.user-search-popup { position: absolute; top: 100%; left: 0; right: 0; z-index: 10;
background: white; border: 1px solid var(--p-surface-200);
border-radius: 4px; max-height: 160px; overflow-y: auto;
box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.user-result { padding: 0.5rem 0.75rem; cursor: pointer; font-size: 0.875rem; }
.user-result:hover { background: var(--p-primary-50); }
.user-fullname { color: var(--p-text-muted-color); font-size: 0.75rem; margin-left: auto; }
.existing-shares { margin-top: 0.5rem; }
.share-perm-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.875rem; flex-wrap: wrap; }
.share-perm-item.editing { background: var(--p-surface-50); padding: 0.5rem; border-radius: 4px; }
+5 -1
View File
@@ -22,7 +22,11 @@
<div class="header-actions">
<Button icon="pi pi-folder-plus" label="Neuer Ordner" size="small" outlined @click="showNewFolder = true" />
<Button icon="pi pi-upload" label="Dateien" size="small" @click="triggerUpload" />
<Button icon="pi pi-folder" label="Ordner" size="small" outlined @click="triggerFolderUpload" />
<Button size="small" outlined @click="triggerFolderUpload">
<i class="pi pi-upload" style="margin-right:0.35rem"></i>
<i class="pi pi-folder" style="margin-right:0.5rem"></i>
Ordner
</Button>
<input ref="fileInput" type="file" multiple hidden @change="handleUpload" />
<input ref="folderInput" type="file" hidden webkitdirectory @change="handleFolderUpload" />
</div>
+55 -4
View File
@@ -12,15 +12,31 @@
<span class="label">Benutzername:</span>
<span>{{ auth.user?.username }}</span>
</div>
<div class="info-row">
<span class="label">E-Mail:</span>
<span>{{ auth.user?.email || 'Nicht angegeben' }}</span>
</div>
<div class="info-row">
<span class="label">Rolle:</span>
<Tag :value="auth.user?.role" :severity="auth.user?.role === 'admin' ? 'danger' : 'info'" />
</div>
</div>
<p class="hint" style="margin:0.75rem 0 0.5rem;font-size:0.8rem;color:var(--p-text-muted-color)">
Vor- und Nachname werden anderen Benutzern angezeigt, wenn du etwas mit ihnen teilst.
</p>
<form @submit.prevent="saveProfile" class="profile-form">
<div class="field-row">
<div class="field">
<label>Vorname</label>
<InputText v-model="profile.first_name" fluid />
</div>
<div class="field">
<label>Nachname</label>
<InputText v-model="profile.last_name" fluid />
</div>
</div>
<div class="field">
<label>E-Mail</label>
<InputText v-model="profile.email" type="email" fluid />
</div>
<Button type="submit" label="Profil speichern" :loading="profileLoading" size="small" />
</form>
</div>
<!-- Change Password -->
@@ -192,6 +208,36 @@ function downloadClient(client) {
window.location.href = `/api/clients/${client.platform}/download`
}
// --- Profile (Vorname/Nachname/E-Mail) ---
const profile = ref({ first_name: '', last_name: '', email: '' })
const profileLoading = ref(false)
async function loadProfile() {
try {
const res = await apiClient.get('/auth/me')
profile.value = {
first_name: res.data.first_name || '',
last_name: res.data.last_name || '',
email: res.data.email || '',
}
auth.user = { ...auth.user, ...res.data }
} catch { /* ignore */ }
}
async function saveProfile() {
profileLoading.value = true
try {
const res = await apiClient.put('/auth/me', profile.value)
auth.user = { ...auth.user, ...res.data }
toast.add({ severity: 'success', summary: 'Profil gespeichert', life: 2500 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler',
detail: err.response?.data?.error || err.message, life: 4000 })
} finally {
profileLoading.value = false
}
}
// --- Password change ---
const currentPassword = ref('')
const newPassword = ref('')
@@ -334,6 +380,7 @@ async function doDeleteAccount() {
onMounted(async () => {
loadAccounts()
loadProfile()
try {
const res = await apiClient.get('/clients')
availableClients.value = res.data.clients
@@ -352,6 +399,10 @@ onMounted(async () => {
.section-header h3 { margin: 0; }
.settings-info { display: flex; flex-direction: column; gap: 0.5rem; }
.info-row { display: flex; align-items: center; gap: 0.5rem; }
.profile-form { display: flex; flex-direction: column; gap: 0.5rem; max-width: 540px; }
.profile-form .field-row { display: flex; gap: 0.75rem; }
.profile-form .field-row .field { flex: 1; }
.profile-form .field label { display: block; font-size: 0.8rem; margin-bottom: 0.25rem; }
.info-row .label { font-weight: 500; min-width: 120px; }
.password-form { max-width: 400px; }
.password-form .field { margin-bottom: 1rem; }
+773
View File
@@ -0,0 +1,773 @@
<template>
<div class="view-container">
<div class="view-header">
<h2>Aufgaben</h2>
<div class="header-actions">
<Button icon="pi pi-list" label="Neue Liste" size="small" outlined @click="showNewList = true" />
<Button icon="pi pi-upload" label="Import" size="small" outlined @click="triggerImport" />
<input ref="importInput" type="file" accept=".ics,.ical,.csv" hidden @change="onImportFile" />
<Button icon="pi pi-download" label="Export" size="small" outlined
:disabled="!selectedListId" @click="showExportDialog = true" />
<Button icon="pi pi-plus" label="Neue Aufgabe" size="small"
:disabled="!writableLists.length" @click="openNewTask" />
</div>
</div>
<div class="tasks-layout">
<aside class="lists-sidebar">
<h4>Listen</h4>
<div v-for="tl in lists" :key="tl.id"
class="list-item" :class="{ active: selectedListId === tl.id }"
@click="selectedListId = tl.id">
<span class="list-color" :style="{ background: tl.color }"></span>
<span class="list-name">{{ tl.name }}</span>
<span v-if="tl.permission !== 'owner'" class="shared-label"
:title="`Geteilt von ${tl.owner_display_name || tl.owner_name}`">
(geteilt von {{ tl.owner_display_name || tl.owner_name }})
</span>
<span class="count">{{ tl.task_count }}</span>
<Button icon="pi pi-ellipsis-v" text size="small" class="list-menu"
@click.stop="openListMenu(tl)" />
</div>
</aside>
<div class="tasks-main">
<div class="toolbar">
<InputText v-model="search" placeholder="Aufgaben suchen..." fluid />
<label class="toggle"><Checkbox v-model="hideDone" :binary="true" /> Erledigte ausblenden</label>
</div>
<div v-if="selectedTaskIds.length" class="bulk-bar">
<span>{{ selectedTaskIds.length }} ausgewaehlt</span>
<Button icon="pi pi-trash" :label="`${selectedTaskIds.length} loeschen`"
severity="danger" size="small" @click="bulkDelete" />
<Button label="Auswahl aufheben" size="small" text @click="selectedTaskIds = []" />
</div>
<table class="task-table">
<thead>
<tr>
<th class="col-check">
<Checkbox v-model="allSelected" :binary="true" @change="toggleAll" />
</th>
<th class="col-done"></th>
<th>Titel</th>
<th>Faellig</th>
<th>Prio</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="t in filteredTasks" :key="t.id" class="task-row"
:class="{ done: t.status === 'COMPLETED', selected: selectedTaskIds.includes(t.id) }"
@click="openEditTask(t)">
<td class="col-check" @click.stop>
<Checkbox :modelValue="selectedTaskIds.includes(t.id)" :binary="true"
@update:modelValue="toggleSelect(t.id, $event)" />
</td>
<td class="col-done" @click.stop>
<Checkbox :modelValue="t.status === 'COMPLETED'" :binary="true"
@update:modelValue="toggleDone(t, $event)" title="Erledigt" />
</td>
<td class="col-title">
<span>{{ t.summary || '(ohne Titel)' }}</span>
<small v-if="t.description" class="meta">{{ shortDesc(t.description) }}</small>
</td>
<td class="col-date">{{ formatDue(t.due) }}</td>
<td>{{ formatPrio(t.priority) }}</td>
<td><span class="status-badge" :class="statusClass(t.status)">{{ statusLabel(t.status) }}</span></td>
<td class="col-actions" @click.stop>
<Button icon="pi pi-trash" text size="small" severity="danger" @click="confirmDelete(t)" />
</td>
</tr>
<tr v-if="!filteredTasks.length">
<td colspan="7" class="empty-row">Keine Aufgaben.</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- New List Dialog -->
<Dialog v-model:visible="showNewList" header="Neue Aufgabenliste" modal :style="{ width: '400px' }">
<div class="field">
<label>Name</label>
<InputText v-model="newListName" fluid autofocus @keyup.enter="createList" />
</div>
<div class="field">
<label>Farbe</label>
<InputText v-model="newListColor" type="color" style="width: 60px; height: 36px" />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showNewList = false" />
<Button label="Erstellen" @click="createList" />
</template>
</Dialog>
<!-- List Menu -->
<Dialog v-model:visible="showListMenu" header="Listen-Optionen" modal :style="{ width: '480px' }">
<div v-if="menuList">
<div class="rename-row">
<template v-if="!isRenaming">
<strong>{{ menuList.name }}</strong>
<Button v-if="menuList.permission === 'owner'"
icon="pi pi-pencil" text size="small" title="Umbenennen"
@click="startRename" />
</template>
<template v-else>
<InputText v-model="renameValue" fluid autofocus
@keyup.enter="saveRename" @keyup.escape="isRenaming = false" />
<Button icon="pi pi-check" text size="small" severity="success"
title="Speichern" @click="saveRename" />
<Button icon="pi pi-times" text size="small"
title="Abbrechen" @click="isRenaming = false" />
</template>
</div>
<div class="field">
<label>Farbe</label>
<InputText :modelValue="menuList.color" @change="onListColor($event)" type="color" style="width:60px; height:36px" />
</div>
<div v-if="menuList.permission === 'owner'" class="field">
<label>Mit Benutzer teilen</label>
<div class="share-row">
<div style="position: relative; flex: 1;">
<InputText v-model="shareUsername" placeholder="Benutzername suchen..."
fluid @input="onShareSearch" />
<div v-if="shareSearchResults.length" class="user-search-popup">
<div v-for="u in shareSearchResults" :key="u.id" class="user-result"
@click="shareUsername = u.username; shareSearchResults = []">
<i class="pi pi-user"></i>
<span>{{ u.username }}</span>
<small v-if="u.full_name" class="user-fullname">{{ u.full_name }}</small>
</div>
</div>
</div>
<Select v-model="sharePermission" :options="permOptions" optionLabel="label" optionValue="value" />
<Button label="Teilen" size="small" @click="doShare" />
</div>
<div v-if="listShares.length" class="existing-shares">
<template v-for="s in listShares" :key="s.id">
<div v-if="editingShareId !== s.id" class="share-perm-item">
<i class="pi pi-user"></i> <span>{{ s.username }}</span>
<span class="perm-label">{{ s.permission === 'readwrite' ? 'Lesen+Schreiben' : 'Lesen' }}</span>
<Button icon="pi pi-pencil" text size="small" title="Bearbeiten" @click="startEditShare(s)" />
<Button icon="pi pi-trash" text size="small" severity="danger" title="Entfernen" @click="removeShare(s.id)" />
</div>
<div v-else class="share-perm-item editing">
<i class="pi pi-user"></i> <span>{{ s.username }}</span>
<Select v-model="editSharePermission" :options="permOptions" optionLabel="label" optionValue="value" />
<Button icon="pi pi-check" text size="small" severity="success" title="Speichern" @click="saveEditShare(s)" />
<Button icon="pi pi-times" text size="small" title="Abbrechen" @click="editingShareId = null" />
</div>
</template>
</div>
</div>
<div v-if="menuList.permission === 'owner'" class="field" style="border-top:1px solid var(--p-surface-200); padding-top:1rem">
<Button label="Liste loeschen" severity="danger" outlined size="small" @click="confirmDeleteList = true" />
</div>
<div class="field" style="border-top:1px solid var(--p-surface-200); padding-top:1rem">
<label><i class="pi pi-info-circle"></i> CalDAV-Zugang (Handy / DAVx5)</label>
<div class="caldav-hint">In DAVx5 unter demselben Konto sichtbar wie Kalender. Aufgabenlisten sind mit "OpenTasks" synchronisierbar.</div>
<div class="url-row">
<strong>Listen-URL:</strong>
<code>{{ origin }}/dav/{{ username }}/tl-{{ menuList.id }}/</code>
<Button icon="pi pi-copy" text size="small" @click="copy(`${origin}/dav/${username}/tl-${menuList.id}/`)" />
</div>
</div>
</div>
</Dialog>
<!-- Task Dialog -->
<Dialog v-model:visible="showTaskDialog" :header="editingTaskId ? 'Aufgabe bearbeiten' : 'Neue Aufgabe'"
modal :style="{ width: '560px' }">
<div v-if="writableLists.length > 1" class="field">
<label>Liste</label>
<Select v-model="taskTargetListId" :options="writableListOptions"
optionLabel="label" optionValue="id" fluid />
</div>
<div class="field">
<label>Titel</label>
<InputText v-model="taskForm.summary" fluid autofocus />
</div>
<div class="field">
<label>Beschreibung</label>
<Textarea v-model="taskForm.description" rows="3" fluid />
</div>
<div class="field-row">
<div class="field">
<label>Faellig</label>
<InputText v-model="taskForm.due" type="datetime-local" fluid />
</div>
<div class="field">
<label>Status</label>
<Select v-model="taskForm.status" :options="statusOptions" optionLabel="label" optionValue="value" fluid />
</div>
</div>
<div class="field-row">
<div class="field">
<label>Prioritaet</label>
<Select v-model="taskForm.priority" :options="prioOptions" optionLabel="label" optionValue="value" fluid />
</div>
<div class="field">
<label>Fortschritt %</label>
<InputText v-model.number="taskForm.percent_complete" type="number" min="0" max="100" fluid />
</div>
</div>
<div class="field">
<label>Kategorien (kommagetrennt)</label>
<InputText v-model="taskForm.categories" fluid />
</div>
<template #footer>
<Button v-if="editingTaskId" label="Loeschen" text severity="danger" @click="deleteCurrent" />
<Button label="Abbrechen" text @click="showTaskDialog = false" />
<Button :label="editingTaskId ? 'Speichern' : 'Erstellen'" @click="saveTask" />
</template>
</Dialog>
<Dialog v-model:visible="confirmDeleteList" header="Liste loeschen" modal :style="{ width: '400px' }">
<p>Liste <strong>{{ menuList?.name }}</strong> mit allen Aufgaben loeschen?</p>
<template #footer>
<Button label="Abbrechen" text @click="confirmDeleteList = false" />
<Button label="Loeschen" severity="danger" @click="deleteList" />
</template>
</Dialog>
<!-- Export Dialog -->
<Dialog v-model:visible="showExportDialog" header="Aufgaben exportieren" modal :style="{ width: '420px' }">
<p>Aus Liste <strong>{{ currentList?.name }}</strong></p>
<div class="field">
<label>Format</label>
<Select v-model="exportFormat" :options="exportFormats" optionLabel="label" optionValue="value" fluid />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showExportDialog = false" />
<Button label="Herunterladen" icon="pi pi-download" @click="doExport" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useAuthStore } from '../stores/auth'
import apiClient from '../api/client'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import Select from 'primevue/select'
import Checkbox from 'primevue/checkbox'
const toast = useToast()
const auth = useAuthStore()
const origin = computed(() => window.location.origin)
const username = computed(() => auth.user?.username || '')
const lists = ref([])
const selectedListId = ref(null)
const taskTargetListId = ref(null)
const writableLists = computed(() =>
lists.value.filter(l => l.permission === 'owner' || l.permission === 'readwrite')
)
const writableListOptions = computed(() => writableLists.value.map(l => ({
...l,
label: l.permission === 'owner'
? l.name
: `${l.name} (geteilt von ${l.owner_display_name || l.owner_name})`,
})))
const tasks = ref([])
const search = ref('')
const hideDone = ref(false)
const selectedTaskIds = ref([])
const showNewList = ref(false)
const newListName = ref('')
const newListColor = ref('#10b981')
const showListMenu = ref(false)
const menuList = ref(null)
const shareUsername = ref('')
const sharePermission = ref('read')
const listShares = ref([])
const shareSearchResults = ref([])
const editingShareId = ref(null)
const editSharePermission = ref('read')
const isRenaming = ref(false)
const renameValue = ref('')
function startRename() {
renameValue.value = menuList.value?.name || ''
isRenaming.value = true
}
async function saveRename() {
const newName = renameValue.value.trim()
if (!newName || !menuList.value || newName === menuList.value.name) {
isRenaming.value = false
return
}
try {
await apiClient.put(`/tasklists/${menuList.value.id}`, { name: newName })
menuList.value.name = newName
isRenaming.value = false
await loadLists()
toast.add({ severity: 'success', summary: 'Umbenannt', life: 2000 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler',
detail: err.response?.data?.error || err.message, life: 4000 })
}
}
let shareSearchTimer = null
function startEditShare(s) {
editingShareId.value = s.id
editSharePermission.value = s.permission
}
async function saveEditShare(s) {
if (!menuList.value) return
try {
await apiClient.post(`/tasklists/${menuList.value.id}/share`, {
username: s.username,
permission: editSharePermission.value,
})
editingShareId.value = null
await loadShares()
toast.add({ severity: 'success', summary: 'Berechtigung aktualisiert', life: 2500 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler',
detail: err.response?.data?.error || err.message, life: 4000 })
}
}
function onShareSearch() {
clearTimeout(shareSearchTimer)
const q = shareUsername.value.trim()
if (q.length < 2) { shareSearchResults.value = []; return }
shareSearchTimer = setTimeout(async () => {
try {
const res = await apiClient.get('/users/search', { params: { q } })
shareSearchResults.value = res.data
} catch { shareSearchResults.value = [] }
}, 250)
}
const permOptions = [
{ label: 'Lesen', value: 'read' },
{ label: 'Lesen+Schreiben', value: 'readwrite' },
]
const confirmDeleteList = ref(false)
const showTaskDialog = ref(false)
const editingTaskId = ref(null)
const taskForm = reactive({
summary: '', description: '',
due: '', status: 'NEEDS-ACTION', priority: null, percent_complete: null,
categories: '',
})
const statusOptions = [
{ label: 'Offen', value: 'NEEDS-ACTION' },
{ label: 'In Arbeit', value: 'IN-PROCESS' },
{ label: 'Erledigt', value: 'COMPLETED' },
{ label: 'Abgebrochen', value: 'CANCELLED' },
]
const prioOptions = [
{ label: '—', value: null },
{ label: 'Hoch (1)', value: 1 },
{ label: 'Mittel (5)', value: 5 },
{ label: 'Niedrig (9)', value: 9 },
]
const showExportDialog = ref(false)
const exportFormat = ref('ics')
const exportFormats = [
{ label: 'iCalendar (.ics)', value: 'ics' },
{ label: 'CSV (.csv)', value: 'csv' },
]
const importInput = ref(null)
const currentList = computed(() => lists.value.find(l => l.id === selectedListId.value))
const filteredTasks = computed(() => {
const q = search.value.trim().toLowerCase()
return tasks.value.filter(t => {
if (hideDone.value && t.status === 'COMPLETED') return false
if (q && !(t.summary || '').toLowerCase().includes(q)
&& !(t.description || '').toLowerCase().includes(q)) return false
return true
})
})
const allSelected = computed({
get: () => filteredTasks.value.length > 0 && filteredTasks.value.every(t => selectedTaskIds.value.includes(t.id)),
set: () => {},
})
function toggleAll() {
const ids = filteredTasks.value.map(t => t.id)
const allSel = ids.every(id => selectedTaskIds.value.includes(id))
if (allSel) selectedTaskIds.value = selectedTaskIds.value.filter(id => !ids.includes(id))
else {
const set = new Set(selectedTaskIds.value); ids.forEach(id => set.add(id))
selectedTaskIds.value = [...set]
}
}
function toggleSelect(id, checked) {
if (checked && !selectedTaskIds.value.includes(id)) selectedTaskIds.value = [...selectedTaskIds.value, id]
else if (!checked) selectedTaskIds.value = selectedTaskIds.value.filter(x => x !== id)
}
function shortDesc(s) { return s.length > 80 ? s.slice(0, 80) + '…' : s }
function formatDue(d) {
if (!d) return ''
return new Date(d).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
function formatPrio(p) {
if (p === null || p === undefined) return ''
if (p <= 3) return 'Hoch'
if (p >= 7) return 'Niedrig'
return 'Mittel'
}
function statusLabel(s) {
return ({ 'NEEDS-ACTION': 'Offen', 'IN-PROCESS': 'In Arbeit', 'COMPLETED': 'Erledigt', 'CANCELLED': 'Abgebrochen' })[s] || 'Offen'
}
function statusClass(s) {
return { 'NEEDS-ACTION': 'todo', 'IN-PROCESS': 'progress', 'COMPLETED': 'done', 'CANCELLED': 'cancelled' }[s] || 'todo'
}
async function loadLists() {
const res = await apiClient.get('/tasklists')
lists.value = res.data
if (!selectedListId.value && lists.value.length) selectedListId.value = lists.value[0].id
if (!lists.value.length) {
await apiClient.post('/tasklists', { name: 'Meine Aufgaben', color: '#10b981' })
await loadLists()
}
}
async function loadTasks() {
if (!selectedListId.value) { tasks.value = []; return }
try {
const res = await apiClient.get(`/tasklists/${selectedListId.value}/tasks`)
tasks.value = res.data
} catch { tasks.value = [] }
}
async function createList() {
if (!newListName.value.trim()) return
await apiClient.post('/tasklists', { name: newListName.value.trim(), color: newListColor.value })
showNewList.value = false
newListName.value = ''
await loadLists()
}
function openListMenu(tl) {
menuList.value = tl
shareUsername.value = ''
shareSearchResults.value = []
isRenaming.value = false
showListMenu.value = true
loadShares()
}
async function loadShares() {
if (!menuList.value || menuList.value.permission !== 'owner') { listShares.value = []; return }
try {
const res = await apiClient.get(`/tasklists/${menuList.value.id}/shares`)
listShares.value = res.data
} catch { listShares.value = [] }
}
async function doShare() {
if (!menuList.value || !shareUsername.value.trim()) return
try {
await apiClient.post(`/tasklists/${menuList.value.id}/share`, {
username: shareUsername.value.trim(), permission: sharePermission.value,
})
toast.add({ severity: 'success', summary: 'Geteilt', life: 2500 })
shareUsername.value = ''
shareSearchResults.value = []
await loadShares()
} catch (err) {
toast.add({ severity: 'error', summary: err.response?.data?.error || 'Fehler', life: 4000 })
}
}
async function removeShare(id) {
await apiClient.delete(`/tasklists/${menuList.value.id}/shares/${id}`)
await loadShares()
}
async function onListColor(ev) {
const color = ev.target.value
await apiClient.put(`/tasklists/${menuList.value.id}/my-color`, { color })
menuList.value.color = color
await loadLists()
}
async function deleteList() {
if (!menuList.value) return
await apiClient.delete(`/tasklists/${menuList.value.id}`)
confirmDeleteList.value = false
showListMenu.value = false
if (selectedListId.value === menuList.value.id) selectedListId.value = null
await loadLists()
await loadTasks()
}
function openNewTask() {
if (!writableLists.value.length) {
toast.add({ severity: 'warn', summary: 'Keine beschreibbare Liste', life: 3000 })
return
}
editingTaskId.value = null
Object.assign(taskForm, {
summary: '', description: '', due: '',
status: 'NEEDS-ACTION', priority: null, percent_complete: null,
categories: '',
})
// Default-Liste: aktuell markierte falls beschreibbar, sonst erste beschreibbare
const sel = writableLists.value.find(l => l.id === selectedListId.value)
taskTargetListId.value = sel ? sel.id : writableLists.value[0].id
showTaskDialog.value = true
}
function openEditTask(t) {
editingTaskId.value = t.id
Object.assign(taskForm, {
summary: t.summary || '',
description: t.description || '',
due: t.due ? t.due.slice(0, 16) : '',
status: t.status || 'NEEDS-ACTION',
priority: t.priority,
percent_complete: t.percent_complete,
categories: (t.categories || []).join(', '),
})
showTaskDialog.value = true
}
async function saveTask() {
if (!taskForm.summary.trim()) return
const payload = {
summary: taskForm.summary.trim(),
description: taskForm.description,
due: taskForm.due ? new Date(taskForm.due).toISOString() : null,
status: taskForm.status,
priority: taskForm.priority,
percent_complete: taskForm.percent_complete,
categories: taskForm.categories.split(',').map(s => s.trim()).filter(Boolean),
}
try {
if (editingTaskId.value) {
await apiClient.put(`/tasks/${editingTaskId.value}`, payload)
} else {
const target = taskTargetListId.value || selectedListId.value
if (!target) {
toast.add({ severity: 'error', summary: 'Bitte Liste waehlen', life: 3000 })
return
}
await apiClient.post(`/tasklists/${target}/tasks`, payload)
}
showTaskDialog.value = false
await loadLists()
await loadTasks()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 4000 })
}
}
async function toggleDone(t, checked) {
try {
await apiClient.put(`/tasks/${t.id}`, { status: checked ? 'COMPLETED' : 'NEEDS-ACTION' })
await loadTasks()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', life: 3000 })
}
}
async function deleteCurrent() {
if (!editingTaskId.value) return
if (!confirm('Aufgabe wirklich loeschen?')) return
await apiClient.delete(`/tasks/${editingTaskId.value}`)
showTaskDialog.value = false
await loadLists()
await loadTasks()
}
async function confirmDelete(t) {
if (!confirm(`"${t.summary || '(ohne Titel)'}" loeschen?`)) return
await apiClient.delete(`/tasks/${t.id}`)
await loadLists()
await loadTasks()
}
async function bulkDelete() {
const ids = [...selectedTaskIds.value]
if (!ids.length || !confirm(`${ids.length} Aufgabe(n) loeschen?`)) return
let ok = 0, fail = 0
for (const id of ids) {
try { await apiClient.delete(`/tasks/${id}`); ok++ } catch { fail++ }
}
selectedTaskIds.value = []
toast.add({
severity: fail ? 'warn' : 'success',
summary: `${ok} geloescht${fail ? `, ${fail} fehlgeschlagen` : ''}`, life: 3000,
})
await loadLists()
await loadTasks()
}
function triggerImport() {
if (!selectedListId.value) {
toast.add({ severity: 'warn', summary: 'Keine Liste ausgewaehlt', life: 3000 })
return
}
importInput.value?.click()
}
async function onImportFile(ev) {
const file = ev.target.files?.[0]
ev.target.value = ''
if (!file) return
const fd = new FormData()
fd.append('file', file)
try {
const res = await apiClient.post(`/tasklists/${selectedListId.value}/import`, fd,
{ headers: { 'Content-Type': 'multipart/form-data' } })
toast.add({
severity: 'success',
summary: `${res.data.imported} importiert`,
detail: res.data.skipped ? `${res.data.skipped} uebersprungen` : undefined,
life: 4000,
})
await loadLists()
await loadTasks()
} catch (err) {
toast.add({ severity: 'error', summary: 'Import fehlgeschlagen', detail: err.response?.data?.error, life: 5000 })
}
}
async function doExport() {
if (!selectedListId.value) return
try {
const res = await apiClient.get(`/tasklists/${selectedListId.value}/export`,
{ params: { format: exportFormat.value }, responseType: 'blob' })
const ext = exportFormat.value === 'csv' ? 'csv' : 'ics'
const url = URL.createObjectURL(new Blob([res.data]))
const a = document.createElement('a')
a.href = url
a.download = `${currentList.value?.name || 'aufgaben'}.${ext}`
a.click()
URL.revokeObjectURL(url)
showExportDialog.value = false
} catch (err) {
toast.add({ severity: 'error', summary: 'Export fehlgeschlagen', life: 4000 })
}
}
function copy(text) {
navigator.clipboard.writeText(text)
toast.add({ severity: 'info', summary: 'Kopiert', life: 1500 })
}
// --- Live refresh via SSE ---
let eventSource = null
let reloadTimer = null
function scheduleReload() {
if (reloadTimer) return
reloadTimer = setTimeout(async () => {
reloadTimer = null
await loadLists()
await loadTasks()
}, 300)
}
onMounted(async () => {
await loadLists()
await loadTasks()
if (auth.accessToken) {
try {
eventSource = new EventSource(`/api/sync/events?token=${encodeURIComponent(auth.accessToken)}`)
eventSource.addEventListener('tasklist', scheduleReload)
eventSource.addEventListener('message', scheduleReload)
eventSource.onerror = () => {}
} catch {}
}
})
onUnmounted(() => {
if (reloadTimer) clearTimeout(reloadTimer)
if (eventSource) eventSource.close()
})
watch(selectedListId, loadTasks)
</script>
<style scoped>
.view-container { padding: 1.5rem; }
.view-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.view-header h2 { margin: 0; }
.header-actions { display: flex; gap: 0.5rem; }
.tasks-layout { display: flex; gap: 1rem; align-items: flex-start; }
.lists-sidebar { width: 260px; flex-shrink: 0; }
.lists-sidebar h4 { margin: 0 0 0.5rem; font-size: 0.85rem; text-transform: uppercase; color: var(--p-text-muted-color); }
.list-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; border-radius: 4px;
cursor: pointer; font-size: 0.875rem; }
.list-item:hover { background: var(--p-surface-50); }
.list-item.active { background: var(--p-primary-50); }
.list-color { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; }
.list-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.shared-label { color: var(--p-text-muted-color); font-size: 0.7rem; }
.count { color: var(--p-text-muted-color); font-size: 0.8rem; }
.list-menu { opacity: 0; transition: opacity .15s; }
.list-item:hover .list-menu { opacity: 1; }
.tasks-main { flex: 1; min-width: 0; }
.toolbar { display: flex; gap: 0.75rem; align-items: center; margin-bottom: 0.75rem; }
.toggle { display: flex; align-items: center; gap: 0.35rem; font-size: 0.875rem; white-space: nowrap; }
.bulk-bar { display: flex; gap: 0.5rem; align-items: center; padding: 0.5rem 0.75rem;
background: var(--p-primary-50); border-radius: 6px; margin-bottom: 0.5rem; font-size: 0.875rem; }
.task-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
.task-table th { text-align: left; padding: 0.5rem; border-bottom: 2px solid var(--p-surface-200); font-weight: 600; }
.task-table td { padding: 0.5rem; border-bottom: 1px solid var(--p-surface-100); vertical-align: top; }
.task-row { cursor: pointer; }
.task-row:hover { background: var(--p-surface-50); }
.task-row.done .col-title span { text-decoration: line-through; color: var(--p-text-muted-color); }
.task-row.selected { background: var(--p-primary-50); }
.col-check, .col-done { width: 36px; }
.col-actions { width: 60px; text-align: right; }
.col-date { white-space: nowrap; }
.col-title { }
.meta { display: block; color: var(--p-text-muted-color); font-size: 0.75rem; margin-top: 0.1rem; }
.empty-row { text-align: center; color: var(--p-text-muted-color); padding: 2rem !important; }
.status-badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 10px; font-size: 0.72rem; }
.status-badge.todo { background: var(--p-surface-100); }
.status-badge.progress { background: var(--p-blue-100); color: var(--p-blue-700); }
.status-badge.done { background: var(--p-green-100); color: var(--p-green-700); }
.status-badge.cancelled { background: var(--p-red-100); color: var(--p-red-700); }
.field { margin-bottom: 0.75rem; }
.field label { display: block; margin-bottom: 0.25rem; font-weight: 500; font-size: 0.875rem; }
.field-row { display: flex; gap: 0.75rem; }
.field-row .field { flex: 1; }
.share-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.rename-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }
.rename-row strong { font-size: 1rem; }
.user-search-popup { position: absolute; top: 100%; left: 0; right: 0; z-index: 10;
background: white; border: 1px solid var(--p-surface-200);
border-radius: 4px; max-height: 160px; overflow-y: auto;
box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.user-result { padding: 0.5rem 0.75rem; cursor: pointer; font-size: 0.875rem;
display: flex; gap: 0.5rem; align-items: center; }
.user-result:hover { background: var(--p-primary-50); }
.user-fullname { color: var(--p-text-muted-color); font-size: 0.75rem; margin-left: auto; }
.existing-shares { margin-top: 0.5rem; }
.share-perm-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.875rem; flex-wrap: wrap; }
.share-perm-item.editing { background: var(--p-surface-50); padding: 0.5rem; border-radius: 4px; }
.perm-label { color: var(--p-text-muted-color); font-size: 0.75rem; }
.url-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.url-row strong { min-width: 110px; font-size: 0.8rem; }
.url-row code { background: var(--p-surface-100); padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; flex: 1; word-break: break-all; }
.caldav-hint { font-size: 0.8rem; color: var(--p-text-muted-color); margin: 0 0 0.5rem; }
</style>