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)
|
ws_id = int(ws_cookie)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
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,),
|
(ws_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
items = []
|
items = []
|
||||||
@@ -197,7 +197,7 @@ def _load_children(parent_id: int) -> list:
|
|||||||
"""Recursively load children of a page."""
|
"""Recursively load children of a page."""
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
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,),
|
(parent_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
children = []
|
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}")
|
@router.delete("/api/local-workspace/items/{item_id:int}")
|
||||||
async def delete_local_workspace_item(request: Request, 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."""
|
"""Soft-delete a file/folder (sets deleted_at)."""
|
||||||
import json
|
from datetime import datetime
|
||||||
from pathlib import Path
|
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
# Check if it's a file-type page with a disk file
|
conn.execute(
|
||||||
row = conn.execute("SELECT content, content_format FROM pages WHERE id=?", (item_id,)).fetchone()
|
"UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
|
||||||
if row and row["content_format"] == "file":
|
(datetime.utcnow().isoformat(), item_id),
|
||||||
try:
|
)
|
||||||
meta = json.loads(row["content"])
|
conn.commit()
|
||||||
fp = Path(f"/data/{meta['file_path']}")
|
return {"status": "ok"}
|
||||||
if fp.exists():
|
|
||||||
fp.unlink()
|
|
||||||
except Exception:
|
@router.post("/api/local-workspace/items/{item_id:int}/restore")
|
||||||
pass
|
async def restore_local_workspace_item(request: Request, item_id: int):
|
||||||
conn.execute("DELETE FROM pages WHERE id=? OR parent_id=?", (item_id, item_id))
|
"""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()
|
conn.commit()
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|||||||
@@ -567,11 +567,20 @@ window._wsInitData = (function() {
|
|||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
if (!confirm('Delete ' + ids.length + ' selected items?')) return;
|
if (!confirm('Delete ' + ids.length + ' selected items?')) return;
|
||||||
var self = this;
|
var self = this;
|
||||||
|
// Soft-delete all selected items
|
||||||
for (var i=0; i<ids.length; i++) {
|
for (var i=0; i<ids.length; i++) {
|
||||||
await fetch('/api/local-workspace/items/' + ids[i], { method: 'DELETE' });
|
await fetch('/api/local-workspace/items/' + ids[i], { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
this.clearSelection();
|
this.clearSelection();
|
||||||
this._reloadAfterAction();
|
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() {
|
clearSelection() {
|
||||||
for (var k in this.selectedIds) delete this.selectedIds[k];
|
for (var k in this.selectedIds) delete this.selectedIds[k];
|
||||||
@@ -1223,11 +1232,23 @@ window._wsInitData = (function() {
|
|||||||
undoDelete() {
|
undoDelete() {
|
||||||
if (this.undoTimeout) clearTimeout(this.undoTimeout);
|
if (this.undoTimeout) clearTimeout(this.undoTimeout);
|
||||||
this.undoVisible = false;
|
this.undoVisible = false;
|
||||||
var id = this.undoItemId;
|
var ids = (this.undoItemId || '').toString().split(',');
|
||||||
if (!id) return;
|
|
||||||
// Recreate the item (we can't really undo a DELETE, so reload)
|
|
||||||
this._reloadAfterAction();
|
|
||||||
this.undoItemId = null;
|
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) {
|
_removeFromTree(nodes, id) {
|
||||||
for (var i=0; i<nodes.length; i++) {
|
for (var i=0; i<nodes.length; i++) {
|
||||||
|
|||||||
Reference in New Issue
Block a user