diff --git a/app/db.py b/app/db.py index b5eeed3..de4f6b6 100644 --- a/app/db.py +++ b/app/db.py @@ -141,6 +141,49 @@ def init_db(): created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); + + -- v1.3.0: Database Concept â Notion-style collections + CREATE TABLE IF NOT EXISTS collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT DEFAULT '', + icon TEXT DEFAULT 'đ', + schema_json TEXT NOT NULL DEFAULT '[]', + gitea_owner TEXT, + gitea_repo TEXT, + is_locked BOOLEAN NOT NULL DEFAULT 0, + is_inline BOOLEAN NOT NULL DEFAULT 0, + parent_page_id INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS collection_pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + title TEXT NOT NULL DEFAULT '', + icon TEXT DEFAULT 'đ', + position INTEGER NOT NULL DEFAULT 0, + parent_id INTEGER REFERENCES collection_pages(id), + gitea_issue_id INTEGER, + gitea_issue_number INTEGER, + property_values_json TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_cp_collection ON collection_pages(collection_id, position); + CREATE INDEX IF NOT EXISTS idx_cp_parent ON collection_pages(parent_id); + CREATE INDEX IF NOT EXISTS idx_cp_gitea ON collection_pages(gitea_issue_id); + + CREATE TABLE IF NOT EXISTS collection_views ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT 'Default View', + view_type TEXT NOT NULL DEFAULT 'table', + config_json TEXT NOT NULL DEFAULT '{}', + position INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); """) # Migration: add parent_id if missing (v1.0.0+) try: @@ -158,6 +201,11 @@ def init_db(): conn.execute("ALTER TABLE pages ADD COLUMN content_format TEXT NOT NULL DEFAULT 'markdown'") except sqlite3.OperationalError: pass + # Migration: add deleted_at for trash/soft-delete + try: + conn.execute("ALTER TABLE pages ADD COLUMN deleted_at TEXT") + except sqlite3.OperationalError: + pass conn.commit() diff --git a/app/routers/board.py b/app/routers/board.py index b1a560d..eaa2d08 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -120,7 +120,7 @@ def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, m if depth >= max_depth: return [] rows = conn.execute( - "SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? ORDER BY sort_order ASC, updated_at DESC", + "SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? AND deleted_at IS NULL ORDER BY sort_order ASC, updated_at DESC", (ws_key, parent_id), ).fetchall() items = [] @@ -288,6 +288,39 @@ async def library_page(request: Request): template = env.get_template("library.html") return template.render(**sidebar) +# âââââââââââ Trash (must be before /{owner}/{repo} catch-all) âââââââââââ + +@router.get("/api/trash") +async def list_trash(request: Request): + with get_conn() as conn: + rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall() + return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "đ", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows] + + +@router.post("/api/trash/{page_id}/restore") +async def restore_page(request: Request, page_id: int): + with get_conn() as conn: + conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,)) + conn.commit() + return {"status": "ok", "restored": page_id} + + +@router.delete("/api/trash/{page_id}") +async def permanent_delete(request: Request, page_id: int): + with get_conn() as conn: + conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,)) + conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,)) + conn.commit() + return {"status": "ok", "deleted": page_id} + + +@router.get("/trash", response_class=HTMLResponse) +async def trash_page(request: Request): + from jinja2 import Environment, FileSystemLoader + env = Environment(loader=FileSystemLoader("app/templates")) + template = env.get_template("trash.html") + return template.render(**_sidebar_data(request)) + # âââââââââââ Board page âââââââââââ @@ -572,19 +605,13 @@ async def move_page(request: Request, page_id: int, @router.delete("/api/pages/{page_id}") async def delete_page(request: Request, page_id: int): - """Move a page to trash (soft delete) or permanently delete.""" + """Move a page to trash (soft delete).""" with get_conn() as conn: - row = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone() + row = conn.execute("SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)).fetchone() if not row: raise HTTPException(404, "Page not found") - # Soft delete: set deleted_at timestamp - # For now, hard delete since we have no trash view yet - # If sub-pages exist, move them to root level - conn.execute( - "UPDATE pages SET parent_id=NULL, sort_order=0 WHERE parent_id=?", - (page_id,), - ) - conn.execute("DELETE FROM pages WHERE id=?", (page_id,)) + import datetime + conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.utcnow().isoformat(), page_id,)) conn.commit() return {"status": "ok", "deleted": page_id, "title": row["title"]} diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 1ae25de..fe38c13 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -45,9 +45,18 @@ def _sidebar_data(request: Request, repos: list[dict]) -> dict: } +@router.get("/trash", response_class=HTMLResponse) +async def trash_page(request: Request): + """Trash page.""" + from jinja2 import Environment, FileSystemLoader + env = Environment(loader=FileSystemLoader("app/templates")) + sidebar = _sidebar_data(request, []) + template = env.get_template("trash.html") + return template.render(**sidebar) + + @router.get("/library", response_class=HTMLResponse) async def library_page(request: Request): - """Library page avec onglets Recents, Favorites, Shared, Private.""" from jinja2 import Environment, FileSystemLoader env = Environment(loader=FileSystemLoader("app/templates")) sidebar = _sidebar_data(request, []) diff --git a/app/templates/base.html b/app/templates/base.html index 244aa5a..0211ec9 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -202,7 +202,7 @@
diff --git a/app/templates/library.html b/app/templates/library.html index 3f1d79e..bf3d98d 100644 --- a/app/templates/library.html +++ b/app/templates/library.html @@ -10,7 +10,7 @@ - +