diff --git a/VERSION b/VERSION index 9266671..c355d6e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.9.1 \ No newline at end of file +5.10.0 \ No newline at end of file diff --git a/app/main.py b/app/main.py index 552772c..ca3f9ac 100644 --- a/app/main.py +++ b/app/main.py @@ -81,13 +81,17 @@ async def lifespan(_app: FastAPI): from app.services.projects import project_sync_scheduler projects_task = asyncio.create_task(project_sync_scheduler()) - logger.info("FlowDeck v5.9.1 started on port %d", settings.app_port) + # ── Global trash purge (v5.4.0): daily cleanup of 30-day-old pages ── + from app.services.trash import trash_purge_scheduler + trash_task = asyncio.create_task(trash_purge_scheduler()) + + logger.info("FlowDeck v5.10.0 started on port %d", settings.app_port) try: yield finally: - for task in (scheduler_task, automation_task, backup_task, projects_task): + for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task): task.cancel() - for task in (scheduler_task, automation_task, backup_task, projects_task): + for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task): try: await task except asyncio.CancelledError: @@ -96,7 +100,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="5.9.1", + version="5.10.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, diff --git a/app/migrations.py b/app/migrations.py index 91c4fcb..21a934e 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -300,6 +300,41 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None: ) +@register(7, "v5.4.0/v5.5.0: page versions, cover + icon") +def _migration_v54_page_versions_cover(conn: sqlite3.Connection) -> None: + """v5.4.0 (version history + page duplication) & v5.5.0 (cover & icon). + + ``page_versions`` — undoable version snapshots for block-editor pages + (NOT tied to ``collection_pages`` like the legacy + ``page_history`` table). One row per save with the + full block list + title so the UI can browse/restore. + ``pages.cover_url`` — image cover shown above the page title. + ``pages.page_icon`` — emoji / icon label shown next to the title. + """ + conn.execute( + """ + CREATE TABLE IF NOT EXISTS page_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id), + title TEXT NOT NULL DEFAULT '', + blocks_json TEXT NOT NULL DEFAULT '[]', + note TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_page_versions_page ON page_versions(page_id, created_at)" + ) + + _pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()} + if "cover_url" not in _pcols: + conn.execute("ALTER TABLE pages ADD COLUMN cover_url TEXT DEFAULT ''") + if "page_icon" not in _pcols: + conn.execute("ALTER TABLE pages ADD COLUMN page_icon TEXT DEFAULT ''") + + @register(4, "database templates (icon) + property validation") def _migration_db_templates_validation(conn: sqlite3.Connection) -> None: """v5.3.0: database templates get an icon, properties a validation config, diff --git a/app/routers/board.py b/app/routers/board.py index d3db1da..3a1d55c 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -677,6 +677,33 @@ async def update_share(request: Request, page_id: int): return {"status": "ok", "share_mode": mode, "published": published} +@router.post("/api/pages/{page_id:int}/publish") +async def publish_page(request: Request, page_id: int): + """Publish a page to the web (generates publish_slug).""" + import secrets + slug = "p-" + secrets.token_urlsafe(8) + with get_conn() as conn: + conn.execute( + "UPDATE pages SET is_published=1, publish_slug=?, share_mode='anyone' WHERE id=?", + (slug, page_id), + ) + conn.commit() + row = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone() + return {"is_published": True, "publish_slug": slug, "title": row["title"] if row else ""} + + +@router.delete("/api/pages/{page_id:int}/publish") +async def unpublish_page(request: Request, page_id: int): + """Unpublish a page from the web.""" + with get_conn() as conn: + conn.execute( + "UPDATE pages SET is_published=0, publish_slug='' WHERE id=?", + (page_id,), + ) + conn.commit() + return {"is_published": False} + + # ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════ @router.get("/api/trash") @@ -945,13 +972,20 @@ async def update_page(request: Request, page_id: int, title: str = Query(default @router.post("/api/pages/{page_id}/blocks") async def save_page_blocks(request: Request, page_id: int): - """Save blocks JSON content (Notion-style block editor).""" + """Save blocks JSON content (Notion-style block editor). + + v5.4.0: a version snapshot is recorded (if the block content actually + changed) so the UI can browse the version history and restore any of them. + """ try: body = await request.json() except Exception: raise HTTPException(400, "Invalid JSON body") blocks_json = json.dumps(body.get("blocks", [])) title = body.get("title", "") + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + uid = (user or {}).get("id") + with get_conn() as conn: if title: conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id)) @@ -959,12 +993,383 @@ async def save_page_blocks(request: Request, page_id: int): "UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?", (blocks_json, page_id), ) + _record_version(conn, page_id, uid, title or "", blocks_json) conn.commit() await fire_event("page.updated", {"page_id": page_id, "title": title or "", "content_format": "blocks"}) return {"status": "ok", "id": page_id} +def _record_version(conn, page_id: int, user_id, title: str, blocks_json: str) -> None: + """Insert a version snapshot unless it is byte-identical to the latest one.""" + prev = conn.execute( + "SELECT COALESCE(title, ''), blocks_json FROM page_versions " + "WHERE page_id=? ORDER BY id DESC LIMIT 1", + (page_id,), + ).fetchone() + if prev is not None and prev["blocks_json"] == blocks_json: + if prev["title"] != (title or ""): + conn.execute( + "UPDATE page_versions SET title=? WHERE id=" + "(SELECT id FROM page_versions WHERE page_id=? ORDER BY id DESC LIMIT 1)", + (title or "", page_id), + ) + return + conn.execute( + "INSERT INTO page_versions (page_id, user_id, title, blocks_json, note) VALUES (?,?,?,?,'edited')", + (page_id, user_id, title or "", blocks_json), + ) + + +def _block_texts(b: dict) -> list[str]: + """Flatten a block (including children) into searchable text chunks.""" + out = [] + raw = b.get("content") + if isinstance(raw, str) and raw.strip(): + out.append(raw) + for child in b.get("children") or []: + out.extend(_block_texts(child)) + return out + + +@router.get("/api/pages/{page_id}/backlinks") +async def page_backlinks(request: Request, page_id: int): + """v5.4.0: pages that link to this one ("Lié depuis…"). + + Scans every non-deleted page's blocks (and raw markdown) for an internal + reference to ``/pages/{page_id}`` or ``#fdblk-…`` inside ``/pages/{page_id}``. + """ + target = f"/pages/{page_id}" if page_id else None + backlinks = [] + with get_conn() as conn: + rows = conn.execute( + "SELECT id, title, workspace, content, content_format, updated_at " + "FROM pages WHERE deleted_at IS NULL AND id != ?", + (page_id,), + ).fetchall() + for r in rows: + fmt = r["content_format"] + hits = False + if fmt == "blocks" and r["content"]: + try: + blocks = json.loads(r["content"]) + for b in blocks if isinstance(blocks, list) else []: + for text in _block_texts(b): + if target and f"/pages/{page_id}" in text: + hits = True + break + if hits: + break + except (json.JSONDecodeError, TypeError): + hits = target and f"/pages/{page_id}" in (r["content"] or "") + elif fmt == "markdown": + hits = target and f"/pages/{page_id}" in (r["content"] or "") + elif r["content"]: + hits = target and f"/pages/{page_id}" in json.dumps(r["content"]) + if not hits and target: + hits = f"/pages/{page_id}" in (r["content"] or "") + if hits: + backlinks.append({ + "id": r["id"], + "title": r["title"] or "Untitled", + "workspace": r["workspace"] or "", + "updated_at": r["updated_at"] or "", + }) + backlinks.sort(key=lambda x: x.get("updated_at") or "", reverse=True) + return {"backlinks": backlinks} + + +@router.get("/api/pages/{page_id}/versions") +async def page_versions(request: Request, page_id: int): + """v5.4.0: version history for a block-editor page.""" + with get_conn() as conn: + rows = conn.execute( + "SELECT pv.id, pv.title, pv.note, pv.created_at, " + "COALESCE(u.login, '') AS author " + "FROM page_versions pv LEFT JOIN users u ON u.id=pv.user_id " + "WHERE pv.page_id=? ORDER BY pv.id DESC LIMIT 100", + (page_id,), + ).fetchall() + return {"versions": [dict(r) for r in rows]} + + +@router.post("/api/pages/{page_id}/versions/{version_id}/restore") +async def restore_version(request: Request, page_id: int, version_id: int): + """v5.4.0: restore a page from a version snapshot.""" + with get_conn() as conn: + ver = conn.execute( + "SELECT * FROM page_versions WHERE id=? AND page_id=?", + (version_id, page_id), + ).fetchone() + if not ver: + raise HTTPException(404, "Version not found") + conn.execute( + "UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (ver["blocks_json"], ver["title"] or "", page_id), + ) + conn.commit() + await fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "", + "content_format": "blocks"}) + return {"status": "ok", "restored": version_id} + + +# ═══════════ v5.4.0: Page & collection duplication ═══════════ + + +@router.post("/api/pages/{page_id}/duplicate") +async def duplicate_page(request: Request, page_id: int): + """Duplicate a page (block/markdown content included) as a sibling.""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + with get_conn() as conn: + row = conn.execute( + "SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Page not found") + page = dict(row) + + def copy_tree(src_id: int, parent_id) -> int: + with get_conn() as conn: + src = conn.execute("SELECT * FROM pages WHERE id=?", (src_id,)).fetchone() + cur = conn.execute( + "INSERT INTO pages (workspace, workspace_id, title, content, content_format, " + "parent_section, parent_id, sort_order, share_mode, published, is_published, " + "publish_slug, is_shared, cover_url, page_icon, created_at, updated_at) " + "SELECT workspace, workspace_id, title || ' copy', content, content_format, " + "parent_section, ?, sort_order, share_mode, 0, is_published, '', is_shared, " + "cover_url, page_icon, created_at, updated_at FROM pages WHERE id=?", + (parent_id, src_id), + ) + new_id = cur.lastrowid + conn.commit() + for child in conn.execute( + "SELECT id FROM pages WHERE parent_id=? ", (src_id,) + ).fetchall(): + copy_tree(child["id"], new_id) + return new_id + + new_id = copy_tree(page_id, page.get("parent_id")) + title = (page.get("title") or "Untitled") + " copy" + with get_conn() as conn: + conn.execute("UPDATE pages SET title=? WHERE id=?", (title, new_id)) + conn.commit() + await fire_event("page.created", {"page_id": new_id, "title": title, + "workspace": page.get("workspace")}) + return {"status": "ok", "id": new_id, "title": title} + + +# ═══════════ v5.5.0: Cover & icon ═══════════ + + +def _upload_root() -> "Path": + import os + from pathlib import Path + return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data")) + + +def _ws_id_for(request: Request, page_id: int) -> int: + """The active workspace id for the page (cookie, then page, then fallback 1).""" + cookie = request.cookies.get("flowdeck_workspace", "") + try: + ws_id = int(cookie) + if ws_id > 0: + return ws_id + except (ValueError, TypeError): + pass + with get_conn() as conn: + row = conn.execute( + "SELECT workspace_id FROM pages WHERE id=?", (page_id,) + ).fetchone() + if row and row["workspace_id"]: + return int(row["workspace_id"]) + return 1 + + +async def _store_uploaded_file(request: Request, ws_id: int) -> dict: + """Persist an uploaded file under uploads/workspace_{ws_id}/ and return + {file_url, file_path, mime_type, size, file_name}.""" + import datetime + import re as _re + + form = await request.form() + upload = form.get("file") + if upload is None or not hasattr(upload, "filename"): + raise HTTPException(400, "file field required") + original = (upload.filename or "cover.png").replace("\\", "/").rsplit("/", 1)[-1] + name = _re.sub(r"[^A-Za-z0-9._-]", "_", original)[:120] + ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin" + if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}: + raise HTTPException(400, "Unsupported image format") + stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S") + folder = _upload_root() / f"uploads/workspace_{ws_id}" + folder.mkdir(parents=True, exist_ok=True) + final = f"{stamp}_{name}" + (folder / final).write_bytes(await upload.read()) + mime = f"image/{'svg+xml' if ext == 'svg' else 'jpeg' if ext == 'jpg' else ext}" + return { + "file_url": f"/api/files/{ws_id}/{final}", + "file_path": f"uploads/workspace_{ws_id}/{final}", + "mime_type": mime, + "size": (folder / final).stat().st_size, + "file_name": name, + } + + +@router.post("/api/pages/{page_id}/cover") +async def set_page_cover(request: Request, page_id: int): + """v5.5.0: upload an image cover for a page. + + JSON body {cover_url} accepts an external URL; multipart ``file`` uploads + an image stored in the workspace's uploads directory. + """ + ctype = (request.headers.get("content-type") or "").lower() + if ctype.startswith("application/json"): + body = await request.json() + cover_url = (body.get("cover_url") or "").strip() + if not cover_url: + raise HTTPException(400, "cover_url required") + with get_conn() as conn: + conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (cover_url, page_id)) + conn.commit() + return {"status": "ok", "page_id": page_id, "cover_url": cover_url} + ws_id = _ws_id_for(request, page_id) + meta = await _store_uploaded_file(request, ws_id) + with get_conn() as conn: + conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (meta["file_url"], page_id)) + conn.commit() + return {"status": "ok", "page_id": page_id, "cover_url": meta["file_url"]} + + +@router.delete("/api/pages/{page_id}/cover") +async def remove_page_cover(request: Request, page_id: int): + with get_conn() as conn: + conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,)) + conn.commit() + return {"status": "ok", "page_id": page_id} + + +@router.post("/api/pages/{page_id}/icon") +async def set_page_icon(request: Request, page_id: int): + """v5.5.0: set a page emoji/icon label.""" + body = await request.json() + icon = (body.get("icon") or "").strip() + if len(icon) > 16: + raise HTTPException(400, "icon too long") + with get_conn() as conn: + conn.execute("UPDATE pages SET page_icon=? WHERE id=?", (icon, page_id)) + conn.commit() + return {"status": "ok", "page_id": page_id, "icon": icon} + + +# ═══════════ v5.4.0: Import (Markdown / .md / Notion .zip) ═══════════ + + +async def _create_page_from_markdown(request: Request, markdown: str, title: str = "") -> int: + """Convert markdown → blocks (server-side, same mapping as the editor) and + create a page in the caller's workspace.""" + from app.services.export import _md_to_blocks + + blocks = _md_to_blocks(markdown or "") + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + ws_key = user.get("login", "Bruno") if user else "Bruno" + file_title = title.strip() or "Import" + fallback = (file_title or "Imported page").replace("/", "-").replace("\\", "-")[:120] + if not blocks: + blocks = [{"type": "paragraph", "content": markdown or ""}] + with get_conn() as conn: + next_order = conn.execute( + "SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL", + (ws_key,), + ).fetchone()[0] + cur = conn.execute( + "INSERT INTO pages (workspace, title, content, content_format, parent_section, sort_order, workspace_id) " + "VALUES (?,?,?,?,'Private',?,(SELECT id FROM workspaces WHERE name=? LIMIT 1))", + (ws_key, fallback, json.dumps(blocks), "blocks", next_order, ws_key), + ) + conn.commit() + return cur.lastrowid + + +@router.post("/api/pages/import") +async def import_page(request: Request): + """v5.4.0: import markdown text as a new page (blocks) in the workspace.""" + try: + body = await request.json() + except Exception: + raise HTTPException(400, "Invalid JSON body") + markdown = body.get("markdown", "") + title = body.get("title", "") + if not markdown and not body.get("csv"): + raise HTTPException(400, "markdown field required") + if not markdown.strip(): + raise HTTPException(400, "markdown is empty") + page_id = await _create_page_from_markdown(request, markdown, title) + await fire_event("page.created", {"page_id": page_id, "title": title or "Import", + "workspace": ""}) + return {"status": "ok", "id": page_id} + + +@router.post("/api/pages/import/file") +async def import_file(request: Request): + """v5.4.0: import an uploaded .md file (or a Notion export .zip containing + markdown pages) into the workspace. Returns the created page ids.""" + import io as _io + import zipfile + + form = await request.form() + upload = form.get("file") + if upload is None or not hasattr(upload, "filename"): + raise HTTPException(400, "file field required") + filename = (upload.filename or "import.md").replace("\\", "/").rsplit("/", 1)[-1] + data = await upload.read() + created_ids = [] + + if filename.lower().endswith(".zip"): + try: + zf = zipfile.ZipFile(_io.BytesIO(data)) + except zipfile.BadZipFile: + raise HTTPException(400, "Invalid zip archive") + md_entries = sorted( + (n for n in zf.namelist() if n.lower().endswith((".md", ".markdown"))), + key=lambda n: (n.count("/"), n.lower()), + ) + if not md_entries: + raise HTTPException(400, "No .md files found in archive") + for name in md_entries: + raw = zf.read(name).decode("utf-8", errors="replace") + title = name.replace("\\", "/").rsplit("/", 1)[-1][:-3] + try: + created_ids.append(await _create_page_from_markdown(request, raw, title)) + except Exception as exc: # noqa: BLE001 - keep importing the rest + logger.warning("import failed for %s: %s", name, exc) + else: + try: + raw = data.decode("utf-8") + except UnicodeDecodeError: + raise HTTPException(400, "Only text/markdown files are supported") + title = filename.replace(".md", "").replace(".markdown", "").replace(".txt", "") + created_ids.append(await _create_page_from_markdown(request, raw, title)) + + if not created_ids: + raise HTTPException(422, "No pages could be imported") + return {"status": "ok", "ids": created_ids, "count": len(created_ids)} + + +@router.post("/api/og/metadata") +async def og_metadata(request: Request): + """v5.5.0: Open Graph metadata for a bookmark card.""" + try: + body = await request.json() + except Exception: + raise HTTPException(400, "Invalid JSON body") + url = (body.get("url") or "").strip() + if not url: + raise HTTPException(400, "url required") + from app.services.og_fetcher import fetch_og_metadata + data = await fetch_og_metadata(url) + return {"ok": True, **data} + + @router.put("/api/pages/{page_id}/move") async def move_page(request: Request, page_id: int): """Move a page to another workspace or reorder within tree. @@ -1053,7 +1458,7 @@ async def view_page(request: Request, page_id: int): ).fetchone() # Build page_data, including file metadata for uploaded files - page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0))} + page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), "cover_url": page.get("cover_url", "") or "", "page_icon": page.get("page_icon", "") or ""} # For file pages, extract file metadata and add to page_data if page.get("content_format") == "file": diff --git a/app/routers/collections.py b/app/routers/collections.py index 8d4082c..a793efe 100644 --- a/app/routers/collections.py +++ b/app/routers/collections.py @@ -223,6 +223,145 @@ async def delete_collection_api(request: Request, collection_id: int): return {"id": collection_id, "status": "deleted"} +@router.post("/{collection_id}/duplicate") +async def duplicate_collection_api(request: Request, collection_id: int): + """v5.4.0: deep-duplicate a database (views + properties + pages + data + sources) into a new collection named ' (copy)'.""" + with get_conn() as conn: + src = conn.execute( + "SELECT * FROM collections WHERE id=?", (collection_id,) + ).fetchone() + if not src: + raise HTTPException(status_code=404, detail="Collection not found") + + new_name = (src["name"] or "Database") + " copy" + cur = conn.execute( + """INSERT INTO collections + (name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked, + is_inline, parent_page_id, workspace_id, created_by, is_task, updated_at) + SELECT ?, description, icon, schema_json, gitea_owner, gitea_repo, is_locked, + is_inline, parent_page_id, workspace_id, created_by, is_task, updated_at + FROM collections WHERE id=?""", + (new_name, collection_id), + ) + new_id = cur.lastrowid + + # ── Properties (remap ids so relation/rollup refs stay valid) ── + prop_map: dict[int, int] = {} + rows = conn.execute( + "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", + (collection_id,), + ).fetchall() + for p in rows: + ncur = conn.execute( + """INSERT INTO collection_properties + (collection_id, name, prop_type, options_json, number_format, + related_collection_id, reverse_name, relation_property_id, + target_property_id, rollup_function, formula_expression, + position, required, visible_in_views) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + (new_id, p["name"], p["prop_type"], p["options_json"], p["number_format"], + None, p["reverse_name"], None, None, p["rollup_function"], + p["formula_expression"], p["position"], p["required"], + p["visible_in_views"]), + ) + prop_map[p["id"]] = ncur.lastrowid + + # Fix cross-property references after all rows exist (creates may target + # columns not inserted yet). Related collection remapped to the copy. + for p in rows: + p = dict(p) # convert sqlite3.Row to dict + new_pid = prop_map[p["id"]] + related = p["related_collection_id"] + related_new = new_id if related == collection_id else related + if p["prop_type"] == "relation": + conn.execute( + "UPDATE collection_properties SET related_collection_id=? WHERE id=?", + (related_new, new_pid), + ) + if p.get("relation_property_id") and p["relation_property_id"] in prop_map: + conn.execute( + "UPDATE collection_properties SET relation_property_id=? WHERE id=?", + (prop_map[p["relation_property_id"]], new_pid), + ) + if p.get("target_property_id") and p["target_property_id"] in prop_map: + conn.execute( + "UPDATE collection_properties SET target_property_id=? WHERE id=?", + (prop_map[p["target_property_id"]], new_pid), + ) + + # ── Views ── + vrows = conn.execute( + "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", + (collection_id,), + ).fetchall() + for v in vrows: + conn.execute( + """INSERT INTO collection_views + (collection_id, name, view_type, config_json, position) + VALUES (?,?,?,?,?)""", + (new_id, v["name"], v["view_type"], v["config_json"], v["position"]), + ) + + # ── Pages (rows) with property ids remapped to the copy's properties ── + proxies = {} + prows = conn.execute( + "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", + (collection_id,), + ).fetchall() + page_map: dict[int, int] = {} + for p in prows: + try: + pv = json.loads(p["property_values_json"]) if p["property_values_json"] else {} + except (json.JSONDecodeError, TypeError): + pv = {} + pv_new = {} + for k, val in pv.items(): + try: + prop_id = int(k) + except (ValueError, TypeError): + prop_id = None + new_key = str(prop_map.get(prop_id, prop_id)) if prop_id is not None else k + pv_new[new_key] = val + ncur = conn.execute( + """INSERT INTO collection_pages + (collection_id, title, icon, position, parent_id, gitea_issue_id, + gitea_issue_number, property_values_json, created_at, updated_at) + SELECT ?, title, icon, position, NULL, NULL, NULL, ?, created_at, updated_at + FROM collection_pages WHERE id=?""", + (new_id, json.dumps(pv_new), p["id"]), + ) + page_map[p["id"]] = ncur.lastrowid + + # Re-parent sub-items to the copied rows. + for p in prows: + if p["parent_id"] and p["parent_id"] in page_map: + conn.execute( + "UPDATE collection_pages SET parent_id=? WHERE id=?", + (page_map[p["parent_id"]], page_map[p["id"]]), + ) + + # ── Data sources (linked DBs) ── + drows = conn.execute( + "SELECT * FROM collection_data_sources WHERE collection_id=?", + (collection_id,), + ).fetchall() + for d in drows: + src_coll = d["source_collection_id"] + src_now = new_id if src_coll == collection_id else src_coll + conn.execute( + """INSERT INTO collection_data_sources + (collection_id, source_collection_id, source_name, is_linked, position) + VALUES (?,?,?,?,?)""", + (new_id, src_now, d["source_name"], d["is_linked"], d["position"]), + ) + + conn.commit() + + await fire_event("collection.created", {"collection_id": new_id, "name": new_name}) + return {"id": new_id, "name": new_name, "status": "duplicated"} + + # ── Page CRUD (standalone, BEFORE collection wildcards) ── diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 0e77477..9e3469b 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -2022,7 +2022,7 @@ async def public_published_page(request: Request, slug: str): with get_conn() as conn: row = conn.execute( - "SELECT id, title, content, content_format, updated_at, created_at " + "SELECT id, title, content, content_format, updated_at, created_at, cover_url, page_icon " "FROM pages WHERE publish_slug=? AND is_published=1", (slug,), ).fetchone() @@ -2060,6 +2060,8 @@ h1{font-size:3rem;opacity:.3}

404

""", content_html=content_html, updated_at=page.get("updated_at", ""), created_at=page.get("created_at", ""), + cover_url=page.get("cover_url", ""), + page_icon=page.get("page_icon", ""), ) @@ -2169,9 +2171,66 @@ def _render_blocks_public(blocks: list) -> str: alt = b.get("alt", "") html_parts.append( f'
' - f'{alt}' + f'{alt}' f'
' ) + elif t == "video": + src = b.get("src", "") + if src: + html_parts.append( + f'' + ) + elif t == "audio": + src = b.get("src", "") + if src: + html_parts.append( + f'' + ) + elif t == "bookmark": + url = b.get("url") or b.get("src") or "" + title = b.get("title") or url + desc = b.get("description") or "" + img = b.get("image") or "" + site = b.get("site_name") or "" + img_html = ( + f'' if img else "" + ) + desc_html = f'
{desc}
' if desc else "" + site_html = f'
{site}
' if site else "" + html_parts.append( + f'' + f'
' + f'
{title}
' + f'{desc_html}{site_html}
{img_html}
' + ) + elif t == "embed": + url = b.get("src", "") + emb = b.get("embed_type") or "" + if emb in ("inline_dbs", "collection"): + html_parts.append('
[Embedded content]
') + elif emb == "download": + html_parts.append( + f'⬇ {b.get("file_name") or "Download"}' + ) + elif emb == "pdf" and url: + html_parts.append( + f'' + ) + elif url: + from app.services.embeds import embed_src + src = embed_src(url) or url + height = b.get("height") or 520 + try: + height = int(height) + except (ValueError, TypeError): + height = 520 + html_parts.append( + f'
' + ) else: html_parts.append(f'

{c}

') return "\n".join(html_parts) diff --git a/app/services/embeds.py b/app/services/embeds.py new file mode 100644 index 0000000..dc084bc --- /dev/null +++ b/app/services/embeds.py @@ -0,0 +1,194 @@ +"""FlowDeck — Media embeds (v5.5.0): provider detection + iframe rewriting. + +Maps a raw http(s) URL to a provider-specific embed URL so that one generic +``embed`` block can render YouTube, Vimeo, Figma, Google Maps, Loom, +CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter, Pinterest, Office +docs… exactly like Notion's universal embed. + +Unknown/showable URLs (PDF, images, direct video/audio files, plain http) +fall back to a plain iframe so the link is still visible inline. +""" +from __future__ import annotations + +import re +from urllib.parse import parse_qs, urlparse + +_PROVIDERS = ( + # (host pattern, extra path check, to_embed(url, path, query) -> str | None) +) + + +def _q(params, key): + vals = params.get(key) + return vals[0] if vals else "" + + +def _embed_youtube(url: str, path: str, params) -> str | None: + m = re.search(r"((?:v|shorts|embed)/|be/)([A-Za-z0-9_-]{6,20})", path) + vid = m.group(2) if m else _q(params, "v") + if not vid: + return None + start = _q(params, "t") or _q(params, "start") + frag = f"?start={start}" if start else "" + return f"https://www.youtube.com/embed/{vid}{frag}" + + +def _embed_vimeo(url: str, path: str, params) -> str | None: + m = re.search(r"/(\d{6,12})", path) + if not m: + return None + return f"https://player.vimeo.com/video/{m.group(1)}" + + +def _embed_loom(url: str, path: str, params) -> str | None: + m = re.search(r"/(embed/)?([0-9a-f]{32})", path) + if not m: + return None + return f"https://www.loom.com/embed/{m.group(2)}" + + +def _embed_figma(url: str, path: str, params) -> str | None: + if "figma.com/file/" not in url and "figma.com/proto/" not in url: + return None + m = re.search(r"(/[^/]+/[^?]+)", url.split("?", 1)[0]) + if not m: + return None + return "https://www.figma.com/embed?embed_host=flowdeck&url=" + url.split("?", 1)[0] + + +def _embed_map(url: str, path: str, params) -> str | None: + if "google.com/maps" not in url and "maps.app.goo.gl" not in url: + return None + return f"https://www.google.com/maps?output=embed&q={url}" + + +def _embed_codepen(url: str, path: str, params) -> str | None: + m = re.search(r"codepen\.io/([^/]+)/pen/([^/?#]+)", url) + if not m: + return None + return f"https://codepen.io/{m.group(1)}/embed/{m.group(2)}?default-tab=result" + + +def _embed_miro(url: str, path: str, params) -> str | None: + m = re.search(r"miro\.com/app/board/([^/?#]+)", url) + if not m: + return None + return f"https://miro.com/app/live-embed/{m.group(1)}" + + +def _embed_spotify(url: str, path: str, params) -> str | None: + m = re.search(r"/(track|playlist|album|episode|show|artist)/([A-Za-z0-9]+)", url) + if not m: + return None + return f"https://open.spotify.com/embed/{m.group(1)}/{m.group(2)}" + + +def _embed_soundcloud(url: str, path: str, params) -> str | None: + m = re.search(r"(?:soundcloud\.com/[^/]+/[^/?#]+)", url) + if not m: + return None + return f"https://w.soundcloud.com/player/?url={url}&color=%2300aaff" + + +def _embed_twitch(url: str, path: str, params) -> str | None: + if "twitch.tv" not in url: + return None + m = re.search(r"twitch\.tv/([^/?#]+)", url) + if not m: + return None + return f"https://player.twitch.tv/?channel={m.group(1)}&parent=" + + +def _embed_twitter(url: str, path: str, params) -> str | None: + if "twitter.com" not in url and "x.com" not in url: + return None + m = re.search(r"/([^/?#]+)/status/(\d+)", url) + if not m: + return f"https://platform.twitter.com/embed/Tweet.html?url={url}" + return f"https://platform.twitter.com/embed/Tweet.html?id={m.group(2)}" + + +def _embed_pinterest(url: str, path: str, params) -> str | None: + if "pinterest" not in url: + return None + return f"https://pinterest.com/pin/embed?url={url}" + + +def _embed_files(url: str, path: str, params) -> str | None: + """Direct media: PDF/images/videos/audio can live in a plain iframe.""" + return url + + +_HANDLERS = ( + ("youtube.com", _embed_youtube), + ("youtu.be", _embed_youtube), + ("vimeo.com", _embed_vimeo), + ("loom.com", _embed_loom), + ("figma.com", _embed_figma), + ("google.com/maps", _embed_map), + ("maps.app.goo.gl", _embed_map), + ("codepen.io", _embed_codepen), + ("miro.com", _embed_miro), + ("open.spotify.com", _embed_spotify), + ("spotify.com", _embed_spotify), + ("soundcloud.com", _embed_soundcloud), + ("twitch.tv", _embed_twitch), + ("twitter.com", _embed_twitter), + ("x.com", _embed_twitter), + ("pinterest", _embed_pinterest), +) + + +def embed_src(url: str) -> str | None: + """Return the embeddable iframe src for a URL, or None if it can't embed.""" + if not url: + return None + url = url.strip() + u = urlparse(url if url.startswith("http") else "https://" + url) + if u.scheme not in ("http", "https"): + return None + host = (u.hostname or "").lower().replace("www.", "") + netloc = (u.netloc or "").lower() + params = parse_qs(u.query) + # Google Maps share links encode the query in the path (…&q=/maps/…) + encoded = url + for needle, handler in _HANDLERS: + if needle in netloc: + return handler(encoded, u.path, params) + return _embed_files(encoded, u.path, params) + + +_IMAGE_EXT = re.compile(r"\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$", re.I) +_PDF_EXT = re.compile(r"\.pdf$", re.I) +_VIDEO_EXT = re.compile(r"\.(mp4|webm|ogg|ogv|mov|m4v)$", re.I) +_AUDIO_EXT = re.compile(r"\.(mp3|wav|ogg|oga|m4a|flac|aac)$", re.I) + + +def inline_kind(url: str) -> str | None: + """Best inline renderer for a URL: 'iframe' | 'image' | 'pdf' | 'video' | + | 'audio'. Returns None when the URL should open in a new tab.""" + if not url: + return None + url = url.strip() + path = urlparse(url).path + if _IMAGE_EXT.search(path): + return "image" + if _PDF_EXT.search(path): + return "pdf" + if _VIDEO_EXT.search(path): + return "video" + if _AUDIO_EXT.search(path): + return "audio" + if url.startswith(("http://", "https://")): + return "iframe" + return None + + +def embed_html(src: str, *, height: int = 520) -> str: + """A responsive, borderless iframe for a provider embed URL.""" + return ( + f'' + ) \ No newline at end of file diff --git a/app/services/export.py b/app/services/export.py index 66bdd31..11526eb 100644 --- a/app/services/export.py +++ b/app/services/export.py @@ -464,6 +464,11 @@ def _page_markdown_source(page: dict) -> str: return "" +def markdown_to_blocks(md: str) -> list: + """Public wrapper around the GFM→blocks parser (used by page import).""" + return _md_to_blocks(md) + + # ═══════════════ Markdown ═══════════════ def blocks_to_markdown(blocks: list) -> str: @@ -508,6 +513,20 @@ def blocks_to_markdown(blocks: list) -> str: src = b.get("src") or "" alt = (b.get("alt") or "").strip() or "image" out.append(f"![{alt}]({src})") + elif t == "video": + out.append(f"[Video]({b.get('src') or ''})") + elif t == "audio": + out.append(f"[Audio]({b.get('src') or ''})") + elif t == "bookmark": + url = b.get("url") or b.get("src") or "" + title = (b.get("title") or "").strip() + out.append(f"[{title or url}]({url})" if title else url) + elif t == "embed": + url = b.get("src") or "" + if b.get("embed_type") in ("pdf", "download", None, ""): + out.append(f"[{url}]({url})" if url else "[embed]") + else: + out.append(f"[{url}]({url})" if url else "[embed]") elif t == "table": out.append(_table_to_markdown(b)) else: @@ -609,7 +628,54 @@ def blocks_to_html(blocks: list) -> str: elif t == "image": src = b.get("src") or "" alt = _text(b.get("alt")) - parts.append(f'
{alt}
{alt}
') + parts.append(f'
{alt}
{alt}
') + elif t == "video": + src = b.get("src") or "" + if src: + parts.append(f'') + elif t == "audio": + src = b.get("src") or "" + if src: + parts.append(f'') + elif t == "bookmark": + url = b.get("url") or b.get("src") or "" + title = _text(b.get("title")) or url + desc = _text(b.get("description")) + img = b.get("image") or "" + site = _text(b.get("site_name")) or "" + img_html = f'' if img else "" + desc_html = f'
{desc}
' if desc else "" + site_html = f'
{site}
' if site else "" + parts.append( + f'' + f'
' + f'
{title}
' + f'{desc_html}{site_html}
{img_html}
' + ) + elif t == "embed": + url = b.get("src") or "" + emb = (b.get("embed_type") or "") + if emb in ("inline_dbs", "collection"): + parts.append(f'
[Embedded content]
') + elif emb == "download": + parts.append(f'⬇ {_text(b.get("file_name") or "Download")}') + elif emb == "pdf" and url: + parts.append(f'') + elif url: + from app.services.embeds import embed_src + src = embed_src(url) or url + height = 520 + if b.get("height"): + try: + height = int(b["height"]) + except (ValueError, TypeError): + pass + parts.append( + f'
' + ) elif t == "table": parts.append(_table_to_html(b)) else: diff --git a/app/services/og_fetcher.py b/app/services/og_fetcher.py new file mode 100644 index 0000000..00a2df5 --- /dev/null +++ b/app/services/og_fetcher.py @@ -0,0 +1,124 @@ +"""FlowDeck — Bookmark cards (v5.5.0): Open Graph metadata via httpx. + +Fetches a URL server-side, extracts OG/Twitter meta tags (title, description, +image, site name, favicon) and returns a safe, compact payload used to render +Notion-style bookmark cards. Robust to missing tags, non-HTML bodies and +slow/unreachable hosts. +""" +from __future__ import annotations + +import logging +import re +from urllib.parse import urljoin, urlparse + +logger = logging.getLogger(__name__) + +_META_RE = re.compile( + r']*?(?:content=[\'"]([^\'"]*)[\'"]|property=[\'"]([^\'"]*)[\'"]|name=[\'"]([^\'"]*)[\'"]|content=[\'"]([^\'"]*)[\'"])[^>]*?>', + re.I, +) + +# Property/name keys we look for, in priority order, mapped to our payload keys. +_OG_TITLE = ("og:title", "twitter:title", "title", "og:site_name") +_OG_DESC = ("og:description", "twitter:description", "description") +_OG_IMG = ("og:image", "twitter:image", "twitter:image:src") +_OG_SITE = ("og:site_name", "twitter:site", "application-name") + + +def _extract_og(html: str) -> dict: + text = html[:400_000] # only scan the beginning — that's where lives + props: dict[str, str] = {} + for m in _META_RE.finditer(text): + groups = m.groups() + content = "" + prop = "" + for s in re.findall(r"(?:content|property|name)=[\"']([^\"']*)[\"']", m.group(0)): + if prop == "": + prop = s + else: + content = s + break + if prop: + props[prop.lower()] = content + return props + + +def _pick(props: dict, keys: tuple) -> str: + for k in keys: + v = props.get(k) + if v: + return v + return "" + + +def _title_of(props: dict, raw_title: str) -> str: + t = _pick(props, _OG_TITLE) + if t: + return t + m = re.search(r"]*>(.*?)", raw_title[:200_000], re.I | re.S) + return (m.group(1).strip() if m else "") or urlparse(props.get("_url", "")).netloc + + +def _site_name(url: str) -> str: + host = urlparse(url).netloc.replace("www.", "") + return host.split(".")[0].capitalize() if host else "" + + +async def fetch_og_metadata(url: str, timeout: float = 6.0) -> dict: + """Fetch ``url`` and return {url, title, description, image, site_name, + favicon}. Empty strings are omitted. Never raises for network errors.""" + src = url.strip() + if not src.startswith(("http://", "https://")): + src = "https://" + src + base = {"url": src, "title": "", "description": "", "image": "", "site_name": "", "favicon": ""} + try: + import httpx + headers = { + "User-Agent": "FlowDeck/5.10 bookmark-fetcher (+https://flowdeck.dracodev.net)", + "Accept": "text/html,application/xhtml+xml", + } + async with httpx.AsyncClient(follow_redirects=True, timeout=timeout) as client: + resp = await client.get(src, headers=headers) + resp.raise_for_status() + except Exception as exc: # noqa: BLE001 - network/parse failures are non-fatal + logger.debug("og fetch failed for %s: %s", src, exc) + base["title"] = urlparse(src).netloc or src + base["site_name"] = _site_name(src) + return base + + ctype = (resp.headers.get("content-type") or "").lower() + if "text/html" not in ctype and "xhtml" not in ctype: + base["title"] = urlparse(src).netloc or src + base["site_name"] = _site_name(src) + return base + + import html as htmlmod + + body = resp.text + props = _extract_og(body) + props["_url"] = src + title = htmlmod.unescape(_title_of(props, body)) + desc = htmlmod.unescape(_pick(props, _OG_DESC)) + img = _pick(props, _OG_IMG) + site = htmlmod.unescape(_pick(props, _OG_SITE)) or _site_name(src) + + def abs_url(u: str) -> str: + if not u: + return "" + return urljoin(src, htmlmod.unescape(u)) + + favicon = "" + fm = re.search(r"]+rel=[\"'](?:shortcut )?icon[\"'][^>]+href=[\"']([^\"']+)[\"']", body, re.I) + if not fm: + fm = re.search(r"]+href=[\"']([^\"']+)[\"'][^>]+rel=[\"'](?:shortcut )?icon[\"']", body, re.I) + if fm: + favicon = abs_url(fm.group(1)) + + return { + "url": src, + "title": title.strip()[:200] or urlparse(src).netloc or src, + "description": desc.strip()[:400], + "image": abs_url(img), + "site_name": site.strip()[:100], + "favicon": favicon, + } \ No newline at end of file diff --git a/app/services/trash.py b/app/services/trash.py new file mode 100644 index 0000000..87cb5f1 --- /dev/null +++ b/app/services/trash.py @@ -0,0 +1,87 @@ +"""FlowDeck — Global trash (v5.4.0): automatic 30-day purge. + +Pages soft-deleted via ``deleted_at`` stay in the trash for the retention +window (default 30 days, matching the UI copy in ``trash.html``), then are +permanently deleted together with their child pages and orphaned versions. +The purge is run from a background scheduler in ``app.main`` so it is fully +automatic (no per-request cost). +""" +from __future__ import annotations + +import logging +import re +from datetime import datetime, timedelta + +from app.db import get_conn + +logger = logging.getLogger(__name__) + +ISO_RE = re.compile(r"^\d{4}-\d{2}-\d{2}") + + +def _parse_dt(value: str) -> datetime | None: + """Parse the ISO-ish ``deleted_at`` timestamps (either 'YYYY-MM-DD...' or + SQLite 'YYYY-MM-DD HH:MM:SS'). Returns None when unparseable.""" + if not value: + return None + try: + s = value[:19].replace("T", " ") + dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S") + except ValueError: + try: + dt = datetime.fromisoformat(value) + except (ValueError, TypeError): + return None + if not ISO_RE.match(value): + return None + return dt + + +def purge_expired(days: int = 30) -> dict: + """Permanently delete pages whose ``deleted_at`` is older than ``days``. + + Children are re-parented to their grandparent before deletion in order + to avoid silently dropping whole sub-trees; deleted pages are removed + together with their ``page_versions`` (FK cascade). + + Returns a summary of what was purged. + """ + cutoff = datetime.utcnow() - timedelta(days=days) + purged: list[int] = [] + with get_conn() as conn: + rows = conn.execute( + "SELECT id, deleted_at FROM pages WHERE deleted_at IS NOT NULL" + ).fetchall() + for row in rows: + dt = _parse_dt(row["deleted_at"]) + if dt is None or dt >= cutoff: + continue + page_id = row["id"] + # Promote direct children to the deleted page's parent so no live + # sub-tree is orphaned (matches the permanent-delete semantics). + conn.execute( + "UPDATE pages SET parent_id=(SELECT parent_id FROM pages WHERE id=?) " + "WHERE parent_id=?", + (page_id, page_id), + ) + conn.execute("DELETE FROM pages WHERE id=?", (page_id,)) + purged.append(page_id) + conn.commit() + if purged: + logger.info("Trash purge: %d expired page(s) permanently deleted", len(purged)) + return {"purged": purged, "count": len(purged)} + + +async def trash_purge_scheduler(interval_hours: int = 24): + """Background loop purging the trash once a day (idempotent, cheap when + there is nothing expired).""" + import asyncio + + # Run once shortly after startup, then on the configured interval. + await asyncio.sleep(5) + while True: + try: + purge_expired(days=30) + except Exception as exc: # pragma: no cover - defensive only + logger.warning("Trash purge failed: %s", exc) + await asyncio.sleep(interval_hours * 3600) \ No newline at end of file diff --git a/app/templates/_page_editor_content.html b/app/templates/_page_editor_content.html index 600d2d3..acad499 100644 --- a/app/templates/_page_editor_content.html +++ b/app/templates/_page_editor_content.html @@ -1,4 +1,8 @@ -
+
@@ -250,6 +254,46 @@
+ +
+
{{ fd_icon('image',14) }} Cover & Icon
+
+ + +
+
+
+ + + + + +
+
+ +
+
+
+
+ + +
+
+
+ +
+