feat(workspace): deleted folders go to the trash as a whole, after a confirmation

A deleted personal folder is kept in trashed_folders with its subfolders;
its notes are trashed and tied to it. The trash lists the folder as one
entry with its note count; restoring brings back the folder tree (empty
subfolders too) and all notes, deleting for good, emptying the trash and
the notes trash delay remove it with its notes. Folders in shared folders
keep the old way.

Deleting a folder now asks first, in a small popover at the click
(ConfirmPopover, documented in ui-patterns).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-02 19:36:22 +01:00
1 parent 9dcfa6c27e
commit b26b07cfea
18 files changed
+564 -25

No files matched your search

+4 -2
View File
@@ -196,7 +196,9 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
| Methode | Pfad | Zweck | | Methode | Pfad | Zweck |
|---|---|---| |---|---|---|
| GET/POST/DELETE | `/folders` | Ordnerliste, anlegen (`path`), löschen | | GET/POST/DELETE | `/folders` | Ordnerliste, anlegen (`path`), löschen; ein eigener Ordner kommt mit Unterordnern und Notizen als ein Eintrag in den Papierkorb |
| POST | `/folders/trash/{id}/restore` | Ordner aus dem Papierkorb wiederherstellen (mit allen Unterordnern, auch leeren, und Notizen) → `restoredDocIds` |
| DELETE | `/folders/trash/{id}` | Ordner im Papierkorb samt Notizen endgültig löschen |
| POST | `/folders/move` | `sourcePath`, `targetParentPath` | | POST | `/folders/move` | `sourcePath`, `targetParentPath` |
| GET/POST | `/workspace/folders/shared` | Geteilte Ordner; anlegen (`name`, `mountPath`) | | GET/POST | `/workspace/folders/shared` | Geteilte Ordner; anlegen (`name`, `mountPath`) |
| GET/DELETE | `/workspace/folders/shared/{id}` | Abruf; Löschen | | GET/DELETE | `/workspace/folders/shared/{id}` | Abruf; Löschen |
@@ -207,7 +209,7 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
| PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) | | PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) |
| GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`; `icon: ""` = ohne Symbol, fehlt es: 🗒️) | | GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`; `icon: ""` = ohne Symbol, fehlt es: 🗒️) |
| 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/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 | | GET/DELETE | `/documents/trash` | Papierkorb auflisten (`id`, `kind` `note`/`folder`, `title`, `icon`, `folderPath`, `deletedAt`, `isOwner`, bei Ordnern `noteCount`; Notizen eines gelöschten Ordners stecken in dessen Eintrag) bzw. leeren (Notizen und Ordner) |
| GET | `/documents/{id}/backlinks` | Notizen mit `[[Titel]]`-Link auf diese Notiz (nur sichtbare, ohne Papierkorb): `docId`, `title`, `icon`, `folderPath`, bis zu 5 `blocks` (`id`, `content`), `more` | | GET | `/documents/{id}/backlinks` | Notizen mit `[[Titel]]`-Link auf diese Notiz (nur sichtbare, ohne Papierkorb): `docId`, `title`, `icon`, `folderPath`, bis zu 5 `blocks` (`id`, `content`), `more` |
| POST | `/documents/{id}/restore` | Aus dem Papierkorb zurückholen (fehlender Ordner wird neu angelegt) | | 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 | | DELETE | `/documents/{id}/permanent` | Notiz im Papierkorb endgültig löschen |
+7
View File
@@ -50,6 +50,13 @@ Komponente: [FormSheet.tsx](../frontend/src/components/FormSheet.tsx), Tastatur-
- Escape und Klick auf den Hintergrund schließen. Genutzt für neue Aufgabenliste, neue Einkaufsliste, neue Notiz und neuen Ordner; das Emoji-Sheet nutzt denselben Tastatur-Abstand. - Escape und Klick auf den Hintergrund schließen. Genutzt für neue Aufgabenliste, neue Einkaufsliste, neue Notiz und neuen Ordner; das Emoji-Sheet nutzt denselben Tastatur-Abstand.
- **Icon-Feld:** [EmojiIconButton.tsx](../frontend/src/components/EmojiIconButton.tsx) statt eines Textfelds: zeigt das Emoji (ohne Icon ein dezentes „Icon wählen“) und öffnet denselben Emoji-Auswähler wie der Notiz-Kopf, inklusive „Entfernen“. Im Dialog liegt der Auswähler per Portal über der Seite; Escape schließt nur ihn. - **Icon-Feld:** [EmojiIconButton.tsx](../frontend/src/components/EmojiIconButton.tsx) statt eines Textfelds: zeigt das Emoji (ohne Icon ein dezentes „Icon wählen“) und öffnet denselben Emoji-Auswähler wie der Notiz-Kopf, inklusive „Entfernen“. Im Dialog liegt der Auswähler per Portal über der Seite; Escape schließt nur ihn.
## Bestätigung an der Mausposition
Komponente: [ConfirmPopover.tsx](../frontend/src/components/ConfirmPopover.tsx)
- Für Rückfragen zu einer einzelnen Aktion, z. B. Ordner löschen: ein kleines Fenster direkt an der Stelle des Klicks (unten rechts vom Mauszeiger, am Fensterrand nach oben bzw. links geklappt), kein Dialog in der Bildmitte.
- „Abbrechen“ und die Aktion (gefährliche Aktionen rot mit `danger`); die Aktion hat den Fokus, Enter bestätigt, Escape oder ein Klick daneben schließt. Per Tastatur ausgelöst öffnet es am Knopf. Am Handy sind die Knöpfe 44 px hoch.
## Hinweis mit „Rückgängig“ ## Hinweis mit „Rückgängig“
- Kurzer Hinweis unten mittig (`role="status"`, Fläche `bg-panel-elevated`), am Handy über der unteren Leiste; mit Aktion bleibt er 8–10 s, sonst 3–4 s. - Kurzer Hinweis unten mittig (`role="status"`, Fläche `bg-panel-elevated`), am Handy über der unteren Leiste; mit Aktion bleibt er 8–10 s, sonst 3–4 s.
@@ -0,0 +1,81 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
interface ConfirmPopoverProps {
// Where the click was (clientX/clientY); the popover opens right there, kept inside the window
x: number;
y: number;
message: React.ReactNode;
confirmLabel: string;
danger?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
const MARGIN = 8;
// Small confirmation right at the mouse (Doku/ui-patterns.md): Enter or the button confirms, Escape, a
// click outside or "Abbrechen" closes it.
export const ConfirmPopover: React.FC<ConfirmPopoverProps> = ({ x, y, message, confirmLabel, danger, onConfirm, onCancel }) => {
const ref = useRef<HTMLDivElement>(null);
const confirmRef = useRef<HTMLButtonElement>(null);
const [pos, setPos] = useState({ left: x, top: y, ready: false });
// Below and right of the pointer; flipped above or to the left when it would leave the window
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const { width, height } = el.getBoundingClientRect();
let left = x - 12;
let top = y + MARGIN;
if (left + width > window.innerWidth - MARGIN) left = window.innerWidth - width - MARGIN;
if (top + height > window.innerHeight - MARGIN) top = y - height - MARGIN;
setPos({ left: Math.max(MARGIN, left), top: Math.max(MARGIN, top), ready: true });
confirmRef.current?.focus();
}, [x, y]);
useEffect(() => {
const onDown = (e: Event) => {
if (ref.current && !ref.current.contains(e.target as Node)) onCancel();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel();
};
document.addEventListener('pointerdown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('pointerdown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [onCancel]);
return createPortal(
<div
ref={ref}
role="alertdialog"
aria-modal="false"
style={{ left: pos.left, top: pos.top, visibility: pos.ready ? 'visible' : 'hidden' }}
className="fixed z-[70] w-72 max-w-[calc(100vw-1rem)] p-3 bg-panel border border-border rounded-card shadow-panel text-xs text-main space-y-3"
>
<div className="leading-snug">{message}</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={onCancel} className="btn-action h-11 sm:h-8 px-3 text-xs">
Abbrechen
</button>
<button
ref={confirmRef}
type="button"
onClick={onConfirm}
className={`h-11 sm:h-8 px-3 text-xs font-semibold rounded-btn border transition-colors ${
danger
? 'bg-status-err/15 text-status-err border-status-err/50 hover:bg-status-err/25'
: 'btn-primary'
}`}
>
{confirmLabel}
</button>
</div>
</div>,
document.body
);
};
@@ -0,0 +1,25 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ConfirmPopover } from '../ConfirmPopover';
describe('ConfirmPopover', () => {
it('confirms with its button, which has the focus (Enter)', () => {
const onConfirm = vi.fn();
render(<ConfirmPopover x={100} y={100} message="Ordner löschen?" confirmLabel="In den Papierkorb" onConfirm={onConfirm} onCancel={vi.fn()} />);
const button = screen.getByRole('button', { name: 'In den Papierkorb' });
expect(document.activeElement).toBe(button);
fireEvent.click(button);
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('closes without confirming on Abbrechen, Escape or a click outside', () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
render(<ConfirmPopover x={100} y={100} message="Ordner löschen?" confirmLabel="Löschen" onConfirm={onConfirm} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: 'Abbrechen' }));
fireEvent.keyDown(document, { key: 'Escape' });
fireEvent.pointerDown(document.body);
expect(onCancel).toHaveBeenCalledTimes(3);
expect(onConfirm).not.toHaveBeenCalled();
});
});
+25 -8
View File
@@ -7,14 +7,15 @@ import { formatDeletedAt } from '../../lib/due';
interface TrashViewProps { interface TrashViewProps {
items: TrashedDocument[]; items: TrashedDocument[];
onOpen: (docId: string) => void; onOpen: (docId: string) => void;
onRestore: (docId: string) => Promise<void>; onRestore: (item: TrashedDocument) => Promise<void>;
onPurge: (docId: string) => Promise<void>; onPurge: (item: TrashedDocument) => Promise<void>;
onEmpty: () => Promise<void>; onEmpty: () => Promise<void>;
onBackMobile: () => void; onBackMobile: () => void;
} }
// Deleted notes: open read-only, restore, or delete for good. Deleting for good always takes a second // Deleted notes and folders: open a note read-only, restore, or delete for good. A folder is one entry
// tap on the same button ("Wirklich löschen?"), which works the same with mouse and touch. // with its notes and comes back as a whole. 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<TrashViewProps> = ({ items, onOpen, onRestore, onPurge, onEmpty, onBackMobile }) => { export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore, onPurge, onEmpty, onBackMobile }) => {
const [confirmId, setConfirmId] = useState<string | null>(null); const [confirmId, setConfirmId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null); const [busyId, setBusyId] = useState<string | null>(null);
@@ -66,8 +67,8 @@ export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore,
)} )}
</div> </div>
<p className="text-xs text-muted"> <p className="text-xs text-muted">
Gelöschte Notizen bleiben hier, bis du sie wiederherstellst oder endgültig löschst. Für alle, mit denen sie geteilt Gelöschte Notizen und Ordner bleiben hier, bis du sie wiederherstellst oder endgültig löschst. Ein Ordner kommt mit
waren, sind sie bis dahin ausgeblendet. allen Unterordnern und Notizen zurück. Für alle, mit denen etwas geteilt war, ist es bis dahin ausgeblendet.
</p> </p>
{error && <p className="text-xs text-status-err">{error}</p>} {error && <p className="text-xs text-status-err">{error}</p>}
@@ -82,6 +83,21 @@ export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore,
key={t.id} key={t.id}
className="p-3 bg-panel border border-border rounded-card flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3" className="p-3 bg-panel border border-border rounded-card flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3"
> >
{t.kind === 'folder' ? (
<div className="flex items-center gap-2.5 flex-1 min-w-0">
<Folder className="w-5 h-5 text-accent shrink-0" />
<span className="min-w-0">
<span className="block text-sm font-semibold text-main truncate">{t.title}</span>
<span className="flex flex-wrap items-center gap-x-2 text-[11px] text-muted">
<span>
Ordner · {t.noteCount === 1 ? '1 Notiz' : `${t.noteCount || 0} Notizen`}
</span>
{t.folderPath && <span className="truncate">in {t.folderPath}</span>}
<span>gelöscht {formatDeletedAt(t.deletedAt)}</span>
</span>
</span>
</div>
) : (
<button <button
type="button" type="button"
onClick={() => onOpen(t.id)} onClick={() => onOpen(t.id)}
@@ -102,11 +118,12 @@ export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore,
</span> </span>
</span> </span>
</button> </button>
)}
<div className="grid grid-cols-2 sm:flex gap-2 shrink-0"> <div className="grid grid-cols-2 sm:flex gap-2 shrink-0">
<button <button
type="button" type="button"
disabled={busyId !== null} disabled={busyId !== null}
onClick={() => run(t.id, () => onRestore(t.id))} onClick={() => run(t.id, () => onRestore(t))}
className="btn-action h-11 sm:h-8 px-3 text-xs inline-flex items-center justify-center gap-1.5 disabled:opacity-50" className="btn-action h-11 sm:h-8 px-3 text-xs inline-flex items-center justify-center gap-1.5 disabled:opacity-50"
> >
<RotateCcw className="w-3.5 h-3.5 text-accent" /> <RotateCcw className="w-3.5 h-3.5 text-accent" />
@@ -115,7 +132,7 @@ export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore,
<button <button
type="button" type="button"
disabled={busyId !== null} disabled={busyId !== null}
onClick={() => (confirmId === t.id ? run(t.id, () => onPurge(t.id)) : setConfirmId(t.id))} onClick={() => (confirmId === t.id ? run(t.id, () => onPurge(t)) : setConfirmId(t.id))}
className={`h-11 sm:h-8 px-3 text-xs rounded-btn border inline-flex items-center justify-center gap-1.5 transition-colors disabled:opacity-50 ${ className={`h-11 sm:h-8 px-3 text-xs rounded-btn border inline-flex items-center justify-center gap-1.5 transition-colors disabled:opacity-50 ${
confirmId === t.id confirmId === t.id
? 'bg-status-err/20 text-status-err border-status-err font-semibold' ? 'bg-status-err/20 text-status-err border-status-err font-semibold'
@@ -57,6 +57,7 @@ import { JournalCalendar } from './JournalCalendar';
import { isJournalEnabled, readWorkspaceSession, todayISO, writeWorkspaceSession } from '../../lib/journal'; import { isJournalEnabled, readWorkspaceSession, todayISO, writeWorkspaceSession } from '../../lib/journal';
import { MergeDialog } from './MergeDialog'; import { MergeDialog } from './MergeDialog';
import { CountBadge } from '../../components/CountBadge'; import { CountBadge } from '../../components/CountBadge';
import { ConfirmPopover } from '../../components/ConfirmPopover';
import { EmojiIconButton } from '../../components/EmojiIconButton'; import { EmojiIconButton } from '../../components/EmojiIconButton';
import { LazyMount } from '../../components/LazyMount'; import { LazyMount } from '../../components/LazyMount';
import { extractHashtags } from '../../lib/hashtags'; import { extractHashtags } from '../../lib/hashtags';
@@ -97,7 +98,8 @@ interface FolderTreeItemProps {
handleCreateDocument: (folderPath?: string) => void; handleCreateDocument: (folderPath?: string) => void;
handleOpenNewFolder: (parentPath?: string, e?: React.MouseEvent) => void; handleOpenNewFolder: (parentPath?: string, e?: React.MouseEvent) => void;
handleShareFolderClick: (folderPath: string, existingSf?: SharedFolder) => void; handleShareFolderClick: (folderPath: string, existingSf?: SharedFolder) => void;
handleDeleteFolder: (folderPath: string) => void; // Asks first (popover at the given point), then moves the folder to the trash
handleDeleteFolder: (folderPath: string, at: { x: number; y: number }) => void;
onSelectDoc: (id: string) => void; onSelectDoc: (id: string) => void;
// Dropping a note onto another one offers to merge them // Dropping a note onto another one offers to merge them
docDropProps: (targetId: string) => React.HTMLAttributes<HTMLDivElement>; docDropProps: (targetId: string) => React.HTMLAttributes<HTMLDivElement>;
@@ -218,7 +220,10 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDeleteFolder(node.fullPath); // Keyboard clicks have no pointer position: open at the button instead
const r = e.currentTarget.getBoundingClientRect();
const byPointer = e.clientX !== 0 || e.clientY !== 0;
handleDeleteFolder(node.fullPath, byPointer ? { x: e.clientX, y: e.clientY } : { x: r.left, y: r.bottom });
}} }}
className="p-1 h-6 w-6 flex items-center justify-center text-faint hover:text-status-err transition-colors cursor-pointer" className="p-1 h-6 w-6 flex items-center justify-center text-faint hover:text-status-err transition-colors cursor-pointer"
title="Ordner löschen" title="Ordner löschen"
@@ -736,6 +741,22 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
reloadTrash(); reloadTrash();
}; };
// Trash entries: a note, or a deleted folder that comes back (or goes) with all of its notes
const handleRestoreTrashItem = async (item: TrashedDocument) => {
if (item.kind !== 'folder') return handleRestoreDocument(item.id);
await api.restoreFolder(item.id);
const [freshDocs, freshFolders] = await Promise.all([api.getDocuments(), api.getFolders()]);
setDocs(freshDocs);
setFolders(freshFolders);
reloadTrash();
onStatsChange();
};
const handlePurgeTrashItem = async (item: TrashedDocument) => {
if (item.kind !== 'folder') return handlePurgeDocument(item.id);
await api.purgeFolder(item.id);
reloadTrash();
};
const handleEmptyTrash = async () => { const handleEmptyTrash = async () => {
await api.emptyTrash(); await api.emptyTrash();
if (selectedDocId && trash.some((t) => t.id === selectedDocId)) setSelectedDocId(null); if (selectedDocId && trash.some((t) => t.id === selectedDocId)) setSelectedDocId(null);
@@ -770,6 +791,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
} }
}; };
// Deleting a folder asks first, in a small popover where the click was
const [pendingFolderDelete, setPendingFolderDelete] = useState<{ path: string; x: number; y: number } | null>(null);
const requestDeleteFolder = (path: string, at: { x: number; y: number }) => setPendingFolderDelete({ path, ...at });
const closeFolderDelete = useCallback(() => setPendingFolderDelete(null), []);
const handleDeleteFolder = async (folderPath: string) => { const handleDeleteFolder = async (folderPath: string) => {
try { try {
const res = await api.deleteFolder(folderPath); const res = await api.deleteFolder(folderPath);
@@ -2031,7 +2057,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
handleCreateDocument={handleCreateDocument} handleCreateDocument={handleCreateDocument}
handleOpenNewFolder={handleOpenNewFolder} handleOpenNewFolder={handleOpenNewFolder}
handleShareFolderClick={handleShareFolderClick} handleShareFolderClick={handleShareFolderClick}
handleDeleteFolder={handleDeleteFolder} handleDeleteFolder={requestDeleteFolder}
onSelectDoc={(id) => { onSelectDoc={(id) => {
setSelectedDocId(id); setSelectedDocId(id);
setTargetBlockId(null); setTargetBlockId(null);
@@ -2190,8 +2216,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<TrashView <TrashView
items={trash} items={trash}
onOpen={handleOpenTrashedDoc} onOpen={handleOpenTrashedDoc}
onRestore={handleRestoreDocument} onRestore={handleRestoreTrashItem}
onPurge={handlePurgeDocument} onPurge={handlePurgeTrashItem}
onEmpty={handleEmptyTrash} onEmpty={handleEmptyTrash}
onBackMobile={() => { onBackMobile={() => {
setSubView('editor'); setSubView('editor');
@@ -2872,6 +2898,33 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</FormSheet> </FormSheet>
)} )}
{pendingFolderDelete &&
(() => {
const { path } = pendingFolderDelete;
const name = path.split('/').pop() || path;
const notes = docs.filter((d) => d.folderPath === path || (d.folderPath || '').startsWith(`${path}/`)).length;
return (
<ConfirmPopover
x={pendingFolderDelete.x}
y={pendingFolderDelete.y}
danger
confirmLabel="In den Papierkorb"
message={
<>
Ordner <strong>„{name}“</strong>
{notes > 0 ? ` mit ${notes === 1 ? '1 Notiz' : `${notes} Notizen`}` : ''} löschen? Er kommt mit allen
Unterordnern in den Papierkorb und lässt sich dort wiederherstellen.
</>
}
onCancel={closeFolderDelete}
onConfirm={() => {
closeFolderDelete();
void handleDeleteFolder(path);
}}
/>
);
})()}
{mergeDialog && ( {mergeDialog && (
<MergeDialog <MergeDialog
docs={docs} docs={docs}
@@ -15,7 +15,7 @@ describe('TrashView', () => {
await act(async () => { await act(async () => {
fireEvent.click(screen.getByText('Wirklich löschen?')); fireEvent.click(screen.getByText('Wirklich löschen?'));
}); });
expect(onPurge).toHaveBeenCalledWith('doc_1'); expect(onPurge).toHaveBeenCalledWith(items[0]);
}); });
it('restores with one tap and shows the original folder', async () => { it('restores with one tap and shows the original folder', async () => {
@@ -25,6 +25,31 @@ describe('TrashView', () => {
await act(async () => { await act(async () => {
fireEvent.click(screen.getByText('Wiederherstellen')); fireEvent.click(screen.getByText('Wiederherstellen'));
}); });
expect(onRestore).toHaveBeenCalledWith('doc_1'); expect(onRestore).toHaveBeenCalledWith(items[0]);
});
it('shows a deleted folder as one entry with its notes and restores it as a whole', async () => {
const folder = {
id: 'tfld_1',
kind: 'folder' as const,
title: 'Stadt',
icon: '',
folderPath: 'Projekte',
deletedAt: '2026-10-29T08:00:00Z',
isOwner: true,
noteCount: 3,
};
const onOpen = vi.fn();
const onRestore = vi.fn(async () => {});
render(<TrashView items={[folder]} onOpen={onOpen} onRestore={onRestore} onPurge={vi.fn(async () => {})} onEmpty={vi.fn(async () => {})} onBackMobile={vi.fn()} />);
expect(screen.getByText('Stadt')).toBeTruthy();
expect(screen.getByText(/Ordner · 3 Notizen/)).toBeTruthy();
expect(screen.getByText('in Projekte')).toBeTruthy();
fireEvent.click(screen.getByText('Stadt'));
expect(onOpen).not.toHaveBeenCalled();
await act(async () => {
fireEvent.click(screen.getByText('Wiederherstellen'));
});
expect(onRestore).toHaveBeenCalledWith(folder);
}); });
}); });
+8
View File
@@ -832,6 +832,14 @@ export const api = {
await request(`/api/v1/documents/${id}/permanent`, { method: 'DELETE' }); await request(`/api/v1/documents/${id}/permanent`, { method: 'DELETE' });
}, },
async restoreFolder(trashId: string): Promise<void> {
await request(`/api/v1/folders/trash/${trashId}/restore`, { method: 'POST' });
},
async purgeFolder(trashId: string): Promise<void> {
await request(`/api/v1/folders/trash/${trashId}`, { method: 'DELETE' });
},
async emptyTrash(): Promise<{ deleted: number }> { async emptyTrash(): Promise<{ deleted: number }> {
return request<{ deleted: number }>('/api/v1/documents/trash', { method: 'DELETE' }); return request<{ deleted: number }>('/api/v1/documents/trash', { method: 'DELETE' });
}, },
+4
View File
@@ -415,13 +415,17 @@ export interface TrashedTask {
canEdit: boolean; canEdit: boolean;
} }
// A note in the trash, or a deleted folder (kind "folder": id of the trashed folder, title its name,
// folderPath where it was, noteCount the notes it holds)
export interface TrashedDocument { export interface TrashedDocument {
id: string; id: string;
kind?: 'note' | 'folder';
title: string; title: string;
icon: string; icon: string;
folderPath: string; folderPath: string;
deletedAt: string; deletedAt: string;
isOwner: boolean; isOwner: boolean;
noteCount?: number;
} }
export interface Document { export interface Document {
+32
View File
@@ -63,6 +63,38 @@ func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
}) })
} }
// handleRestoreFolder brings a folder from the trash back with its subfolders and notes.
func (s *Server) handleRestoreFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docIDs, err := s.store.RestoreFolder(userID, chi.URLParam(r, "id"))
if err != nil {
writeTrashError(w, err)
return
}
// Everyone with access to one of the notes reloads their tree
users := map[string]bool{userID: true}
for _, docID := range docIDs {
authorized, _ := s.store.GetAuthorizedDocUsers(docID)
for _, uid := range authorized {
users[uid] = true
}
}
for uid := range users {
s.wsHub.BroadcastToUser(uid, "WORKSPACE_FOLDERS_UPDATED", map[string]string{"restored": "folder"})
}
writeJSON(w, http.StatusOK, map[string]interface{}{"restored": true, "restoredDocIds": docIDs})
}
// handlePurgeFolder deletes a folder in the trash with its notes for good.
func (s *Server) handlePurgeFolder(w http.ResponseWriter, r *http.Request) {
docIDs, err := s.store.PurgeFolder(auth.GetUserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
writeTrashError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"deleted": true, "deletedDocIds": docIDs})
}
func (s *Server) handleMoveFolder(w http.ResponseWriter, r *http.Request) { func (s *Server) handleMoveFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
var req struct { var req struct {
+2
View File
@@ -238,6 +238,8 @@ func (s *Server) Router() http.Handler {
r.Post("/api/v1/folders", s.handleCreateFolder) r.Post("/api/v1/folders", s.handleCreateFolder)
r.Post("/api/v1/folders/move", s.handleMoveFolder) r.Post("/api/v1/folders/move", s.handleMoveFolder)
r.Delete("/api/v1/folders", s.handleDeleteFolder) r.Delete("/api/v1/folders", s.handleDeleteFolder)
r.Post("/api/v1/folders/trash/{id}/restore", s.handleRestoreFolder)
r.Delete("/api/v1/folders/trash/{id}", s.handlePurgeFolder)
r.Get("/api/v1/workspace/folders/shared", s.handleGetSharedFolders) r.Get("/api/v1/workspace/folders/shared", s.handleGetSharedFolders)
r.Post("/api/v1/workspace/folders/shared", s.handleCreateSharedFolder) r.Post("/api/v1/workspace/folders/shared", s.handleCreateSharedFolder)
+1
View File
@@ -86,6 +86,7 @@ func purgeStmts(section string) []struct {
{`DELETE FROM doc_user_folders WHERE user_id = ?`, 1}, {`DELETE FROM doc_user_folders WHERE user_id = ?`, 1},
{`DELETE FROM doc_pins WHERE user_id = ?`, 1}, {`DELETE FROM doc_pins WHERE user_id = ?`, 1},
{`DELETE FROM workspace_folders WHERE user_id = ?`, 1}, {`DELETE FROM workspace_folders WHERE user_id = ?`, 1},
{`DELETE FROM trashed_folders WHERE user_id = ?`, 1},
{`DELETE FROM tag_pages WHERE user_id = ?`, 1}, {`DELETE FROM tag_pages WHERE user_id = ?`, 1},
} }
case SectionLists: case SectionLists:
+18
View File
@@ -133,11 +133,13 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
var deletedDocIDs []string var deletedDocIDs []string
var writes []sqlStmt var writes []sqlStmt
seenDocID := make(map[string]bool) seenDocID := make(map[string]bool)
inSharedFolder := false
mounts, _, err := s.getUserFolderMounts(userID) mounts, _, err := s.getUserFolderMounts(userID)
if err == nil { if err == nil {
sfID, subPath, isMatch := matchMountPath(clean, mounts) sfID, subPath, isMatch := matchMountPath(clean, mounts)
if isMatch && sfID != "" { if isMatch && sfID != "" {
inSharedFolder = true
if subPath == "" { if subPath == "" {
// Root of a shared folder // Root of a shared folder
shFolder, sfErr := s.GetSharedFolderByID(userID, sfID) shFolder, sfErr := s.GetSharedFolderByID(userID, sfID)
@@ -201,6 +203,22 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
for _, id := range deletedDocIDs { for _, id := range deletedDocIDs {
writes = append(writes, trashStmts(userID, id)...) writes = append(writes, trashStmts(userID, id)...)
} }
// A personal folder goes to the trash as a whole (with its subfolders), so it can come back as it was
if !inSharedFolder {
subfolders := []string{}
if frows, err := s.db.Query(`SELECT path FROM workspace_folders WHERE user_id = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)
AND path LIKE ?`, userID, clean+"/%"); err == nil {
for frows.Next() {
var p string
if frows.Scan(&p) == nil {
subfolders = append(subfolders, p)
}
}
_ = frows.Close()
}
_, folderWrites := trashFolderStmts(userID, clean, subfolders, deletedDocIDs)
writes = append(writes, folderWrites...)
}
// Also clear any personal folder placements for shared notes in this folder // Also clear any personal folder placements for shared notes in this folder
writes = append(writes, writes = append(writes,
+1
View File
@@ -38,6 +38,7 @@ var migrations = []migration{
{15, "Spracheingabe pro Person", (*Store).migrateVoiceInput}, {15, "Spracheingabe pro Person", (*Store).migrateVoiceInput},
{16, "Aufbewahrung und Papierkorb für Aufgaben", (*Store).migrateRetention}, {16, "Aufbewahrung und Papierkorb für Aufgaben", (*Store).migrateRetention},
{17, "OAuth für den MCP-Zugang", (*Store).migrateOAuth}, {17, "OAuth für den MCP-Zugang", (*Store).migrateOAuth},
{18, "Ordner im Papierkorb", (*Store).migrateFolderTrash},
} }
func (s *Store) migrate() error { func (s *Store) migrate() error {
+7
View File
@@ -207,6 +207,13 @@ func (s *Store) PurgeExpired(now time.Time, allowNever bool) (PurgeResult, error
} }
res.Notes = append(res.Notes, id) res.Notes = append(res.Notes, id)
} }
// Folders in the notes trash: same delay; their notes went above (they were trashed at the same time)
if _, err := s.db.Exec(`DELETE FROM trashed_folders WHERE id IN (
SELECT tf.id FROM trashed_folders tf JOIN users u ON u.id = tf.user_id
WHERE `+daysSQL("u.note_trash_days")+` > 0 AND `+cutoffSQL("tf.trashed_at", daysSQL("u.note_trash_days"))+`)`,
neverAs(allowNever, d.NoteTrash), since, since, sqlNow(now), neverAs(allowNever, d.NoteTrash)); err != nil {
return res, err
}
// Ticked-off list items: the list owner's delay, from ticking them off // Ticked-off list items: the list owner's delay, from ticking them off
rows, err = s.db.Query(`SELECT li.id, li.list_id FROM list_items li JOIN lists l ON l.id = li.list_id JOIN users u ON u.id = l.user_id rows, err = s.db.Query(`SELECT li.id, li.list_id FROM list_items li JOIN lists l ON l.id = li.list_id JOIN users u ON u.id = l.user_id
+3 -3
View File
@@ -429,10 +429,10 @@ func TestFolderManagementAndDeletion(t *testing.T) {
t.Fatalf("expected 2 deleted docs, got %d (%v)", len(deletedDocs), deletedDocs) t.Fatalf("expected 2 deleted docs, got %d (%v)", len(deletedDocs), deletedDocs)
} }
// The notes are in the trash now, no longer in the tree // The folder is in the trash with both notes, which are no longer in the tree
trash, _ := store.GetTrash(user.ID) trash, _ := store.GetTrash(user.ID)
if len(trash) != 2 { if len(trash) != 1 || trash[0].Kind != "folder" || trash[0].NoteCount != 2 {
t.Fatalf("expected doc1 and doc2 in the trash, got %v", trash) t.Fatalf("expected the folder with doc1 and doc2 in the trash, got %v", trash)
} }
docs, _ := store.GetDocuments(user.ID) docs, _ := store.GetDocuments(user.ID)
for _, d := range docs { for _, d := range docs {
+159 -5
View File
@@ -2,7 +2,10 @@ package storage
import ( import (
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"path"
"sort"
"github.com/sandrodach/mindsift/internal/domain" "github.com/sandrodach/mindsift/internal/domain"
) )
@@ -18,14 +21,38 @@ var ErrNotInTrash = errors.New("Notiz nicht im Papierkorb")
// ErrDocInTrash refuses changes to a trashed note; it has to be restored first. // 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.") var ErrDocInTrash = errors.New("Die Notiz liegt im Papierkorb. Stelle sie zuerst wieder her.")
// TrashedDocument is one entry of the trash list. // Deleted personal folders go to the trash as a whole: trashed_folders keeps the folder and its
// subfolders, and its notes are trashed with trash_folder_id pointing there. The trash lists the folder
// as one entry; restoring it brings back the folder tree (empty subfolders too) and all of its notes.
// Folders inside shared folders keep the old way (their notes go to the trash one by one).
// TrashedDocument is one entry of the trash list: a note, or a deleted folder (Kind "folder", ID of the
// trashed folder, Title its name, FolderPath the folder it was in, NoteCount the notes it holds).
type TrashedDocument struct { type TrashedDocument struct {
ID string `json:"id"` ID string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"` Title string `json:"title"`
Icon string `json:"icon"` Icon string `json:"icon"`
FolderPath string `json:"folderPath"` FolderPath string `json:"folderPath"`
DeletedAt string `json:"deletedAt"` DeletedAt string `json:"deletedAt"`
IsOwner bool `json:"isOwner"` IsOwner bool `json:"isOwner"`
NoteCount int `json:"noteCount,omitempty"`
}
func (s *Store) migrateFolderTrash() error {
if err := s.addColumnIfMissing("documents", "trash_folder_id", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
return s.execInTx(
stmt(`CREATE TABLE IF NOT EXISTS trashed_folders (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
path TEXT NOT NULL,
folders TEXT NOT NULL DEFAULT '[]',
trashed_at TEXT NOT NULL
)`),
stmt(`CREATE INDEX IF NOT EXISTS idx_trashed_folders_user ON trashed_folders(user_id)`),
)
} }
const trashScope = `is_archived = 1 AND (user_id = ? OR archived_by = ?)` const trashScope = `is_archived = 1 AND (user_id = ? OR archived_by = ?)`
@@ -41,12 +68,15 @@ func (s *Store) trashDocument(userID, docID string) error {
return s.execInTx(trashStmts(userID, docID)...) return s.execInTx(trashStmts(userID, docID)...)
} }
// GetTrash lists the notes in the user's trash, newest deletion first. // GetTrash lists the notes and folders in the user's trash, newest deletion first. Notes of a trashed
// folder are part of the folder's entry.
func (s *Store) GetTrash(userID string) ([]TrashedDocument, error) { 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, 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, '') COALESCE(wsf.name, '')
FROM documents d LEFT JOIN workspace_shared_folders wsf ON wsf.id = d.shared_folder_id 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) WHERE d.`+trashScope+`
AND NOT EXISTS (SELECT 1 FROM trashed_folders tf WHERE tf.id = d.trash_folder_id)
ORDER BY d.archived_at DESC`, userID, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -64,12 +94,128 @@ func (s *Store) GetTrash(userID string) ([]TrashedDocument, error) {
t.FolderPath += "/" + subPath t.FolderPath += "/" + subPath
} }
} }
t.Kind = "note"
t.IsOwner = ownerID == userID t.IsOwner = ownerID == userID
out = append(out, t) out = append(out, t)
} }
_ = rows.Close()
frows, err := s.db.Query(`SELECT tf.id, tf.path, tf.trashed_at,
(SELECT COUNT(*) FROM documents d WHERE d.trash_folder_id = tf.id AND d.is_archived = 1)
FROM trashed_folders tf WHERE tf.user_id = ?`, userID)
if err != nil {
return nil, err
}
defer frows.Close()
for frows.Next() {
var t TrashedDocument
var p string
if frows.Scan(&t.ID, &p, &t.DeletedAt, &t.NoteCount) != nil {
continue
}
t.Kind = "folder"
t.Title = path.Base(p)
if parent := path.Dir(p); parent != "." {
t.FolderPath = parent
}
t.IsOwner = true
out = append(out, t)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].DeletedAt > out[j].DeletedAt })
return out, nil return out, nil
} }
// trashFolderStmts records a deleted personal folder (with its subfolders) in the trash and ties its
// notes to it; the notes themselves are trashed by the caller.
func trashFolderStmts(userID, folderPath string, subfolders, docIDs []string) (string, []sqlStmt) {
id := domain.NewID("tfld")
paths, _ := json.Marshal(subfolders)
out := []sqlStmt{stmt(`INSERT INTO trashed_folders (id, user_id, path, folders, trashed_at) VALUES (?, ?, ?, ?, ?)`,
id, userID, folderPath, string(paths), domain.NowISO())}
for _, docID := range docIDs {
out = append(out, stmt(`UPDATE documents SET trash_folder_id = ? WHERE id = ?`, id, docID))
}
return id, out
}
type trashedFolder struct {
path string
folders []string
}
func (s *Store) trashedFolder(userID, id string) (*trashedFolder, error) {
var f trashedFolder
var raw string
err := s.db.QueryRow(`SELECT path, folders FROM trashed_folders WHERE id = ? AND user_id = ?`, id, userID).Scan(&f.path, &raw)
if err == sql.ErrNoRows {
return nil, ErrNotInTrash
}
if err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(raw), &f.folders)
return &f, nil
}
func (s *Store) trashedFolderDocs(id string) ([]string, error) {
rows, err := s.db.Query(`SELECT id FROM documents WHERE trash_folder_id = ? AND is_archived = 1`, id)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var docID string
if rows.Scan(&docID) == nil {
ids = append(ids, docID)
}
}
return ids, nil
}
// RestoreFolder brings a trashed folder back: the folder and all of its subfolders (empty ones too),
// then its notes. Returns the restored note IDs.
func (s *Store) RestoreFolder(userID, id string) ([]string, error) {
f, err := s.trashedFolder(userID, id)
if err != nil {
return nil, err
}
for _, p := range append([]string{f.path}, f.folders...) {
if _, err := s.CreateFolder(userID, p); err != nil {
return nil, err
}
}
docIDs, err := s.trashedFolderDocs(id)
if err != nil {
return nil, err
}
for _, docID := range docIDs {
if err := s.RestoreDocument(userID, docID); err != nil {
return nil, err
}
}
_, err = s.db.Exec(`DELETE FROM trashed_folders WHERE id = ?`, id)
return docIDs, err
}
// PurgeFolder deletes a trashed folder and its notes for good. Returns the deleted note IDs.
func (s *Store) PurgeFolder(userID, id string) ([]string, error) {
if _, err := s.trashedFolder(userID, id); err != nil {
return nil, err
}
docIDs, err := s.trashedFolderDocs(id)
if err != nil {
return nil, err
}
for _, docID := range docIDs {
if err := s.hardDeleteDocument(docID); err != nil {
return nil, err
}
}
_, err = s.db.Exec(`DELETE FROM trashed_folders WHERE id = ?`, id)
return docIDs, err
}
func (s *Store) trashedDocOwner(userID, docID string) (string, error) { func (s *Store) trashedDocOwner(userID, docID string) (string, error) {
var owner string var owner string
err := s.db.QueryRow(`SELECT user_id FROM documents WHERE id = ? AND `+trashScope, docID, userID, userID).Scan(&owner) err := s.db.QueryRow(`SELECT user_id FROM documents WHERE id = ? AND `+trashScope, docID, userID, userID).Scan(&owner)
@@ -101,7 +247,7 @@ func (s *Store) RestoreDocument(userID, docID string) error {
return err return err
} }
} }
_, err = s.db.Exec(`UPDATE documents SET is_archived = 0, archived_at = '', archived_by = '' WHERE id = ?`, docID) _, err = s.db.Exec(`UPDATE documents SET is_archived = 0, archived_at = '', archived_by = '', trash_folder_id = '' WHERE id = ?`, docID)
return err return err
} }
@@ -113,7 +259,7 @@ func (s *Store) PurgeDocument(userID, docID string) error {
return s.hardDeleteDocument(docID) return s.hardDeleteDocument(docID)
} }
// EmptyTrash deletes every note in the user's trash for good and returns their IDs. // EmptyTrash deletes every note and folder in the user's trash for good and returns the note IDs.
func (s *Store) EmptyTrash(userID string) ([]string, error) { func (s *Store) EmptyTrash(userID string) ([]string, error) {
items, err := s.GetTrash(userID) items, err := s.GetTrash(userID)
if err != nil { if err != nil {
@@ -121,6 +267,14 @@ func (s *Store) EmptyTrash(userID string) ([]string, error) {
} }
ids := make([]string, 0, len(items)) ids := make([]string, 0, len(items))
for _, t := range items { for _, t := range items {
if t.Kind == "folder" {
docIDs, err := s.PurgeFolder(userID, t.ID)
ids = append(ids, docIDs...)
if err != nil {
return ids, err
}
continue
}
if err := s.hardDeleteDocument(t.ID); err != nil { if err := s.hardDeleteDocument(t.ID); err != nil {
return ids, err return ids, err
} }
+102
View File
@@ -3,6 +3,7 @@ package storage
import ( import (
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"github.com/sandrodach/mindsift/internal/domain" "github.com/sandrodach/mindsift/internal/domain"
) )
@@ -91,3 +92,104 @@ func TestTrashHidesRestoresAndPurgesNotes(t *testing.T) {
t.Errorf("purge must remove the note and its shares") t.Errorf("purge must remove the note and its shares")
} }
} }
// A deleted personal folder goes to the trash as one entry with its notes and subfolders (empty ones too)
// and comes back as it was; deleting it for good or letting it expire removes it with its notes.
func TestDeletedFolderGoesToTrashAsAWhole(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "foldertrash.db"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
for _, p := range []string{"Projekte", "Projekte/Stadt", "Projekte/Leer", "Privat"} {
if _, err := store.CreateFolder(anna.ID, p); err != nil {
t.Fatal(err)
}
}
top, _ := store.CreateDocument(anna.ID, "", "Übersicht", "", "Projekte", nil, nil, false)
inner, _ := store.CreateDocument(anna.ID, "", "Verkehr", "", "Projekte/Stadt", nil, nil, false)
loose, _ := store.CreateDocument(anna.ID, "", "Einzeln", "", "Privat", nil, nil, false)
_ = store.DeleteDocument(anna.ID, loose.ID)
if _, err := store.DeleteFolder(anna.ID, "Projekte"); err != nil {
t.Fatalf("DeleteFolder: %v", err)
}
folders, _ := store.GetFolders(anna.ID)
for _, f := range folders {
if f == "Projekte" || f == "Projekte/Leer" {
t.Fatalf("deleted folders must be gone from the tree: %v", folders)
}
}
trash, _ := store.GetTrash(anna.ID)
var folderEntry *TrashedDocument
for i := range trash {
if trash[i].Kind == "folder" {
folderEntry = &trash[i]
} else if trash[i].ID != loose.ID {
t.Errorf("notes of the folder belong to its entry, not to the list: %+v", trash[i])
}
}
if len(trash) != 2 || folderEntry == nil || folderEntry.Title != "Projekte" || folderEntry.NoteCount != 2 {
t.Fatalf("trash should list the folder with 2 notes and the single note: %+v", trash)
}
if _, err := store.RestoreFolder(ben.ID, folderEntry.ID); err != ErrNotInTrash {
t.Errorf("someone else must not restore the folder: %v", err)
}
restored, err := store.RestoreFolder(anna.ID, folderEntry.ID)
if err != nil || len(restored) != 2 {
t.Fatalf("RestoreFolder: %v %v", restored, err)
}
folders, _ = store.GetFolders(anna.ID)
want := map[string]bool{"Projekte": false, "Projekte/Stadt": false, "Projekte/Leer": false}
for _, f := range folders {
if _, ok := want[f]; ok {
want[f] = true
}
}
for f, ok := range want {
if !ok {
t.Errorf("folder %q should be back (empty ones too): %v", f, folders)
}
}
for _, id := range []string{top.ID, inner.ID} {
if d, err := store.GetDocumentByID(anna.ID, id); err != nil || d.IsArchived {
t.Errorf("note %s should be back: %+v %v", id, d, err)
}
}
if trash, _ = store.GetTrash(anna.ID); len(trash) != 1 || trash[0].ID != loose.ID {
t.Fatalf("only the single note stays in the trash: %+v", trash)
}
// Deleted for good: folder entry and notes are gone
_, _ = store.DeleteFolder(anna.ID, "Projekte")
trash, _ = store.GetTrash(anna.ID)
for _, e := range trash {
if e.Kind == "folder" {
if ids, err := store.PurgeFolder(anna.ID, e.ID); err != nil || len(ids) != 2 {
t.Fatalf("PurgeFolder: %v %v", ids, err)
}
}
}
var n int
_ = store.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE id IN (?, ?)`, top.ID, inner.ID).Scan(&n)
if n != 0 {
t.Errorf("notes of a purged folder must be gone")
}
// Expired like notes: entry and notes vanish with the notes trash delay
again, _ := store.CreateDocument(anna.ID, "", "Neu", "", "Archiv", nil, nil, false)
_, _ = store.CreateFolder(anna.ID, "Archiv")
_, _ = store.DeleteFolder(anna.ID, "Archiv")
if _, err := store.PurgeExpired(time.Now().AddDate(0, 2, 0), true); err != nil {
t.Fatal(err)
}
_ = store.db.QueryRow(`SELECT COUNT(*) FROM trashed_folders`).Scan(&n)
var docs int
_ = store.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE id = ?`, again.ID).Scan(&docs)
if n != 0 || docs != 0 {
t.Errorf("expired folder and its note should be gone: folders %d, notes %d", n, docs)
}
}