feat: soft-delete and undo/restore for local-workspace items
Backend:
- DELETE /api/local-workspace/items/{id} now soft-deletes (sets deleted_at
instead of hard DELETE) — items go to trash, not permanently gone
- New POST /api/local-workspace/items/{id}/restore to undo a delete
- _load_workspace_pages() and _load_children() now filter deleted_at IS NULL
so soft-deleted items disappear from sidebar and tree queries
Frontend:
- undoDelete() now calls the restore API per item (single or bulk)
- bulkDelete() now shows the same undo banner as context menu delete
- All delete paths (ctxMenu, bulk, modal) use the same soft-delete + undo flow
This commit is contained in:
@@ -172,7 +172,7 @@ def _load_workspace_pages(ws_cookie: str) -> list:
|
||||
ws_id = int(ws_cookie)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format FROM pages WHERE workspace_id=? AND parent_id IS NULL ORDER BY created_at DESC",
|
||||
"SELECT id, title, parent_section, content_format FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
items = []
|
||||
@@ -197,7 +197,7 @@ def _load_children(parent_id: int) -> list:
|
||||
"""Recursively load children of a page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format FROM pages WHERE parent_id=? ORDER BY created_at",
|
||||
"SELECT id, title, parent_section, content_format FROM pages WHERE parent_id=? AND deleted_at IS NULL ORDER BY created_at",
|
||||
(parent_id,),
|
||||
).fetchall()
|
||||
children = []
|
||||
|
||||
+18
-14
@@ -837,21 +837,25 @@ async def rename_local_workspace_item(request: Request, item_id: int):
|
||||
|
||||
@router.delete("/api/local-workspace/items/{item_id:int}")
|
||||
async def delete_local_workspace_item(request: Request, item_id: int):
|
||||
"""Delete a file (and its children). Also removes uploaded file from disk."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
"""Soft-delete a file/folder (sets deleted_at)."""
|
||||
from datetime import datetime
|
||||
with get_conn() as conn:
|
||||
# Check if it's a file-type page with a disk file
|
||||
row = conn.execute("SELECT content, content_format FROM pages WHERE id=?", (item_id,)).fetchone()
|
||||
if row and row["content_format"] == "file":
|
||||
try:
|
||||
meta = json.loads(row["content"])
|
||||
fp = Path(f"/data/{meta['file_path']}")
|
||||
if fp.exists():
|
||||
fp.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
conn.execute("DELETE FROM pages WHERE id=? OR parent_id=?", (item_id, item_id))
|
||||
conn.execute(
|
||||
"UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
|
||||
(datetime.utcnow().isoformat(), item_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/items/{item_id:int}/restore")
|
||||
async def restore_local_workspace_item(request: Request, item_id: int):
|
||||
"""Restore a soft-deleted file/folder."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET deleted_at=NULL WHERE id=?",
|
||||
(item_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@@ -567,11 +567,20 @@ window._wsInitData = (function() {
|
||||
if (ids.length === 0) return;
|
||||
if (!confirm('Delete ' + ids.length + ' selected items?')) return;
|
||||
var self = this;
|
||||
// Soft-delete all selected items
|
||||
for (var i=0; i<ids.length; i++) {
|
||||
await fetch('/api/local-workspace/items/' + ids[i], { method: 'DELETE' });
|
||||
}
|
||||
this.clearSelection();
|
||||
this._reloadAfterAction();
|
||||
// Show undo banner for bulk delete
|
||||
this.undoItemId = ids.join(',');
|
||||
this.undoMessage = 'Deleted ' + ids.length + ' item(s)';
|
||||
this.undoVisible = true;
|
||||
this.undoTimeout = setTimeout(function() {
|
||||
self.undoVisible = false;
|
||||
self.undoItemId = null;
|
||||
}, 5000);
|
||||
},
|
||||
clearSelection() {
|
||||
for (var k in this.selectedIds) delete this.selectedIds[k];
|
||||
@@ -1223,11 +1232,23 @@ window._wsInitData = (function() {
|
||||
undoDelete() {
|
||||
if (this.undoTimeout) clearTimeout(this.undoTimeout);
|
||||
this.undoVisible = false;
|
||||
var id = this.undoItemId;
|
||||
if (!id) return;
|
||||
// Recreate the item (we can't really undo a DELETE, so reload)
|
||||
this._reloadAfterAction();
|
||||
var ids = (this.undoItemId || '').toString().split(',');
|
||||
this.undoItemId = null;
|
||||
if (!ids.length || !ids[0]) return;
|
||||
// Restore via API (soft-delete undo) — single or bulk
|
||||
var self = this;
|
||||
var restored = 0;
|
||||
var total = ids.length;
|
||||
function restoreOne(i) {
|
||||
if (i >= total) {
|
||||
self._reloadAfterAction();
|
||||
return;
|
||||
}
|
||||
fetch('/api/local-workspace/items/' + ids[i] + '/restore', { method: 'POST' })
|
||||
.then(function(r) { if (r.ok) restored++; })
|
||||
.finally(function() { restoreOne(i + 1); });
|
||||
}
|
||||
restoreOne(0);
|
||||
},
|
||||
_removeFromTree(nodes, id) {
|
||||
for (var i=0; i<nodes.length; i++) {
|
||||
|
||||
Reference in New Issue
Block a user