diff --git a/CHANGELOG.md b/CHANGELOG.md index fef9d43..23fc2a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.22.0] - 2026-10-31 + +### Added +- Kanban am Handy und Tablet: Karten lassen sich mit dem Finger verschieben – kurz gedrückt halten, bis sie sich anhebt, dann auf eine andere Karte oder Spalte ziehen und loslassen. Am Rand scrollt die Seite mit (am Handy stehen die Spalten untereinander). Ein kurzer Tipp öffnet die Aufgabe wie bisher, Wischen scrollt. +- Admin-Einstellungen: Datenbank-Backups lassen sich neben „Download“ auch löschen (mit Rückfrage). Nur für Admins; Benutzer-Backups laufen weiter über die Aufbewahrungsdauer ab. Neu dafür ist `DELETE /api/v1/admin/backups/{name}`. + +### Changed +- Meldung nach dem Teilen: größer und mit dem Daumen gut erreichbar – Text oben, darunter zwei große Knöpfe **Liste** und **Aufgabe** wie in der Triage, ein kleines ✕ in der Ecke; ein Tipp daneben schließt sie. + ## [0.21.0] - 2026-10-31 ### Added @@ -450,7 +459,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.21.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.0...dev +[0.22.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.21.0...v0.22.0 [0.21.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.20.0...v0.21.0 [0.20.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.19.0...v0.20.0 [0.19.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.18.1...v0.19.0 diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 555fc37..f346d49 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -221,6 +221,7 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant | GET | `/admin/voice` | Aktive Spracherkennung (`mode`: `server`/`browser`/`off`), Einstellungen aus `STT_*` ohne Schlüssel, Grenzen und im Server-Modus ein frischer Zustand (`status.reachable`, `status.modelReady`) | | GET/POST | `/admin/backups` | Datenbank-Abzüge auflisten bzw. sofort sichern (Abzug + alle Benutzer-Backups) | | GET | `/admin/backups/{name}` | Datenbank-Abzug herunterladen | +| DELETE | `/admin/backups/{name}` | Datenbank-Abzug löschen (gibt die verbleibende Liste zurück; nicht in der Demo) | ## Beispiele diff --git a/README.md b/README.md index c0eb84d..442090a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.21.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.22.0-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cb52abb..840fd64 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.21.0", + "version": "0.22.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.21.0", + "version": "0.22.0", "license": "Unlicense", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index c118634..031e7a6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.21.0", + "version": "0.22.0", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/components/SharedImportToast.tsx b/frontend/src/components/SharedImportToast.tsx index 8e033ba..bd724cc 100644 --- a/frontend/src/components/SharedImportToast.tsx +++ b/frontend/src/components/SharedImportToast.tsx @@ -49,52 +49,68 @@ export const SharedImportToast: React.FC = ({ thoughtIds else setChoosingList(true); }; - const chip = - 'h-9 px-3 inline-flex items-center gap-1.5 rounded-btn border border-border bg-canvas text-main text-xs font-medium active:bg-panel-elevated disabled:opacity-50 touch-manipulation'; + const big = + 'min-h-[52px] px-3 py-2 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 touch-manipulation cursor-pointer active:scale-98 disabled:opacity-50'; return ( -
-
- - - {count === 1 ? 'Im Quick Dump abgelegt' : `${count} Einträge im Quick Dump abgelegt`} - - +
+

+ + {count === 1 ? 'Im Quick Dump abgelegt' : `${count} Einträge im Quick Dump abgelegt`} +

+

+ {choosingList ? 'Auf welche Liste?' : 'Oder gleich woanders hin:'} +

+
+ + {failed &&

Umwandeln hat nicht geklappt – der Eintrag bleibt im Quick Dump.

} + + {!choosingList ? ( +
0 ? 'grid-cols-2' : 'grid-cols-1'}`}> + {writableLists.length > 0 && ( + + )} + +
+ ) : ( +
+ {writableLists.map((l) => ( + + ))} +
+ )}
- - {failed &&

Umwandeln hat nicht geklappt – der Eintrag bleibt im Quick Dump.

} - - {!choosingList ? ( -
- Stattdessen - - {writableLists.length > 0 && ( - - )} -
- ) : ( -
- {writableLists.map((l) => ( - - ))} -
- )} -
+ ); }; diff --git a/frontend/src/features/settings/BackupAdminCard.tsx b/frontend/src/features/settings/BackupAdminCard.tsx index 95e74a4..6659f82 100644 --- a/frontend/src/features/settings/BackupAdminCard.tsx +++ b/frontend/src/features/settings/BackupAdminCard.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Archive, Download, Loader2 } from 'lucide-react'; +import { Archive, Download, Loader2, Trash2 } from 'lucide-react'; import { BackupFile, SystemSettings } from '../../lib/types'; import { api } from '../../lib/api'; import { ToggleSwitch } from '../../components/ToggleSwitch'; @@ -41,6 +41,16 @@ export const BackupAdminCard: React.FC = ({ settings, onSa } }; + const remove = async (name: string) => { + if (!window.confirm(`Backup ${name} endgültig löschen?`)) return; + setError(null); + try { + setFiles(await api.adminDeleteBackup(name)); + } catch (err: any) { + setError(err.message || 'Löschen fehlgeschlagen'); + } + }; + return (
@@ -109,14 +119,25 @@ export const BackupAdminCard: React.FC = ({ settings, onSa {new Date(f.createdAt).toLocaleString('de-DE')} · {formatBytes(f.size)}

- +
+ + +
)) )} diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 270bd13..8e78900 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -31,6 +31,7 @@ import { import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types'; import { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due'; import { readViewSession, writeViewSession } from '../../lib/viewSession'; +import { useKanbanTouchDrag } from './useKanbanTouchDrag'; import { api, triggerHaptic } from '../../lib/api'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { TaskDetailModal } from './TaskDetailModal'; @@ -807,7 +808,11 @@ export const TasksView: React.FC = ({ setDraggingTaskId(null); setDragOverTaskTarget(null); setDragOverKanbanCol(null); + await moveTaskNextTo(sourceId, targetTask, position, newKanbanCol); + }; + // Shared by mouse drag & drop and the touch drag on the Kanban board + const moveTaskNextTo = async (sourceId: string | null, targetTask: Thought, position: 'before' | 'after', newKanbanCol?: string) => { if (!sourceId || sourceId === targetTask.id) return; triggerHaptic(15); @@ -864,7 +869,10 @@ export const TasksView: React.FC = ({ setDraggingTaskId(null); setDragOverTaskTarget(null); setDragOverKanbanCol(null); + await moveTaskToColumn(sourceId, colId); + }; + const moveTaskToColumn = async (sourceId: string | null, colId: string) => { if (!sourceId) return; triggerHaptic(15); @@ -905,6 +913,28 @@ export const TasksView: React.FC = ({ } }; + // Kanban with a finger: hold a card, drag it, let go (mouse and pen use HTML5 drag & drop) + const clearDragState = () => { + setDraggingTaskId(null); + setDragOverTaskTarget(null); + setDragOverKanbanCol(null); + }; + const kanbanTouch = useKanbanTouchDrag({ + enabled: !canDragTasks && viewMode === 'kanban', + onStart: (taskId) => setDraggingTaskId(taskId), + onHover: (colId, target) => { + setDragOverKanbanCol(colId); + setDragOverTaskTarget(target); + }, + onDrop: (taskId, colId, target) => { + clearDragState(); + const targetTask = target ? tasks.find((t) => t.id === target.taskId) : undefined; + if (targetTask && target) void moveTaskNextTo(taskId, targetTask, target.position, colId ?? undefined); + else if (colId) void moveTaskToColumn(taskId, colId); + }, + onCancel: clearDragState, + }); + const handleCreateTaskList = async (e: React.FormEvent) => { e.preventDefault(); const title = newListTitle.trim(); @@ -2224,6 +2254,7 @@ export const TasksView: React.FC = ({ {viewMode === 'kanban' ? ( /* DYNAMIC KANBAN BOARD */
+ {kanbanTouch.ghost}
= ({ if (dragOverKanbanCol === col.id) setDragOverKanbanCol(null); }} onDrop={(e) => handleDropOnKanbanCol(e, col.id)} + data-kanban-col={col.id} className={`bg-canvas border border-border flex flex-col min-h-[420px] overflow-hidden transition-all ${ dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : '' }`} @@ -2295,11 +2327,14 @@ export const TasksView: React.FC = ({ onDragOver={(e) => handleTaskDragOver(e, task.id)} onDragLeave={(e) => handleTaskDragLeave(e, task.id)} onDrop={(e) => handleDropOnTask(e, task, col.id)} + data-kanban-task={task.id} + {...kanbanTouch.cardProps(task.id, task.content, !readOnly)} onClick={() => { + if (kanbanTouch.consumeClick()) return; setDetailTaskId(task.id); setShowDetailModal(true); }} - className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all shadow-sm group relative ${ + className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all shadow-sm group relative select-none ${ readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing' } ${ isDragging ? 'opacity-30 border border-dashed border-accent/40' : '' diff --git a/frontend/src/features/triage/useKanbanTouchDrag.tsx b/frontend/src/features/triage/useKanbanTouchDrag.tsx new file mode 100644 index 0000000..0595a2a --- /dev/null +++ b/frontend/src/features/triage/useKanbanTouchDrag.tsx @@ -0,0 +1,169 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +// Drag & drop on the Kanban board with a finger (browsers offer HTML5 drag & drop only for mouse and +// pen): hold a card for a moment, it lifts, drag it onto another card or column, let go. A short tap +// still opens the task and a swipe still scrolls, because the drag starts only after the hold. +// Cards carry data-kanban-task="", columns data-kanban-col="". + +const HOLD_MS = 350; +const MOVE_TOLERANCE = 8; // px a finger may wander during the hold before it counts as scrolling +const EDGE = 90; // px near the top/bottom edge that scroll the page while dragging + +export type KanbanDropTarget = { taskId: string; position: 'before' | 'after' } | null; + +interface Options { + enabled: boolean; + onStart: (taskId: string) => void; + onHover: (colId: string | null, target: KanbanDropTarget) => void; + onDrop: (taskId: string, colId: string | null, target: KanbanDropTarget) => void; + onCancel: () => void; +} + +interface Drag { + taskId: string; + label: string; + x: number; + y: number; + offsetX: number; + offsetY: number; + width: number; +} + +export function useKanbanTouchDrag({ enabled, onStart, onHover, onDrop, onCancel }: Options) { + const [drag, setDrag] = useState(null); + const holdRef = useRef<{ timer: number; x: number; y: number } | null>(null); + const dragRef = useRef(null); + const hoverRef = useRef<{ col: string | null; target: KanbanDropTarget }>({ col: null, target: null }); + const suppressClickRef = useRef(false); + const callbacks = useRef({ onStart, onHover, onDrop, onCancel }); + callbacks.current = { onStart, onHover, onDrop, onCancel }; + + const cancelHold = () => { + if (holdRef.current) window.clearTimeout(holdRef.current.timer); + holdRef.current = null; + }; + + const hoverAt = (x: number, y: number) => { + const el = document.elementFromPoint(x, y) as HTMLElement | null; + const card = el?.closest('[data-kanban-task]') as HTMLElement | null; + const col = (el?.closest('[data-kanban-col]') as HTMLElement | null)?.dataset.kanbanCol ?? null; + let target: KanbanDropTarget = null; + const cardId = card?.dataset.kanbanTask; + if (card && cardId && cardId !== dragRef.current?.taskId) { + const r = card.getBoundingClientRect(); + target = { taskId: cardId, position: y - r.top < r.height / 2 ? 'before' : 'after' }; + } + const prev = hoverRef.current; + if (prev.col !== col || prev.target?.taskId !== target?.taskId || prev.target?.position !== target?.position) { + hoverRef.current = { col, target }; + callbacks.current.onHover(col, target); + } + }; + + // While dragging: the page must not scroll under the finger (non-passive listener), it scrolls + // only near the edges + useEffect(() => { + if (!drag) return; + let frame = 0; + const scroll = () => { + const d = dragRef.current; + if (d) { + if (d.y < EDGE) window.scrollBy(0, -Math.ceil((EDGE - d.y) / 6)); + else if (d.y > window.innerHeight - EDGE) window.scrollBy(0, Math.ceil((d.y - (window.innerHeight - EDGE)) / 6)); + hoverAt(d.x, d.y); + } + frame = requestAnimationFrame(scroll); + }; + frame = requestAnimationFrame(scroll); + + const move = (e: TouchEvent) => { + e.preventDefault(); + const t = e.touches[0]; + if (!t || !dragRef.current) return; + dragRef.current = { ...dragRef.current, x: t.clientX, y: t.clientY }; + setDrag(dragRef.current); + }; + const end = (e: TouchEvent) => { + const d = dragRef.current; + dragRef.current = null; + setDrag(null); + suppressClickRef.current = true; + window.setTimeout(() => (suppressClickRef.current = false), 400); + if (!d) return; + if (e.type === 'touchcancel') callbacks.current.onCancel(); + else callbacks.current.onDrop(d.taskId, hoverRef.current.col, hoverRef.current.target); + hoverRef.current = { col: null, target: null }; + }; + document.addEventListener('touchmove', move, { passive: false }); + document.addEventListener('touchend', end); + document.addEventListener('touchcancel', end); + return () => { + cancelAnimationFrame(frame); + document.removeEventListener('touchmove', move); + document.removeEventListener('touchend', end); + document.removeEventListener('touchcancel', end); + }; + // Listeners live as long as one drag; positions are read from refs + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [Boolean(drag)]); + + /** Props for a card; label is what the lifted card shows */ + const cardProps = useCallback( + (taskId: string, label: string, draggable: boolean) => { + if (!enabled || !draggable) return {}; + return { + onTouchStart: (e: React.TouchEvent) => { + if (e.touches.length !== 1) return; + const t = e.touches[0]; + const el = e.currentTarget; + cancelHold(); + holdRef.current = { + x: t.clientX, + y: t.clientY, + timer: window.setTimeout(() => { + const hold = holdRef.current; + holdRef.current = null; + if (!hold) return; + const r = el.getBoundingClientRect(); + navigator.vibrate?.(15); + const d: Drag = { taskId, label, x: hold.x, y: hold.y, offsetX: hold.x - r.left, offsetY: hold.y - r.top, width: r.width }; + dragRef.current = d; + hoverRef.current = { col: null, target: null }; + setDrag(d); + callbacks.current.onStart(taskId); + }, HOLD_MS), + }; + }, + onTouchMove: (e: React.TouchEvent) => { + const h = holdRef.current; + const t = e.touches[0]; + if (h && t && Math.hypot(t.clientX - h.x, t.clientY - h.y) > MOVE_TOLERANCE) cancelHold(); // scrolling + }, + onTouchEnd: cancelHold, + onTouchCancel: cancelHold, + // The long press must not open the browser's context menu or select text + onContextMenu: (e: React.MouseEvent) => e.preventDefault(), + style: { WebkitTouchCallout: 'none' } as React.CSSProperties, + }; + }, + [enabled] + ); + + /** A tap right after a drag must not open the task */ + const consumeClick = () => suppressClickRef.current; + + const ghost = drag + ? createPortal( +
+ {drag.label} +
, + document.body + ) + : null; + + return { cardProps, ghost, consumeClick, dragging: Boolean(drag) }; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2128569..755e1a8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1305,6 +1305,10 @@ export const api = { return ensureArray(await request('/api/v1/admin/backups', { method: 'POST' })); }, + async adminDeleteBackup(name: string): Promise { + return ensureArray(await request(`/api/v1/admin/backups/${encodeURIComponent(name)}`, { method: 'DELETE' })); + }, + async adminDownloadBackup(name: string): Promise { return downloadBlob(`/api/v1/admin/backups/${encodeURIComponent(name)}`); }, diff --git a/internal/api/backups.go b/internal/api/backups.go index 65bd487..03bee56 100644 --- a/internal/api/backups.go +++ b/internal/api/backups.go @@ -110,6 +110,16 @@ func (s *Server) handleAdminRunBackup(w http.ResponseWriter, r *http.Request) { s.handleAdminListBackups(w, r) } +// handleAdminDeleteBackup removes one database snapshot (admins only; users cannot delete their own +// backups, those expire with the retention period). +func (s *Server) handleAdminDeleteBackup(w http.ResponseWriter, r *http.Request) { + if err := storage.DeleteBackup(filepath.Join(s.backupRoot(), "system"), chi.URLParam(r, "name")); err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + s.handleAdminListBackups(w, r) +} + func (s *Server) handleAdminDownloadBackup(w http.ResponseWriter, r *http.Request) { serveBackupFile(w, r, filepath.Join(s.backupRoot(), "system"), chi.URLParam(r, "name"), "application/gzip") } diff --git a/internal/api/backups_test.go b/internal/api/backups_test.go index 62161da..06114d8 100644 --- a/internal/api/backups_test.go +++ b/internal/api/backups_test.go @@ -89,3 +89,41 @@ func mustTime(day string) (t time.Time) { t, _ = time.Parse("2006-01-02", day) return t } + +// Admins can delete database snapshots; members cannot, and only real backup names are accepted +func TestAdminDeletesBackups(t *testing.T) { + srv, store, token := setupTestServer(t) + router := srv.Router() + call := func(method, path, tok string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, nil) + req.Header.Set("Authorization", "Bearer "+tok) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec + } + var files []storage.BackupFile + _ = json.Unmarshal(call("POST", "/api/v1/admin/backups", token).Body.Bytes(), &files) + if len(files) != 1 { + t.Fatalf("expected one snapshot, got %+v", files) + } + name := files[0].Name + + _, memberToken := newTestUser(t, store, "member@example.com") + if rec := call("DELETE", "/api/v1/admin/backups/"+name, memberToken); rec.Code != http.StatusForbidden { + t.Fatalf("members must not delete backups, got %d", rec.Code) + } + if rec := call("DELETE", "/api/v1/admin/backups/..%2F..%2Fmindsift.db", token); rec.Code != http.StatusNotFound { + t.Fatalf("path tricks must be refused, got %d", rec.Code) + } + rec := call("DELETE", "/api/v1/admin/backups/"+name, token) + if rec.Code != http.StatusOK { + t.Fatalf("delete: %d %s", rec.Code, rec.Body.String()) + } + _ = json.Unmarshal(rec.Body.Bytes(), &files) + if len(files) != 0 { + t.Fatalf("snapshot should be gone, got %+v", files) + } + if _, err := os.Stat(filepath.Join(srv.backupRoot(), "system", name)); !os.IsNotExist(err) { + t.Fatalf("file must be removed from disk") + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 269e435..0db7a35 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -303,6 +303,7 @@ func (s *Server) Router() http.Handler { r.Get("/api/v1/admin/backups", s.handleAdminListBackups) r.Post("/api/v1/admin/backups", s.notInDemo(s.handleAdminRunBackup)) r.Get("/api/v1/admin/backups/{name}", s.handleAdminDownloadBackup) + r.Delete("/api/v1/admin/backups/{name}", s.notInDemo(s.handleAdminDeleteBackup)) r.Put("/api/v1/admin/settings", s.notInDemo(s.handleAdminUpdateSettings)) r.Get("/api/v1/admin/settings/sharing-impact", s.handleAdminSharingImpact) r.Get("/api/v1/admin/voice", s.handleAdminVoiceStatus) diff --git a/internal/storage/backup.go b/internal/storage/backup.go index 8501a32..965c34d 100644 --- a/internal/storage/backup.go +++ b/internal/storage/backup.go @@ -54,6 +54,15 @@ func BackupPath(dir, name string) (string, error) { return p, nil } +// DeleteBackup removes one backup in dir; like BackupPath it only accepts our own file names. +func DeleteBackup(dir, name string) error { + p, err := BackupPath(dir, name) + if err != nil { + return err + } + return os.Remove(p) +} + // ListBackups lists the backups in dir, newest first. func ListBackups(dir string) ([]BackupFile, error) { entries, err := os.ReadDir(dir)