Initial Daybook MVP

This commit is contained in:
peter 2026-06-10 15:48:19 +01:00
commit 3974276c9b
25 changed files with 1647 additions and 0 deletions

343
artifacts/views.py Normal file
View file

@ -0,0 +1,343 @@
import html
import mimetypes
import secrets
from pathlib import Path
from urllib.parse import urlencode
import markdown
from django.conf import settings
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404, render
from django.template.defaultfilters import slugify
from django.urls import reverse
from django.utils.dateparse import parse_date
from django.utils import timezone
from django.views.decorators.clickjacking import xframe_options_sameorigin
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import Artifact
TEXT_KINDS = {Artifact.HTML_FRAGMENT, Artifact.HTML_FULL, Artifact.MARKDOWN, Artifact.DECK}
DEFAULT_THEMES = {
Artifact.HTML_FRAGMENT: "report",
Artifact.MARKDOWN: "report",
Artifact.DECK: "deck",
}
class ArtifactListCreateView(APIView):
parser_classes = [JSONParser, FormParser, MultiPartParser]
def get(self, request):
artifacts = Artifact.objects.all()
if persona := request.query_params.get("persona"):
artifacts = artifacts.filter(persona=persona)
if day := request.query_params.get("day"):
artifacts = artifacts.filter(day=day)
if tag := request.query_params.get("tag"):
artifacts = artifacts.filter(tags__contains=[tag])
return Response([serialize_artifact(artifact, request) for artifact in artifacts])
def post(self, request):
if not has_valid_token(request):
return HttpResponseForbidden("Invalid or missing API token")
if request.content_type and request.content_type.startswith("multipart/"):
artifact = create_binary_artifact(request.data)
if isinstance(artifact, Response):
return artifact
return Response({"id": artifact.id, "url": reverse("artifact", args=[artifact.id])}, status=201)
data = request.data
kind = data.get("kind")
if kind not in TEXT_KINDS:
return Response({"detail": "kind must be html_fragment, html_full, markdown, or deck"}, status=400)
required = ["persona", "title", "content"]
missing = [field for field in required if not data.get(field)]
if missing:
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400)
artifact = create_text_artifact(data, request)
return Response({"id": artifact.id, "url": reverse("artifact", args=[artifact.id])}, status=201)
def feed(request):
persona = request.GET.get("persona", "")
selected_kind = request.GET.get("kind", "")
title_query = request.GET.get("q", "")
selected_day = parse_date(request.GET.get("day", "")) or timezone.localdate()
selected_artifact_id = request.GET.get("artifact", "")
selected_view = "raw" if request.GET.get("view") == "raw" else "rendered"
all_artifacts = Artifact.objects.all()
artifacts = all_artifacts
if persona:
artifacts = artifacts.filter(persona=persona)
if selected_kind:
artifacts = artifacts.filter(kind=selected_kind)
if title_query:
artifacts = artifacts.filter(title__icontains=title_query)
days = list(artifacts.dates("day", "day", order="DESC"))
if selected_day not in days:
days.append(selected_day)
days.sort(reverse=True)
day_artifacts = artifacts.filter(day=selected_day).order_by("-created_at")
selected_artifact = day_artifacts.filter(pk=selected_artifact_id).first()
toggle_query = {"day": selected_day.isoformat(), "view": "rendered"}
if persona:
toggle_query["persona"] = persona
if selected_kind:
toggle_query["kind"] = selected_kind
if title_query:
toggle_query["q"] = title_query
if selected_artifact:
toggle_query["artifact"] = selected_artifact.id
rendered_url = f"?{urlencode(toggle_query)}"
toggle_query["view"] = "raw"
raw_url = f"?{urlencode(toggle_query)}"
personas = all_artifacts.order_by("persona").values_list("persona", flat=True).distinct()
return render(
request,
"artifacts/feed.html",
{
"artifacts": day_artifacts,
"days": days,
"personas": personas,
"raw_url": raw_url,
"rendered_url": rendered_url,
"selected_artifact": selected_artifact,
"selected_day": selected_day,
"selected_kind": selected_kind,
"selected_persona": persona,
"selected_view": selected_view,
"title_query": title_query,
"kind_choices": Artifact.KIND_CHOICES,
},
)
@xframe_options_sameorigin
def artifact_view(request, artifact_id):
artifact = get_object_or_404(Artifact, pk=artifact_id)
if request.GET.get("raw") == "1":
raw_path = raw_artifact_path(artifact)
if raw_path and raw_path.exists():
return FileResponse(raw_path.open("rb"), content_type="text/plain; charset=utf-8")
return HttpResponse(raw_unavailable_html(artifact), content_type="text/html")
path = safe_artifact_path(artifact.path)
if not path.exists():
raise Http404("Artifact file not found")
content_type, _ = mimetypes.guess_type(path.name)
return FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream")
def create_text_artifact(data, request):
now = timezone.localtime()
artifact_id = generate_id()
persona = slugify(data["persona"]) or "unknown"
kind = data["kind"]
theme = data.get("theme") or DEFAULT_THEMES.get(kind, "base")
title = data["title"]
content = data["content"]
directory = Path(persona) / now.date().isoformat()
target_dir = settings.ARTIFACTS_ROOT / directory
target_dir.mkdir(parents=True, exist_ok=True)
if kind == Artifact.HTML_FULL:
rendered = content
elif kind == Artifact.MARKDOWN:
rendered = wrap_html(markdown.markdown(content, extensions=["extra", "sane_lists"]), title, theme, request)
(target_dir / f"{artifact_id}.md").write_text(content, encoding="utf-8")
elif kind == Artifact.DECK:
rendered = wrap_deck(content, title, request)
else:
rendered = wrap_html(content, title, theme, request)
stored_path = directory / f"{artifact_id}.html"
(settings.ARTIFACTS_ROOT / stored_path).write_text(rendered, encoding="utf-8")
return Artifact.objects.create(
id=artifact_id,
persona=persona,
title=title,
kind=kind,
theme=theme,
day=now.date(),
path=stored_path.as_posix(),
original_kind=kind,
tags=normalize_tags(data.get("tags", [])),
source_prompt=data.get("source_prompt", ""),
)
def create_binary_artifact(data):
uploaded = data.get("file")
required = ["persona", "title"]
missing = [field for field in required if not data.get(field)]
if not uploaded:
missing.append("file")
if missing:
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400)
now = timezone.localtime()
artifact_id = generate_id()
persona = slugify(data["persona"]) or "unknown"
title = data["title"]
content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or ""
kind = binary_kind(content_type)
directory = Path(persona) / now.date().isoformat()
target_dir = settings.ARTIFACTS_ROOT / directory
target_dir.mkdir(parents=True, exist_ok=True)
suffix = safe_suffix(uploaded.name, content_type)
stored_path = directory / f"{artifact_id}{suffix}"
with (settings.ARTIFACTS_ROOT / stored_path).open("wb") as target:
for chunk in uploaded.chunks():
target.write(chunk)
return Artifact.objects.create(
id=artifact_id,
persona=persona,
title=title,
kind=kind,
theme="",
day=now.date(),
path=stored_path.as_posix(),
original_kind=kind,
tags=normalize_tags(data.get("tags", [])),
source_prompt=data.get("source_prompt", ""),
)
def wrap_html(body, title, theme, request):
stylesheet = request.build_absolute_uri(f"/static/themes/{theme}.css")
return f"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}</title>
<link rel="stylesheet" href="{stylesheet}">
</head>
<body class="artifact">
{body}
</body>
</html>
"""
def wrap_deck(body, title, request):
stylesheet = request.build_absolute_uri("/static/themes/deck.css")
return f"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}</title>
<link rel="stylesheet" href="{stylesheet}">
</head>
<body class="deck">
<main class="slides">{body}</main>
</body>
</html>
"""
def generate_id():
while True:
artifact_id = secrets.token_hex(4)
if not Artifact.objects.filter(pk=artifact_id).exists():
return artifact_id
def has_valid_token(request):
expected = getattr(settings, "DAYBOOK_API_TOKEN", "")
auth = request.headers.get("Authorization", "")
return bool(expected) and auth == f"Bearer {expected}"
def normalize_tags(tags):
if isinstance(tags, str):
return [tag.strip() for tag in tags.split(",") if tag.strip()]
if isinstance(tags, list):
return [str(tag).strip() for tag in tags if str(tag).strip()]
return []
def binary_kind(content_type):
if content_type.startswith("image/"):
return Artifact.IMAGE
if content_type == "application/pdf":
return Artifact.PDF
return Artifact.BINARY
def safe_suffix(filename, content_type):
suffix = Path(filename).suffix.lower()
if suffix and suffix[1:].isalnum() and len(suffix) <= 10:
return suffix
return mimetypes.guess_extension(content_type) or ".bin"
def serialize_artifact(artifact, request):
return {
"id": artifact.id,
"url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])),
"persona": artifact.persona,
"title": artifact.title,
"kind": artifact.kind,
"theme": artifact.theme,
"day": artifact.day.isoformat(),
"created_at": artifact.created_at.isoformat(),
"tags": artifact.tags,
"source_prompt": artifact.source_prompt,
}
def safe_artifact_path(relative_path):
root = settings.ARTIFACTS_ROOT.resolve()
path = (root / relative_path).resolve()
if root not in path.parents and path != root:
raise Http404("Invalid artifact path")
return path
def raw_artifact_path(artifact):
path = safe_artifact_path(artifact.path)
if artifact.kind == Artifact.MARKDOWN:
return path.with_suffix(".md")
if artifact.kind == Artifact.HTML_FULL:
return path
return None
def raw_unavailable_html(artifact):
return f"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Raw source unavailable</title>
<style>
body {{ background: #f4f1ea; color: #1d2528; font-family: system-ui, sans-serif; margin: 0; padding: 2rem; }}
main {{ background: #fffaf0; border-radius: 16px; max-width: 42rem; padding: 1.4rem; }}
</style>
</head>
<body>
<main>
<h1>Raw source unavailable</h1>
<p>Daybook does not have preserved raw source for <strong>{html.escape(artifact.title)}</strong>.</p>
</main>
</body>
</html>
"""