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:
1 parent
9dcfa6c27e
commit
b26b07cfea
18 files changed
+564
-25
No files matched your search
+4
-2
@@ -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 |
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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' });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user