Add a Prompts library alongside Links, bump to v2.0.0
New /prompts section extends the existing database (new Prompt model,
same SQLite file) and mirrors the Links experience: add a prompt, get
it auto-categorized and tagged by AI, filter by category, full-text
search, sort, edit, or have the AI re-categorize it. A header nav
switch ("Links" / "Prompts") toggles between the two collections; the
shared topbar/menu markup was factored into _topbar.html and
_topbar_actions.html so both pages (and settings) stay in sync.
Each prompt card has a dedicated copy-to-clipboard icon next to
edit/delete, so a stored prompt can be reused immediately. Copying
uses the raw markdown source (not the rendered HTML) so structure
survives when pasted into another AI tool. The copy handler tries the
async Clipboard API first and falls back to a hidden-textarea +
execCommand('copy') for plain-http/non-secure contexts, with clear
success/failure icon feedback either way.
Prompt content supports Markdown and is rendered server-side
(app/mdrender.py) for the card preview. Since this is user-supplied
HTML-adjacent content, rendering goes through two defenses: the raw
text is escaped (only '<' and '&', not '>', so blockquotes keep
working) before conversion so no raw tag can survive, and the
resulting HTML is passed through bleach with a tag/attribute/protocol
allowlist so Markdown-generated links can't carry a javascript: URL.
Verified against raw <script>, <img onerror>, and javascript: link
payloads. The edit form always shows the raw Markdown source, never
the rendered HTML.
Also bumps the default APP_VERSION (shown in the footer) from 1.0.0
to 2.0.0 to mark this feature addition.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
276
app/main.py
276
app/main.py
@@ -6,13 +6,14 @@ from urllib.parse import quote, urlparse
|
||||
from fastapi import Depends, FastAPI, File, Form, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from markupsafe import Markup
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from . import ai, backup, config, scraper, search
|
||||
from . import ai, backup, config, mdrender, scraper, search
|
||||
from .database import Base, engine, get_db
|
||||
from .models import Link, User, utcnow
|
||||
from .models import Link, Prompt, User, utcnow
|
||||
from .security import hash_password, verify_password
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -53,14 +54,24 @@ def domain_of(url: str) -> str:
|
||||
return host[4:] if host.startswith("www.") else host
|
||||
|
||||
|
||||
def _hash_hue(value: str) -> int:
|
||||
return sum(ord(c) for c in (value or "")) % 360
|
||||
|
||||
|
||||
def avatar_hue(url: str) -> int:
|
||||
"""Stabile Farbe pro Domain, damit Karten wiedererkennbar sind."""
|
||||
return sum(ord(c) for c in domain_of(url)) % 360
|
||||
"""Stabile Farbe pro Domain, damit Link-Karten wiedererkennbar sind."""
|
||||
return _hash_hue(domain_of(url))
|
||||
|
||||
|
||||
def render_markdown(text: str) -> Markup:
|
||||
return Markup(mdrender.render(text))
|
||||
|
||||
|
||||
templates.env.filters["datum"] = format_datum
|
||||
templates.env.filters["domain"] = domain_of
|
||||
templates.env.filters["hue"] = avatar_hue
|
||||
templates.env.filters["texthue"] = _hash_hue
|
||||
templates.env.filters["markdown"] = render_markdown
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -170,6 +181,51 @@ def query_links(
|
||||
return sort_links(matches, sort)
|
||||
|
||||
|
||||
def prompt_facets(db: Session, user: User) -> dict:
|
||||
cat_rows = db.execute(
|
||||
select(Prompt.category, func.count())
|
||||
.where(Prompt.user_id == user.id, Prompt.category != "")
|
||||
.group_by(Prompt.category)
|
||||
.order_by(func.count().desc())
|
||||
).all()
|
||||
return {"categories": [(name, count) for name, count in cat_rows]}
|
||||
|
||||
|
||||
def sort_prompts(prompts: list[Prompt], sort: str) -> list[Prompt]:
|
||||
if sort == "date_asc":
|
||||
return sorted(prompts, key=lambda p: p.created_at)
|
||||
if sort == "name_asc":
|
||||
return sorted(prompts, key=lambda p: (p.title or "").lower())
|
||||
if sort == "name_desc":
|
||||
return sorted(prompts, key=lambda p: (p.title or "").lower(), reverse=True)
|
||||
return sorted(prompts, key=lambda p: p.created_at, reverse=True)
|
||||
|
||||
|
||||
def query_prompts(
|
||||
db: Session,
|
||||
user: User,
|
||||
q: str = "",
|
||||
category: str = "",
|
||||
sort: str = DEFAULT_SORT,
|
||||
) -> list[Prompt]:
|
||||
stmt = select(Prompt).where(Prompt.user_id == user.id)
|
||||
if category:
|
||||
stmt = stmt.where(Prompt.category == category)
|
||||
prompts = list(db.scalars(stmt.order_by(Prompt.created_at.desc())).all())
|
||||
|
||||
q = (q or "").strip()
|
||||
if not q:
|
||||
return sort_prompts(prompts, sort)
|
||||
|
||||
ql = q.lower()
|
||||
matches = [
|
||||
p
|
||||
for p in prompts
|
||||
if ql in " ".join([p.title, p.content, p.tags, p.category]).lower()
|
||||
]
|
||||
return sort_prompts(matches, sort)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authentifizierung
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -531,6 +587,217 @@ def reanalyze_link(
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompts
|
||||
# ---------------------------------------------------------------------------
|
||||
@app.get("/prompts", response_class=HTMLResponse)
|
||||
def prompts_index(
|
||||
request: Request,
|
||||
q: str = "",
|
||||
category: str = "",
|
||||
sort: str = DEFAULT_SORT,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = current_user(request, db)
|
||||
if not user:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
if sort not in SORT_OPTIONS:
|
||||
sort = DEFAULT_SORT
|
||||
prompts = query_prompts(db, user, q, category, sort)
|
||||
return templates.TemplateResponse(
|
||||
"prompts.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user,
|
||||
"prompts": prompts,
|
||||
"facets": prompt_facets(db, user),
|
||||
"q": q,
|
||||
"active_category": category,
|
||||
"ai_enabled": config.AI_ENABLED,
|
||||
"sort": sort,
|
||||
"sort_options": SORT_OPTIONS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/prompts/search", response_class=HTMLResponse)
|
||||
def search_prompts(
|
||||
request: Request,
|
||||
q: str = "",
|
||||
category: str = "",
|
||||
sort: str = DEFAULT_SORT,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = current_user(request, db)
|
||||
if not user:
|
||||
return Response(status_code=401, headers={"HX-Redirect": "/login"})
|
||||
|
||||
if sort not in SORT_OPTIONS:
|
||||
sort = DEFAULT_SORT
|
||||
prompts = query_prompts(db, user, q, category, sort)
|
||||
return templates.TemplateResponse(
|
||||
"_prompts_list.html", {"request": request, "prompts": prompts}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/prompts", response_class=HTMLResponse)
|
||||
def add_prompt(
|
||||
request: Request,
|
||||
content: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = current_user(request, db)
|
||||
if not user:
|
||||
return Response(status_code=401, headers={"HX-Redirect": "/login"})
|
||||
|
||||
content = content.strip()
|
||||
if not content:
|
||||
return Response(status_code=400)
|
||||
|
||||
existing = prompt_facets(db, user)
|
||||
meta = ai.categorize_prompt(content, [name for name, _ in existing["categories"]])
|
||||
|
||||
prompt = Prompt(
|
||||
user_id=user.id,
|
||||
title=meta["title"],
|
||||
content=content,
|
||||
category=meta["category"],
|
||||
tags=json.dumps(meta["tags"], ensure_ascii=False),
|
||||
)
|
||||
db.add(prompt)
|
||||
db.commit()
|
||||
db.refresh(prompt)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"_prompt_card.html", {"request": request, "prompt": prompt}
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/prompts/{prompt_id}", response_class=HTMLResponse)
|
||||
def delete_prompt(
|
||||
prompt_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"})
|
||||
prompt = db.get(Prompt, prompt_id)
|
||||
if prompt and prompt.user_id == user.id:
|
||||
db.delete(prompt)
|
||||
db.commit()
|
||||
return Response(status_code=200)
|
||||
|
||||
|
||||
def _get_owned_prompt(db: Session, user: User, prompt_id: int) -> Prompt | None:
|
||||
prompt = db.get(Prompt, prompt_id)
|
||||
if prompt and prompt.user_id == user.id:
|
||||
return prompt
|
||||
return None
|
||||
|
||||
|
||||
def _prompt_edit_context(request: Request, db: Session, user: User, prompt: Prompt) -> dict:
|
||||
existing = prompt_facets(db, user)
|
||||
return {
|
||||
"request": request,
|
||||
"prompt": prompt,
|
||||
"ai_enabled": config.AI_ENABLED,
|
||||
"all_categories": [name for name, _ in existing["categories"]],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/prompts/{prompt_id}/edit", response_class=HTMLResponse)
|
||||
def edit_prompt_form(
|
||||
prompt_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"})
|
||||
prompt = _get_owned_prompt(db, user, prompt_id)
|
||||
if not prompt:
|
||||
return Response(status_code=404)
|
||||
return templates.TemplateResponse(
|
||||
"_prompt_card_edit.html", _prompt_edit_context(request, db, user, prompt)
|
||||
)
|
||||
|
||||
|
||||
@app.get("/prompts/{prompt_id}/view", response_class=HTMLResponse)
|
||||
def view_prompt_card(
|
||||
prompt_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"})
|
||||
prompt = _get_owned_prompt(db, user, prompt_id)
|
||||
if not prompt:
|
||||
return Response(status_code=404)
|
||||
return templates.TemplateResponse(
|
||||
"_prompt_card.html", {"request": request, "prompt": prompt}
|
||||
)
|
||||
|
||||
|
||||
@app.put("/prompts/{prompt_id}", response_class=HTMLResponse)
|
||||
def update_prompt(
|
||||
prompt_id: int,
|
||||
request: Request,
|
||||
title: str = Form(""),
|
||||
content: str = Form(""),
|
||||
category: 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"})
|
||||
prompt = _get_owned_prompt(db, user, prompt_id)
|
||||
if not prompt:
|
||||
return Response(status_code=404)
|
||||
|
||||
prompt.title = title.strip()[:120] or "Prompt"
|
||||
prompt.content = content.strip()
|
||||
prompt.category = category.strip()[:120]
|
||||
prompt.tags = json.dumps(_parse_tags(tags), ensure_ascii=False)
|
||||
|
||||
db.commit()
|
||||
db.refresh(prompt)
|
||||
return templates.TemplateResponse(
|
||||
"_prompt_card.html", {"request": request, "prompt": prompt}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/prompts/{prompt_id}/reanalyze", response_class=HTMLResponse)
|
||||
def reanalyze_prompt(
|
||||
prompt_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"})
|
||||
prompt = _get_owned_prompt(db, user, prompt_id)
|
||||
if not prompt:
|
||||
return Response(status_code=404)
|
||||
|
||||
existing = prompt_facets(db, user)
|
||||
meta = ai.categorize_prompt(
|
||||
prompt.content, [name for name, _ in existing["categories"]]
|
||||
)
|
||||
prompt.title = meta["title"]
|
||||
prompt.category = meta["category"]
|
||||
prompt.tags = json.dumps(meta["tags"], ensure_ascii=False)
|
||||
|
||||
db.commit()
|
||||
db.refresh(prompt)
|
||||
return templates.TemplateResponse(
|
||||
"_prompt_card_edit.html", _prompt_edit_context(request, db, user, prompt)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Einstellungen: Export / Import
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -551,6 +818,7 @@ def settings_page(request: Request, db: Session = Depends(get_db)):
|
||||
"request": request,
|
||||
"user": user,
|
||||
"link_count": link_count or 0,
|
||||
"ai_enabled": config.AI_ENABLED,
|
||||
"imported": request.query_params.get("imported"),
|
||||
"import_error": request.query_params.get("import_error"),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user