From a0db3e514741ebecf892cfca7e9e90be6bb80cd6 Mon Sep 17 00:00:00 2001 From: Erik Thiele Date: Sun, 19 Jul 2026 21:54:01 +0200 Subject: [PATCH] Add link editing, AI re-description, and footer Links can now be edited manually (title, summary, category, manufacturer, tags) via an inline edit form, with a "KI neu beschreiben lassen" action that re-fetches the URL and lets the AI regenerate all fields. Editing recomputes the search embedding. Also adds an app footer showing author, version, and hostname. Co-Authored-By: Claude Sonnet 5 --- app/config.py | 5 ++ app/main.py | 132 +++++++++++++++++++++++++++++ app/templates/_link_card.html | 16 ++-- app/templates/_link_card_edit.html | 54 ++++++++++++ app/templates/base.html | 7 ++ 5 files changed, 209 insertions(+), 5 deletions(-) create mode 100644 app/templates/_link_card_edit.html diff --git a/app/config.py b/app/config.py index e99a003..f847091 100644 --- a/app/config.py +++ b/app/config.py @@ -1,9 +1,14 @@ import os +import socket from dotenv import load_dotenv load_dotenv() +APP_AUTHOR = "Erik Thiele" +APP_VERSION = os.getenv("APP_VERSION", "1.0.0") +APP_HOST = socket.gethostname() + SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-secret-change-me") OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "").strip() OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini") diff --git a/app/main.py b/app/main.py index 4c5051f..43ca1c5 100644 --- a/app/main.py +++ b/app/main.py @@ -19,6 +19,11 @@ app = FastAPI(title="LinkVault") app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, max_age=60 * 60 * 24 * 14) templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates")) +templates.env.globals.update( + app_author=config.APP_AUTHOR, + app_version=config.APP_VERSION, + app_host=config.APP_HOST, +) # --------------------------------------------------------------------------- @@ -281,3 +286,130 @@ def delete_link( db.delete(link) db.commit() return Response(status_code=200) + + +def _get_owned_link(db: Session, user: User, link_id: int) -> Link | None: + link = db.get(Link, link_id) + if link and link.user_id == user.id: + return link + return None + + +def _parse_tags(raw: str) -> list[str]: + return [t.strip() for t in raw.split(",") if t.strip()][:8] + + +@app.get("/links/{link_id}/edit", response_class=HTMLResponse) +def edit_link_form( + link_id: int, + request: Request, + db: Session = Depends(get_db), +): + user = current_user(request, db) + if not user: + return Response(status_code=401, headers={"HX-Redirect": "/login"}) + link = _get_owned_link(db, user, link_id) + if not link: + return Response(status_code=404) + return templates.TemplateResponse( + "_link_card_edit.html", + {"request": request, "link": link, "ai_enabled": config.AI_ENABLED}, + ) + + +@app.get("/links/{link_id}/view", response_class=HTMLResponse) +def view_link_card( + link_id: int, + request: Request, + db: Session = Depends(get_db), +): + user = current_user(request, db) + if not user: + return Response(status_code=401, headers={"HX-Redirect": "/login"}) + link = _get_owned_link(db, user, link_id) + if not link: + return Response(status_code=404) + return templates.TemplateResponse( + "_link_card.html", {"request": request, "link": link} + ) + + +@app.put("/links/{link_id}", response_class=HTMLResponse) +def update_link( + link_id: int, + request: Request, + title: str = Form(""), + summary: str = Form(""), + category: str = Form(""), + manufacturer: str = Form(""), + tags: str = Form(""), + db: Session = Depends(get_db), +): + user = current_user(request, db) + if not user: + return Response(status_code=401, headers={"HX-Redirect": "/login"}) + link = _get_owned_link(db, user, link_id) + if not link: + return Response(status_code=404) + + link.title = title.strip()[:300] or link.url + link.summary = summary.strip()[:1000] + link.category = category.strip()[:120] + link.manufacturer = manufacturer.strip()[:120] + link.tags = json.dumps(_parse_tags(tags), ensure_ascii=False) + + embed_source = " ".join([link.title, link.summary, tags]) + embedding = ai.embed(embed_source) + if embedding: + link.embedding = json.dumps(embedding) + + db.commit() + db.refresh(link) + return templates.TemplateResponse( + "_link_card.html", {"request": request, "link": link} + ) + + +@app.post("/links/{link_id}/reanalyze", response_class=HTMLResponse) +def reanalyze_link( + link_id: int, + request: Request, + db: Session = Depends(get_db), +): + user = current_user(request, db) + if not user: + return Response(status_code=401, headers={"HX-Redirect": "/login"}) + link = _get_owned_link(db, user, link_id) + if not link: + return Response(status_code=404) + + title, text = scraper.fetch(link.url) + existing = facets(db, user) + meta = ai.categorize( + link.url, + title, + text, + [name for name, _ in existing["categories"] if name != link.category], + [name for name, _ in existing["manufacturers"] if name != link.manufacturer], + ) + + embed_source = " ".join( + [meta["title"], meta["summary"], " ".join(meta["tags"]), text[:1000]] + ) + embedding = ai.embed(embed_source) + + link.title = meta["title"] + link.summary = meta["summary"] + link.category = meta["category"] + link.manufacturer = meta["manufacturer"] + link.tags = json.dumps(meta["tags"], ensure_ascii=False) + if embedding: + link.embedding = json.dumps(embedding) + link.status = "ok" if text else "no_content" + + db.commit() + db.refresh(link) + return templates.TemplateResponse( + "_link_card_edit.html", + {"request": request, "link": link, "ai_enabled": config.AI_ENABLED}, + ) diff --git a/app/templates/_link_card.html b/app/templates/_link_card.html index 3e1d860..9848a40 100644 --- a/app/templates/_link_card.html +++ b/app/templates/_link_card.html @@ -7,11 +7,17 @@ {{ link.url }} - +
+ + +
{% if link.summary %} diff --git a/app/templates/_link_card_edit.html b/app/templates/_link_card_edit.html new file mode 100644 index 0000000..49d3720 --- /dev/null +++ b/app/templates/_link_card_edit.html @@ -0,0 +1,54 @@ + diff --git a/app/templates/base.html b/app/templates/base.html index 5bc2a4d..8332275 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -59,9 +59,16 @@ .htmx-indicator { opacity: 0; transition: opacity .2s; } .htmx-request .htmx-indicator { opacity: 1; } .htmx-request.htmx-indicator { opacity: 1; } + footer.app-footer { + text-align: center; padding: 16px 24px; margin-top: 24px; + color: var(--muted); font-size: .8rem; border-top: 1px solid var(--border); + } {% block body %}{% endblock %} +