From f69f74fdc765ab3b823ad3ef116638cf4f3352a5 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 07:57:42 +0100 Subject: [PATCH] feat(workspace): trash for notes Deleting a note (or its folder) moves it to the trash instead of erasing it. Trashed notes leave the tree, search, export and stats and are hidden from everyone they were shared with; the owner sees them read-only with a restore banner. New endpoints list, restore, purge and empty the trash; restoring recreates a deleted folder. Sidebar entry and mobile menu item with counter, list view with two-tap permanent delete. Co-Authored-By: Claude Opus 5.5 --- Doku/api/rest.md | 5 +- Doku/database.md | 2 +- Doku/modules/workspace.md | 7 + frontend/src/features/help/HelpView.tsx | 3 + .../src/features/workspace/DocumentEditor.tsx | 31 +++- frontend/src/features/workspace/TrashView.tsx | 141 +++++++++++++++++ .../src/features/workspace/WorkspaceView.tsx | 106 ++++++++++++- frontend/src/lib/api.ts | 18 +++ frontend/src/lib/types.ts | 10 ++ internal/api/server.go | 53 +++++++ internal/storage/docsync.go | 14 +- internal/storage/sqlite.go | 41 +++-- internal/storage/sqlite_test.go | 16 +- internal/storage/trash.go | 149 ++++++++++++++++++ internal/storage/trash_test.go | 93 +++++++++++ 15 files changed, 649 insertions(+), 40 deletions(-) create mode 100644 frontend/src/features/workspace/TrashView.tsx create mode 100644 internal/storage/trash.go create mode 100644 internal/storage/trash_test.go diff --git a/Doku/api/rest.md b/Doku/api/rest.md index c585d32..2fd0d33 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -104,7 +104,10 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein | GET | `/workspace/tags`, `/workspace/tags/{tag}` | Tag-Übersicht und -Details | | PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) | | GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) | -| GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern von `title`, `icon`, `folderPath`, `isPinned` (Blöcke → `400`, sie laufen über `/docs/{id}/sync`); Löschen | +| GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern von `title`, `icon`, `folderPath`, `isPinned` (Blöcke → `400`, sie laufen über `/docs/{id}/sync`); Löschen = in den Papierkorb (Eigentümer) bzw. Freigabe verlassen (andere) | +| GET/DELETE | `/documents/trash` | Papierkorb auflisten (`id`, `title`, `icon`, `folderPath`, `deletedAt`, `isOwner`) bzw. leeren | +| POST | `/documents/{id}/restore` | Aus dem Papierkorb zurückholen (fehlender Ordner wird neu angelegt) | +| DELETE | `/documents/{id}/permanent` | Notiz im Papierkorb endgültig löschen | | PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) | | POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) | | GET/POST/DELETE | `/documents/{id}/shares[/{userId}]` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) | diff --git a/Doku/database.md b/Doku/database.md index fb7cd62..07d44bb 100644 --- a/Doku/database.md +++ b/Doku/database.md @@ -29,7 +29,7 @@ Eine Aufgabe ist ein `thought` mit `status='kept'` oder `status='converted' AND ### Workspace | Tabelle | Zweck | |---|---| -| `documents` | Notizen: `title`, `icon`, `folder_path`, `tags` (JSON), `is_archived`, `markdown_cache`, `shared_folder_id`, `sub_folder_path`, `head_seq` (letzte Log-Position), `title_version`, `log_floor` (bis hierhin ist das Log verdichtet), Altspalten `content`, `blocks_json` | +| `documents` | Notizen: `title`, `icon`, `folder_path`, `tags` (JSON), `is_archived` (1 = im Papierkorb), `archived_at`, `archived_by` (wer gelöscht hat), `markdown_cache`, `shared_folder_id`, `sub_folder_path`, `head_seq` (letzte Log-Position), `title_version`, `log_floor` (bis hierhin ist das Log verdichtet), Altspalten `content`, `blocks_json` | | `blocks` | Outliner-Blöcke als Baum: `document_id`, `parent_id` (`''` = oberste Ebene), `order_key` (Sortierschlüssel unter Geschwistern), `type`, `content`, `checked`, `collapsed`, `lang`, `version` (Textversion), `conflict_of`/`conflict_author` (Konfliktkopien), `updated_by`, `deleted` (Grabstein) und `deleted_at` (Zeitpunkt des Löschens, für das Aufräumen). `level`/`position` sind Altspalten und werden nicht mehr gepflegt; Ebene und Reihenfolge berechnet der Server beim Lesen aus dem Baum. | | `doc_ops` | Änderungs-Log je Notiz: `seq` (fortlaufend pro Notiz), `op_id`, `user_id`, `client_id`, `kind`, `block_id`, `payload` (Effekt als JSON), `created_at`. Abspielen aller Einträge ab einem Snapshot ergibt den Stand. Wird verdichtet (siehe unten). | | `doc_op_results` | Ergebnis je Client-Operation (`op_id` eindeutig) für idempotente Wiederholungen; wird nach 90 Tagen gelöscht | diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index cefea81..94d0256 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -41,6 +41,13 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts - **To-Do-Rollup:** Die Ansicht „To-Dos“ sammelt alle `todo`-Blöcke aller Notizen, filterbar nach Ordner. Abhaken im Rollup ändert den Block in der Notiz. - **Enhanced Tasks:** Ein `todo`-Block lässt sich als verknüpfte Aufgabe führen (`/documents/{id}/blocks/{blockId}/enhanced-task`); Erledigt-Status bleibt zwischen Notiz und Aufgabenansicht synchron. +## Papierkorb + +- Löschen (einzeln oder mit dem Ordner) verschiebt eigene Notizen in den Papierkorb (`is_archived = 1`). Wer eine geteilte Notiz löscht, die ihm nicht gehört, verlässt nur die Freigabe. +- Notizen im Papierkorb fehlen im Baum, in Suche, Export und Statistik und sind für alle Empfänger unsichtbar (`DocRole` = keine Rolle). Eigentümer und Löschende sehen sie schreibgeschützt mit Hinweisleiste und „Wiederherstellen“; Umbenennen, Verschieben und Teilen werden abgelehnt. +- Eintrag „Papierkorb“ mit Zähler unten in der Ordnerleiste und im mobilen Menü. Die Liste zeigt Ordner und Löschzeitpunkt; „Endgültig löschen“ und „Papierkorb leeren“ brauchen einen zweiten Tipp. +- Wiederherstellen legt einen inzwischen gelöschten Ordner neu an; existiert ein geteilter Ordner nicht mehr, landet die Notiz auf der Hauptebene. Freigaben bleiben erhalten. Der Papierkorb leert sich nicht von selbst. + ## Freigabe - Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen geben. Empfänger haben **nur Leserechte**; nur der Eigentümer schreibt ([sharing-and-permissions.md](../sharing-and-permissions.md), [FLAW-17](../flaws.md)). diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index 42c9354..7ec01c1 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -240,6 +240,9 @@ export const HelpView: React.FC = ({ onBack }) => {
  • Ansicht „Tags & Filter“: Im oberen Workspace-Reiter findest du die Tag-Übersicht. Hier siehst du alle verwendeten Hashtags, kannst Fundstellen aus allen Dokumenten gebündelt durchsuchen und sogar eigene freie Notizen auf Tag-Seiten hinterlegen.
  • +
  • + Papierkorb: Gelöschte Notizen (auch aus gelöschten Ordnern) landen im „Papierkorb“ unten in der Ordnerleiste bzw. im mobilen Menü. Dort kannst du sie schreibgeschützt ansehen, wiederherstellen oder endgültig löschen. Solange eine Notiz im Papierkorb liegt, sehen sie auch die Personen nicht, mit denen sie geteilt war. +
  • ), diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 19d97ae..5de1a4f 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -3,6 +3,7 @@ import { Pin, Users, Trash2, + RotateCcw, Download, FileText, Loader2, @@ -36,6 +37,7 @@ export interface DocumentEditorProps { initialFocusedBlockId?: string | null; onDocMetadataChange: (updated: Document) => void; onDeleteDoc: (docId: string) => void; + onRestoreDoc?: (docId: string) => void; onOpenShareModal: (doc: Document) => void; onSelectTag: (tag: string) => void; onWikiLinkClick?: (pageTitle: string) => void; @@ -179,6 +181,7 @@ export const DocumentEditor: React.FC = ({ initialFocusedBlockId, onDocMetadataChange, onDeleteDoc, + onRestoreDoc, onOpenShareModal, onSelectTag, onWikiLinkClick, @@ -244,7 +247,9 @@ export const DocumentEditor: React.FC = ({ }, [title, doc?.title, blocks, zoomedBlockId]); // Owner and editors may edit; the role comes from the server via the sync engine + const inTrash = Boolean(doc?.isArchived); const canEdit = useMemo(() => { + if (doc?.isArchived) return false; if (engineState?.role) return engineState.role === 'owner' || engineState.role === 'editor'; if (!doc) return false; return Boolean(doc.isOwner || doc.permission === 'owner' || doc.permission === 'write'); @@ -770,7 +775,7 @@ export const DocumentEditor: React.FC = ({ {/* Who else has this note open */} {/* Sharing Section */} - {doc && (() => { + {doc && !inTrash && (() => { const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin'); const sharesCount = doc.sharedWith?.length || 0; const isShared = sharesCount > 0 || !isOwner || Boolean(doc.sharedFolderId); @@ -814,7 +819,7 @@ export const DocumentEditor: React.FC = ({ ); })()} - + } + )} + + )} + {/* Read-only notification banner */} - {!canEdit && ( + {!canEdit && !inTrash && (
    Dieses Dokument wurde schreibgeschützt mit dir geteilt (Nur Lesen). diff --git a/frontend/src/features/workspace/TrashView.tsx b/frontend/src/features/workspace/TrashView.tsx new file mode 100644 index 0000000..c5ccd54 --- /dev/null +++ b/frontend/src/features/workspace/TrashView.tsx @@ -0,0 +1,141 @@ +import React, { useState } from 'react'; +import { ArrowLeft, Folder, RotateCcw, Trash2 } from 'lucide-react'; +import { TrashedDocument } from '../../lib/types'; +import { CountBadge } from '../../components/CountBadge'; + +interface TrashViewProps { + items: TrashedDocument[]; + onOpen: (docId: string) => void; + onRestore: (docId: string) => Promise; + onPurge: (docId: string) => Promise; + onEmpty: () => Promise; + onBackMobile: () => void; +} + +const formatDeletedAt = (iso: string) => { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); +}; + +// Deleted notes: open read-only, restore, or delete for good. Deleting for good always takes a second +// tap on the same button ("Wirklich löschen?"), which works the same with mouse and touch. +export const TrashView: React.FC = ({ items, onOpen, onRestore, onPurge, onEmpty, onBackMobile }) => { + const [confirmId, setConfirmId] = useState(null); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(null); + + const run = async (id: string, action: () => Promise) => { + setBusyId(id); + setError(null); + try { + await action(); + setConfirmId(null); + } catch (err: any) { + setError(err.message || 'Aktion fehlgeschlagen'); + } finally { + setBusyId(null); + } + }; + + return ( +
    +
    +
    + + +

    Papierkorb

    + + {items.length > 0 && ( + + )} +
    +

    + Gelöschte Notizen bleiben hier, bis du sie wiederherstellst oder endgültig löschst. Für alle, mit denen sie geteilt + waren, sind sie bis dahin ausgeblendet. +

    + {error &&

    {error}

    } + + {items.length === 0 ? ( +
    + Der Papierkorb ist leer. +
    + ) : ( +
      + {items.map((t) => ( +
    • + +
      + + +
      +
    • + ))} +
    + )} +
    +
    + ); +}; diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index d469d02..6b2646e 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -36,6 +36,7 @@ import { TagDetails, TagSummary, Team, + TrashedDocument, UnifiedShare, User, } from '../../lib/types'; @@ -53,6 +54,8 @@ import { FormSheet } from '../../components/FormSheet'; import { sendDocOps } from '../../lib/docSync/oneoff'; import { useMobileActions } from '../../context/MobileActionContext'; import { DocumentEditor, DocSaveStatus } from './DocumentEditor'; +import { TrashView } from './TrashView'; +import { CountBadge } from '../../components/CountBadge'; import { extractHashtags } from '../../lib/hashtags'; interface WorkspaceViewProps { @@ -63,7 +66,7 @@ interface WorkspaceViewProps { onOpenTask?: (taskId: string) => void; } -type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags'; +type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags' | 'trash'; interface FolderTreeNode { name: string; @@ -307,6 +310,15 @@ export const WorkspaceView: React.FC = ({ const [targetBlockId, setTargetBlockId] = useState(initialBlockId || null); const [newlyCreatedDocId, setNewlyCreatedDocId] = useState(null); const [subView, setSubView] = useState('editor'); + const [trash, setTrash] = useState([]); + // Bumped after a restore so the open editor reloads the note without the trash banner + const [editorNonce, setEditorNonce] = useState(0); + const reloadTrash = useCallback(() => { + api.getTrash().then(setTrash).catch(() => {}); + }, []); + useEffect(() => { + reloadTrash(); + }, [reloadTrash]); const [selectedTag, setSelectedTag] = useState(null); const [tagDetails, setTagDetails] = useState(null); const [tagNoteDraft, setTagNoteDraft] = useState(''); @@ -466,14 +478,16 @@ export const WorkspaceView: React.FC = ({ const delId = msg.payload.id as string; setDocs((prev) => prev.filter((d) => d.id !== delId)); setSelectedDocId((prev) => (prev === delId ? null : prev)); + reloadTrash(); } else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') { api.getFolders().then(setFolders).catch(() => {}); api.getSharedFolders().then(setSharedFolders).catch(() => {}); api.getDocuments().then(setDocs).catch(() => {}); + reloadTrash(); } }); return unsub; - }, [currentUser]); + }, [currentUser, reloadTrash]); const handleCreateDocument = useCallback( async (folderPath = '') => { @@ -524,6 +538,12 @@ export const WorkspaceView: React.FC = ({ setMobileShowDetail(true); }, []); + const openTrash = useCallback(() => { + reloadTrash(); + setSubView('trash'); + setMobileShowDetail(true); + }, [reloadTrash]); + // Register mobile FAB actions useEffect(() => { setPrimaryAction(() => handleCreateDocument('')); @@ -545,6 +565,13 @@ export const WorkspaceView: React.FC = ({ icon: , onClick: () => setShowNewFolderModal(true), }, + { + id: 'trash', + label: 'Papierkorb', + icon: , + badge: trash.length, + onClick: openTrash, + }, ], }, ]); @@ -552,7 +579,7 @@ export const WorkspaceView: React.FC = ({ setGroups([]); setPrimaryAction(undefined); }; - }, [handleCreateDocument, setGroups, setPrimaryAction]); + }, [handleCreateDocument, setGroups, setPrimaryAction, trash.length, openTrash]); const handleDeleteDocument = useCallback( async (docId: string) => { @@ -567,13 +594,47 @@ export const WorkspaceView: React.FC = ({ }); setMobileShowDetail(false); onStatsChange(); + reloadTrash(); } catch { // ignore } }, - [onStatsChange, selectedDocId] + [onStatsChange, selectedDocId, reloadTrash] ); + const handleOpenTrashedDoc = (docId: string) => { + setSelectedDocId(docId); + setTargetBlockId(null); + setNewlyCreatedDocId(null); + setSubView('editor'); + setMobileShowDetail(true); + }; + + const handleRestoreDocument = useCallback( + async (docId: string) => { + await api.restoreDocument(docId); + const [freshDocs, freshFolders] = await Promise.all([api.getDocuments(), api.getFolders()]); + setDocs(freshDocs); + setFolders(freshFolders); + setEditorNonce((n) => n + 1); + reloadTrash(); + onStatsChange(); + }, + [reloadTrash, onStatsChange] + ); + + const handlePurgeDocument = async (docId: string) => { + await api.purgeDocument(docId); + if (selectedDocId === docId) setSelectedDocId(null); + reloadTrash(); + }; + + const handleEmptyTrash = async () => { + await api.emptyTrash(); + if (selectedDocId && trash.some((t) => t.id === selectedDocId)) setSelectedDocId(null); + reloadTrash(); + }; + const handleDocMetadataChange = useCallback((updated: Document) => { setDocs((prev) => prev.map((d) => (d.id === updated.id ? { ...d, ...updated } : d))); }, []); @@ -615,6 +676,7 @@ export const WorkspaceView: React.FC = ({ } } onStatsChange(); + reloadTrash(); } catch { // ignore } @@ -1257,7 +1319,7 @@ export const WorkspaceView: React.FC = ({ {/* LEFT MASTER SIDEBAR */}
    )} + + {/* Trash at the bottom of the tree */} +
    + +
    {/* RIGHT DETAIL AREA (STRICTLY ISOLATED BY KEY) */} @@ -1477,9 +1556,21 @@ export const WorkspaceView: React.FC = ({ subView !== 'editor' || mobileShowDetail || isSidebarCollapsed ? 'flex' : 'hidden lg:flex' } flex-1 flex-col min-w-0 h-full overflow-hidden bg-canvas`} > - {subView === 'editor' && selectedDocId ? ( + {subView === 'trash' ? ( + { + setSubView('editor'); + setMobileShowDetail(false); + }} + /> + ) : subView === 'editor' && selectedDocId ? ( = ({ initialFocusedBlockId={targetBlockId} onDocMetadataChange={handleDocMetadataChange} onDeleteDoc={handleDeleteDocument} + onRestoreDoc={handleRestoreDocument} onOpenShareModal={openDocShareModal} onSelectTag={handleSelectTag} onWikiLinkClick={handleWikiLinkClick} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 23ea823..d9ceb88 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -3,6 +3,7 @@ import { AuthStatus, DashboardStats, Document, + TrashedDocument, DocumentBlock, DocShare, KanbanColumn, @@ -647,10 +648,27 @@ export const api = { }); }, + /** Moves the note to the trash (owner) or leaves a shared note (others). */ async deleteDocument(id: string): Promise { await request(`/api/v1/documents/${id}`, { method: 'DELETE' }); }, + async getTrash(): Promise { + return request('/api/v1/documents/trash'); + }, + + async restoreDocument(id: string): Promise { + await request(`/api/v1/documents/${id}/restore`, { method: 'POST' }); + }, + + async purgeDocument(id: string): Promise { + await request(`/api/v1/documents/${id}/permanent`, { method: 'DELETE' }); + }, + + async emptyTrash(): Promise<{ deleted: number }> { + return request<{ deleted: number }>('/api/v1/documents/trash', { method: 'DELETE' }); + }, + async moveDocument(id: string, folderPath: string): Promise { const d = await request(`/api/v1/documents/${id}/move`, { method: 'PUT', diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 3df73ec..66fcef8 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -232,6 +232,16 @@ export interface DocShare { createdAt: string; } +/** A note in the trash (GET /documents/trash). */ +export interface TrashedDocument { + id: string; + title: string; + icon: string; + folderPath: string; + deletedAt: string; + isOwner: boolean; +} + export interface Document { id: string; userId: string; diff --git a/internal/api/server.go b/internal/api/server.go index 3611aad..44c2b62 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -173,6 +173,10 @@ func (s *Server) Router() http.Handler { r.Put("/api/v1/documents/{id}", s.handleUpdateDocument) r.Put("/api/v1/documents/{id}/move", s.handleMoveDocument) r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument) + r.Get("/api/v1/documents/trash", s.handleGetTrash) + r.Delete("/api/v1/documents/trash", s.handleEmptyTrash) + r.Post("/api/v1/documents/{id}/restore", s.handleRestoreDocument) + r.Delete("/api/v1/documents/{id}/permanent", s.handlePurgeDocument) r.Post("/api/v1/documents/{id}/blocks/{blockId}/enhanced-task", s.handleCreateEnhancedTask) // Collaborative note editing (operation log) @@ -1163,6 +1167,55 @@ func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } +func (s *Server) handleGetTrash(w http.ResponseWriter, r *http.Request) { + items, err := s.store.GetTrash(auth.GetUserID(r.Context())) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, items) +} + +func (s *Server) handleRestoreDocument(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + docID := chi.URLParam(r, "id") + if err := s.store.RestoreDocument(userID, docID); err != nil { + writeTrashError(w, err) + return + } + // Everyone with access reloads their tree, so the note is back for shared users as well + authorized, _ := s.store.GetAuthorizedDocUsers(docID) + for _, uid := range append(authorized, userID) { + s.wsHub.BroadcastToUser(uid, "WORKSPACE_FOLDERS_UPDATED", map[string]string{"restored": docID}) + } + writeJSON(w, http.StatusOK, map[string]bool{"restored": true}) +} + +func (s *Server) handlePurgeDocument(w http.ResponseWriter, r *http.Request) { + if err := s.store.PurgeDocument(auth.GetUserID(r.Context()), chi.URLParam(r, "id")); err != nil { + writeTrashError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) +} + +func (s *Server) handleEmptyTrash(w http.ResponseWriter, r *http.Request) { + ids, err := s.store.EmptyTrash(auth.GetUserID(r.Context())) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]int{"deleted": len(ids)}) +} + +func writeTrashError(w http.ResponseWriter, err error) { + if errors.Is(err, storage.ErrNotInTrash) { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeError(w, http.StatusInternalServerError, err.Error()) +} + func (s *Server) handleCreateEnhancedTask(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") diff --git a/internal/storage/docsync.go b/internal/storage/docsync.go index befa5ed..9f1e363 100644 --- a/internal/storage/docsync.go +++ b/internal/storage/docsync.go @@ -549,14 +549,22 @@ func (s *Store) DocRole(userID, docID string) string { if userID == "" { return "" } - var ownerID, sharedFolderID, folderOwner string + var ownerID, sharedFolderID, folderOwner, archivedBy string + var archived bool err := s.db.QueryRow( - `SELECT d.user_id, d.shared_folder_id, COALESCE(wsf.owner_id, '') + `SELECT d.user_id, d.shared_folder_id, COALESCE(wsf.owner_id, ''), d.is_archived, d.archived_by FROM documents d LEFT JOIN workspace_shared_folders wsf ON wsf.id = d.shared_folder_id - WHERE d.id = ?`, docID).Scan(&ownerID, &sharedFolderID, &folderOwner) + WHERE d.id = ?`, docID).Scan(&ownerID, &sharedFolderID, &folderOwner, &archived, &archivedBy) if err != nil { return "" } + if archived { + // In the trash: read-only for the owner and whoever trashed it, hidden from everyone else + if ownerID == userID || archivedBy == userID { + return DocRoleViewer + } + return "" + } if ownerID == userID { return DocRoleOwner } diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 0fbe05b..f2d3d65 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -379,6 +379,8 @@ func (s *Store) migrate() error { // Idempotent column migrations for existing databases columnMigrations := []string{ + "ALTER TABLE documents ADD COLUMN archived_at TEXT NOT NULL DEFAULT '';", + "ALTER TABLE documents ADD COLUMN archived_by TEXT NOT NULL DEFAULT '';", "ALTER TABLE users ADD COLUMN username TEXT NOT NULL DEFAULT '';", "CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);", "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'member';", @@ -1979,13 +1981,16 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) d.shared_folder_id, d.sub_folder_path FROM documents d LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ? - WHERE d.id = ? AND d.user_id = ?`, - userID, docID, userID, + WHERE d.id = ? AND (d.user_id = ? OR (d.is_archived = 1 AND d.archived_by = ?))`, + userID, docID, userID, userID, ) doc, err := s.scanDocumentWithBlocks(row) if err == nil { doc.Permission = "owner" - doc.IsOwner = true + doc.IsOwner = doc.UserID == userID + if doc.IsArchived { + doc.Permission = "read" // read-only while in the trash + } resolveDocumentPath(userID, doc, mounts, names, userFolders) if shares, err := s.GetDocShares(doc.ID); err == nil { doc.SharedWith = shares @@ -2022,7 +2027,7 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) ) wfs ON d.shared_folder_id = wfs.folder_id LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ? - WHERE d.id = ? AND (s.user_id = ? OR dts.doc_id IS NOT NULL OR wsf.owner_id = ? OR wfs.folder_id IS NOT NULL) + WHERE d.id = ? AND d.is_archived = 0 AND (s.user_id = ? OR dts.doc_id IS NOT NULL OR wsf.owner_id = ? OR wfs.folder_id IS NOT NULL) LIMIT 1`, userID, userID, userID, userID, userID, userID, docID, userID, userID, ) @@ -2060,6 +2065,9 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st if err != nil { return nil, err } + if doc.IsArchived { + return nil, ErrDocInTrash + } // Pins and folder placement are personal; title and icon need edit rights. The content (blocks) is // only changed through the operation log (POST /docs/{id}/sync). @@ -2121,11 +2129,10 @@ func (s *Store) DeleteDocument(userID, docID string) error { _, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID) return s.DeleteDocShare(userID, docID, userID) } - _, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ?`, docID) - _, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, docID) - _, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE doc_id = ?`, docID) - _, err = s.db.Exec(`DELETE FROM documents WHERE id = ?`, docID) - return err + if doc.IsArchived { + return s.hardDeleteDocument(docID) + } + return s.trashDocument(userID, docID) } func (s *Store) reindexAllDocBlockTags() error { @@ -2596,15 +2603,9 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) { } _ = rows.Close() - // Delete documents from SQLite, blocks, pins, shares, tags, user_folders + // The notes of the folder go to the trash; restoring one creates its folder again for _, id := range deletedDocIDs { - _, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ?`, id) - _, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, id) - _, _ = s.db.Exec(`DELETE FROM doc_pins WHERE doc_id = ?`, id) - _, _ = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ?`, id) - _, _ = s.db.Exec(`DELETE FROM doc_team_shares WHERE doc_id = ?`, id) - _, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE doc_id = ?`, id) - _, _ = s.db.Exec(`DELETE FROM documents WHERE id = ?`, id) + _ = s.trashDocument(userID, id) } // Also clear any personal folder placements for shared notes in this folder @@ -2619,6 +2620,9 @@ func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Do if err != nil { return nil, err } + if doc.IsArchived { + return nil, ErrDocInTrash + } cleanTarget := filepath.ToSlash(filepath.Clean(strings.TrimSpace(targetFolderPath))) if cleanTarget == "." { @@ -3376,6 +3380,9 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi if err != nil { return nil, err } + if doc.IsArchived { + return nil, ErrDocInTrash + } if !doc.IsOwner { return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten") } diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index 184b536..a3d957b 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -429,14 +429,16 @@ func TestFolderManagementAndDeletion(t *testing.T) { t.Fatalf("expected 2 deleted docs, got %d (%v)", len(deletedDocs), deletedDocs) } - // Verify docs are gone from DB - _, err = store.GetDocumentByID(user.ID, doc1.ID) - if err == nil { - t.Fatalf("expected doc1 to be deleted") + // The notes are in the trash now, no longer in the tree + trash, _ := store.GetTrash(user.ID) + if len(trash) != 2 { + t.Fatalf("expected doc1 and doc2 in the trash, got %v", trash) } - _, err = store.GetDocumentByID(user.ID, doc2.ID) - if err == nil { - t.Fatalf("expected doc2 to be deleted") + docs, _ := store.GetDocuments(user.ID) + for _, d := range docs { + if d.ID == doc1.ID || d.ID == doc2.ID { + t.Fatalf("trashed note %s still in the tree", d.ID) + } } } diff --git a/internal/storage/trash.go b/internal/storage/trash.go new file mode 100644 index 0000000..8a25289 --- /dev/null +++ b/internal/storage/trash.go @@ -0,0 +1,149 @@ +package storage + +import ( + "database/sql" + "errors" + + "github.com/sandrodach/mindsift/internal/domain" +) + +// Deleted notes go to a trash first (documents.is_archived = 1). Trashed notes vanish from the tree, +// search, export, stats and for everyone they were shared with; their owner can still read them, +// restore them or delete them for good. archived_by records who trashed the note (the owner, or the +// owner of the shared folder it lived in), so that person sees it in their trash as well. + +// ErrNotInTrash is returned when restoring or purging a note that is not in the user's trash. +var ErrNotInTrash = errors.New("Notiz nicht im Papierkorb") + +// ErrDocInTrash refuses changes to a trashed note; it has to be restored first. +var ErrDocInTrash = errors.New("Die Notiz liegt im Papierkorb. Stelle sie zuerst wieder her.") + +// TrashedDocument is one entry of the trash list. +type TrashedDocument struct { + ID string `json:"id"` + Title string `json:"title"` + Icon string `json:"icon"` + FolderPath string `json:"folderPath"` + DeletedAt string `json:"deletedAt"` + IsOwner bool `json:"isOwner"` +} + +const trashScope = `is_archived = 1 AND (user_id = ? OR archived_by = ?)` + +func (s *Store) trashDocument(userID, docID string) error { + _, err := s.db.Exec(`UPDATE documents SET is_archived = 1, archived_at = ?, archived_by = ? WHERE id = ?`, + domain.NowISO(), userID, docID) + if err == nil { + _, _ = s.db.Exec(`DELETE FROM doc_pins WHERE doc_id = ?`, docID) + } + return err +} + +// GetTrash lists the notes in the user's trash, newest deletion first. +func (s *Store) GetTrash(userID string) ([]TrashedDocument, error) { + rows, err := s.db.Query(`SELECT d.id, d.title, d.icon, d.folder_path, d.shared_folder_id, d.sub_folder_path, d.archived_at, d.user_id, + COALESCE(wsf.name, '') + FROM documents d LEFT JOIN workspace_shared_folders wsf ON wsf.id = d.shared_folder_id + WHERE d.`+trashScope+` ORDER BY d.archived_at DESC`, userID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []TrashedDocument{} + for rows.Next() { + var t TrashedDocument + var sharedFolderID, subPath, ownerID, folderName string + if err := rows.Scan(&t.ID, &t.Title, &t.Icon, &t.FolderPath, &sharedFolderID, &subPath, &t.DeletedAt, &ownerID, &folderName); err != nil { + continue + } + if sharedFolderID != "" { + t.FolderPath = folderName + if subPath != "" { + t.FolderPath += "/" + subPath + } + } + t.IsOwner = ownerID == userID + out = append(out, t) + } + return out, nil +} + +func (s *Store) trashedDocOwner(userID, docID string) (string, error) { + var owner string + err := s.db.QueryRow(`SELECT user_id FROM documents WHERE id = ? AND `+trashScope, docID, userID, userID).Scan(&owner) + if err == sql.ErrNoRows { + return "", ErrNotInTrash + } + return owner, err +} + +// RestoreDocument takes a note out of the trash. Its folder is created again if it was deleted in the +// meantime; a shared folder that no longer exists puts the note back into the owner's top level. +func (s *Store) RestoreDocument(userID, docID string) error { + owner, err := s.trashedDocOwner(userID, docID) + if err != nil { + return err + } + var folderPath, sharedFolderID string + _ = s.db.QueryRow(`SELECT folder_path, shared_folder_id FROM documents WHERE id = ?`, docID).Scan(&folderPath, &sharedFolderID) + if sharedFolderID != "" { + var exists int + _ = s.db.QueryRow(`SELECT COUNT(*) FROM workspace_shared_folders WHERE id = ?`, sharedFolderID).Scan(&exists) + if exists == 0 { + _, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '', folder_path = '' WHERE id = ?`, docID) + } + } else if folderPath != "" { + _, _ = s.CreateFolder(owner, folderPath) + } + _, err = s.db.Exec(`UPDATE documents SET is_archived = 0, archived_at = '', archived_by = '' WHERE id = ?`, docID) + return err +} + +// PurgeDocument deletes a trashed note for good. +func (s *Store) PurgeDocument(userID, docID string) error { + if _, err := s.trashedDocOwner(userID, docID); err != nil { + return err + } + return s.hardDeleteDocument(docID) +} + +// EmptyTrash deletes every note in the user's trash for good and returns their IDs. +func (s *Store) EmptyTrash(userID string) ([]string, error) { + items, err := s.GetTrash(userID) + if err != nil { + return nil, err + } + ids := make([]string, 0, len(items)) + for _, t := range items { + if err := s.hardDeleteDocument(t.ID); err != nil { + return ids, err + } + ids = append(ids, t.ID) + } + return ids, nil +} + +func (s *Store) hardDeleteDocument(docID string) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, q := range []string{ + // Tasks created from blocks of this note keep existing without the link + `UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE converted_type = 'document_block' + AND substr(converted_ref_id, 1, instr(converted_ref_id, ':') - 1) = ?`, + `DELETE FROM blocks WHERE document_id = ?`, + `DELETE FROM block_tags WHERE document_id = ?`, + `DELETE FROM doc_pins WHERE doc_id = ?`, + `DELETE FROM doc_shares WHERE doc_id = ?`, + `DELETE FROM doc_team_shares WHERE doc_id = ?`, + `DELETE FROM doc_user_folders WHERE doc_id = ?`, + `DELETE FROM documents WHERE id = ?`, + } { + if _, err := tx.Exec(q, docID); err != nil { + return err + } + } + return tx.Commit() +} diff --git a/internal/storage/trash_test.go b/internal/storage/trash_test.go new file mode 100644 index 0000000..065c06d --- /dev/null +++ b/internal/storage/trash_test.go @@ -0,0 +1,93 @@ +package storage + +import ( + "path/filepath" + "testing" + + "github.com/sandrodach/mindsift/internal/domain" +) + +func TestTrashHidesRestoresAndPurgesNotes(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "trash.db")) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + defer store.Close() + openSharing(t, store) + anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member") + ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member") + blocks := []domain.DocumentBlock{{ID: "b1", Type: "bullet", Content: "Zebrastreifen", Level: 0}} + doc, _ := store.CreateDocument(anna.ID, "", "Verkehr", "🚦", "Projekte/Stadt", blocks, nil, false) + _, _ = store.CreateFolder(anna.ID, "Projekte/Stadt") + if _, err := store.CreateDocShare(anna.ID, doc.ID, ben.ID, "write"); err != nil { + t.Fatalf("share: %v", err) + } + + if err := store.DeleteDocument(anna.ID, doc.ID); err != nil { + t.Fatalf("trash: %v", err) + } + visible := func(uid string) bool { + docs, _ := store.GetDocuments(uid) + for _, d := range docs { + if d.ID == doc.ID { + return true + } + } + return false + } + if visible(anna.ID) || visible(ben.ID) { + t.Errorf("trashed note must leave the tree of owner and shared users") + } + if res, _ := store.SearchGlobal(anna.ID, "Zebrastreifen"); len(res.Documents) != 0 { + t.Errorf("trashed note must not be found: %+v", res.Documents) + } + if store.DocRole(anna.ID, doc.ID) != DocRoleViewer || store.DocRole(ben.ID, doc.ID) != "" { + t.Errorf("trash is read-only for the owner and hidden from others") + } + if got, err := store.GetDocumentByID(anna.ID, doc.ID); err != nil || !got.IsArchived || got.Permission != "read" { + t.Errorf("owner reads the trashed note read-only: %+v %v", got, err) + } + if _, err := store.GetDocumentByID(ben.ID, doc.ID); err == nil { + t.Errorf("shared user must not open a trashed note") + } + title := "Neu" + if _, err := store.UpdateDocument(anna.ID, doc.ID, &title, nil, nil, nil); err == nil { + t.Errorf("trashed note must not be renamed") + } + trash, _ := store.GetTrash(anna.ID) + if len(trash) != 1 || trash[0].FolderPath != "Projekte/Stadt" || trash[0].DeletedAt == "" { + t.Fatalf("trash list wrong: %+v", trash) + } + if err := store.RestoreDocument(ben.ID, doc.ID); err == nil { + t.Errorf("only the owner may restore") + } + + // The folder was deleted meanwhile; restoring creates it again + _, _ = store.db.Exec(`DELETE FROM workspace_folders WHERE user_id = ?`, anna.ID) + if err := store.RestoreDocument(anna.ID, doc.ID); err != nil { + t.Fatalf("restore: %v", err) + } + if !visible(anna.ID) || !visible(ben.ID) || store.DocRole(ben.ID, doc.ID) != DocRoleEditor { + t.Errorf("restored note is back for everyone, shares intact") + } + if folders, _ := store.GetFolders(anna.ID); len(folders) == 0 { + t.Errorf("folder should be created again on restore") + } + + // Deleting a folder moves its notes to the trash; emptying deletes them for good + if _, err := store.DeleteFolder(anna.ID, "Projekte"); err != nil { + t.Fatalf("DeleteFolder: %v", err) + } + if trash, _ := store.GetTrash(anna.ID); len(trash) != 1 { + t.Fatalf("folder deletion should trash the note, got %+v", trash) + } + if ids, err := store.EmptyTrash(anna.ID); err != nil || len(ids) != 1 { + t.Fatalf("EmptyTrash: %v %v", ids, err) + } + var docs, shares int + _ = store.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE id = ?`, doc.ID).Scan(&docs) + _ = store.db.QueryRow(`SELECT COUNT(*) FROM doc_shares WHERE doc_id = ?`, doc.ID).Scan(&shares) + if docs+shares != 0 { + t.Errorf("purge must remove the note and its shares") + } +}