Initial Daybook MVP
This commit is contained in:
commit
3974276c9b
25 changed files with 1647 additions and 0 deletions
0
artifacts/__init__.py
Normal file
0
artifacts/__init__.py
Normal file
10
artifacts/admin.py
Normal file
10
artifacts/admin.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from .models import Artifact
|
||||
|
||||
|
||||
@admin.register(Artifact)
|
||||
class ArtifactAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "title", "persona", "kind", "day", "created_at")
|
||||
list_filter = ("persona", "kind", "day")
|
||||
search_fields = ("id", "title", "persona")
|
||||
6
artifacts/apps.py
Normal file
6
artifacts/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ArtifactsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "artifacts"
|
||||
27
artifacts/migrations/0001_initial.py
Normal file
27
artifacts/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Artifact",
|
||||
fields=[
|
||||
("id", models.CharField(max_length=8, primary_key=True, serialize=False)),
|
||||
("persona", models.CharField(db_index=True, max_length=80)),
|
||||
("title", models.CharField(max_length=200)),
|
||||
("kind", models.CharField(choices=[("html_fragment", "HTML fragment"), ("html_full", "Full HTML"), ("markdown", "Markdown"), ("deck", "Deck"), ("image", "Image"), ("pdf", "PDF"), ("binary", "Binary")], max_length=20)),
|
||||
("theme", models.CharField(blank=True, max_length=40)),
|
||||
("day", models.DateField(db_index=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("path", models.CharField(max_length=500)),
|
||||
("original_kind", models.CharField(max_length=20)),
|
||||
("tags", models.JSONField(blank=True, default=list)),
|
||||
("source_prompt", models.TextField(blank=True)),
|
||||
],
|
||||
options={"ordering": ["-created_at"]},
|
||||
),
|
||||
]
|
||||
0
artifacts/migrations/__init__.py
Normal file
0
artifacts/migrations/__init__.py
Normal file
39
artifacts/models.py
Normal file
39
artifacts/models.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from django.db import models
|
||||
|
||||
|
||||
class Artifact(models.Model):
|
||||
HTML_FRAGMENT = "html_fragment"
|
||||
HTML_FULL = "html_full"
|
||||
MARKDOWN = "markdown"
|
||||
DECK = "deck"
|
||||
IMAGE = "image"
|
||||
PDF = "pdf"
|
||||
BINARY = "binary"
|
||||
|
||||
KIND_CHOICES = [
|
||||
(HTML_FRAGMENT, "HTML fragment"),
|
||||
(HTML_FULL, "Full HTML"),
|
||||
(MARKDOWN, "Markdown"),
|
||||
(DECK, "Deck"),
|
||||
(IMAGE, "Image"),
|
||||
(PDF, "PDF"),
|
||||
(BINARY, "Binary"),
|
||||
]
|
||||
|
||||
id = models.CharField(max_length=8, primary_key=True)
|
||||
persona = models.CharField(max_length=80, db_index=True)
|
||||
title = models.CharField(max_length=200)
|
||||
kind = models.CharField(max_length=20, choices=KIND_CHOICES)
|
||||
theme = models.CharField(max_length=40, blank=True)
|
||||
day = models.DateField(db_index=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
path = models.CharField(max_length=500)
|
||||
original_kind = models.CharField(max_length=20)
|
||||
tags = models.JSONField(default=list, blank=True)
|
||||
source_prompt = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} ({self.persona})"
|
||||
10
artifacts/urls.py
Normal file
10
artifacts/urls.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.feed, name="feed"),
|
||||
path("a/<str:artifact_id>", views.artifact_view, name="artifact"),
|
||||
path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"),
|
||||
]
|
||||
343
artifacts/views.py
Normal file
343
artifacts/views.py
Normal 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>
|
||||
"""
|
||||
Loading…
Add table
Add a link
Reference in a new issue