Add json and csv artifact kinds with interactive viewers

Gap 1 from planning/2026-09-02-artifact_kind_gaps_for_frequent_agent_output.md.
Chose new first-class kinds over riding on html_fragment, resolving that
doc's open design question in favor of filtering support, raw-source
preservation (like markdown's .md sidecar), and server-owned validation.

- json: validated with json.loads at post time (400 on invalid JSON),
  rendered with renderjson (collapsible tree), vendored locally.
- csv: parsed server-side with the stdlib csv module into columns/rows and
  handed to Grid.js as plain data - no client-side CSV-parsing plugin
  needed, keeping normalization server-owned. Vendored locally (MIT).
- Both preserve their original text as a sidecar file, generalizing what
  was previously markdown-only handling (storage.py's SIDECAR_SUFFIXES) so
  disk-size accounting, eviction cleanup, and ?raw=1 all cover json/csv too.
- Raw JSON is embedded in a <script type="application/json"> block with
  `</script` occurrences escaped (safe because JSON's \/ escape round-trips
  correctly) - verified with a live </script>-injection payload against the
  actual running server, not just unit tests.

New migration (0012) for the KIND_CHOICES change - SQLite doesn't enforce
choices at the schema level, but Django's migration state tracks it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
peter 2026-09-02 14:56:20 +01:00
parent af9ecf28a9
commit e5d2f192df
12 changed files with 476 additions and 18 deletions

View file

@ -1,5 +1,7 @@
import csv
import hashlib
import html
import io
import json
import mimetypes
import re
@ -34,6 +36,7 @@ from .forms import DaybookLoginForm
from .models import Artifact, Asset, TagColor
from .storage import (
QuotaExceeded,
SIDECAR_SUFFIXES,
artifact_disk_size,
delete_artifact_files,
disk_free_bytes,
@ -45,7 +48,14 @@ from .storage import (
)
TEXT_KINDS = {Artifact.HTML_FRAGMENT, Artifact.HTML_FULL, Artifact.MARKDOWN, Artifact.DECK}
TEXT_KINDS = {
Artifact.HTML_FRAGMENT,
Artifact.HTML_FULL,
Artifact.MARKDOWN,
Artifact.DECK,
Artifact.JSON,
Artifact.CSV,
}
# Affects storage layout, renderer choice, or feed ordering - never editable after create.
IMMUTABLE_PATCH_FIELDS = {"content", "source", "kind", "day"}
@ -104,7 +114,10 @@ class ArtifactListCreateView(APIView):
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)
return Response(
{"detail": "kind must be html_fragment, html_full, markdown, deck, json, or csv"},
status=400,
)
required = ["source", "title", "content"]
missing = [field for field in required if not data.get(field)]
@ -285,7 +298,7 @@ class CapabilitiesView(APIView):
"artifact_kinds": [choice[0] for choice in Artifact.KIND_CHOICES],
"filters": ["source", "label", "day", "kind", "q"],
"views": ["rendered", "raw"],
"raw_supported_for": [Artifact.MARKDOWN, Artifact.HTML_FULL],
"raw_supported_for": [Artifact.MARKDOWN, Artifact.HTML_FULL, Artifact.JSON, Artifact.CSV],
"source_types": [choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES],
"auth": "bearer_token",
"methods": {
@ -512,12 +525,19 @@ def create_text_artifact(data, request, user):
theme,
request,
)
(target_dir / f"{artifact_id}.md").write_text(content, encoding="utf-8")
elif kind == Artifact.DECK:
rendered = wrap_deck(content, title, request)
elif kind == Artifact.JSON:
rendered = wrap_json_viewer(content, title, request)
elif kind == Artifact.CSV:
rendered = wrap_csv_viewer(content, title, request)
else:
rendered = wrap_html(content, title, theme, request)
sidecar_suffix = SIDECAR_SUFFIXES.get(kind)
if sidecar_suffix:
(target_dir / f"{artifact_id}{sidecar_suffix}").write_text(content, encoding="utf-8")
stored_path = directory / f"{artifact_id}.html"
(settings.ARTIFACTS_ROOT / stored_path).write_text(rendered, encoding="utf-8")
@ -638,6 +658,92 @@ def wrap_deck(body, title, request):
"""
SCRIPT_CLOSE_RE = re.compile(r"</script", re.IGNORECASE)
def escape_script_body(text):
"""Prevent literal `</script` in embedded raw text from prematurely
closing the <script> tag it's placed inside.
Safe specifically for JSON text: JSON's grammar treats `\\/` as an
escaped '/', so this round-trips correctly through JSON.parse - it is
not a general-purpose HTML/JS escaping technique.
"""
return SCRIPT_CLOSE_RE.sub("<\\/script", text)
def wrap_json_viewer(content, title, request):
renderjson_src = request.build_absolute_uri("/static/vendor/renderjson/renderjson.js")
safe_json = escape_script_body(content)
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>
<script src="{renderjson_src}"></script>
<style>
body {{ margin: 0; padding: 1.2rem; background: #fffaf0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9rem; }}
</style>
</head>
<body class="artifact">
<script type="application/json" id="daybook-data">{safe_json}</script>
<div id="daybook-json-view"></div>
<script>
(function () {{
var raw = document.getElementById("daybook-data").textContent;
var data = JSON.parse(raw);
renderjson.set_show_to_level(2);
document.getElementById("daybook-json-view").appendChild(renderjson(data));
}})();
</script>
</body>
</html>
"""
def wrap_csv_viewer(content, title, request):
"""Parses CSV server-side into columns/rows and hands Grid.js plain data -
no client-side CSV parsing plugin needed, keeping normalization server-owned.
"""
gridjs_js = request.build_absolute_uri("/static/vendor/gridjs/gridjs.umd.js")
gridjs_css = request.build_absolute_uri("/static/vendor/gridjs/mermaid.min.css")
rows = [row for row in csv.reader(io.StringIO(content)) if row]
columns = rows[0] if rows else []
data = rows[1:] if len(rows) > 1 else []
table_json = escape_script_body(json.dumps({"columns": columns, "data": data}))
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="{gridjs_css}">
<script src="{gridjs_js}"></script>
<style>
body {{ margin: 0; padding: 1.2rem; background: #fffaf0; }}
</style>
</head>
<body class="artifact">
<script type="application/json" id="daybook-data">{table_json}</script>
<div id="daybook-csv-view"></div>
<script>
(function () {{
var table = JSON.parse(document.getElementById("daybook-data").textContent);
new gridjs.Grid({{
columns: table.columns,
data: table.data,
search: true,
sort: true,
pagination: {{ limit: 20 }},
}}).render(document.getElementById("daybook-csv-view"));
}})();
</script>
</body>
</html>
"""
def generate_id():
while True:
artifact_id = secrets.token_hex(4)
@ -757,6 +863,11 @@ def validate_content_shape(kind, content):
)
if kind == Artifact.MARKDOWN and not (content or "").strip():
return Response({"detail": "markdown content cannot be blank"}, status=400)
if kind == Artifact.JSON:
try:
json.loads(content)
except (TypeError, ValueError) as exc:
return Response({"detail": f"content must be valid JSON: {exc}"}, status=400)
return None
@ -875,10 +986,11 @@ def safe_artifact_path(relative_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
sidecar_suffix = SIDECAR_SUFFIXES.get(artifact.kind)
if sidecar_suffix:
return path.with_suffix(sidecar_suffix)
return None