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 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
132
app/main.py
132
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},
|
||||
)
|
||||
|
||||
@@ -7,12 +7,18 @@
|
||||
{{ link.url }}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:4px; flex-shrink:0;">
|
||||
<button class="ghost" style="padding:4px 10px; font-size:.8rem;"
|
||||
hx-get="/links/{{ link.id }}/edit"
|
||||
hx-target="#link-{{ link.id }}"
|
||||
hx-swap="outerHTML">✎ Bearbeiten</button>
|
||||
<button class="danger"
|
||||
hx-delete="/links/{{ link.id }}"
|
||||
hx-target="#link-{{ link.id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Diesen Link wirklich löschen?">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if link.summary %}
|
||||
<p style="margin:10px 0 8px;">{{ link.summary }}</p>
|
||||
|
||||
54
app/templates/_link_card_edit.html
Normal file
54
app/templates/_link_card_edit.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<div class="panel link-card" id="link-{{ link.id }}">
|
||||
<form hx-put="/links/{{ link.id }}" hx-target="#link-{{ link.id }}" hx-swap="outerHTML"
|
||||
hx-disabled-elt="button">
|
||||
<div class="muted" style="font-size:.8rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; margin-bottom:8px;">
|
||||
{{ link.url }}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Titel</label>
|
||||
<input type="text" name="title" value="{{ link.title }}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Zusammenfassung</label>
|
||||
<textarea name="summary" rows="3" style="width:100%; background:var(--panel2); border:1px solid var(--border); color:var(--text); border-radius:8px; padding:10px 12px; font-size:.95rem; font-family:inherit;">{{ link.summary }}</textarea>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:12px;">
|
||||
<div class="field" style="flex:1;">
|
||||
<label>Kategorie</label>
|
||||
<input type="text" name="category" value="{{ link.category }}">
|
||||
</div>
|
||||
<div class="field" style="flex:1;">
|
||||
<label>Hersteller / Quelle</label>
|
||||
<input type="text" name="manufacturer" value="{{ link.manufacturer }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Tags (mit Komma getrennt)</label>
|
||||
<input type="text" name="tags" value="{{ link.tag_list | join(', ') }}">
|
||||
</div>
|
||||
|
||||
<div style="display:flex; justify-content:space-between; gap:8px; margin-top:4px;">
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button type="submit">Speichern</button>
|
||||
<button type="button" class="ghost"
|
||||
hx-get="/links/{{ link.id }}/view"
|
||||
hx-target="#link-{{ link.id }}"
|
||||
hx-swap="outerHTML">Abbrechen</button>
|
||||
</div>
|
||||
{% if ai_enabled %}
|
||||
<button type="button" class="ghost"
|
||||
hx-post="/links/{{ link.id }}/reanalyze"
|
||||
hx-target="#link-{{ link.id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-disabled-elt="this"
|
||||
hx-confirm="Link erneut laden und von der KI neu beschreiben lassen? Bestehende Angaben werden dabei ersetzt.">
|
||||
<span class="htmx-indicator">⏳</span> 🤖 KI neu beschreiben lassen
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -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);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}{% endblock %}
|
||||
<footer class="app-footer">
|
||||
© {{ app_author }} · Version {{ app_version }} · {{ app_host }}
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user