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:
parent
af9ecf28a9
commit
e5d2f192df
12 changed files with 476 additions and 18 deletions
18
artifacts/migrations/0012_alter_artifact_kind.py
Normal file
18
artifacts/migrations/0012_alter_artifact_kind.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 5.2.15 on 2026-09-02 13:49
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('artifacts', '0011_add_asset'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='artifact',
|
||||||
|
name='kind',
|
||||||
|
field=models.CharField(choices=[('html_fragment', 'HTML fragment'), ('html_full', 'Full HTML'), ('markdown', 'Markdown'), ('deck', 'Deck'), ('image', 'Image'), ('pdf', 'PDF'), ('binary', 'Binary'), ('json', 'JSON'), ('csv', 'CSV')], max_length=20),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -10,6 +10,8 @@ class Artifact(models.Model):
|
||||||
IMAGE = "image"
|
IMAGE = "image"
|
||||||
PDF = "pdf"
|
PDF = "pdf"
|
||||||
BINARY = "binary"
|
BINARY = "binary"
|
||||||
|
JSON = "json"
|
||||||
|
CSV = "csv"
|
||||||
|
|
||||||
AGENT = "agent"
|
AGENT = "agent"
|
||||||
PERSON = "person"
|
PERSON = "person"
|
||||||
|
|
@ -23,6 +25,8 @@ class Artifact(models.Model):
|
||||||
(IMAGE, "Image"),
|
(IMAGE, "Image"),
|
||||||
(PDF, "PDF"),
|
(PDF, "PDF"),
|
||||||
(BINARY, "Binary"),
|
(BINARY, "Binary"),
|
||||||
|
(JSON, "JSON"),
|
||||||
|
(CSV, "CSV"),
|
||||||
]
|
]
|
||||||
|
|
||||||
SOURCE_TYPE_CHOICES = [
|
SOURCE_TYPE_CHOICES = [
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
"""Disk-usage accounting, per-user quota, and automatic eviction.
|
"""Disk-usage accounting, per-user quota, and automatic eviction.
|
||||||
|
|
||||||
An artifact's on-disk footprint is the primary stored file plus, for markdown,
|
An artifact's on-disk footprint is the primary stored file plus, for kinds
|
||||||
the preserved `.md` sidecar. We record it on the row (`size_bytes`) at ingest so
|
that preserve their original source (see `SIDECAR_SUFFIXES`), the sidecar
|
||||||
usage is a cheap SUM rather than a stat of every file.
|
file. We record it on the row (`size_bytes`) at ingest so usage is a cheap
|
||||||
|
SUM rather than a stat of every file.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
|
|
@ -19,22 +20,31 @@ class QuotaExceeded(Exception):
|
||||||
"""Raised when a post cannot fit even after evicting every unkept artifact."""
|
"""Raised when a post cannot fit even after evicting every unkept artifact."""
|
||||||
|
|
||||||
|
|
||||||
|
# Kinds that preserve their original source alongside the rendered .html file.
|
||||||
|
SIDECAR_SUFFIXES = {
|
||||||
|
Artifact.MARKDOWN: ".md",
|
||||||
|
Artifact.JSON: ".json",
|
||||||
|
Artifact.CSV: ".csv",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def artifact_disk_size(relative_path, kind):
|
def artifact_disk_size(relative_path, kind):
|
||||||
"""Bytes an artifact occupies on disk, including the markdown sidecar."""
|
"""Bytes an artifact occupies on disk, including any sidecar file."""
|
||||||
root = settings.ARTIFACTS_ROOT
|
root = settings.ARTIFACTS_ROOT
|
||||||
total = 0
|
total = 0
|
||||||
primary = root / relative_path
|
primary = root / relative_path
|
||||||
if primary.exists():
|
if primary.exists():
|
||||||
total += primary.stat().st_size
|
total += primary.stat().st_size
|
||||||
if kind == Artifact.MARKDOWN:
|
sidecar_suffix = SIDECAR_SUFFIXES.get(kind)
|
||||||
sidecar = primary.with_suffix(".md")
|
if sidecar_suffix:
|
||||||
|
sidecar = primary.with_suffix(sidecar_suffix)
|
||||||
if sidecar.exists():
|
if sidecar.exists():
|
||||||
total += sidecar.stat().st_size
|
total += sidecar.stat().st_size
|
||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
def delete_artifact_files(artifact):
|
def delete_artifact_files(artifact):
|
||||||
"""Remove an artifact's files from disk (primary, markdown sidecar, assets).
|
"""Remove an artifact's files from disk (primary, sidecar, assets).
|
||||||
|
|
||||||
Assets are cascade-deleted from the DB by the FK, but their files must be
|
Assets are cascade-deleted from the DB by the FK, but their files must be
|
||||||
removed here explicitly.
|
removed here explicitly.
|
||||||
|
|
@ -42,8 +52,9 @@ def delete_artifact_files(artifact):
|
||||||
root = settings.ARTIFACTS_ROOT
|
root = settings.ARTIFACTS_ROOT
|
||||||
primary = root / artifact.path
|
primary = root / artifact.path
|
||||||
primary.unlink(missing_ok=True)
|
primary.unlink(missing_ok=True)
|
||||||
if artifact.kind == Artifact.MARKDOWN:
|
sidecar_suffix = SIDECAR_SUFFIXES.get(artifact.kind)
|
||||||
primary.with_suffix(".md").unlink(missing_ok=True)
|
if sidecar_suffix:
|
||||||
|
primary.with_suffix(sidecar_suffix).unlink(missing_ok=True)
|
||||||
for asset in artifact.assets.all():
|
for asset in artifact.assets.all():
|
||||||
delete_asset_file(asset)
|
delete_asset_file(asset)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
import csv
|
||||||
import hashlib
|
import hashlib
|
||||||
import html
|
import html
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
|
|
@ -34,6 +36,7 @@ from .forms import DaybookLoginForm
|
||||||
from .models import Artifact, Asset, TagColor
|
from .models import Artifact, Asset, TagColor
|
||||||
from .storage import (
|
from .storage import (
|
||||||
QuotaExceeded,
|
QuotaExceeded,
|
||||||
|
SIDECAR_SUFFIXES,
|
||||||
artifact_disk_size,
|
artifact_disk_size,
|
||||||
delete_artifact_files,
|
delete_artifact_files,
|
||||||
disk_free_bytes,
|
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.
|
# Affects storage layout, renderer choice, or feed ordering - never editable after create.
|
||||||
IMMUTABLE_PATCH_FIELDS = {"content", "source", "kind", "day"}
|
IMMUTABLE_PATCH_FIELDS = {"content", "source", "kind", "day"}
|
||||||
|
|
@ -104,7 +114,10 @@ class ArtifactListCreateView(APIView):
|
||||||
data = request.data
|
data = request.data
|
||||||
kind = data.get("kind")
|
kind = data.get("kind")
|
||||||
if kind not in TEXT_KINDS:
|
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"]
|
required = ["source", "title", "content"]
|
||||||
missing = [field for field in required if not data.get(field)]
|
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],
|
"artifact_kinds": [choice[0] for choice in Artifact.KIND_CHOICES],
|
||||||
"filters": ["source", "label", "day", "kind", "q"],
|
"filters": ["source", "label", "day", "kind", "q"],
|
||||||
"views": ["rendered", "raw"],
|
"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],
|
"source_types": [choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES],
|
||||||
"auth": "bearer_token",
|
"auth": "bearer_token",
|
||||||
"methods": {
|
"methods": {
|
||||||
|
|
@ -512,12 +525,19 @@ def create_text_artifact(data, request, user):
|
||||||
theme,
|
theme,
|
||||||
request,
|
request,
|
||||||
)
|
)
|
||||||
(target_dir / f"{artifact_id}.md").write_text(content, encoding="utf-8")
|
|
||||||
elif kind == Artifact.DECK:
|
elif kind == Artifact.DECK:
|
||||||
rendered = wrap_deck(content, title, request)
|
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:
|
else:
|
||||||
rendered = wrap_html(content, title, theme, request)
|
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"
|
stored_path = directory / f"{artifact_id}.html"
|
||||||
(settings.ARTIFACTS_ROOT / stored_path).write_text(rendered, encoding="utf-8")
|
(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():
|
def generate_id():
|
||||||
while True:
|
while True:
|
||||||
artifact_id = secrets.token_hex(4)
|
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():
|
if kind == Artifact.MARKDOWN and not (content or "").strip():
|
||||||
return Response({"detail": "markdown content cannot be blank"}, status=400)
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -875,10 +986,11 @@ def safe_artifact_path(relative_path):
|
||||||
|
|
||||||
def raw_artifact_path(artifact):
|
def raw_artifact_path(artifact):
|
||||||
path = safe_artifact_path(artifact.path)
|
path = safe_artifact_path(artifact.path)
|
||||||
if artifact.kind == Artifact.MARKDOWN:
|
|
||||||
return path.with_suffix(".md")
|
|
||||||
if artifact.kind == Artifact.HTML_FULL:
|
if artifact.kind == Artifact.HTML_FULL:
|
||||||
return path
|
return path
|
||||||
|
sidecar_suffix = SIDECAR_SUFFIXES.get(artifact.kind)
|
||||||
|
if sidecar_suffix:
|
||||||
|
return path.with_suffix(sidecar_suffix)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
14
docs/api.md
14
docs/api.md
|
|
@ -35,6 +35,12 @@ Currently supported kinds:
|
||||||
| `image` | Multipart file | Browser image preview | No |
|
| `image` | Multipart file | Browser image preview | No |
|
||||||
| `pdf` | Multipart file | Browser PDF preview | No |
|
| `pdf` | Multipart file | Browser PDF preview | No |
|
||||||
| `binary` | Multipart file | Stored and downloadable | No |
|
| `binary` | Multipart file | Stored and downloadable | No |
|
||||||
|
| `json` | JSON text | Interactive collapsible tree view (must be valid JSON) and original text is preserved | Yes |
|
||||||
|
| `csv` | JSON text | Interactive sortable/searchable table (parsed server-side) and original text is preserved | Yes |
|
||||||
|
|
||||||
|
`json` and `csv` content is required to be a JSON/DRF **string** field even
|
||||||
|
though the value itself is JSON/CSV text — i.e. `{"kind": "json", "content":
|
||||||
|
"{\"a\": 1}"}`, not a nested JSON object in the request body.
|
||||||
|
|
||||||
## Source Fields
|
## Source Fields
|
||||||
|
|
||||||
|
|
@ -84,15 +90,17 @@ Required fields:
|
||||||
| Field | Notes |
|
| Field | Notes |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `source` | Required. Max 80 characters. |
|
| `source` | Required. Max 80 characters. |
|
||||||
| `kind` | One of `html_fragment`, `html_full`, `markdown`, `deck`. |
|
| `kind` | One of `html_fragment`, `html_full`, `markdown`, `deck`, `json`, `csv`. |
|
||||||
| `title` | Required. Max 200 characters. |
|
| `title` | Required. Max 200 characters. |
|
||||||
| `content` | Required text content. Must match the shape `kind` implies — see below. |
|
| `content` | Required text content. Must match the shape `kind` implies — see below. |
|
||||||
|
|
||||||
`content` shape is validated per `kind`: `html_fragment` must not contain
|
`content` shape is validated per `kind`: `html_fragment` must not contain
|
||||||
`<html`, `<!doctype`, or `<body` (body-only HTML, wrapped in a theme
|
`<html`, `<!doctype`, or `<body` (body-only HTML, wrapped in a theme
|
||||||
server-side); `html_full` must contain `<html`; `deck` must contain
|
server-side); `html_full` must contain `<html`; `deck` must contain
|
||||||
`<section`; `markdown` must not be blank or whitespace-only. A mismatch
|
`<section`; `markdown` must not be blank or whitespace-only; `json` must be
|
||||||
returns `400`.
|
valid JSON (`json.loads` must succeed). `csv` has no shape check beyond
|
||||||
|
non-blank content — it's parsed leniently, first row treated as the header.
|
||||||
|
A mismatch returns `400`.
|
||||||
|
|
||||||
Optional fields:
|
Optional fields:
|
||||||
|
|
||||||
|
|
|
||||||
21
static/vendor/gridjs/LICENSE
vendored
Normal file
21
static/vendor/gridjs/LICENSE
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) Afshin Mehrabani <afshin.meh@gmail.com>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
3
static/vendor/gridjs/NOTICE.md
vendored
Normal file
3
static/vendor/gridjs/NOTICE.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
Vendored from https://cdn.jsdelivr.net/npm/gridjs@6.2.0 (MIT, see `LICENSE`).
|
||||||
|
|
||||||
|
Files: `gridjs.umd.js` (library), `mermaid.min.css` (bundled theme).
|
||||||
2
static/vendor/gridjs/gridjs.umd.js
vendored
Normal file
2
static/vendor/gridjs/gridjs.umd.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
static/vendor/gridjs/mermaid.min.css
vendored
Normal file
1
static/vendor/gridjs/mermaid.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
static/vendor/renderjson/NOTICE.md
vendored
Normal file
4
static/vendor/renderjson/NOTICE.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
Vendored from https://github.com/caldwell/renderjson (v1.4.0).
|
||||||
|
|
||||||
|
No standalone `LICENSE` file exists in the upstream repo; its `package.json`
|
||||||
|
declares `"license": "ISC"` (MIT-equivalent, permissive).
|
||||||
216
static/vendor/renderjson/renderjson.js
vendored
Normal file
216
static/vendor/renderjson/renderjson.js
vendored
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
// Copyright © 2013-2017 David Caldwell <david@porkrind.org>
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||||
|
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||||
|
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
|
||||||
|
// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||||
|
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
|
||||||
|
// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
|
||||||
|
// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
// -----
|
||||||
|
// The module exports one entry point, the `renderjson()` function. It takes in
|
||||||
|
// the JSON you want to render as a single argument and returns an HTML
|
||||||
|
// element.
|
||||||
|
//
|
||||||
|
// Options
|
||||||
|
// -------
|
||||||
|
// renderjson.set_icons("+", "-")
|
||||||
|
// This Allows you to override the disclosure icons.
|
||||||
|
//
|
||||||
|
// renderjson.set_show_to_level(level)
|
||||||
|
// Pass the number of levels to expand when rendering. The default is 0, which
|
||||||
|
// starts with everything collapsed. As a special case, if level is the string
|
||||||
|
// "all" then it will start with everything expanded.
|
||||||
|
//
|
||||||
|
// renderjson.set_max_string_length(length)
|
||||||
|
// Strings will be truncated and made expandable if they are longer than
|
||||||
|
// `length`. As a special case, if `length` is the string "none" then
|
||||||
|
// there will be no truncation. The default is "none".
|
||||||
|
//
|
||||||
|
// renderjson.set_sort_objects(sort_bool)
|
||||||
|
// Sort objects by key (default: false)
|
||||||
|
//
|
||||||
|
// renderjson.set_replacer(replacer_function)
|
||||||
|
// Equivalent of JSON.stringify() `replacer` argument when it's a function
|
||||||
|
//
|
||||||
|
// renderjson.set_collapse_msg(collapse_function)
|
||||||
|
// Accepts a function (len:number):string => {} where len is the length of the
|
||||||
|
// object collapsed. Function should return the message displayed when a
|
||||||
|
// object is collapsed. The default message is "X items"
|
||||||
|
//
|
||||||
|
// renderjson.set_property_list(property_list)
|
||||||
|
// Equivalent of JSON.stringify() `replacer` argument when it's an array
|
||||||
|
//
|
||||||
|
// Theming
|
||||||
|
// -------
|
||||||
|
// The HTML output uses a number of classes so that you can theme it the way
|
||||||
|
// you'd like:
|
||||||
|
// .disclosure ("⊕", "⊖")
|
||||||
|
// .syntax (",", ":", "{", "}", "[", "]")
|
||||||
|
// .string (includes quotes)
|
||||||
|
// .number
|
||||||
|
// .boolean
|
||||||
|
// .key (object key)
|
||||||
|
// .keyword ("null", "undefined")
|
||||||
|
// .object.syntax ("{", "}")
|
||||||
|
// .array.syntax ("[", "]")
|
||||||
|
|
||||||
|
var module, window, define, renderjson=(function() {
|
||||||
|
var themetext = function(/* [class, text]+ */) {
|
||||||
|
var spans = [];
|
||||||
|
while (arguments.length)
|
||||||
|
spans.push(append(span(Array.prototype.shift.call(arguments)),
|
||||||
|
text(Array.prototype.shift.call(arguments))));
|
||||||
|
return spans;
|
||||||
|
};
|
||||||
|
var append = function(/* el, ... */) {
|
||||||
|
var el = Array.prototype.shift.call(arguments);
|
||||||
|
for (var a=0; a<arguments.length; a++)
|
||||||
|
if (arguments[a].constructor == Array)
|
||||||
|
append.apply(this, [el].concat(arguments[a]));
|
||||||
|
else
|
||||||
|
el.appendChild(arguments[a]);
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
var prepend = function(el, child) {
|
||||||
|
el.insertBefore(child, el.firstChild);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
var isempty = function(obj, pl) { var keys = pl || Object.keys(obj);
|
||||||
|
for (var i in keys) if (Object.hasOwnProperty.call(obj, keys[i])) return false;
|
||||||
|
return true; }
|
||||||
|
var text = function(txt) { return document.createTextNode(txt) };
|
||||||
|
var div = function() { return document.createElement("div") };
|
||||||
|
var span = function(classname) { var s = document.createElement("span");
|
||||||
|
if (classname) s.className = classname;
|
||||||
|
return s; };
|
||||||
|
var A = function A(txt, classname, callback) { var a = document.createElement("a");
|
||||||
|
if (classname) a.className = classname;
|
||||||
|
a.appendChild(text(txt));
|
||||||
|
a.href = '#';
|
||||||
|
a.onclick = function(e) { callback(); if (e) e.stopPropagation(); return false; };
|
||||||
|
return a; };
|
||||||
|
|
||||||
|
function _renderjson(json, indent, dont_indent, show_level, options) {
|
||||||
|
var my_indent = dont_indent ? "" : indent;
|
||||||
|
|
||||||
|
var disclosure = function(open, placeholder, close, type, builder) {
|
||||||
|
var content;
|
||||||
|
var empty = span(type);
|
||||||
|
var show = function() { if (!content) append(empty.parentNode,
|
||||||
|
content = prepend(builder(),
|
||||||
|
A(options.hide, "disclosure",
|
||||||
|
function() { content.style.display="none";
|
||||||
|
empty.style.display="inline"; } )));
|
||||||
|
content.style.display="inline";
|
||||||
|
empty.style.display="none"; };
|
||||||
|
append(empty,
|
||||||
|
A(options.show, "disclosure", show),
|
||||||
|
themetext(type+ " syntax", open),
|
||||||
|
A(placeholder, null, show),
|
||||||
|
themetext(type+ " syntax", close));
|
||||||
|
|
||||||
|
var el = append(span(), text(my_indent.slice(0,-1)), empty);
|
||||||
|
if (show_level > 0 && type != "string")
|
||||||
|
show();
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (json === null) return themetext(null, my_indent, "keyword", "null");
|
||||||
|
if (json === void 0) return themetext(null, my_indent, "keyword", "undefined");
|
||||||
|
|
||||||
|
if (typeof(json) == "string" && json.length > options.max_string_length)
|
||||||
|
return disclosure('"', json.substr(0,options.max_string_length)+" ...", '"', "string", function () {
|
||||||
|
return append(span("string"), themetext(null, my_indent, "string", JSON.stringify(json)));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof(json) != "object" || [Number, String, Boolean, Date].indexOf(json.constructor) >= 0) // Strings, numbers and bools
|
||||||
|
return themetext(null, my_indent, typeof(json), JSON.stringify(json));
|
||||||
|
|
||||||
|
if (json.constructor == Array) {
|
||||||
|
if (json.length == 0) return themetext(null, my_indent, "array syntax", "[]");
|
||||||
|
|
||||||
|
return disclosure("[", options.collapse_msg(json.length), "]", "array", function () {
|
||||||
|
var as = append(span("array"), themetext("array syntax", "[", null, "\n"));
|
||||||
|
for (var i=0; i<json.length; i++)
|
||||||
|
append(as,
|
||||||
|
_renderjson(options.replacer.call(json, i, json[i]), indent+" ", false, show_level-1, options),
|
||||||
|
i != json.length-1 ? themetext("syntax", ",") : [],
|
||||||
|
text("\n"));
|
||||||
|
append(as, themetext(null, indent, "array syntax", "]"));
|
||||||
|
return as;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// object
|
||||||
|
if (isempty(json, options.property_list))
|
||||||
|
return themetext(null, my_indent, "object syntax", "{}");
|
||||||
|
|
||||||
|
return disclosure("{", options.collapse_msg(Object.keys(json).length), "}", "object", function () {
|
||||||
|
var os = append(span("object"), themetext("object syntax", "{", null, "\n"));
|
||||||
|
for (var k in json) var last = k;
|
||||||
|
var keys = options.property_list || Object.keys(json);
|
||||||
|
if (options.sort_objects)
|
||||||
|
keys = keys.sort();
|
||||||
|
for (var i in keys) {
|
||||||
|
var k = keys[i];
|
||||||
|
if (!(k in json)) continue;
|
||||||
|
append(os, themetext(null, indent+" ", "key", '"'+k+'"', "object syntax", ': '),
|
||||||
|
_renderjson(options.replacer.call(json, k, json[k]), indent+" ", true, show_level-1, options),
|
||||||
|
k != last ? themetext("syntax", ",") : [],
|
||||||
|
text("\n"));
|
||||||
|
}
|
||||||
|
append(os, themetext(null, indent, "object syntax", "}"));
|
||||||
|
return os;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var renderjson = function renderjson(json)
|
||||||
|
{
|
||||||
|
var options = new Object(renderjson.options);
|
||||||
|
options.replacer = typeof(options.replacer) == "function" ? options.replacer : function(k,v) { return v; };
|
||||||
|
var pre = append(document.createElement("pre"), _renderjson(json, "", false, options.show_to_level, options));
|
||||||
|
pre.className = "renderjson";
|
||||||
|
return pre;
|
||||||
|
}
|
||||||
|
renderjson.set_icons = function(show, hide) { renderjson.options.show = show;
|
||||||
|
renderjson.options.hide = hide;
|
||||||
|
return renderjson; };
|
||||||
|
renderjson.set_show_to_level = function(level) { renderjson.options.show_to_level = typeof level == "string" &&
|
||||||
|
level.toLowerCase() === "all" ? Number.MAX_VALUE
|
||||||
|
: level;
|
||||||
|
return renderjson; };
|
||||||
|
renderjson.set_max_string_length = function(length) { renderjson.options.max_string_length = typeof length == "string" &&
|
||||||
|
length.toLowerCase() === "none" ? Number.MAX_VALUE
|
||||||
|
: length;
|
||||||
|
return renderjson; };
|
||||||
|
renderjson.set_sort_objects = function(sort_bool) { renderjson.options.sort_objects = sort_bool;
|
||||||
|
return renderjson; };
|
||||||
|
renderjson.set_replacer = function(replacer) { renderjson.options.replacer = replacer;
|
||||||
|
return renderjson; };
|
||||||
|
renderjson.set_collapse_msg = function(collapse_msg) { renderjson.options.collapse_msg = collapse_msg;
|
||||||
|
return renderjson; };
|
||||||
|
renderjson.set_property_list = function(prop_list) { renderjson.options.property_list = prop_list;
|
||||||
|
return renderjson; };
|
||||||
|
// Backwards compatiblity. Use set_show_to_level() for new code.
|
||||||
|
renderjson.set_show_by_default = function(show) { renderjson.options.show_to_level = show ? Number.MAX_VALUE : 0;
|
||||||
|
return renderjson; };
|
||||||
|
renderjson.options = {};
|
||||||
|
renderjson.set_icons('⊕', '⊖');
|
||||||
|
renderjson.set_show_by_default(false);
|
||||||
|
renderjson.set_sort_objects(false);
|
||||||
|
renderjson.set_max_string_length("none");
|
||||||
|
renderjson.set_replacer(void 0);
|
||||||
|
renderjson.set_property_list(void 0);
|
||||||
|
renderjson.set_collapse_msg(function(len) { return len + " item" + (len==1 ? "" : "s") })
|
||||||
|
return renderjson;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (define) define({renderjson:renderjson})
|
||||||
|
else (module||{}).exports = (window||{}).renderjson = renderjson;
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -369,3 +370,60 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
self.assertIn('class="codehilite"', stored_html)
|
self.assertIn('class="codehilite"', stored_html)
|
||||||
self.assertIn('<span class="k">def</span>', stored_html)
|
self.assertIn('<span class="k">def</span>', stored_html)
|
||||||
self.assertIn('href="http://testserver/static/themes/pygments.css"', stored_html)
|
self.assertIn('href="http://testserver/static/themes/pygments.css"', stored_html)
|
||||||
|
|
||||||
|
# -- json kind ------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_create_json_artifact(self):
|
||||||
|
response = self.create_artifact(kind="json", content='{"a": 1, "b": [1, 2, 3]}')
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
|
||||||
|
artifact = Artifact.objects.get(pk=response.data["id"])
|
||||||
|
stored_html = (self.artifacts_root / artifact.path).read_text(encoding="utf-8")
|
||||||
|
self.assertIn('renderjson/renderjson.js', stored_html)
|
||||||
|
self.assertIn('type="application/json"', stored_html)
|
||||||
|
|
||||||
|
# raw sidecar preserves the exact original JSON text
|
||||||
|
raw_path = (self.artifacts_root / artifact.path).with_suffix(".json")
|
||||||
|
self.assertEqual(raw_path.read_text(encoding="utf-8"), '{"a": 1, "b": [1, 2, 3]}')
|
||||||
|
|
||||||
|
def test_create_json_artifact_rejects_invalid_json(self):
|
||||||
|
response = self.create_artifact(kind="json", content="{not valid json")
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_json_artifact_escapes_script_close_tag_safely(self):
|
||||||
|
payload = json.dumps({"value": "</script><script>alert(1)</script>"})
|
||||||
|
response = self.create_artifact(kind="json", content=payload)
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
|
||||||
|
artifact = Artifact.objects.get(pk=response.data["id"])
|
||||||
|
stored_html = (self.artifacts_root / artifact.path).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# The page has exactly 3 real <script> elements (renderjson lib, the
|
||||||
|
# embedded data block, the init snippet). If the injected payload's
|
||||||
|
# "</script" wasn't escaped, it would add a 4th closing tag here.
|
||||||
|
self.assertEqual(stored_html.count("</script>"), 3)
|
||||||
|
|
||||||
|
# The escaped content must still round-trip to the exact original value.
|
||||||
|
start = stored_html.index('id="daybook-data">') + len('id="daybook-data">')
|
||||||
|
end = stored_html.index("</script>", start)
|
||||||
|
embedded = stored_html[start:end]
|
||||||
|
self.assertEqual(json.loads(embedded)["value"], "</script><script>alert(1)</script>")
|
||||||
|
|
||||||
|
# -- csv kind ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_create_csv_artifact(self):
|
||||||
|
response = self.create_artifact(kind="csv", content="name,score\nalice,10\nbob,7")
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
|
||||||
|
artifact = Artifact.objects.get(pk=response.data["id"])
|
||||||
|
stored_html = (self.artifacts_root / artifact.path).read_text(encoding="utf-8")
|
||||||
|
self.assertIn("gridjs/gridjs.umd.js", stored_html)
|
||||||
|
self.assertIn('"columns": ["name", "score"]', stored_html)
|
||||||
|
self.assertIn('["alice", "10"]', stored_html)
|
||||||
|
|
||||||
|
raw_path = (self.artifacts_root / artifact.path).with_suffix(".csv")
|
||||||
|
self.assertEqual(raw_path.read_text(encoding="utf-8"), "name,score\nalice,10\nbob,7")
|
||||||
|
|
||||||
|
def test_create_csv_artifact_with_header_only(self):
|
||||||
|
response = self.create_artifact(kind="csv", content="name,score")
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue