Initial public commit

Daybook: a self-hosted, multi-user Django + DRF app where agents, scripts,
or people publish artifacts and each logged-in user browses their own
time-ordered, label-filterable feed.

This squashes prior local development history (kept privately on a separate
branch) into a single clean starting point for the public repository.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
peter 2026-09-01 21:55:02 +01:00
commit 940a703cd7
62 changed files with 5755 additions and 0 deletions

View file

@ -0,0 +1,13 @@
---
description: Post one demo artifact of every kind (markdown, html_fragment, html_full, deck, image, pdf, binary), tagged, dated today.
---
Run the demo seeding script to populate today with one tagged sample artifact per supported kind:
```bash
python scripts/seed_demo_artifacts.py
```
Requires the Daybook dev server to be running on the project's fixed local port: `python manage.py runserver 127.0.0.1:8001`. The script defaults to `DAYBOOK_URL=http://127.0.0.1:8001` / `DAYBOOK_TOKEN=dev-token`; no overrides needed unless the user has changed these.
After running, report each posted artifact's kind and URL, and remind the user to reload the feed for today.

19
.dockerignore Normal file
View file

@ -0,0 +1,19 @@
.git/
.idea/
.venv/
__pycache__/
*.py[cod]
*.pyo
*.pyd
.pytest_cache/
.coverage
htmlcov/
staticfiles/
db.sqlite3
artifacts_store/
django-runserver.log
django-runserver.err.log
.env
.env.*
deploy/.env
deploy/data/

40
.gitignore vendored Normal file
View file

@ -0,0 +1,40 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
# Virtualenvs
.venv/
venv/
env/
# Django/local runtime
db.sqlite3
artifacts_store/
django-runserver.log
django-runserver.err.log
# Static/build/cache
staticfiles/
.coverage
htmlcov/
.pytest_cache/
# Secrets/env
.env
.env.*
# IDE/OS
.idea/
.vscode/
.DS_Store
Thumbs.db
# Internal-only planning/ops docs (home infrastructure details)
_archive_/
planning/
# Local machine-specific tool config (MCP servers, absolute local paths)
opencode.json

75
CLAUDE.md Normal file
View file

@ -0,0 +1,75 @@
# CLAUDE.md
## Project
Daybook is a self-hosted, multi-user Django + Django REST Framework web app where AI agents, scripts, or people publish artifacts and each logged-in user browses their own time-ordered, label-filterable feed.
The core product principle is: agents publish content, not files. The server owns IDs, styling, storage layout, dates, and normalization into browser-native HTML, image, or PDF outputs.
## Current MVP Decisions
- Each user has a personal ingest API token (DRF `Token` model), viewable/regenerable from Settings → Account.
- Require `source` in every artifact payload — a free-text label identifying what posted it (agent-profile, script, or person name). Ownership of the resulting artifact comes from whichever user's token authenticated the POST, not from the `source` value itself.
- `source` and `tags` together form one unified **label** system: every artifact shows all its labels next to the title, and a single `label` filter/query param matches against either.
- Require login (Django session auth) for the whole app, including the feed and API reads. Ingest (POST/PATCH) stays per-user bearer-token authenticated, since agents don't have browser sessions.
- User accounts are created/managed via Django admin (superusers only — regular users are not staff and cannot access `/admin/`).
- `TagColor` (label color overrides) stays global/shared across all users.
- Use random 8-character artifact IDs.
- Start as a plain Django project; add Docker after core ingest and viewer work.
- Preserve original markdown as `.md` alongside rendered `.html`.
## Architecture Notes
- Planning source: `planning/daybook_planning.md`.
- Django project: `daybook_project/`.
- Main app: `artifacts/`.
- Theme CSS: `static/themes/`.
- Feed template: `templates/artifacts/feed.html`.
- Settings templates: `templates/artifacts/settings_*.html`.
- Stored artifact files live under `artifacts_store/{source}/{YYYY-MM-DD}/`.
## Implementation Guidelines
- Prefer small, direct changes over broad abstractions.
- Keep ingest normalization server-side.
- The viewer should only need to render HTML, image, or PDF.
- Do not make agents responsible for styling, filenames, or storage paths.
- Preserve existing planning decisions unless the user explicitly changes them.
- Avoid adding backwards-compatibility code before anything has shipped.
## Development And Deployment Workflow
- Develop and test locally in this workspace by default.
- Use the local Django commands for normal iteration: `python manage.py check`, `python manage.py migrate`, and `python manage.py runserver`.
- Run the local development server on `127.0.0.1:8001`. Port `8000` may be reserved or blocked on this workstation.
- Always use this same fixed port for the dev server. Never start a second instance on a different port; if `8001` is already serving, reuse it rather than starting another.
- All artifact-posting tools (`skills/daybook-posting/scripts/daybook_post.py`, `scripts/seed_demo_artifacts.py`, and any future posting skill) must default `DAYBOOK_URL` to `http://127.0.0.1:8001` to match.
- Do not deploy to the Proxmox NUC during routine code iteration.
- Treat the NUC container as the local live/staging target. Deploy there only at explicit stable checkpoints, such as an end-of-day build.
- Before any NUC deployment, preserve `/opt/daybook/deploy/.env` and `/opt/daybook/deploy/data`.
- After any NUC deployment or configuration change, run the documented health checks and update the NUC ops knowledge base plus `planning/daybook-container-handoff.md` if takeover facts changed.
## Repository Notes
- `_archive_/` exists for retired or moved project docs and experiments that should not drive current implementation decisions.
- Current deployment/takeover details live in `planning/daybook-container-handoff.md`; canonical NUC infrastructure details live in the NUC ops repo.
## Commands
- Install dependencies: `pip install -r requirements.txt`
- Run migrations: `python manage.py migrate`
- Start dev server: `python manage.py runserver 127.0.0.1:8001`
- Local app URL: `http://127.0.0.1:8001/`
- Local API health check: `http://127.0.0.1:8001/api/capabilities`
- Django checks: `python manage.py check`
## API Defaults
- MVP ingest endpoint: `POST /api/artifacts`
- MVP list endpoint: `GET /api/artifacts?label=&day=&kind=&q=`
- Artifact view: `GET /a/{id}`
- Auth: personal per-user token from Settings → Account, `Authorization: Bearer <token>`. Get one by creating a superuser (`python manage.py createsuperuser`), logging in, and visiting Settings → Account.
## Claude Code / OpenCode
This project uses `CLAUDE.md` for project instructions, compatible with both Claude Code and OpenCode. The `opencode.json` configuration file loads this file. After changing `opencode.json`, `.opencode/`, or this file, restart your AI coding assistant so the updated instructions are loaded.

0
artifacts/__init__.py Normal file
View file

23
artifacts/admin.py Normal file
View file

@ -0,0 +1,23 @@
from django.contrib import admin
from .models import Artifact, Asset, UserProfile
@admin.register(Artifact)
class ArtifactAdmin(admin.ModelAdmin):
list_display = ("id", "title", "owner", "source", "kind", "is_kept", "size_bytes", "day", "created_at")
list_filter = ("owner", "source", "source_type", "kind", "is_kept", "day")
search_fields = ("id", "title", "source", "source_name", "source_id")
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
list_display = ("user", "quota_bytes")
search_fields = ("user__username",)
@admin.register(Asset)
class AssetAdmin(admin.ModelAdmin):
list_display = ("id", "owner", "artifact", "content_type", "size_bytes", "created_at")
list_filter = ("owner", "content_type")
search_fields = ("id",)

6
artifacts/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ArtifactsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "artifacts"

87
artifacts/demo_data.py Normal file
View file

@ -0,0 +1,87 @@
"""Sample artifact content for the Settings -> Demo Data button.
Creates one artifact of every supported kind, owned by the requesting user,
tagged "demo", with source "demo".
"""
SOURCE = "demo"
TEXT_SAMPLES = [
(
"markdown",
"Seed: Markdown Sample",
"# Markdown Sample\n\nThis is a **seed** artifact demonstrating markdown rendering.\n\n- item one\n- item two\n",
"markdown",
),
(
"html_fragment",
"Seed: HTML Fragment Sample",
"<h2>HTML Fragment</h2><p>This is a seed HTML fragment artifact.</p>",
"html-fragment",
),
(
"html_full",
"Seed: Full HTML Sample",
"<!doctype html><html><head><title>Seed Full HTML</title></head>"
"<body><h1>Full HTML Doc</h1><p>Seed artifact.</p></body></html>",
"html-full",
),
(
"deck",
"Seed: Deck Sample",
"<section><h1>Slide One</h1><p>Seed deck artifact.</p></section>"
"<section><h1>Slide Two</h1><p>More content.</p></section>",
"deck",
),
]
SEED_PNG_BYTES = bytes.fromhex(
"89504e470d0a1a0a0000000d494844520000000100000001080600000"
"01f15c4890000000a49444154789c6360000002000155e59a1a0000000049454e44ae426082"
)
SEED_PDF_BYTES = (
b"%PDF-1.1\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\n"
b"trailer<</Root 1 0 R>>\n"
)
SEED_BIN_BYTES = b"\x00\x01\x02\x03seed-binary-data\xff\xfe"
BINARY_SAMPLES = [
("Seed: Image Sample", "seed_pixel.png", "image/png", SEED_PNG_BYTES, "image"),
("Seed: PDF Sample", "seed_doc.pdf", "application/pdf", SEED_PDF_BYTES, "pdf"),
("Seed: Binary Sample", "seed_data.bin", "application/octet-stream", SEED_BIN_BYTES, "binary"),
]
def create_demo_artifacts(user, request):
from django.core.files.uploadedfile import SimpleUploadedFile
from .views import create_binary_artifact, create_text_artifact
created = 0
for kind, title, content, tag in TEXT_SAMPLES:
data = {
"source": SOURCE,
"title": title,
"kind": kind,
"content": content,
"tags": ["demo", tag],
}
create_text_artifact(data, request, user)
created += 1
for title, filename, content_type, file_bytes, tag in BINARY_SAMPLES:
uploaded = SimpleUploadedFile(filename, file_bytes, content_type=content_type)
data = {
"source": SOURCE,
"title": title,
"file": uploaded,
"tags": ["demo", tag],
}
create_binary_artifact(data, user)
created += 1
return created

6
artifacts/forms.py Normal file
View file

@ -0,0 +1,6 @@
from django import forms
from django.contrib.auth.forms import AuthenticationForm
class DaybookLoginForm(AuthenticationForm):
remember_me = forms.BooleanField(required=False, initial=True)

View 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"]},
),
]

View file

@ -0,0 +1,41 @@
# Generated by Django 5.2.15 on 2026-06-10 15:14
from django.db import migrations, models
def fill_existing_source_names(apps, schema_editor):
Artifact = apps.get_model('artifacts', 'Artifact')
for artifact in Artifact.objects.filter(source_name=''):
artifact.source_name = artifact.persona
artifact.save(update_fields=['source_name'])
class Migration(migrations.Migration):
dependencies = [
('artifacts', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='artifact',
name='metadata',
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name='artifact',
name='source_id',
field=models.CharField(blank=True, max_length=120),
),
migrations.AddField(
model_name='artifact',
name='source_name',
field=models.CharField(blank=True, max_length=120),
),
migrations.AddField(
model_name='artifact',
name='source_type',
field=models.CharField(choices=[('agent', 'Agent'), ('person', 'Person'), ('system', 'System')], default='agent', max_length=20),
),
migrations.RunPython(fill_existing_source_names, migrations.RunPython.noop),
]

View file

@ -0,0 +1,20 @@
# Generated by Django 5.2.15 on 2026-07-23 06:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('artifacts', '0002_artifact_metadata_artifact_source_id_and_more'),
]
operations = [
migrations.CreateModel(
name='TagColor',
fields=[
('name', models.CharField(max_length=40, primary_key=True, serialize=False)),
('color', models.CharField(max_length=20)),
],
),
]

View file

@ -0,0 +1,16 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("artifacts", "0003_tagcolor"),
]
operations = [
migrations.RenameField(
model_name="artifact",
old_name="persona",
new_name="source",
),
]

View file

@ -0,0 +1,24 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("artifacts", "0004_rename_persona_to_source"),
]
operations = [
migrations.AddField(
model_name="artifact",
name="owner",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="artifacts",
to=settings.AUTH_USER_MODEL,
),
),
]

View file

@ -0,0 +1,27 @@
from django.db import migrations
def backfill_owner(apps, schema_editor):
User = apps.get_model("auth", "User")
Artifact = apps.get_model("artifacts", "Artifact")
first_superuser = User.objects.filter(is_superuser=True).order_by("date_joined").first()
if first_superuser is None:
return
Artifact.objects.filter(owner__isnull=True).update(owner=first_superuser)
def noop_reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("artifacts", "0005_add_owner_nullable"),
]
operations = [
migrations.RunPython(backfill_owner, noop_reverse),
]

View file

@ -0,0 +1,23 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("artifacts", "0006_backfill_owner"),
]
operations = [
migrations.AlterField(
model_name="artifact",
name="owner",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="artifacts",
to=settings.AUTH_USER_MODEL,
),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.15 on 2026-07-23 21:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('artifacts', '0007_owner_required'),
]
operations = [
migrations.AddField(
model_name='artifact',
name='read_at',
field=models.DateTimeField(blank=True, null=True),
),
]

View file

@ -0,0 +1,34 @@
# Generated by Django 5.2.15 on 2026-07-24 07:15
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('artifacts', '0008_artifact_read_at'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='artifact',
name='is_kept',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='artifact',
name='size_bytes',
field=models.BigIntegerField(default=0),
),
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quota_bytes', models.BigIntegerField(default=1073741824)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -0,0 +1,43 @@
from pathlib import Path
from django.conf import settings
from django.db import migrations
def backfill(apps, schema_editor):
Artifact = apps.get_model("artifacts", "Artifact")
User = apps.get_model("auth", "User")
UserProfile = apps.get_model("artifacts", "UserProfile")
root = Path(settings.ARTIFACTS_ROOT)
for artifact in Artifact.objects.all():
total = 0
primary = root / artifact.path
if primary.exists():
total += primary.stat().st_size
if artifact.kind == "markdown":
sidecar = primary.with_suffix(".md")
if sidecar.exists():
total += sidecar.stat().st_size
if artifact.size_bytes != total:
artifact.size_bytes = total
artifact.save(update_fields=["size_bytes"])
default_quota = 1024 * 1024 * 1024 # 1 GB
for user in User.objects.all():
UserProfile.objects.get_or_create(user=user, defaults={"quota_bytes": default_quota})
def noop_reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("artifacts", "0009_quota_and_keep"),
]
operations = [
migrations.RunPython(backfill, noop_reverse),
]

View file

@ -0,0 +1,31 @@
# Generated by Django 5.2.15 on 2026-07-24 10:13
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('artifacts', '0010_backfill_sizes_and_profiles'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Asset',
fields=[
('id', models.CharField(max_length=12, primary_key=True, serialize=False)),
('path', models.CharField(max_length=500)),
('content_type', models.CharField(max_length=100)),
('size_bytes', models.BigIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('artifact', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='assets', to='artifacts.artifact')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assets', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
]

View file

105
artifacts/models.py Normal file
View file

@ -0,0 +1,105 @@
from django.conf import settings
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"
AGENT = "agent"
PERSON = "person"
SYSTEM = "system"
KIND_CHOICES = [
(HTML_FRAGMENT, "HTML fragment"),
(HTML_FULL, "Full HTML"),
(MARKDOWN, "Markdown"),
(DECK, "Deck"),
(IMAGE, "Image"),
(PDF, "PDF"),
(BINARY, "Binary"),
]
SOURCE_TYPE_CHOICES = [
(AGENT, "Agent"),
(PERSON, "Person"),
(SYSTEM, "System"),
]
id = models.CharField(max_length=8, primary_key=True)
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="artifacts")
source = 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)
source_type = models.CharField(max_length=20, choices=SOURCE_TYPE_CHOICES, default=AGENT)
source_name = models.CharField(max_length=120, blank=True)
source_id = models.CharField(max_length=120, blank=True)
metadata = models.JSONField(default=dict, blank=True)
read_at = models.DateTimeField(null=True, blank=True)
is_kept = models.BooleanField(default=False)
size_bytes = models.BigIntegerField(default=0)
@property
def is_unread(self):
return self.read_at is None
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.title} ({self.source})"
class TagColor(models.Model):
name = models.CharField(max_length=40, primary_key=True)
color = models.CharField(max_length=20)
def __str__(self):
return f"{self.name} ({self.color})"
class UserProfile(models.Model):
DEFAULT_QUOTA_BYTES = 1024 * 1024 * 1024 # 1 GB
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile")
quota_bytes = models.BigIntegerField(default=DEFAULT_QUOTA_BYTES)
def __str__(self):
return f"{self.user} quota={self.quota_bytes}"
class Asset(models.Model):
"""An image uploaded for embedding inside an artifact's content.
Uploaded before the artifact exists, so `artifact` is null until the
artifact referencing its URL is created. Unattached assets are swept
after ASSET_ORPHAN_GRACE; attached ones die with their artifact.
"""
id = models.CharField(max_length=12, primary_key=True)
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="assets")
artifact = models.ForeignKey(
Artifact, on_delete=models.CASCADE, related_name="assets", null=True, blank=True
)
path = models.CharField(max_length=500)
content_type = models.CharField(max_length=100)
size_bytes = models.BigIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.id} ({self.content_type})"

135
artifacts/storage.py Normal file
View file

@ -0,0 +1,135 @@
"""Disk-usage accounting, per-user quota, and automatic eviction.
An artifact's on-disk footprint is the primary stored file plus, for markdown,
the preserved `.md` sidecar. 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
from datetime import timedelta
from django.conf import settings
from django.db.models import Sum
from django.utils import timezone
from .models import Artifact, Asset, UserProfile
class QuotaExceeded(Exception):
"""Raised when a post cannot fit even after evicting every unkept artifact."""
def artifact_disk_size(relative_path, kind):
"""Bytes an artifact occupies on disk, including the markdown sidecar."""
root = settings.ARTIFACTS_ROOT
total = 0
primary = root / relative_path
if primary.exists():
total += primary.stat().st_size
if kind == Artifact.MARKDOWN:
sidecar = primary.with_suffix(".md")
if sidecar.exists():
total += sidecar.stat().st_size
return total
def delete_artifact_files(artifact):
"""Remove an artifact's files from disk (primary, markdown sidecar, assets).
Assets are cascade-deleted from the DB by the FK, but their files must be
removed here explicitly.
"""
root = settings.ARTIFACTS_ROOT
primary = root / artifact.path
primary.unlink(missing_ok=True)
if artifact.kind == Artifact.MARKDOWN:
primary.with_suffix(".md").unlink(missing_ok=True)
for asset in artifact.assets.all():
delete_asset_file(asset)
def delete_asset_file(asset):
(settings.ARTIFACTS_ROOT / asset.path).unlink(missing_ok=True)
def artifact_total_bytes(artifact):
"""An artifact's own bytes plus those of the assets embedded in it."""
asset_bytes = artifact.assets.aggregate(total=Sum("size_bytes")).get("total") or 0
return artifact.size_bytes + asset_bytes
def user_usage_bytes(user):
artifact_bytes = (
Artifact.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0
)
asset_bytes = (
Asset.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0
)
return artifact_bytes + asset_bytes
def user_quota_bytes(user):
profile, _ = UserProfile.objects.get_or_create(user=user)
return profile.quota_bytes
def make_room_for(user, incoming_bytes):
"""Evict oldest unkept artifacts until `incoming_bytes` fits under quota.
Returns the list of evicted artifacts (already deleted). Raises QuotaExceeded
if even removing every unkept artifact leaves no room.
"""
quota = user_quota_bytes(user)
if incoming_bytes > quota:
raise QuotaExceeded("Artifact is larger than the entire quota.")
used = user_usage_bytes(user)
if used + incoming_bytes <= quota:
return []
evicted = []
unkept = (
Artifact.objects.filter(owner=user, is_kept=False).order_by("created_at")
)
for artifact in unkept:
if used + incoming_bytes <= quota:
break
# Freeing an artifact also frees the assets embedded in it.
used -= artifact_total_bytes(artifact)
delete_artifact_files(artifact)
artifact.delete()
evicted.append(artifact)
if used + incoming_bytes > quota:
raise QuotaExceeded("Quota is full of kept artifacts; free space to post.")
return evicted
ASSET_ORPHAN_GRACE = timedelta(hours=24)
def sweep_orphan_assets(user=None):
"""Delete assets uploaded but never referenced by an artifact.
Runs opportunistically on upload. The grace period gives an agent time to
upload images and then post the artifact that references them.
"""
cutoff = timezone.now() - ASSET_ORPHAN_GRACE
orphans = Asset.objects.filter(artifact__isnull=True, created_at__lt=cutoff)
if user is not None:
orphans = orphans.filter(owner=user)
count = 0
for asset in orphans:
delete_asset_file(asset)
asset.delete()
count += 1
return count
def disk_free_bytes():
"""Free space on the volume that holds the artifact store."""
return shutil.disk_usage(settings.ARTIFACTS_ROOT).free
def total_allocated_bytes():
return UserProfile.objects.aggregate(total=Sum("quota_bytes")).get("total") or 0

21
artifacts/urls.py Normal file
View file

@ -0,0 +1,21 @@
from django.urls import path
from . import views
urlpatterns = [
path("", views.feed, name="feed"),
path("feed/older", views.feed_older, name="feed-older"),
path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"),
path("a/<str:artifact_id>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"),
path("a/<str:artifact_id>", views.artifact_view, name="artifact"),
path("assets/<str:asset_id>", views.asset_view, name="asset"),
path("api/assets", views.AssetUploadView.as_view(), name="asset-upload-api"),
path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"),
path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"),
path("api/artifacts/<str:artifact_id>", views.ArtifactDetailView.as_view(), name="artifact-detail-api"),
path("settings/", views.settings_home, name="settings-home"),
path("settings/account/", views.settings_account, name="settings-account"),
path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"),
path("settings/storage/", views.settings_storage, name="settings-storage"),
]

903
artifacts/views.py Normal file
View file

@ -0,0 +1,903 @@
import hashlib
import html
import json
import mimetypes
import re
import secrets
from datetime import timedelta
from pathlib import Path
from urllib.parse import urlencode
import markdown
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import views as auth_views
from django.contrib.auth.decorators import login_required
from django.db.models import Count, Q, Sum
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, 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.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.decorators.http import require_POST
from rest_framework.authtoken.models import Token
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
from .demo_data import create_demo_artifacts
from .forms import DaybookLoginForm
from .models import Artifact, Asset, TagColor
from .storage import (
QuotaExceeded,
artifact_disk_size,
delete_artifact_files,
disk_free_bytes,
make_room_for,
sweep_orphan_assets,
total_allocated_bytes,
user_quota_bytes,
user_usage_bytes,
)
TEXT_KINDS = {Artifact.HTML_FRAGMENT, Artifact.HTML_FULL, Artifact.MARKDOWN, Artifact.DECK}
REMEMBER_ME_SESSION_AGE = 60 * 60 * 24 * 30 # 30 days
MAX_ASSET_BYTES = 50 * 1024 * 1024 # 50 MB per uploaded image
# Matches the asset id in a Daybook asset URL, e.g. /assets/ab12cd34ef56
ASSET_URL_RE = re.compile(r"/assets/([0-9a-f]{12})")
def filter_by_label(queryset, label):
matching_ids = [artifact.id for artifact in queryset.only("id", "tags") if label in artifact.tags]
return queryset.filter(pk__in=matching_ids)
DEFAULT_THEMES = {
Artifact.HTML_FRAGMENT: "report",
Artifact.MARKDOWN: "report",
Artifact.DECK: "deck",
}
@method_decorator(login_required, name="get")
class ArtifactListCreateView(APIView):
parser_classes = [JSONParser, FormParser, MultiPartParser]
def get(self, request):
artifacts = Artifact.objects.filter(owner=request.user)
if source := request.query_params.get("source"):
artifacts = artifacts.filter(source=source)
if label := request.query_params.get("label"):
artifacts = filter_by_label(artifacts, label)
if day := request.query_params.get("day"):
artifacts = artifacts.filter(day=day)
if kind := request.query_params.get("kind"):
artifacts = artifacts.filter(kind=kind)
if title_query := request.query_params.get("q"):
artifacts = artifacts.filter(title__icontains=title_query)
return Response([serialize_artifact(artifact, request) for artifact in artifacts])
def post(self, request):
user = resolve_token_user(request)
if user is None:
return HttpResponseForbidden("Invalid or missing API token")
if request.content_type and request.content_type.startswith("multipart/"):
artifact = create_binary_artifact(request.data, user)
if isinstance(artifact, Response):
return artifact
apply_tag_colors(request.data)
return Response(serialize_artifact(artifact, request), 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 = ["source", "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)
source_error = validate_source_fields(data)
if source_error:
return source_error
artifact = create_text_artifact(data, request, user)
if isinstance(artifact, Response):
return artifact
apply_tag_colors(data)
return Response(serialize_artifact(artifact, request), status=201)
class AssetUploadView(APIView):
"""Upload an image for embedding inside artifact content.
Returns a stable URL the agent puts in its markdown/fragment. The asset is
attached to whichever artifact later references that URL.
"""
parser_classes = [MultiPartParser, FormParser]
def post(self, request):
user = resolve_token_user(request)
if user is None:
return HttpResponseForbidden("Invalid or missing API token")
uploaded = request.data.get("file")
if not uploaded:
return Response({"detail": "Missing required field: file"}, status=400)
content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or ""
if not content_type.startswith("image/"):
return Response({"detail": "Only image uploads are supported."}, status=400)
if uploaded.size > MAX_ASSET_BYTES:
return Response(
{"detail": f"Image exceeds the {MAX_ASSET_BYTES // (1024 * 1024)} MB limit."},
status=413,
)
sweep_orphan_assets(user)
asset_id = generate_asset_id()
suffix = safe_suffix(uploaded.name, content_type)
directory = Path("_assets") / timezone.localdate().isoformat()
(settings.ARTIFACTS_ROOT / directory).mkdir(parents=True, exist_ok=True)
stored_path = directory / f"{asset_id}{suffix}"
with (settings.ARTIFACTS_ROOT / stored_path).open("wb") as target:
for chunk in uploaded.chunks():
target.write(chunk)
size_bytes = (settings.ARTIFACTS_ROOT / stored_path).stat().st_size
try:
make_room_for(user, size_bytes)
except QuotaExceeded as exc:
(settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True)
return Response({"detail": str(exc)}, status=413)
asset = Asset.objects.create(
id=asset_id,
owner=user,
path=stored_path.as_posix(),
content_type=content_type,
size_bytes=size_bytes,
)
return Response(
{
"id": asset.id,
"url": request.build_absolute_uri(reverse("asset", args=[asset.id])),
"content_type": asset.content_type,
"size_bytes": asset.size_bytes,
},
status=201,
)
@login_required
@xframe_options_sameorigin
def asset_view(request, asset_id):
asset = get_object_or_404(Asset, pk=asset_id, owner=request.user)
path = safe_artifact_path(asset.path)
if not path.exists():
raise Http404("Asset file not found")
return FileResponse(path.open("rb"), content_type=asset.content_type or "application/octet-stream")
@method_decorator(login_required, name="get")
class ArtifactDetailView(APIView):
def get(self, request, artifact_id):
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
return Response(serialize_artifact(artifact, request))
def patch(self, request, artifact_id):
user = resolve_token_user(request)
if user is None:
return HttpResponseForbidden("Invalid or missing API token")
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
data = request.data
if "title" in data:
if not data["title"]:
return Response({"detail": "title cannot be blank"}, status=400)
artifact.title = data["title"]
if "tags" in data:
artifact.tags = normalize_tags(data["tags"])
if "source_prompt" in data:
artifact.source_prompt = data["source_prompt"] or ""
if "source_type" in data:
if data["source_type"] not in {choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES}:
return Response({"detail": "source_type must be agent, person, or system"}, status=400)
artifact.source_type = data["source_type"]
if "source_name" in data:
artifact.source_name = data["source_name"] or ""
if "source_id" in data:
artifact.source_id = data["source_id"] or ""
if "metadata" in data:
if not isinstance(data["metadata"], dict):
return Response({"detail": "metadata must be an object"}, status=400)
artifact.metadata = data["metadata"]
if "tag_colors" in data:
try:
apply_tag_colors(data)
except ValueError as exc:
return Response({"detail": str(exc)}, status=400)
artifact.save(
update_fields=[
"title",
"tags",
"source_prompt",
"source_type",
"source_name",
"source_id",
"metadata",
]
)
return Response(serialize_artifact(artifact, request))
def delete(self, request, artifact_id):
user = resolve_token_user(request)
if user is None:
return HttpResponseForbidden("Invalid or missing API token")
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
delete_artifact_files(artifact)
artifact.delete()
return Response(status=204)
class CapabilitiesView(APIView):
"""Static capability info. Reachable by a logged-in session or a valid API token,
so agents can check connectivity before posting. Exposes no user data."""
def get(self, request):
if not request.user.is_authenticated and resolve_token_user(request) is None:
return HttpResponseForbidden("Login or a valid API token is required")
return Response(
{
"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],
"source_types": [choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES],
"auth": "bearer_token",
"methods": {
"/api/artifacts": ["GET", "POST"],
"/api/artifacts/{id}": ["GET", "PATCH", "DELETE"],
"/api/assets": ["POST"],
},
}
)
FEED_WINDOW_DAYS = 7
def apply_feed_filters(queryset, source="", label="", kind="", title_query=""):
if source:
queryset = queryset.filter(source=source)
if label:
queryset = filter_by_label(queryset, label)
if kind:
queryset = queryset.filter(kind=kind)
if title_query:
queryset = queryset.filter(title__icontains=title_query)
return queryset
def group_artifacts_by_day(artifacts, overrides):
"""Turn a flat, reverse-chronological list into [{day, artifacts}] groups."""
groups = []
for artifact in artifacts:
# Source has its own column in the row, so chips are the free-form tags only.
artifact.label_chips = tags_with_colors(artifact.tags, overrides)
if not groups or groups[-1]["day"] != artifact.day:
groups.append({"day": artifact.day, "artifacts": []})
groups[-1]["artifacts"].append(artifact)
return groups
@login_required
def feed(request):
selected_source = request.GET.get("source", "")
selected_label = request.GET.get("label", "")
selected_kind = request.GET.get("kind", "")
title_query = request.GET.get("q", "")
today = timezone.localdate()
selected_artifact_id = request.GET.get("artifact", "")
preview_none = request.GET.get("preview") == "none"
selected_view = "raw" if request.GET.get("view") == "raw" else "rendered"
all_artifacts = Artifact.objects.filter(owner=request.user)
artifacts = apply_feed_filters(
all_artifacts, selected_source, selected_label, selected_kind, title_query
)
window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1)
if not artifacts.filter(day__gte=window_start).exists():
# Nothing recent for these filters: fall back to the most recent days that
# do match, so selecting a source is never met with a blank list.
recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS])
if recent_days:
window_start = recent_days[-1]
window_artifacts = list(artifacts.filter(day__gte=window_start).order_by("-created_at"))
older_count = artifacts.filter(day__lt=window_start).count()
overrides = tag_color_overrides()
day_groups = group_artifacts_by_day(window_artifacts, overrides)
selected_artifact = None
if not preview_none:
selected_artifact = next((a for a in window_artifacts if a.id == selected_artifact_id), None)
toggle_query = {"view": "rendered"}
if selected_source:
toggle_query["source"] = selected_source
if selected_label:
toggle_query["label"] = selected_label
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)}"
# Source counts come from the other filters only, so the sidebar still shows
# every source available under the current label/type/name filters.
source_scoped = apply_feed_filters(
all_artifacts, "", selected_label, selected_kind, title_query
)
source_counts = (
source_scoped.values("source")
.annotate(count=Count("id", filter=Q(read_at__isnull=True)))
.order_by("source")
)
all_labels = sorted({tag for tags in all_artifacts.values_list("tags", flat=True) for tag in tags})
return render(
request,
"artifacts/feed.html",
{
"artifacts": window_artifacts,
"day_groups": day_groups,
"older_count": older_count,
"window_start": window_start,
"source_counts": source_counts,
"total_count": source_scoped.filter(read_at__isnull=True).count(),
"all_labels": labels_with_colors_list(all_labels, overrides),
"raw_url": raw_url,
"rendered_url": rendered_url,
"selected_artifact": selected_artifact,
"selected_kind": selected_kind,
"selected_label": selected_label,
"selected_source": selected_source,
"selected_view": selected_view,
"preview_none": preview_none,
"today": today,
"title_query": title_query,
"kind_choices": Artifact.KIND_CHOICES,
},
)
@login_required
@require_POST
def mark_artifact_read(request, artifact_id):
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
if artifact.read_at is None:
artifact.read_at = timezone.now()
artifact.save(update_fields=["read_at"])
return JsonResponse({"id": artifact.id, "read_at": artifact.read_at.isoformat()})
@login_required
@require_POST
def toggle_artifact_keep(request, artifact_id):
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
artifact.is_kept = not artifact.is_kept
artifact.save(update_fields=["is_kept"])
return JsonResponse({"id": artifact.id, "is_kept": artifact.is_kept})
@login_required
def feed_older(request):
"""Return the next page of older day-groups as an HTML fragment for infinite scroll."""
before = parse_date(request.GET.get("before", "")) or timezone.localdate()
artifacts = apply_feed_filters(
Artifact.objects.filter(owner=request.user),
request.GET.get("source", ""),
request.GET.get("label", ""),
request.GET.get("kind", ""),
request.GET.get("q", ""),
).filter(day__lt=before).order_by("-created_at")
# Take a whole page of days at a time so a day is never split across pages.
page_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS])
if not page_days:
return HttpResponse("")
page_artifacts = list(artifacts.filter(day__gte=page_days[-1]))
day_groups = group_artifacts_by_day(page_artifacts, tag_color_overrides())
remaining = artifacts.filter(day__lt=page_days[-1]).count()
return render(
request,
"artifacts/_day_groups.html",
{
"day_groups": day_groups,
"next_before": page_days[-1].isoformat(),
"has_more": remaining > 0,
"selected_artifact": None,
"selected_source": request.GET.get("source", ""),
"selected_label": request.GET.get("label", ""),
"selected_kind": request.GET.get("kind", ""),
"title_query": request.GET.get("q", ""),
"selected_view": "raw" if request.GET.get("view") == "raw" else "rendered",
"today": timezone.localdate(),
},
)
@login_required
@xframe_options_sameorigin
def artifact_view(request, artifact_id):
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
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, user):
now = timezone.localtime()
artifact_id = generate_id()
source = slugify(data["source"]) or "unknown"
kind = data["kind"]
theme = data.get("theme") or DEFAULT_THEMES.get(kind, "base")
title = data["title"]
content = data["content"]
directory = Path(source) / 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")
size_bytes = artifact_disk_size(stored_path.as_posix(), kind)
try:
make_room_for(user, size_bytes)
except QuotaExceeded as exc:
(settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True)
if kind == Artifact.MARKDOWN:
(target_dir / f"{artifact_id}.md").unlink(missing_ok=True)
return Response({"detail": str(exc)}, status=413)
artifact = Artifact.objects.create(
id=artifact_id,
owner=user,
source=source,
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", ""),
size_bytes=size_bytes,
**source_fields(data, source),
)
attach_referenced_assets(artifact, content)
return artifact
def create_binary_artifact(data, user):
uploaded = data.get("file")
required = ["source", "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)
source_error = validate_source_fields(data)
if source_error:
return source_error
now = timezone.localtime()
artifact_id = generate_id()
source = slugify(data["source"]) 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(source) / 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)
size_bytes = artifact_disk_size(stored_path.as_posix(), kind)
try:
make_room_for(user, size_bytes)
except QuotaExceeded as exc:
(settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True)
return Response({"detail": str(exc)}, status=413)
return Artifact.objects.create(
id=artifact_id,
owner=user,
source=source,
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", ""),
size_bytes=size_bytes,
**source_fields(data, source),
)
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 generate_asset_id():
while True:
asset_id = secrets.token_hex(6)
if not Asset.objects.filter(pk=asset_id).exists():
return asset_id
def attach_referenced_assets(artifact, content):
"""Link assets whose URLs appear in the artifact's content.
Assets are uploaded before the artifact exists, so this runs at creation
time and ties each referenced asset to the artifact that embeds it.
"""
referenced = set(ASSET_URL_RE.findall(content or ""))
if not referenced:
return 0
return Asset.objects.filter(
owner=artifact.owner, id__in=referenced, artifact__isnull=True
).update(artifact=artifact)
def resolve_token_user(request):
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return None
key = auth[len("Bearer "):].strip()
if not key:
return None
try:
return Token.objects.select_related("user").get(key=key).user
except Token.DoesNotExist:
return None
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 []
HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
TAG_PALETTE = [
"#4f6df5", "#e0578c", "#2fa88f", "#d98b2b",
"#8c5fd6", "#3fa1d8", "#c14f4f", "#5aa552",
]
def tag_color(name, overrides):
if override := overrides.get(name):
return override
index = int(hashlib.sha256(name.encode("utf-8")).hexdigest(), 16) % len(TAG_PALETTE)
return TAG_PALETTE[index]
def tag_color_overrides():
return dict(TagColor.objects.values_list("name", "color"))
def tags_with_colors(tags, overrides=None):
overrides = overrides if overrides is not None else tag_color_overrides()
return [{"name": tag, "color": tag_color(tag, overrides)} for tag in tags]
def labels_with_colors_list(labels, overrides=None):
overrides = overrides if overrides is not None else tag_color_overrides()
return [{"name": label, "color": tag_color(label, overrides)} for label in labels]
def labels_with_colors(artifact, overrides=None):
return labels_with_colors_list([artifact.source, *artifact.tags], overrides)
def validate_source_fields(data):
if source_type := data.get("source_type"):
if source_type not in {choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES}:
return Response({"detail": "source_type must be agent, person, or system"}, status=400)
try:
parse_metadata_value(data.get("metadata", {}))
except ValueError as exc:
return Response({"detail": str(exc)}, status=400)
try:
parse_tag_colors_value(data.get("tag_colors", {}))
except ValueError as exc:
return Response({"detail": str(exc)}, status=400)
return None
def parse_tag_colors_value(value):
if value in (None, ""):
return {}
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError as exc:
raise ValueError("tag_colors must be an object") from exc
if not isinstance(value, dict):
raise ValueError("tag_colors must be an object")
for tag, color in value.items():
if not HEX_COLOR_RE.match(str(color)):
raise ValueError(f"tag_colors[{tag}] must be a hex color like #RRGGBB")
return value
def apply_tag_colors(data):
tag_colors = parse_tag_colors_value(data.get("tag_colors", {}))
for tag, color in tag_colors.items():
TagColor.objects.update_or_create(name=tag, defaults={"color": color})
def source_fields(data, source):
return {
"source_type": data.get("source_type") or Artifact.AGENT,
"source_name": data.get("source_name") or source,
"source_id": data.get("source_id") or "",
"metadata": parse_metadata_value(data.get("metadata", {})),
}
def parse_metadata_value(value):
if value in (None, ""):
return {}
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
except json.JSONDecodeError as exc:
raise ValueError("metadata must be an object") from exc
if isinstance(parsed, dict):
return parsed
raise ValueError("metadata must be an object")
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])),
"api_url": request.build_absolute_uri(reverse("artifact-detail-api", args=[artifact.id])),
"raw_url": request.build_absolute_uri(f"{reverse('artifact', args=[artifact.id])}?raw=1"),
"raw_available": raw_artifact_path(artifact) is not None,
"source": artifact.source,
"title": artifact.title,
"kind": artifact.kind,
"theme": artifact.theme,
"day": artifact.day.isoformat(),
"created_at": artifact.created_at.isoformat(),
"tags": tags_with_colors(artifact.tags),
"labels": labels_with_colors(artifact),
"source_prompt": artifact.source_prompt,
"source_type": artifact.source_type,
"source_name": artifact.source_name,
"source_id": artifact.source_id,
"metadata": artifact.metadata,
"read_at": artifact.read_at.isoformat() if artifact.read_at else None,
"is_kept": artifact.is_kept,
"size_bytes": artifact.size_bytes,
}
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
class DaybookLoginView(auth_views.LoginView):
template_name = "artifacts/login.html"
authentication_form = DaybookLoginForm
def form_valid(self, form):
response = super().form_valid(form)
if form.cleaned_data.get("remember_me"):
self.request.session.set_expiry(REMEMBER_ME_SESSION_AGE)
else:
self.request.session.set_expiry(0)
return response
@login_required
def settings_home(request):
return redirect("settings-account")
@login_required
def settings_account(request):
token, _ = Token.objects.get_or_create(user=request.user)
if request.method == "POST" and request.POST.get("action") == "regenerate":
token.delete()
token = Token.objects.create(user=request.user)
messages.success(request, "API token regenerated.")
return redirect("settings-account")
return render(
request,
"artifacts/settings_account.html",
{"token": token, "active_section": "account"},
)
@login_required
def settings_demo_data(request):
if request.method == "POST":
count = create_demo_artifacts(request.user, request)
messages.success(request, f"Created {count} demo artifacts.")
return redirect("settings-demo-data")
return render(
request,
"artifacts/settings_demo_data.html",
{"active_section": "demo-data"},
)
@login_required
def settings_storage(request):
used = user_usage_bytes(request.user)
quota = user_quota_bytes(request.user)
kept_bytes = (
Artifact.objects.filter(owner=request.user, is_kept=True)
.aggregate(total=Sum("size_bytes"))
.get("total")
or 0
)
context = {
"active_section": "storage",
"used_bytes": used,
"quota_bytes": quota,
"kept_bytes": kept_bytes,
"percent_used": min(100, round(used / quota * 100)) if quota else 0,
"artifact_count": Artifact.objects.filter(owner=request.user).count(),
"kept_count": Artifact.objects.filter(owner=request.user, is_kept=True).count(),
}
if request.user.is_superuser:
context["is_superuser"] = True
context["total_allocated"] = total_allocated_bytes()
context["disk_free"] = disk_free_bytes()
return render(request, "artifacts/settings_storage.html", context)
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>
"""

View file

8
daybook_project/asgi.py Normal file
View file

@ -0,0 +1,8 @@
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "daybook_project.settings")
application = get_asgi_application()

View file

@ -0,0 +1,82 @@
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = Path(os.environ.get("DAYBOOK_DATA_DIR", BASE_DIR)).resolve()
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-change-me")
DEBUG = os.environ.get("DJANGO_DEBUG", "1").lower() in {"1", "true", "yes", "on"}
ALLOWED_HOSTS = [host.strip() for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",") if host.strip()]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"rest_framework.authtoken",
"artifacts",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "daybook_project.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "daybook_project.wsgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.environ.get("DATABASE_PATH", DATA_DIR / "db.sqlite3"),
}
}
AUTH_PASSWORD_VALIDATORS = []
LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "feed"
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
ARTIFACTS_ROOT = Path(os.environ.get("ARTIFACTS_ROOT", DATA_DIR / "artifacts_store"))

13
daybook_project/urls.py Normal file
View file

@ -0,0 +1,13 @@
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path
from artifacts.views import DaybookLoginView
urlpatterns = [
path("admin/", admin.site.urls),
path("accounts/login/", DaybookLoginView.as_view(), name="login"),
path("accounts/logout/", auth_views.LogoutView.as_view(next_page="login"), name="logout"),
path("", include("artifacts.urls")),
]

8
daybook_project/wsgi.py Normal file
View file

@ -0,0 +1,8 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "daybook_project.settings")
application = get_wsgi_application()

24
deploy/Dockerfile Normal file
View file

@ -0,0 +1,24 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/requirements.txt
RUN pip install --upgrade pip \
&& pip install -r /app/requirements.txt
COPY . /app
RUN sed -i 's/\r$//' /app/deploy/entrypoint.sh \
&& chmod +x /app/deploy/entrypoint.sh
EXPOSE 8000
ENTRYPOINT ["/app/deploy/entrypoint.sh"]
CMD ["gunicorn", "daybook_project.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120"]

19
deploy/docker-compose.yml Normal file
View file

@ -0,0 +1,19 @@
services:
daybook:
build:
context: ..
dockerfile: deploy/Dockerfile
container_name: daybook
restart: unless-stopped
env_file:
- .env
ports:
- "8000:8000"
volumes:
- ./data:/data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/api/capabilities"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s

9
deploy/entrypoint.sh Normal file
View file

@ -0,0 +1,9 @@
#!/usr/bin/env sh
set -eu
mkdir -p "${DAYBOOK_DATA_DIR:-/data}" "${ARTIFACTS_ROOT:-/data/artifacts_store}"
python manage.py migrate --noinput
python manage.py collectstatic --noinput
exec "$@"

7
deploy/env.example Normal file
View file

@ -0,0 +1,7 @@
DJANGO_DEBUG=0
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,your-proxmox-container-ip,your-domain.example
DAYBOOK_API_TOKEN=replace-with-a-long-random-token
DAYBOOK_DATA_DIR=/data
DATABASE_PATH=/data/db.sqlite3
ARTIFACTS_ROOT=/data/artifacts_store

View file

@ -0,0 +1,348 @@
# Proxmox Container Deployment Runbook For LLM Operators
This document is written for an LLM or automation agent that is deploying Daybook into a Linux container on a Proxmox host.
The recommended deployment target is a Proxmox LXC or VM running Debian/Ubuntu with Docker and Docker Compose. Daybook itself runs as a Docker container inside that guest.
## Deployment Model
```text
Proxmox host
Debian/Ubuntu LXC or VM
Docker Engine
daybook container
/app application code baked into image
/data persistent mounted volume
```
Persistent runtime data lives outside the image in `deploy/data` on the guest:
```text
deploy/data/
db.sqlite3
artifacts_store/
```
Do not store runtime data inside the image.
## Files In This Directory
| File | Purpose |
|---|---|
| `Dockerfile` | Builds the Daybook image. |
| `docker-compose.yml` | Runs Daybook with a persistent `./data:/data` bind mount. |
| `entrypoint.sh` | Runs migrations, collects static files, then starts Gunicorn. |
| `env.example` | Template for deployment environment variables. |
| `proxmox_llm_deploy.md` | This runbook. |
## Prerequisites On The Proxmox Guest
The guest OS should have:
- Docker Engine
- Docker Compose plugin
- Git
- Network access to pull Python base images and Python packages
For Debian/Ubuntu guests, install basics:
```bash
sudo apt-get update
sudo apt-get install -y ca-certificates curl git
```
Install Docker using Docker's current official instructions for the guest OS. Then verify:
```bash
docker --version
docker compose version
```
## Clone Or Copy The Project
Choose a stable application path on the guest:
```bash
sudo mkdir -p /opt/daybook
sudo chown "$USER:$USER" /opt/daybook
```
If using Git:
```bash
git clone <REPO_URL> /opt/daybook
cd /opt/daybook
```
If files were copied by another mechanism, ensure the project root contains:
```text
manage.py
requirements.txt
deploy/docker-compose.yml
deploy/Dockerfile
```
## Create The Environment File
From the project root:
```bash
cd /opt/daybook
cp deploy/env.example deploy/.env
```
Generate secrets:
```bash
python3 - <<'PY'
import secrets
print('DJANGO_SECRET_KEY=' + secrets.token_urlsafe(50))
print('DAYBOOK_API_TOKEN=' + secrets.token_urlsafe(32))
PY
```
Edit `deploy/.env`:
```bash
nano deploy/.env
```
Set at minimum:
```text
DJANGO_DEBUG=0
DJANGO_SECRET_KEY=<generated-secret>
DJANGO_ALLOWED_HOSTS=<container-ip>,<dns-name-if-any>,localhost,127.0.0.1
DAYBOOK_API_TOKEN=<generated-token>
DAYBOOK_DATA_DIR=/data
DATABASE_PATH=/data/db.sqlite3
ARTIFACTS_ROOT=/data/artifacts_store
```
Important:
- Do not commit `deploy/.env`.
- Do not use the development token `dev-token` for a real deployment.
- Include the actual IP or DNS name users will use in `DJANGO_ALLOWED_HOSTS`.
## Create Persistent Data Directory
From the project root:
```bash
mkdir -p deploy/data
```
The compose file mounts this path into the container as `/data`.
## Build And Start
From the project root:
```bash
docker compose -f deploy/docker-compose.yml up -d --build
```
Watch logs:
```bash
docker compose -f deploy/docker-compose.yml logs -f daybook
```
Expected startup behavior:
- Django migrations run.
- Static files are collected.
- Gunicorn starts on port `8000` inside the container.
- Host port `8000` maps to container port `8000`.
## Verify Deployment
Check container state:
```bash
docker compose -f deploy/docker-compose.yml ps
```
Check capabilities endpoint from the guest:
```bash
curl -fsS http://127.0.0.1:8000/api/capabilities
```
Expected JSON includes:
```json
{
"artifact_kinds": ["html_fragment", "html_full", "markdown", "deck", "image", "pdf", "binary"],
"auth": "bearer_token"
}
```
Open the app from another machine:
```text
http://<guest-ip>:8000/
```
If this fails:
- Confirm the Proxmox guest firewall allows TCP `8000`.
- Confirm any Proxmox firewall rules allow TCP `8000`.
- Confirm `DJANGO_ALLOWED_HOSTS` includes the hostname/IP being used.
## Publish A Smoke-Test Artifact
Replace `<TOKEN>` with `DAYBOOK_API_TOKEN` from `deploy/.env`.
```bash
curl -fsS -X POST http://127.0.0.1:8000/api/artifacts \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"persona": "deploy-smoke",
"source_type": "agent",
"source_name": "Deployment Smoke Agent",
"kind": "markdown",
"title": "Deployment Smoke Test",
"content": "# Deployment Smoke Test\n\nDaybook accepted an artifact.",
"tags": ["deploy", "smoke"],
"metadata": {"run_id": "deploy-smoke-001"}
}'
```
Then refresh:
```text
http://<guest-ip>:8000/
```
## Operational Commands
Stop:
```bash
docker compose -f deploy/docker-compose.yml down
```
Start:
```bash
docker compose -f deploy/docker-compose.yml up -d
```
Rebuild after code changes:
```bash
docker compose -f deploy/docker-compose.yml up -d --build
```
View logs:
```bash
docker compose -f deploy/docker-compose.yml logs -f daybook
```
Run Django management command:
```bash
docker compose -f deploy/docker-compose.yml exec daybook python manage.py check
```
Open shell:
```bash
docker compose -f deploy/docker-compose.yml exec daybook sh
```
## Backup
Back up the guest directory:
```text
/opt/daybook/deploy/data
```
It contains:
- SQLite database
- Artifact files
Simple backup command:
```bash
tar -czf daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data
```
For consistent backups during heavy writes, stop the container first:
```bash
docker compose -f /opt/daybook/deploy/docker-compose.yml down
tar -czf daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data
docker compose -f /opt/daybook/deploy/docker-compose.yml up -d
```
## Restore
Stop Daybook:
```bash
docker compose -f /opt/daybook/deploy/docker-compose.yml down
```
Replace `/opt/daybook/deploy/data` with the restored data directory, then start:
```bash
docker compose -f /opt/daybook/deploy/docker-compose.yml up -d
```
## Updating The App
If deployed from Git:
```bash
cd /opt/daybook
git pull
docker compose -f deploy/docker-compose.yml up -d --build
```
The container entrypoint runs migrations on startup.
## Reverse Proxy Notes
If putting Daybook behind Nginx, Caddy, Traefik, or a Proxmox-level reverse proxy:
- Proxy to `http://<guest-ip>:8000`.
- Add the public hostname to `DJANGO_ALLOWED_HOSTS`.
- Terminate TLS at the proxy.
- Keep `DJANGO_DEBUG=0`.
Example `DJANGO_ALLOWED_HOSTS`:
```text
DJANGO_ALLOWED_HOSTS=daybook.example.com,localhost,127.0.0.1
```
## LLM Deployment Checklist
Before declaring deployment complete, verify all items:
- `deploy/.env` exists and does not use placeholder secrets.
- `DJANGO_DEBUG=0`.
- `DJANGO_ALLOWED_HOSTS` includes the access hostname/IP.
- `deploy/data/` exists.
- `docker compose -f deploy/docker-compose.yml ps` shows `daybook` running or healthy.
- `curl -fsS http://127.0.0.1:8000/api/capabilities` succeeds on the guest.
- Browser opens `http://<guest-ip>:8000/`.
- Smoke-test artifact POST succeeds with the configured bearer token.
- `deploy/data/db.sqlite3` exists after startup.
- `deploy/data/artifacts_store/` exists after posting an artifact.
## Known MVP Constraints
- SQLite is used by default. This is acceptable for local/self-hosted MVP usage.
- The API uses a single shared bearer token.
- No reader authentication is implemented yet.
- Manual drag/drop upload is deferred.
- Content revisions are deferred.

140
docs/agent_quickstart.md Normal file
View file

@ -0,0 +1,140 @@
# Agent Quickstart
This is the shortest path for an agent to publish and retrieve artifacts from Daybook.
## 0. Get Your API Token
Log in to the app in a browser and copy your personal token from **Settings → Account**
(`http://127.0.0.1:8001/settings/account/`). Every artifact you post with it lands in your
feed, and only yours.
## 1. Check Capabilities
Also confirms your token is valid:
```powershell
Invoke-RestMethod "http://127.0.0.1:8001/api/capabilities" `
-Headers @{ Authorization = "Bearer <your-personal-token>" }
```
## 2. Publish Markdown
```powershell
$body = @{
source = "hermes-research"
source_type = "agent"
source_name = "Hermes Agent"
source_id = "hermes-local"
kind = "markdown"
title = "Agent Status Update"
content = "# Status Update`n`nThe agent completed its run successfully."
tags = @("status", "agent")
metadata = @{
run_id = "run-001"
model = "gpt-5.5"
}
} | ConvertTo-Json -Depth 5
$artifact = Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts" `
-Method Post `
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
-ContentType "application/json" `
-Body $body
$artifact
```
Open the artifact in the browser:
```powershell
$artifact.url
```
## 3. Publish HTML Fragment
```powershell
$body = @{
source = "athena"
source_type = "agent"
source_name = "Athena Agent"
kind = "html_fragment"
theme = "tasks"
title = "Task List"
content = "<h1>Tasks</h1><ul><li>Review API docs</li><li>Publish next artifact</li></ul>"
tags = @("tasks")
} | ConvertTo-Json -Depth 5
Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts" `
-Method Post `
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
-ContentType "application/json" `
-Body $body
```
## 4. Publish A File
```powershell
curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
-H "Authorization: Bearer <your-personal-token>" `
-F "source=orion" `
-F "source_type=agent" `
-F "source_name=Orion Agent" `
-F "title=Generated PDF" `
-F "tags=pdf,report" `
-F "file=@C:\path\to\report.pdf;type=application/pdf"
```
## 5. Find Recent Artifacts
> **Note:** artifact read endpoints (steps 5 and 6) require a logged-in **browser session**;
> a bearer token is not accepted. These are for browsing from the app, not for agents.
> Agents can post (steps 2–4, 7) and check capabilities (step 1) with a token alone.
```powershell
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?source=hermes-research"
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?label=status"
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?kind=markdown&q=status"
```
## 6. Read One Artifact Metadata
```powershell
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts/$($artifact.id)"
```
## 7. Update Metadata
```powershell
$body = @{
tags = @("status", "agent", "reviewed")
metadata = @{
run_id = "run-001"
model = "gpt-5.5"
reviewed = $true
}
} | ConvertTo-Json -Depth 5
Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" `
-Method Patch `
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
-ContentType "application/json" `
-Body $body
```
## Agent Payload Checklist
Recommended fields for every agent post:
- `source`: stable label identifying what posted this (agent-profile, script, or person name).
- `source_type`: usually `agent`.
- `source_name`: readable agent name.
- `source_id`: stable ID if the agent has one.
- `title`: useful human title.
- `kind`: artifact type.
- `content` or `file`: artifact content.
- `tags`: small set of useful filters.
- `metadata.run_id`: lets later artifacts be correlated to the same agent run.
- `metadata.model`: model or system that generated the artifact.

425
docs/api.md Normal file
View file

@ -0,0 +1,425 @@
# Daybook API
Daybook is built around agents publishing artifacts. Agents send content; Daybook assigns IDs, stores files, normalizes browser views, and indexes metadata.
Base URL while running locally:
```text
http://127.0.0.1:8001
```
Authentication for write endpoints (POST/PATCH) — a personal per-user API token, from Settings → Account in the app:
```http
Authorization: Bearer <your-personal-token>
```
Every artifact belongs to the user whose token authenticated the POST — feeds are private per user.
Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) require a
logged-in browser session, not a bearer token — visit the app in a browser and log in first.
`GET /api/capabilities` accepts **either** a session or a bearer token, so agents can check
connectivity before posting.
## Artifact Kinds
Currently supported kinds:
| Kind | Upload type | Rendered behavior | Raw support |
|---|---|---|---|
| `html_fragment` | JSON text | Wrapped with a Daybook theme | No |
| `html_full` | JSON text | Stored as complete HTML | Yes |
| `markdown` | JSON text | Rendered to HTML and original `.md` is preserved | Yes |
| `deck` | JSON text | Wrapped as a simple HTML slide/deck page | No |
| `image` | Multipart file | Browser image preview | No |
| `pdf` | Multipart file | Browser PDF preview | No |
| `binary` | Multipart file | Stored and downloadable | No |
## Source Fields
Every artifact can identify who or what produced it. `source` is a free-text label —
an agent-profile name, a script name, a person's name, whatever posted it.
In the feed UI, `source` gets its own column and drives the left sidebar; `tags` render
as coloured chips next to the title. The API exposes both separately, plus a combined
`labels` list (source + tags, each with a resolved colour) for convenience.
| Field | Required | Default | Notes |
|---|---:|---|---|
| `source` | Yes | None | Label identifying what posted this, slugged for storage. |
| `source_type` | No | `agent` | One of `agent`, `person`, `system`. |
| `source_name` | No | `source` | Human-readable source name. |
| `source_id` | No | Empty string | Stable external agent/person/system ID. |
| `metadata` | No | `{}` | Free-form object for model/run/tool metadata. |
Example source metadata:
```json
{
"source": "hermes-research",
"source_type": "agent",
"source_name": "Hermes Agent",
"source_id": "agent-hermes-prod",
"metadata": {
"model": "gpt-5.5",
"run_id": "run-2026-06-10-001",
"tool": "nightly-brief"
}
}
```
## Create Text Artifact
Endpoint:
```http
POST /api/artifacts
Content-Type: application/json
Authorization: Bearer <your-personal-token>
```
Required fields:
| Field | Notes |
|---|---|
| `source` | Required. |
| `kind` | One of `html_fragment`, `html_full`, `markdown`, `deck`. |
| `title` | Required. |
| `content` | Required text content. |
Optional fields:
| Field | Notes |
|---|---|
| `theme` | Defaults by kind. Common values: `report`, `tasks`, `deck`, `base`. |
| `tags` | Array or comma-separated string. |
| `tag_colors` | Object mapping tag name → `#RRGGBB`. Sets a colour override for those tags. |
| `source_prompt` | Prompt or instruction that led to the artifact. |
| `source_type` | Defaults to `agent`. |
| `source_name` | Defaults to `source`. |
| `source_id` | Optional stable source ID. |
| `metadata` | Optional object. |
### Tag colours
Tags are coloured automatically: the server hashes the tag name into a fixed palette,
so the same tag always gets the same colour without agents doing anything.
To force a specific colour, pass `tag_colors`:
```json
{"tags": ["urgent", "billing"], "tag_colors": {"urgent": "#d1453b"}}
```
Colour overrides are **global and shared across all users** — setting a colour for
`urgent` changes it everywhere, for everyone. Values must be `#RRGGBB`; anything else
returns `400`. Overrides can also be set via `PATCH` on an existing artifact.
PowerShell example:
```powershell
$body = @{
source = "hermes-research"
source_type = "agent"
source_name = "Hermes Agent"
source_id = "agent-hermes-local"
kind = "markdown"
theme = "report"
title = "Morning Brief"
content = "# Morning Brief`n`n- Review overnight output`n- Plan next actions"
tags = @("brief", "daily")
source_prompt = "Prepare a concise morning brief."
metadata = @{
model = "gpt-5.5"
run_id = "brief-001"
}
} | ConvertTo-Json -Depth 5
Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts" `
-Method Post `
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
-ContentType "application/json" `
-Body $body
```
Response shape:
```json
{
"id": "a1b2c3d4",
"url": "http://127.0.0.1:8001/a/a1b2c3d4",
"api_url": "http://127.0.0.1:8001/api/artifacts/a1b2c3d4",
"raw_url": "http://127.0.0.1:8001/a/a1b2c3d4?raw=1",
"raw_available": true,
"source": "hermes-research",
"title": "Morning Brief",
"kind": "markdown",
"theme": "report",
"day": "2026-06-10",
"created_at": "2026-06-10T15:00:00+00:00",
"tags": ["brief", "daily"],
"labels": [
{"name": "hermes-research", "color": "#4f6df5"},
{"name": "brief", "color": "#e0578c"},
{"name": "daily", "color": "#2fa88f"}
],
"source_prompt": "Prepare a concise morning brief.",
"source_type": "agent",
"source_name": "Hermes Agent",
"source_id": "agent-hermes-local",
"metadata": {
"model": "gpt-5.5",
"run_id": "brief-001"
},
"read_at": null
}
```
`read_at` is `null` until the artifact is opened in the feed viewer, then holds the
timestamp it was first read. Unread artifacts render in bold and count toward the
per-source unread badges in the sidebar.
## Create File Artifact
Endpoint:
```http
POST /api/artifacts
Content-Type: multipart/form-data
Authorization: Bearer <your-personal-token>
```
Required fields:
| Field | Notes |
|---|---|
| `source` | Required. |
| `title` | Required. |
| `file` | Required file part. |
File kind is inferred from content type:
| Content type | Artifact kind |
|---|---|
| `image/*` | `image` |
| `application/pdf` | `pdf` |
| Anything else | `binary` |
PowerShell/curl example:
```powershell
curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
-H "Authorization: Bearer <your-personal-token>" `
-F "source=orion" `
-F "source_type=agent" `
-F "source_name=Orion Agent" `
-F "title=Architecture Diagram" `
-F "tags=diagram,architecture" `
-F "file=@C:\path\to\diagram.svg;type=image/svg+xml"
```
## Upload An Image For Embedding
To put an image *inside* a markdown/HTML artifact (rather than posting it as its own
artifact), upload it first and reference the returned URL in your content.
```http
POST /api/assets
Content-Type: multipart/form-data
Authorization: Bearer <your-personal-token>
```
| Field | Notes |
|---|---|
| `file` | Required. Must be an `image/*` content type. Max 50 MB. |
Response:
```json
{
"id": "ab12cd34ef56",
"url": "http://127.0.0.1:8001/assets/ab12cd34ef56",
"content_type": "image/png",
"size_bytes": 20481
}
```
Then reference the URL normally:
```markdown
![Chart](http://127.0.0.1:8001/assets/ab12cd34ef56)
```
Lifecycle and quota:
- The image counts toward your quota **immediately on upload**, and can trigger eviction
of old unkept artifacts like any other post.
- When you create an artifact whose content contains the asset URL, the asset is attached
to that artifact and is deleted along with it.
- Uploads never referenced by any artifact are swept after 24 hours.
- Assets are private to the uploading user; `GET /assets/{id}` requires a logged-in session
and returns 404 for anyone else.
## List Artifacts
Endpoint:
```http
GET /api/artifacts
```
Requires a logged-in browser session. Returns only the current user's artifacts.
Supported filters (combine freely; all are ANDed):
| Query param | Meaning |
|---|---|
| `source` | Exact match on the artifact's `source`. |
| `label` | Matches any of the artifact's `tags`. |
| `day` | Exact day, `YYYY-MM-DD`. |
| `kind` | Exact artifact kind. |
| `q` | Case-insensitive title fragment. |
`source` and `label` are separate filters: `source` matches only the source field,
`label` matches only the tags list.
Examples:
```powershell
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts"
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?source=hermes-research"
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?kind=markdown&q=brief"
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?day=2026-06-10&label=daily"
```
## Get Artifact Metadata
Endpoint:
```http
GET /api/artifacts/{id}
```
Example:
```powershell
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts/a1b2c3d4"
```
## Update Artifact Metadata
Endpoint:
```http
PATCH /api/artifacts/{id}
Content-Type: application/json
Authorization: Bearer <your-personal-token>
```
Editable fields:
| Field | Notes |
|---|---|
| `title` | Must not be blank. |
| `tags` | Array or comma-separated string. |
| `tag_colors` | Object mapping tag name → `#RRGGBB`. Updates the global colour registry. |
| `source_prompt` | String. |
| `source_type` | `agent`, `person`, or `system`. |
| `source_name` | String. |
| `source_id` | String. |
| `metadata` | Object. Replaces existing metadata. |
You can only PATCH artifacts your own token owns; others return `404`.
Not editable yet:
| Field | Reason |
|---|---|
| `content` | Content replacement/revisions are intentionally deferred. |
| `source` | Affects storage layout and feed identity. |
| `kind` | Affects renderer and storage behavior. |
| `day` | Affects time-ordered feed and storage layout. |
PowerShell example:
```powershell
$body = @{
title = "Morning Brief - Reviewed"
tags = @("brief", "daily", "reviewed")
metadata = @{
model = "gpt-5.5"
run_id = "brief-001"
reviewed = $true
}
} | ConvertTo-Json -Depth 5
Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" `
-Method Patch `
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
-ContentType "application/json" `
-Body $body
```
## Rendered And Raw Content
Rendered browser view:
```http
GET /a/{id}
```
Raw/source view:
```http
GET /a/{id}?raw=1
```
Raw currently exists for:
| Kind | Raw content |
|---|---|
| `markdown` | Preserved `.md` source. |
| `html_full` | Stored HTML file served as text. |
If raw content is unavailable, Daybook returns a small HTML fallback page.
## Capabilities
Endpoint:
```http
GET /api/capabilities
```
Example response:
```json
{
"artifact_kinds": ["html_fragment", "html_full", "markdown", "deck", "image", "pdf", "binary"],
"filters": ["source", "label", "day", "kind", "q"],
"views": ["rendered", "raw"],
"raw_supported_for": ["markdown", "html_full"],
"source_types": ["agent", "person", "system"],
"auth": "bearer_token"
}
```
Agents should call this endpoint if they need to adapt to the current Daybook instance, or
simply to check the instance is reachable and their token is valid. Unlike the other read
endpoints, it accepts a bearer token as well as a browser session:
```bash
curl -H "Authorization: Bearer <your-personal-token>" http://127.0.0.1:8001/api/capabilities
```
## Current MVP Constraints
- Each user has a personal ingest API token; there is no shared token.
- Reader/browser access requires login; feeds are private per user.
- Content modification/revisions are deferred.
- Office document conversion is not implemented.
- Manual browser upload/drag-and-drop is deferred.
- Pagination/cursors are not implemented yet.

108
docs/llm_posting_helper.md Normal file
View file

@ -0,0 +1,108 @@
# LLM Posting Helper
Agents should publish to Daybook with `skills/daybook-posting/scripts/daybook_post.py` instead of hand-building HTTP, JSON, curl, or PowerShell multipart requests.
The project also keeps `scripts/daybook_post.py` as a compatibility wrapper.
The helper is intentionally zero-dependency and uses only Python's standard library. It works on Windows and Linux.
## Configuration
```text
DAYBOOK_URL=http://127.0.0.1:8001
DAYBOOK_TOKEN=your-personal-api-token
DAYBOOK_SOURCE=agent
```
`DAYBOOK_TOKEN` is a personal per-user token, from Settings → Account in the app — not a token shared across users. Override these per environment.
## Commands
Check the instance:
```bash
python skills/daybook-posting/scripts/daybook_post.py --check
```
Post Markdown from a file:
```bash
python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" --content-file report.md --tags build,report
```
Post Markdown from stdin:
```bash
cat report.md | python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report"
```
Post a deck:
```bash
python skills/daybook-posting/scripts/daybook_post.py --kind deck --title "Deployment Plan" --content-file deck.html
```
`deck.html` must contain top-level `<section>...</section>` blocks.
Post an image/PDF/binary file:
```bash
python skills/daybook-posting/scripts/daybook_post.py --file diagram.svg --title "Architecture Diagram"
python skills/daybook-posting/scripts/daybook_post.py --file report.pdf --title "Generated Report"
```
Render CSV/JSON/code as Markdown today:
```bash
python skills/daybook-posting/scripts/daybook_post.py --file results.csv --as-markdown --title "Results CSV"
```
Dry run:
```bash
python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Draft" --content-file report.md --dry-run
```
The artifact URL is printed as the final stdout line.
## Validation Guardrails
The helper validates common LLM mistakes before sending:
| Kind/input | Validation |
|---|---|
| `deck` | Must contain `<section`. |
| `html_fragment` | Must not contain `<!doctype`, `<html`, or `<body`. |
| `html_full` | Must contain `<!doctype` and `<html`. |
| `markdown` | Must not be blank. |
| `.svg` upload | Forces `image/svg+xml`. |
| `.pdf` upload | Forces `application/pdf`. |
## Metadata
Use repeatable `--meta key=value` flags:
```bash
python skills/daybook-posting/scripts/daybook_post.py \
--kind markdown \
--title "Run Summary" \
--content-file summary.md \
--meta run_id=run-001 \
--meta model=gpt-5.5
```
Or pass a JSON object:
```bash
python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Run Summary" --content-file summary.md --meta-json '{"run_id":"run-001"}'
```
## Skill Wrapper
A thin LLM-facing wrapper lives at:
```text
skills/daybook-posting/SKILL.md
```
Its key instruction is: do not hand-build requests; use `scripts/daybook_post.py`.

14
manage.py Normal file
View file

@ -0,0 +1,14 @@
#!/usr/bin/env python
import os
import sys
def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "daybook_project.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

5
requirements.txt Normal file
View file

@ -0,0 +1,5 @@
Django>=5.0,<6.0
djangorestframework>=3.15,<4.0
Markdown>=3.6,<4.0
gunicorn>=23.0,<24.0
whitenoise>=6.7,<7.0

28
scripts/README.md Normal file
View file

@ -0,0 +1,28 @@
# Daybook Posting Helper
`scripts/daybook_post.py` is a compatibility wrapper for the canonical zero-dependency helper at `skills/daybook-posting/scripts/daybook_post.py`.
The helper is for agents and scripts that need to publish artifacts to Daybook without hand-crafting JSON, curl, or multipart requests.
Configuration:
```text
DAYBOOK_URL=http://127.0.0.1:8001
DAYBOOK_TOKEN=your-personal-api-token
DAYBOOK_SOURCE=agent
```
`DAYBOOK_TOKEN` is a personal per-user token from Settings → Account in the app, not shared across users.
Examples:
```bash
python skills/daybook-posting/scripts/daybook_post.py --check
python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" --content-file report.md --tags build,report
cat report.md | python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report"
python skills/daybook-posting/scripts/daybook_post.py --kind deck --title "Plan" --content-file deck.html
python skills/daybook-posting/scripts/daybook_post.py --file diagram.svg --title "Architecture Diagram"
python skills/daybook-posting/scripts/daybook_post.py --file results.csv --as-markdown --title "Results CSV"
```
The artifact browser URL is printed as the final stdout line.

16
scripts/daybook_post.py Normal file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env python3
"""Compatibility wrapper for the Daybook posting helper.
The canonical helper lives inside the self-contained skill folder:
skills/daybook-posting/scripts/daybook_post.py
"""
import runpy
from pathlib import Path
HELPER = Path(__file__).resolve().parent.parent / "skills" / "daybook-posting" / "scripts" / "daybook_post.py"
if __name__ == "__main__":
runpy.run_path(str(HELPER), run_name="__main__")

View file

@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Post one demo artifact of every kind, tagged, dated today.
Posts through the real ingest API using the existing daybook_post.py helper,
so seeded data goes through the same normalization as real agent output.
Usage (from project root, with the dev server running):
python scripts/seed_demo_artifacts.py
"""
import subprocess
import sys
import tempfile
from pathlib import Path
HELPER = Path(__file__).resolve().parent.parent / "skills" / "daybook-posting" / "scripts" / "daybook_post.py"
PERSONA = "seed-demo"
TAGS = "seed-demo,demo"
def run_post(args):
result = subprocess.run([sys.executable, str(HELPER), *args], capture_output=True, text=True)
print(result.stdout.strip())
if result.returncode != 0:
print(result.stderr.strip(), file=sys.stderr)
return result.returncode
def post_text(kind, title, content, extra_tag):
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False, encoding="utf-8") as tmp:
tmp.write(content)
content_path = tmp.name
return run_post([
"--kind", kind,
"--title", title,
"--persona", PERSONA,
"--tags", f"{TAGS},{extra_tag}",
"--content-file", content_path,
])
def post_file(title, file_path, extra_tag):
return run_post([
"--file", str(file_path),
"--title", title,
"--persona", PERSONA,
"--tags", f"{TAGS},{extra_tag}",
])
def main():
exit_codes = []
exit_codes.append(post_text(
"markdown",
"Seed: Markdown Sample",
"# Markdown Sample\n\nThis is a **seed** artifact demonstrating markdown rendering.\n\n- item one\n- item two\n",
"markdown",
))
exit_codes.append(post_text(
"html_fragment",
"Seed: HTML Fragment Sample",
"<h2>HTML Fragment</h2><p>This is a seed HTML fragment artifact.</p>",
"html-fragment",
))
exit_codes.append(post_text(
"html_full",
"Seed: Full HTML Sample",
"<!doctype html><html><head><title>Seed Full HTML</title></head>"
"<body><h1>Full HTML Doc</h1><p>Seed artifact.</p></body></html>",
"html-full",
))
exit_codes.append(post_text(
"deck",
"Seed: Deck Sample",
"<section><h1>Slide One</h1><p>Seed deck artifact.</p></section>"
"<section><h1>Slide Two</h1><p>More content.</p></section>",
"deck",
))
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_dir = Path(tmp_dir)
png_path = tmp_dir / "seed_pixel.png"
png_path.write_bytes(bytes.fromhex(
"89504e470d0a1a0a0000000d494844520000000100000001080600000"
"01f15c4890000000a49444154789c6360000002000155e59a1a0000000049454e44ae426082"
))
exit_codes.append(post_file("Seed: Image Sample", png_path, "image"))
pdf_path = tmp_dir / "seed_doc.pdf"
pdf_path.write_bytes(
b"%PDF-1.1\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\n"
b"trailer<</Root 1 0 R>>\n"
)
exit_codes.append(post_file("Seed: PDF Sample", pdf_path, "pdf"))
bin_path = tmp_dir / "seed_data.bin"
bin_path.write_bytes(b"\x00\x01\x02\x03seed-binary-data\xff\xfe")
exit_codes.append(post_file("Seed: Binary Sample", bin_path, "binary"))
return 1 if any(exit_codes) else 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,73 @@
---
name: daybook-posting
description: Use when you have produced a finished deliverable such as a report, summary, brief, diagram, generated HTML, PDF, deck, or status update that a human should review later outside this chat. Publishes the artifact to Daybook and returns a URL.
---
# Daybook Posting
Daybook is a private artifact journal for completed agent outputs. It stores, indexes, and displays artifacts for human review. It does not execute commands, run workflows, or continue conversations.
Always use the helper script. Do not hand-build curl, JSON, PowerShell multipart, or inline shell content.
From the project root:
```bash
python skills/daybook-posting/scripts/daybook_post.py --check
python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" --content-file report.md
cat report.md | python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report"
python skills/daybook-posting/scripts/daybook_post.py --file diagram.svg --title "Architecture Diagram"
```
The project also keeps a compatibility wrapper at `scripts/daybook_post.py`, but the canonical helper is bundled inside this skill.
The artifact URL is printed as the final stdout line.
## Configuration
Use environment variables:
```text
DAYBOOK_URL=http://127.0.0.1:8001
DAYBOOK_TOKEN=your-personal-api-token
DAYBOOK_SOURCE=agent
```
`DAYBOOK_TOKEN` is your personal per-user API token, from Settings → Account in the app (not a token shared by everyone). Artifacts posted with your token land only in your feed.
## Kind Cheat Sheet
| Kind | Use for | Content shape |
|---|---|---|
| `markdown` | Reports, briefs, notes | Markdown text. |
| `html_fragment` | Themed HTML reports/cards/tables | Body-only HTML. No `html/head/body`. |
| `html_full` | Complete custom pages/frameworks | Full HTML document with doctype/html/head/body. |
| `deck` | Simple slide-like artifact | Top-level `<section>...</section>` blocks. |
For images, PDFs, SVGs, and other files, use `--file`; the helper infers multipart and content type.
## Images Inside Content
To embed an image *within* a markdown or HTML artifact (rather than posting it as its own
artifact), upload it first and reference the returned URL:
```bash
# 1. Upload -> prints the URL as the last line
python skills/daybook-posting/scripts/daybook_post.py --upload-image chart.png
# 2. Reference that URL in your markdown, then post as usual
# ![chart](http://127.0.0.1:8001/assets/ab12cd34ef56)
python skills/daybook-posting/scripts/daybook_post.py report.md
```
The image is linked to the artifact that references it and is deleted along with it.
Unreferenced uploads are cleaned up after 24 hours.
For CSV, JSON, or code that should render today, use `--file path --as-markdown` to post it as a Markdown fenced code block. Without `--as-markdown`, these upload as downloadable binary files.
## Rules
- Run `--check` first if unsure Daybook is reachable.
- Use a stable `DAYBOOK_SOURCE` or pass `--source` (the agent-profile, script, or person name that produced this artifact).
- Use `--tags a,b,c` for filtering.
- Use `--meta run_id=... --meta model=...` for machine-readable context.
- Never inline multiline content into the shell command; use a file or stdin.

View file

@ -0,0 +1,429 @@
#!/usr/bin/env python3
"""Post artifacts to Daybook without shell-specific JSON or multipart quoting.
Zero third-party dependencies. Content comes from a file or stdin so agents do not
need to inline multiline JSON/HTML/Markdown in shell commands.
"""
import argparse
import json
import mimetypes
import os
import re
import sys
import uuid
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urljoin
from urllib.request import Request, urlopen
TEXT_KINDS = {"markdown", "html_fragment", "html_full", "deck"}
TEXT_SUFFIXES = {".md", ".markdown", ".txt", ".html", ".htm"}
DEFAULT_URL = "http://127.0.0.1:8001"
DEFAULT_SOURCE = "agent"
def main():
args = parse_args()
base_url = os.environ.get("DAYBOOK_URL", DEFAULT_URL).rstrip("/")
# DAYBOOK_TOKEN is your personal per-user token (Settings -> Account in the app);
# there is no shared fallback token, so posting without it cannot work.
token = os.environ.get("DAYBOOK_TOKEN", "")
source = args.source or os.environ.get("DAYBOOK_SOURCE", DEFAULT_SOURCE)
if args.check:
try:
response = request_json("GET", f"{base_url}/api/capabilities", token)
except (HTTPError, URLError) as exc:
print_http_error(exc)
return 1
print(json.dumps(response, indent=2))
return 0
if not token:
print(
"error: DAYBOOK_TOKEN is not set. Get your personal API token from "
f"{base_url}/settings/account/ and set DAYBOOK_TOKEN.",
file=sys.stderr,
)
return 2
if args.upload_image:
try:
response = upload_asset(base_url, token, Path(args.upload_image))
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
except (HTTPError, URLError) as exc:
print_http_error(exc)
return 1
print(json.dumps(response, indent=2))
print(response.get("url", ""))
return 0
try:
if args.path:
resolve_auto_path(args)
payload = build_payload(args, source)
validate_payload(payload, args)
if args.dry_run:
print_dry_run(base_url, token, payload, args)
return 0
response = post_payload(base_url, token, payload, args)
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
except (HTTPError, URLError) as exc:
print_http_error(exc)
return 1
print(json.dumps(response, indent=2))
print(response.get("url", ""))
return 0
def parse_args():
parser = argparse.ArgumentParser(description="Publish an artifact to Daybook.")
parser.add_argument("path", nargs="?", help="Auto mode: one file path. Kind, content-type, and title are inferred. The simplest way to post.")
parser.add_argument("--kind", choices=sorted(TEXT_KINDS), help="Text artifact kind. Omit when using --file or a positional path.")
parser.add_argument("--title", help="Artifact title. Required unless --check is used.")
parser.add_argument("--source", help="Artifact source (agent-profile, script, or person name). Defaults to DAYBOOK_SOURCE or 'agent'.")
parser.add_argument("--content-file", help="Read text artifact content from this file.")
parser.add_argument("--file", help="Upload this file as image/pdf/binary, or render as markdown with --as-markdown.")
parser.add_argument("--tags", help="Comma-separated tags.")
parser.add_argument("--theme", help="Theme for markdown/html_fragment. Example: report, tasks, bootstrap.")
parser.add_argument("--meta", action="append", default=[], help="Metadata key=value. Repeatable.")
parser.add_argument("--meta-json", help="Metadata JSON object. Merged with --meta values.")
parser.add_argument("--source-name", help="Human-readable source name. Defaults to --source.")
parser.add_argument("--source-id", default="", help="Stable source identifier.")
parser.add_argument("--source-prompt", default="", help="Prompt/instruction that produced the artifact.")
parser.add_argument("--source-type", choices=["agent", "person", "system"], default="agent")
parser.add_argument("--as-markdown", action="store_true", help="Wrap --file content in a Markdown fenced code block instead of uploading it.")
parser.add_argument("--upload-image", help="Upload an image for embedding in artifact content. Prints the URL to reference in markdown/HTML.")
parser.add_argument("--check", action="store_true", help="Fetch /api/capabilities and exit.")
parser.add_argument("--dry-run", action="store_true", help="Print request details without posting.")
args = parser.parse_args()
if args.check or args.upload_image:
return args
if args.path:
if args.file or args.content_file:
parser.error("provide a positional path OR --file/--content-file, not both")
return args # kind and title are inferred in resolve_auto_path
if not args.title:
parser.error("--title is required (or pass a positional path to infer it, or use --check)")
if args.file and args.content_file:
parser.error("use either --file or --content-file, not both")
if args.file and args.kind and not args.as_markdown:
parser.error("omit --kind when using --file unless --as-markdown is set")
if not args.file and not args.kind:
parser.error("--kind is required for text artifacts")
return args
def resolve_auto_path(args):
"""Auto mode: turn a single positional path into the equivalent explicit args.
Text files (.md/.html/.txt) become a text artifact with a sniffed kind; every
other file is uploaded as multipart with an inferred content type. Title is
inferred from the content or the filename when not given. This is the path that
keeps agents from ever building JSON or choosing a kind by hand.
"""
path = Path(args.path)
if not path.is_file():
raise ValueError(f"path does not exist: {path}")
suffix = path.suffix.lower()
if suffix in TEXT_SUFFIXES and not args.as_markdown:
text = path.read_text(encoding="utf-8")
kind = sniff_text_kind(suffix, text)
args.content_file = str(path)
args.kind = kind
if not args.title:
args.title = derive_title(path, kind, text)
else:
# image / pdf / binary -> multipart upload; code/data -> markdown wrap if asked
args.file = str(path)
args.kind = None
if not args.title:
args.title = humanize(path.stem)
return args
def sniff_text_kind(suffix, text):
if suffix in (".md", ".markdown", ".txt"):
return "markdown"
lower = text.lower()
if "<!doctype" in lower or "<html" in lower:
return "html_full"
if "<section" in lower:
return "deck"
return "html_fragment"
def derive_title(path, kind, text):
if kind == "markdown":
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("# "):
return stripped[2:].strip()
else:
match = re.search(r"<title[^>]*>(.*?)</title>", text, re.IGNORECASE | re.DOTALL)
if match and match.group(1).strip():
return match.group(1).strip()
match = re.search(r"<h1[^>]*>(.*?)</h1>", text, re.IGNORECASE | re.DOTALL)
if match:
inner = re.sub(r"<[^>]+>", "", match.group(1)).strip()
if inner:
return inner
return humanize(path.stem)
def humanize(stem):
cleaned = stem.replace("-", " ").replace("_", " ").strip()
return cleaned.title() if cleaned else stem
def build_payload(args, source):
metadata = parse_metadata(args)
common = {
"source": source,
"title": args.title,
"tags": normalize_tags(args.tags),
"source_type": args.source_type,
"source_name": args.source_name or source,
"source_id": args.source_id,
"source_prompt": args.source_prompt,
"metadata": metadata,
}
if args.file and not args.as_markdown:
file_path = Path(args.file)
if not file_path.is_file():
raise ValueError(f"upload file does not exist: {file_path}")
return {**common, "upload_path": file_path}
kind = args.kind or "markdown"
if args.file and args.as_markdown:
file_path = Path(args.file)
if not file_path.is_file():
raise ValueError(f"content file does not exist: {file_path}")
content = markdown_from_file(file_path)
kind = "markdown"
else:
content = read_text_content(args.content_file)
payload = {**common, "kind": kind, "content": content}
if args.theme:
payload["theme"] = args.theme
return payload
def parse_metadata(args):
metadata = {}
if args.meta_json:
try:
metadata = json.loads(args.meta_json)
except json.JSONDecodeError as exc:
raise ValueError(f"--meta-json is invalid JSON: {exc}") from exc
if not isinstance(metadata, dict):
raise ValueError("--meta-json must be a JSON object")
for item in args.meta:
if "=" not in item:
raise ValueError(f"--meta must be key=value, got: {item}")
key, value = item.split("=", 1)
if not key:
raise ValueError("--meta key cannot be blank")
metadata[key] = value
return metadata
def normalize_tags(tags):
if not tags:
return []
return [tag.strip() for tag in tags.split(",") if tag.strip()]
def read_text_content(content_file):
if content_file:
path = Path(content_file)
if not path.is_file():
raise ValueError(f"content file does not exist: {path}")
return path.read_text(encoding="utf-8")
if sys.stdin.isatty():
raise ValueError("provide --content-file, --file, or pipe content on stdin")
return sys.stdin.read()
def markdown_from_file(path):
content = path.read_text(encoding="utf-8")
language = language_for_suffix(path.suffix)
return f"# {path.name}\n\n```{language}\n{content}\n```\n"
def language_for_suffix(suffix):
return {
".csv": "csv",
".json": "json",
".js": "javascript",
".ts": "typescript",
".py": "python",
".ps1": "powershell",
".sh": "bash",
".cpp": "cpp",
".cc": "cpp",
".cxx": "cpp",
".c": "c",
".h": "c",
".hpp": "cpp",
".html": "html",
".css": "css",
".xml": "xml",
".svg": "xml",
".md": "markdown",
".txt": "text",
}.get(suffix.lower(), "text")
def validate_payload(payload, args):
if "upload_path" in payload:
return
kind = payload["kind"]
content_lower = payload["content"].lower()
if kind == "deck" and "<section" not in content_lower:
raise ValueError("deck needs top-level <section>...</section> blocks, not Markdown or unwrapped headings")
if kind == "html_fragment" and ("<html" in content_lower or "<!doctype" in content_lower or "<body" in content_lower):
raise ValueError("html_fragment must be body-only HTML; do not include doctype/html/head/body")
if kind == "html_full" and "<html" not in content_lower:
raise ValueError("html_full must be a complete HTML document with <html> (and ideally <!doctype html>)")
if kind == "markdown" and not payload["content"].strip():
raise ValueError("markdown content cannot be blank")
def print_dry_run(base_url, token, payload, args):
target = f"{base_url}/api/artifacts"
print(f"POST {target}")
print(f"Authorization: Bearer {mask(token)}")
if "upload_path" in payload:
path = payload["upload_path"]
print("Content-Type: multipart/form-data")
print(f"file: {path} ({content_type_for(path)})")
printable = {key: value for key, value in payload.items() if key != "upload_path"}
print(json.dumps(printable, indent=2, default=str))
else:
print("Content-Type: application/json")
printable = dict(payload)
printable["content"] = f"<content length={len(payload['content'])}>"
print(json.dumps(printable, indent=2))
def mask(token):
if len(token) <= 8:
return "***"
return f"{token[:4]}...{token[-4:]}"
def post_payload(base_url, token, payload, args):
url = f"{base_url}/api/artifacts"
if "upload_path" in payload:
body, content_type = multipart_body(payload)
request = Request(url, data=body, method="POST")
request.add_header("Content-Type", content_type)
else:
body = json.dumps(payload).encode("utf-8")
request = Request(url, data=body, method="POST")
request.add_header("Content-Type", "application/json")
request.add_header("Authorization", f"Bearer {token}")
request.add_header("Accept", "application/json")
with urlopen(request, timeout=60) as response:
return json.loads(response.read().decode("utf-8"))
def multipart_body(payload):
boundary = f"----daybook-{uuid.uuid4().hex}"
chunks = []
upload_path = payload["upload_path"]
for key, value in payload.items():
if key == "upload_path":
continue
if key == "metadata":
if not value:
continue
value = json.dumps(value)
elif key == "tags":
if not value:
continue
value = ",".join(value)
elif value in (None, ""):
continue
chunks.append(f"--{boundary}\r\n".encode("utf-8"))
chunks.append(f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode("utf-8"))
chunks.append(str(value).encode("utf-8"))
chunks.append(b"\r\n")
chunks.append(f"--{boundary}\r\n".encode("utf-8"))
chunks.append(
f'Content-Disposition: form-data; name="file"; filename="{upload_path.name}"\r\n'.encode("utf-8")
)
chunks.append(f"Content-Type: {content_type_for(upload_path)}\r\n\r\n".encode("utf-8"))
chunks.append(upload_path.read_bytes())
chunks.append(b"\r\n")
chunks.append(f"--{boundary}--\r\n".encode("utf-8"))
return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
def content_type_for(path):
suffix = path.suffix.lower()
if suffix == ".svg":
return "image/svg+xml"
if suffix == ".pdf":
return "application/pdf"
return mimetypes.guess_type(path.name)[0] or "application/octet-stream"
def upload_asset(base_url, token, path):
"""Upload an image and return its Daybook URL for embedding in content."""
if not path.is_file():
raise ValueError(f"image file does not exist: {path}")
boundary = f"----daybook-{uuid.uuid4().hex}"
chunks = [
f"--{boundary}\r\n".encode("utf-8"),
f'Content-Disposition: form-data; name="file"; filename="{path.name}"\r\n'.encode("utf-8"),
f"Content-Type: {content_type_for(path)}\r\n\r\n".encode("utf-8"),
path.read_bytes(),
b"\r\n",
f"--{boundary}--\r\n".encode("utf-8"),
]
request = Request(f"{base_url}/api/assets", data=b"".join(chunks), method="POST")
request.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
request.add_header("Authorization", f"Bearer {token}")
request.add_header("Accept", "application/json")
with urlopen(request, timeout=60) as response:
return json.loads(response.read().decode("utf-8"))
def request_json(method, url, token=""):
request = Request(url, method=method)
request.add_header("Accept", "application/json")
if token:
request.add_header("Authorization", f"Bearer {token}")
with urlopen(request, timeout=15) as response:
return json.loads(response.read().decode("utf-8"))
def print_http_error(exc):
if isinstance(exc, HTTPError):
body = exc.read().decode("utf-8", errors="replace")
print(f"error: HTTP {exc.code} {exc.reason}", file=sys.stderr)
if body:
print(body, file=sys.stderr)
else:
print(f"error: {exc}", file=sys.stderr)
if __name__ == "__main__":
raise SystemExit(main())

965
static/daybook.css Normal file
View file

@ -0,0 +1,965 @@
:root {
--pane-bar-height: 2.75rem;
--app-bg: #f6f7f9;
--surface: #ffffff;
--surface-alt: #f8f9fb;
--pane-bar-bg: #ffffff;
--grid-line: #dde1e7;
--text: #2f343d;
--muted: #7b8491;
--selected-day-bg: #3d4552;
--day-tile-bg: #eef0f4;
--selected-artifact-bg: #eaf3ff;
--selected-artifact-rail: #dcecff;
--control-border: #d9dee6;
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--app-bg);
color: var(--text);
}
html,
body {
height: 100%;
overflow: hidden;
}
body {
display: flex;
flex-direction: column;
margin: 0;
}
.topbar {
align-items: center;
background: var(--surface-alt);
border-bottom: 1px solid var(--grid-line);
color: var(--text);
display: grid;
flex: 0 0 auto;
gap: 0;
grid-template-columns: max-content 1fr max-content;
padding: 0;
}
.topbar-actions {
align-items: center;
display: flex;
gap: 0.4rem;
padding: 0.32rem 0.8rem;
position: relative;
}
.icon-button {
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--muted);
cursor: pointer;
font-size: 0.85rem;
padding: 0.4rem 0.6rem;
}
.icon-button:hover {
background: var(--surface);
border-color: var(--control-border);
}
.user-menu-dropdown {
background: var(--surface);
border: 1px solid var(--control-border);
border-radius: 8px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
min-width: 10rem;
position: absolute;
right: 0.8rem;
top: calc(100% + 4px);
z-index: 20;
}
.user-menu-dropdown[hidden] {
display: none;
}
.user-menu-dropdown a,
.user-menu-dropdown button {
background: transparent;
border: 0;
color: var(--text);
cursor: pointer;
font-size: 0.85rem;
padding: 0.5rem 0.8rem;
text-align: left;
text-decoration: none;
width: 100%;
}
.user-menu-dropdown a:hover,
.user-menu-dropdown button:hover {
background: var(--surface-alt);
}
.user-menu-dropdown form {
margin: 0;
}
.brandline {
align-items: center;
display: flex;
gap: 0.55rem;
padding: 0.38rem 0.8rem;
}
.brand-link {
color: inherit;
text-decoration: none;
}
.brand-link:hover {
opacity: 0.75;
}
.eyebrow {
color: var(--muted);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.14em;
margin: 0 0 0.18rem;
text-transform: uppercase;
}
h1 {
font-size: clamp(1.2rem, 2vw, 1.75rem);
line-height: 1;
margin: 0;
}
.toolbar {
align-items: center;
align-self: stretch;
background: var(--surface-alt);
display: flex;
gap: 0.45rem;
min-width: 0;
padding: 0.32rem 0.6rem;
}
.filter {
align-items: end;
display: flex;
gap: 0.35rem;
}
.filter label {
color: var(--muted);
display: grid;
font-size: 0.68rem;
gap: 0.08rem;
}
.filter select,
.filter input {
background: var(--surface);
border: 1px solid var(--control-border);
border-radius: 4px;
color: var(--text);
font: inherit;
padding: 0.32rem 0.62rem;
}
.filter input {
width: 9rem;
}
.filter button {
background: #3d4552;
border: 1px solid #343b46;
border-radius: 4px;
color: #ffffff;
cursor: pointer;
font: inherit;
font-weight: 700;
padding: 0.32rem 0.7rem;
}
.view-toggle {
background: #f0f2f5;
border: 1px solid var(--control-border);
border-radius: 4px;
display: flex;
padding: 0.12rem;
}
.view-toggle a {
border-radius: 3px;
color: var(--text);
font-size: 0.76rem;
padding: 0.3rem 0.58rem;
text-decoration: none;
}
.view-toggle a.selected {
background: #3d4552;
color: #ffffff;
}
.feed {
--artifacts-width: 280px;
--days-width: 144px;
display: grid;
flex: 1 1 auto;
grid-template-columns: var(--days-width) 1px var(--artifacts-width) 1px minmax(0, 1fr);
min-height: 0;
overflow: hidden;
}
.source-sidebar,
.artifact-sidebar,
.preview-pane {
min-width: 0;
padding: 0;
}
.source-sidebar,
.artifact-sidebar {
min-height: 0;
overflow-x: hidden;
scrollbar-color: transparent transparent;
scrollbar-width: none;
}
.source-sidebar {
display: flex;
flex-direction: column;
overflow-y: hidden;
}
.artifact-sidebar {
display: flex;
flex-direction: column;
overflow-y: hidden;
padding: 0;
}
.source-list,
.artifact-list {
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
}
/* Source sidebar: scrollbar hidden until hover (short list, rarely scrolls). */
.source-list {
scrollbar-color: transparent transparent;
scrollbar-width: none;
}
.source-sidebar:hover .source-list {
scrollbar-color: rgba(61, 69, 82, 0.35) transparent;
scrollbar-width: thin;
}
.source-list::-webkit-scrollbar {
width: 0;
}
.source-sidebar:hover .source-list::-webkit-scrollbar {
width: 6px;
}
/* Artifact list: scrollbar always visible so it doesn't jump on hover. */
.artifact-list {
scrollbar-color: rgba(61, 69, 82, 0.35) transparent;
scrollbar-width: thin;
}
.artifact-list::-webkit-scrollbar {
width: 8px;
}
.source-list::-webkit-scrollbar-thumb,
.artifact-list::-webkit-scrollbar-thumb {
background: rgba(61, 69, 82, 0.35);
border-radius: 999px;
}
.source-list::-webkit-scrollbar-track,
.artifact-list::-webkit-scrollbar-track {
background: transparent;
}
.source-sidebar {
background: var(--surface-alt);
}
.artifact-sidebar {
background: var(--surface);
}
.preview-pane {
align-items: stretch;
display: flex;
flex-direction: column;
justify-content: flex-start;
min-height: 0;
padding: 0;
}
.splitter {
background: var(--grid-line);
cursor: col-resize;
position: relative;
touch-action: none;
}
.splitter::after {
background: transparent;
bottom: 0;
content: "";
left: -4px;
position: absolute;
right: -4px;
top: 0;
}
.splitter:hover,
.is-resizing .splitter {
background: #b8c0cc;
}
.is-resizing {
cursor: col-resize;
user-select: none;
}
.source-sidebar h2,
.pane-heading h2 {
color: var(--text);
font-size: 0.68rem;
letter-spacing: 0.1em;
margin: 0;
text-transform: uppercase;
}
.source-list {
display: grid;
}
.source-list {
align-content: start;
flex: 1 1 auto;
gap: 0.35rem;
padding: 0.55rem;
}
.source-link,
.artifact-row {
color: inherit;
text-decoration: none;
}
.source-link {
align-items: center;
background: var(--day-tile-bg);
border-radius: 6px;
display: flex;
gap: 0.4rem;
justify-content: space-between;
padding: 0.45rem 0.55rem;
}
.source-link:hover {
background: var(--surface);
box-shadow: none;
}
.source-link:focus-visible {
outline: 2px solid #8ab4f8;
outline-offset: 2px;
}
.source-link.selected {
background: var(--selected-day-bg);
color: #ffffff;
}
.source-link.selected .source-name,
.source-link.selected .source-count {
color: #ffffff;
}
.source-name {
font-size: 0.8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-count {
color: var(--muted);
flex: 0 0 auto;
font-size: 0.7rem;
}
.pane-heading span,
.artifact-time {
color: var(--muted);
font-size: 0.7rem;
}
.day-separator {
align-items: center;
background: var(--surface-alt);
border-bottom: 1px solid var(--grid-line);
color: var(--muted);
display: flex;
font-size: 0.7rem;
font-weight: 700;
justify-content: space-between;
letter-spacing: 0.04em;
padding: 0.32rem 0.55rem;
position: sticky;
text-transform: uppercase;
top: 0;
z-index: 5;
}
.day-separator-count {
font-weight: 400;
}
.feed-more {
height: 1px;
}
.today-badge {
background: #d8f1df;
border-radius: 999px;
color: #245238;
display: inline-block;
font-size: 0.62rem;
font-style: normal;
font-weight: 500;
margin-left: 0.35rem;
padding: 0.08rem 0.38rem;
}
.pane-bar {
align-items: baseline;
background: var(--pane-bar-bg);
box-sizing: border-box;
display: flex;
flex: 0 0 var(--pane-bar-height);
gap: 0.55rem;
height: var(--pane-bar-height);
justify-content: space-between;
margin: 0;
min-height: var(--pane-bar-height);
padding: 0 0.65rem;
}
.pane-heading,
.preview-actions {
border-bottom: 1px solid var(--grid-line);
}
.source-sidebar .pane-bar,
.artifact-sidebar .pane-bar {
align-items: center;
justify-content: center;
text-align: center;
}
.artifact-sidebar .pane-heading {
flex-direction: column;
gap: 0.04rem;
}
.artifact-list {
align-content: start;
flex: 1 1 auto;
}
.artifact-row {
align-items: stretch;
background: var(--surface);
border: 0;
border-bottom: 1px solid var(--grid-line);
border-radius: 0;
display: grid;
gap: 0;
grid-template-columns: 3.15rem minmax(0, 1fr) 7rem;
min-height: 2.7rem;
overflow: hidden;
padding: 0;
}
.artifact-row:hover {
background: #f6f9fd;
box-shadow: none;
}
.artifact-row:focus-visible {
outline: 0;
box-shadow: inset 0 0 0 1px #8ab4f8;
}
.artifact-row.selected {
background: var(--selected-artifact-bg);
color: var(--text);
}
.artifact-row.selected .artifact-time {
background: var(--selected-artifact-rail);
}
.artifact-row.selected .artifact-title,
.artifact-row.selected .artifact-time {
color: var(--text);
}
.artifact-row-main {
align-items: center;
border-right: 1px solid var(--grid-line);
display: flex;
flex-wrap: wrap;
gap: 0.32rem;
min-width: 0;
padding: 0.42rem 0.55rem;
}
.artifact-row.unread .artifact-title {
font-weight: 700;
}
.artifact-row.unread .artifact-source {
color: var(--text);
}
.artifact-row-main > .artifact-title {
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
color: var(--text);
display: -webkit-box;
font-size: 0.86rem;
overflow: hidden;
}
.artifact-kind-text {
background: var(--day-tile-bg);
border-radius: 4px;
color: var(--muted);
font-size: 0.68rem;
padding: 0.1rem 0.34rem;
white-space: nowrap;
}
.kept-badge {
align-items: center;
background: #eaf7ed;
border-radius: 4px;
color: #28583a;
display: inline-flex;
font-size: 0.66rem;
font-weight: 700;
justify-content: center;
line-height: 1;
padding: 0.14rem 0.3rem;
white-space: nowrap;
}
.kept-badge[hidden] {
display: none;
}
.artifact-source {
align-items: center;
color: var(--muted);
display: flex;
font-size: 0.72rem;
overflow: hidden;
padding: 0.42rem 0.55rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-chips {
display: flex;
flex-wrap: wrap;
gap: 0.24rem;
}
.tag-chip {
border-radius: 999px;
color: #ffffff;
font-size: 0.66rem;
font-weight: 600;
line-height: 1;
padding: 0.18rem 0.44rem;
white-space: nowrap;
}
.artifact-time {
align-items: center;
background: #f3f5f7;
border-right: 1px solid var(--grid-line);
display: flex;
justify-content: flex-end;
justify-self: stretch;
line-height: 1;
padding: 0 0.48rem;
text-align: right;
white-space: nowrap;
}
.empty {
background: var(--surface);
border-radius: 16px;
padding: 1.2rem;
}
.empty.compact {
padding: 1rem;
}
.empty.compact h3,
.preview-empty h2 {
margin-top: 0;
}
.preview-empty {
max-width: 34rem;
}
kbd {
background: #f0f2f5;
border: 1px solid var(--control-border);
border-radius: 4px;
color: var(--text);
font: 0.85em ui-monospace, SFMono-Regular, Consolas, monospace;
padding: 0.08rem 0.28rem;
}
.preview-body {
align-items: stretch;
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.preview-body > .preview-empty {
align-self: center;
margin: auto;
}
.preview-frame-wrap {
align-items: center;
display: flex;
flex: 1;
justify-content: center;
min-height: 0;
}
.preview-actions {
align-items: center;
background: var(--pane-bar-bg);
display: flex;
flex: 0 0 auto;
gap: 0.75rem;
justify-content: space-between;
justify-content: space-between;
}
.preview-title {
color: var(--text);
flex: 1 1 auto;
font-size: 0.78rem;
font-weight: 700;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-actions .view-toggle,
.preview-actions .artifact-action-links {
flex: 0 0 auto;
}
.artifact-action-links {
align-items: center;
display: flex;
gap: 0.35rem;
}
.artifact-action-links a,
.artifact-action-links button {
background: var(--surface);
border: 1px solid var(--control-border);
border-radius: 4px;
color: var(--text);
cursor: pointer;
font: inherit;
font-size: 0.76rem;
line-height: 1;
padding: 0.42rem 0.65rem;
text-decoration: none;
}
.view-toggle a.disabled,
.artifact-action-links a.disabled {
cursor: not-allowed;
opacity: 0.48;
pointer-events: none;
}
.artifact-action-links button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
.preview-frame {
background: #fff;
border: 0;
height: 100%;
width: 100%;
}
.preview-image {
background: #fff;
max-height: 100%;
max-width: 100%;
object-fit: contain;
}
@media (max-width: 720px) {
html,
body {
height: auto;
overflow: auto;
}
.topbar {
align-items: stretch;
grid-template-columns: 1fr;
}
.toolbar,
.filter,
.preview-actions {
align-items: stretch;
flex-wrap: wrap;
}
.feed {
grid-template-columns: 1fr;
overflow: visible;
}
.splitter {
display: none;
}
.source-sidebar,
.artifact-sidebar {
border-bottom: 1px solid #d9c9ad;
border-right: 0;
max-height: 32vh;
}
}
.login-page {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
overflow: auto;
}
.login-card {
background: var(--surface);
border: 1px solid var(--control-border);
border-radius: 12px;
display: flex;
flex-direction: column;
gap: 0.8rem;
padding: 2rem;
width: 20rem;
}
.login-card label {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.login-card label span {
color: var(--muted);
font-size: 0.78rem;
}
.login-card input[type="text"],
.login-card input[type="password"] {
border: 1px solid var(--control-border);
border-radius: 6px;
padding: 0.5rem 0.6rem;
}
.login-remember {
align-items: center;
flex-direction: row !important;
gap: 0.5rem !important;
}
.login-error {
color: #c14f4f;
font-size: 0.82rem;
}
.settings-layout {
display: grid;
flex: 1 1 auto;
grid-template-columns: 14rem 1fr;
min-height: 0;
overflow: auto;
}
.settings-nav {
background: var(--surface-alt);
border-right: 1px solid var(--grid-line);
display: flex;
flex-direction: column;
padding: 1rem 0.6rem;
}
.settings-nav a {
border-radius: 6px;
color: var(--text);
padding: 0.5rem 0.7rem;
text-decoration: none;
}
.settings-nav a:hover {
background: var(--surface);
}
.settings-nav a.selected {
background: var(--day-tile-bg);
font-weight: 600;
}
.settings-nav-back {
border-bottom: 1px solid var(--grid-line);
color: var(--muted) !important;
font-size: 0.82rem;
margin-bottom: 0.5rem;
padding-bottom: 0.7rem !important;
}
.settings-panel {
max-width: 42rem;
padding: 1.6rem 2rem;
}
.settings-messages {
background: #eaf7ed;
border-radius: 8px;
color: #28583a;
list-style: none;
margin: 0 0 1.2rem;
padding: 0.6rem 0.9rem;
}
.token-value {
background: var(--surface-alt);
border-radius: 6px;
display: inline-block;
padding: 0.4rem 0.6rem;
word-break: break-all;
}
.keep-button.kept {
background: #eaf7ed;
border-color: #9bd4ab;
color: #28583a;
}
.details-panel {
background: var(--surface-alt);
border-bottom: 1px solid var(--grid-line);
flex: 0 0 auto;
max-height: 40%;
overflow-y: auto;
padding: 0.7rem 1rem;
}
.details-grid {
display: grid;
gap: 0.25rem 1rem;
grid-template-columns: max-content 1fr;
margin: 0;
}
.details-grid dt {
color: var(--muted);
font-size: 0.72rem;
text-transform: uppercase;
}
.details-grid dd {
font-size: 0.82rem;
margin: 0;
word-break: break-word;
}
.usage-bar {
background: var(--day-tile-bg);
border-radius: 999px;
height: 0.75rem;
margin: 0.5rem 0;
overflow: hidden;
}
.usage-bar-fill {
background: #5aa552;
border-radius: 999px;
height: 100%;
transition: width 0.3s ease;
}
.usage-bar-fill--warn {
background: #d98b2b;
}
.usage-bar-fill--full {
background: #c14f4f;
}
.usage-summary {
font-size: 0.9rem;
}
.storage-facts {
color: var(--text);
font-size: 0.85rem;
line-height: 1.7;
padding-left: 1.1rem;
}
.storage-note {
color: var(--muted);
font-size: 0.82rem;
line-height: 1.5;
}
.storage-warning {
background: #fdecea;
border-radius: 8px;
color: #a3312a;
font-size: 0.85rem;
padding: 0.6rem 0.9rem;
}

18
static/themes/base.css Normal file
View file

@ -0,0 +1,18 @@
body.artifact {
box-sizing: border-box;
color: #1d2528;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.65;
margin: 0 auto;
max-width: 900px;
padding: 3rem 1.25rem;
}
img,
table {
max-width: 100%;
}
pre {
overflow-x: auto;
}

30
static/themes/deck.css Normal file
View file

@ -0,0 +1,30 @@
body.deck {
background: #111827;
color: #f9fafb;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
margin: 0;
}
.slides {
display: grid;
gap: 2rem;
margin: 0 auto;
max-width: 1100px;
padding: 2rem;
}
.slides section {
background: linear-gradient(135deg, #1f2937, #111827);
border: 1px solid #374151;
border-radius: 28px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
min-height: 62vh;
padding: clamp(2rem, 6vw, 5rem);
}
.slides h1,
.slides h2 {
color: #fbbf24;
font-size: clamp(2rem, 7vw, 5rem);
line-height: 1;
}

41
static/themes/report.css Normal file
View file

@ -0,0 +1,41 @@
@import url("/static/themes/base.css");
body.artifact {
background: #fffaf0;
color: #1e2b2f;
font-family: Georgia, "Times New Roman", serif;
font-size: 1.08rem;
}
h1,
h2,
h3 {
color: #17313b;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.15;
}
a {
color: #8a4f16;
}
blockquote {
border-left: 4px solid #d5a24c;
color: #5b5449;
margin-left: 0;
padding-left: 1rem;
}
code,
pre {
background: #efe5d2;
border-radius: 8px;
}
code {
padding: 0.1rem 0.3rem;
}
pre {
padding: 1rem;
}

20
static/themes/tasks.css Normal file
View file

@ -0,0 +1,20 @@
@import url("/static/themes/base.css");
body.artifact {
background: #f7fbff;
color: #17202a;
max-width: 760px;
}
h1 {
border-bottom: 3px solid #66a7d8;
padding-bottom: 0.5rem;
}
li {
margin: 0.45rem 0;
}
input[type="checkbox"] {
transform: translateY(1px) scale(1.1);
}

View file

@ -0,0 +1,42 @@
{% for group in day_groups %}
<div class="day-group">
<div class="day-separator">
<span>{{ group.day|date:"D, M j Y" }}{% if group.day == today %} <em class="today-badge">Today</em>{% endif %}</span>
<span class="day-separator-count">{{ group.artifacts|length }}</span>
</div>
{% for artifact in group.artifacts %}
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %} {% if artifact.is_unread %}unread{% endif %} {% if artifact.is_kept %}kept{% endif %}"
href="?{% if selected_source %}source={{ selected_source|urlencode }}&amp;{% endif %}{% if selected_label %}label={{ selected_label|urlencode }}&amp;{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&amp;{% endif %}{% if title_query %}q={{ title_query|urlencode }}&amp;{% endif %}artifact={{ artifact.id }}&amp;view={{ selected_view }}"
data-artifact-id="{{ artifact.id }}"
data-title="{{ artifact.title }}"
data-kind="{{ artifact.kind }}"
data-source="{{ artifact.source }}"
data-source-type="{{ artifact.source_type }}"
data-source-name="{{ artifact.source_name }}"
data-created="{{ artifact.created_at|date:'M j Y, H:i' }}"
data-size="{{ artifact.size_bytes|filesizeformat }}"
data-kept="{{ artifact.is_kept|yesno:'1,0' }}"
data-tags="{{ artifact.tags|join:', ' }}"
data-rendered-url="{% url 'artifact' artifact.id %}"
data-raw-url="{% url 'artifact' artifact.id %}?raw=1">
<time class="artifact-time" datetime="{{ artifact.created_at|date:'c' }}">{{ artifact.created_at|date:"H:i" }}</time>
<span class="artifact-row-main">
<span class="artifact-title">{{ artifact.title }}</span>
<span class="artifact-kind-text">{% if artifact.kind == "image" %}image{% elif artifact.kind == "pdf" %}pdf{% elif artifact.kind == "markdown" %}markdown{% elif artifact.kind == "deck" %}deck{% elif artifact.kind == "binary" %}binary{% else %}html{% endif %}</span>
<span class="kept-badge" data-kept-badge title="Kept — never auto-deleted"{% if not artifact.is_kept %} hidden{% endif %}>K</span>
{% if artifact.label_chips %}
<span class="tag-chips">
{% for label in artifact.label_chips %}
<span class="tag-chip" style="background:{{ label.color }}">{{ label.name }}</span>
{% endfor %}
</span>
{% endif %}
</span>
<span class="artifact-source">{{ artifact.source }}</span>
</a>
{% endfor %}
</div>
{% endfor %}
{% if has_more %}
<div class="feed-more" data-feed-more data-next-before="{{ next_before }}"></div>
{% endif %}

View file

@ -0,0 +1,28 @@
<form method="get" class="filter">
<input type="hidden" name="view" value="{{ selected_view }}">
{% if selected_source %}<input type="hidden" name="source" value="{{ selected_source }}">{% endif %}
{% if selected_artifact %}<input type="hidden" name="artifact" value="{{ selected_artifact.id }}">{% endif %}
<label>
<span>Label</span>
<select name="label" onchange="this.form.submit()">
<option value="">All labels</option>
{% for label in all_labels %}
<option value="{{ label.name }}" {% if label.name == selected_label %}selected{% endif %}>{{ label.name }}</option>
{% endfor %}
</select>
</label>
<label>
<span>Type</span>
<select name="kind" onchange="this.form.submit()">
<option value="">All types</option>
{% for value, label in kind_choices %}
<option value="{{ value }}" {% if value == selected_kind %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>
<span>Name</span>
<input type="search" name="q" value="{{ title_query }}" placeholder="Title contains" onsearch="this.form.submit()">
</label>
<button type="submit">Filter</button>
</form>

View file

@ -0,0 +1,37 @@
<header class="topbar">
<div class="brandline">
<a class="brand-link" href="{% url 'feed' %}"><h1>AgentFeed</h1></a>
</div>
<div class="toolbar">
{% if topbar_toolbar %}{% include topbar_toolbar %}{% endif %}
</div>
<div class="topbar-actions">
<button class="icon-button" type="button" data-user-menu-toggle aria-haspopup="true" aria-expanded="false" title="{{ request.user.username }}">
&#9881; {{ request.user.username }}
</button>
<div class="user-menu-dropdown" hidden data-user-menu>
<a href="{% url 'settings-account' %}">Settings</a>
<a href="{% url 'settings-demo-data' %}">Demo Data</a>
<form method="post" action="{% url 'logout' %}">{% csrf_token %}<button type="submit">Log out</button></form>
</div>
</div>
</header>
<script>
(() => {
const toggle = document.querySelector("[data-user-menu-toggle]");
const menu = document.querySelector("[data-user-menu]");
if (!toggle || !menu) return;
toggle.addEventListener("click", (event) => {
event.stopPropagation();
const isHidden = menu.hidden;
menu.hidden = !isHidden;
toggle.setAttribute("aria-expanded", String(isHidden));
});
document.addEventListener("click", (event) => {
if (!menu.hidden && !menu.contains(event.target) && event.target !== toggle) {
menu.hidden = true;
toggle.setAttribute("aria-expanded", "false");
}
});
})();
</script>

View file

@ -0,0 +1,567 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AgentFeed</title>
<link rel="stylesheet" href="/static/daybook.css">
</head>
<body>
{% include "artifacts/_topbar.html" with topbar_toolbar="artifacts/_feed_toolbar.html" %}
<main class="feed"
data-selected-source="{{ selected_source }}"
data-selected-label="{{ selected_label }}"
data-selected-kind="{{ selected_kind }}"
data-title-query="{{ title_query }}"
data-selected-view="{{ selected_view }}"
data-window-start="{{ window_start|date:'Y-m-d' }}"
data-older-count="{{ older_count }}">
<aside class="source-sidebar" aria-label="Sources">
<div class="pane-bar">
<h2>Sources</h2>
</div>
<nav class="source-list">
<a class="source-link {% if not selected_source %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&amp;{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&amp;{% endif %}{% if title_query %}q={{ title_query|urlencode }}&amp;{% endif %}view={{ selected_view }}&amp;preview=none">
<span class="source-name">All sources</span>
{% if total_count %}<span class="source-count">{{ total_count }}</span>{% endif %}
</a>
{% for entry in source_counts %}
<a class="source-link {% if entry.source == selected_source %}selected{% endif %}" href="?source={{ entry.source|urlencode }}{% if selected_label %}&amp;label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&amp;kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&amp;q={{ title_query|urlencode }}{% endif %}&amp;view={{ selected_view }}&amp;preview=none">
<span class="source-name">{{ entry.source }}</span>
{% if entry.count %}<span class="source-count">{{ entry.count }}</span>{% endif %}
</a>
{% endfor %}
</nav>
</aside>
<div class="splitter" data-splitter="days" role="separator" aria-label="Resize sources sidebar" aria-orientation="vertical"></div>
<aside class="artifact-sidebar" aria-label="Artifact feed">
<div class="pane-bar pane-heading">
<h2>{% if selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
</div>
<div class="artifact-list" data-artifact-list>
{% if day_groups %}
{% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %}
{% else %}
<div class="empty compact">
<h3>No artifacts</h3>
<p>Nothing in the last {{ 7 }} days for these filters.</p>
</div>
{% endif %}
</div>
</aside>
<div class="splitter" data-splitter="artifacts" role="separator" aria-label="Resize artifacts sidebar" aria-orientation="vertical"></div>
<section class="preview-pane">
<header class="pane-bar preview-actions" data-actions>
<div class="preview-title" data-preview-title {% if selected_artifact %}title="{{ selected_artifact.title }}"{% endif %}>
{% if selected_artifact %}{{ selected_artifact.title }}{% else %}No artifact loaded{% endif %}
</div>
<nav class="view-toggle" aria-label="Preview mode">
<a class="{% if selected_view == 'rendered' %}selected{% endif %}{% if not selected_artifact %} disabled{% endif %}" href="{% if selected_artifact %}{{ rendered_url }}{% else %}#{% endif %}" data-view="rendered" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Rendered</a>
<a class="{% if selected_view == 'raw' %}selected{% endif %}{% if not selected_artifact %} disabled{% endif %}" href="{% if selected_artifact %}{{ raw_url }}{% else %}#{% endif %}" data-view="raw" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Raw</a>
</nav>
<div class="artifact-action-links">
<button type="button" class="keep-button {% if selected_artifact.is_kept %}kept{% endif %}" data-keep-button data-artifact-id="{% if selected_artifact %}{{ selected_artifact.id }}{% endif %}" {% if not selected_artifact %}disabled{% endif %}>{% if selected_artifact.is_kept %}✓ Kept{% else %}Keep{% endif %}</button>
<button type="button" data-details-toggle {% if not selected_artifact %}disabled{% endif %}>Details</button>
<a class="{% if not selected_artifact %}disabled{% endif %}" href="{% if selected_artifact %}{% url 'artifact' selected_artifact.id %}{% else %}#{% endif %}" target="_blank" rel="noopener" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Open</a>
<a class="{% if not selected_artifact %}disabled{% endif %}" href="{% if selected_artifact %}{% url 'artifact' selected_artifact.id %}{% else %}#{% endif %}" download {% if not selected_artifact %}aria-disabled="true"{% endif %}>Download</a>
<button type="button" data-copy-url="{% if selected_artifact %}{% url 'artifact' selected_artifact.id %}{% endif %}" {% if not selected_artifact %}disabled{% endif %}>Copy Link</button>
</div>
</header>
<div class="details-panel" hidden data-details-panel></div>
<div class="preview-body">
{% if selected_artifact %}
<div class="preview-frame-wrap">
{% if selected_view == "raw" %}
<iframe class="preview-frame" src="{% url 'artifact' selected_artifact.id %}?raw=1" title="{{ selected_artifact.title }} raw source"></iframe>
{% elif selected_artifact.kind == "image" %}
<img class="preview-image" src="{% url 'artifact' selected_artifact.id %}" alt="{{ selected_artifact.title }}">
{% elif selected_artifact.kind == "binary" %}
<div class="empty preview-empty">
<h2>Binary Artifact</h2>
<p>This artifact type cannot be previewed inline yet.</p>
<p><a href="{% url 'artifact' selected_artifact.id %}">Open or download it</a>.</p>
</div>
{% else %}
<iframe class="preview-frame" src="{% url 'artifact' selected_artifact.id %}{% if selected_view == 'raw' %}?raw=1{% endif %}" title="{{ selected_artifact.title }}"></iframe>
{% endif %}
</div>
{% else %}
<div class="empty preview-empty">
{% if artifacts %}
<h2>No artifact loaded</h2>
<p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p>
{% else %}
<h2>No artifacts for this day</h2>
<p>This day is ready for new agent output.</p>
{% endif %}
</div>
{% endif %}
</div>
</section>
</main>
<script>
(() => {
const feed = document.querySelector(".feed");
if (!feed) return;
let selectedView = "{{ selected_view }}";
const sizes = {
days: Number(localStorage.getItem("daybook.daysWidth")) || 144,
artifacts: Number(localStorage.getItem("daybook.artifactsWidth")) || 280,
};
const applySizes = () => {
feed.style.setProperty("--days-width", `${sizes.days}px`);
feed.style.setProperty("--artifacts-width", `${sizes.artifacts}px`);
};
applySizes();
// Per-pane resize bounds. The artifact list may grow much wider than the
// sources sidebar, but is still capped so it can't swallow the preview.
const sizeBounds = {
days: {min: 96, max: 320},
artifacts: {min: 200, max: 720},
};
document.querySelectorAll(".splitter").forEach((splitter) => {
splitter.addEventListener("pointerdown", (event) => {
const target = splitter.dataset.splitter;
const startX = event.clientX;
const startWidth = sizes[target];
const bounds = sizeBounds[target];
splitter.setPointerCapture(event.pointerId);
document.body.classList.add("is-resizing");
const move = (moveEvent) => {
sizes[target] = Math.max(bounds.min, Math.min(bounds.max, startWidth + moveEvent.clientX - startX));
applySizes();
};
const up = () => {
localStorage.setItem(`daybook.${target}Width`, String(sizes[target]));
document.body.classList.remove("is-resizing");
splitter.removeEventListener("pointermove", move);
splitter.removeEventListener("pointerup", up);
splitter.removeEventListener("pointercancel", up);
};
splitter.addEventListener("pointermove", move);
splitter.addEventListener("pointerup", up);
splitter.addEventListener("pointercancel", up);
});
});
const previewPane = document.querySelector(".preview-pane");
const previewBody = document.querySelector(".preview-body");
const artifactList = document.querySelector("[data-artifact-list]");
// Rows grow as older pages load, so always read them live rather than caching an array.
const allRows = () => Array.from(document.querySelectorAll(".artifact-row"));
const sourceLinks = Array.from(document.querySelectorAll(".source-link"));
const csrfToken = document.querySelector("[name=csrfmiddlewaretoken]")?.value || "";
const escapeHtml = (value) => value.replace(/[&<>"]/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
}[character]));
const copyLink = async (button) => {
const absoluteUrl = new URL(button.dataset.copyUrl, window.location.origin).toString();
try {
await navigator.clipboard.writeText(absoluteUrl);
} catch {
window.prompt("Copy artifact link", absoluteUrl);
}
const original = button.textContent;
button.textContent = "Copied";
setTimeout(() => {
button.textContent = original;
}, 1200);
};
const selectedRow = () => document.querySelector(".artifact-row.selected");
let navigationMode = selectedRow() ? "artifacts" : "sources";
const urlFor = (row, view) => view === "raw" ? row.dataset.rawUrl : row.dataset.renderedUrl;
const pageUrlFor = (row, view) => {
const url = new URL(row.href, window.location.origin);
url.searchParams.set("artifact", row.dataset.artifactId);
url.searchParams.set("view", view);
url.searchParams.delete("preview");
return url;
};
const noPreviewUrl = () => {
const url = new URL(window.location.href);
url.searchParams.delete("artifact");
url.searchParams.set("preview", "none");
url.searchParams.set("view", selectedView);
return url;
};
const updateActions = (row) => {
const actions = document.querySelector("[data-actions]");
if (!actions) return;
const previewTitle = actions.querySelector("[data-preview-title]");
if (previewTitle) {
previewTitle.textContent = row.dataset.title;
previewTitle.title = row.dataset.title;
}
actions.querySelectorAll("[data-view]").forEach((link) => {
const view = link.dataset.view;
link.classList.toggle("selected", view === selectedView);
link.classList.remove("disabled");
link.removeAttribute("aria-disabled");
link.href = pageUrlFor(row, view).toString();
});
actions.querySelectorAll(".artifact-action-links a").forEach((link) => {
link.classList.remove("disabled");
link.removeAttribute("aria-disabled");
link.href = row.dataset.renderedUrl;
});
const copyButton = actions.querySelector("[data-copy-url]");
if (copyButton) {
copyButton.disabled = false;
copyButton.dataset.copyUrl = row.dataset.renderedUrl;
}
syncKeepButton(row.dataset.kept === "1");
const keepButton = actions.querySelector("[data-keep-button]");
if (keepButton) keepButton.dataset.artifactId = row.dataset.artifactId;
actions.querySelector("[data-details-toggle]").disabled = false;
renderDetails(row);
};
const syncKeepButton = (isKept) => {
const keepButton = document.querySelector("[data-keep-button]");
if (!keepButton) return;
keepButton.disabled = false;
keepButton.classList.toggle("kept", isKept);
keepButton.textContent = isKept ? "✓ Kept" : "Keep";
};
const renderDetails = (row) => {
const panel = document.querySelector("[data-details-panel]");
if (!panel) return;
const d = row.dataset;
const tags = d.tags ? d.tags.split(",").map((t) => t.trim()).filter(Boolean) : [];
const rows = [
["Source", `${escapeHtml(d.source)} (${escapeHtml(d.sourceType)})`],
["Source name", escapeHtml(d.sourceName || "—")],
["Type", escapeHtml(d.kind)],
["Created", escapeHtml(d.created)],
["Size", escapeHtml(d.size)],
["Tags", tags.length ? tags.map(escapeHtml).join(", ") : "—"],
];
panel.innerHTML = `<dl class="details-grid">${rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join("")}</dl>`;
};
const disableActions = () => {
const actions = document.querySelector("[data-actions]");
if (!actions) return;
const previewTitle = actions.querySelector("[data-preview-title]");
if (previewTitle) {
previewTitle.textContent = "No artifact loaded";
previewTitle.removeAttribute("title");
}
actions.querySelectorAll("[data-view]").forEach((link) => {
link.classList.add("disabled");
link.setAttribute("aria-disabled", "true");
link.href = "#";
});
actions.querySelectorAll(".artifact-action-links a").forEach((link) => {
link.classList.add("disabled");
link.setAttribute("aria-disabled", "true");
link.href = "#";
});
const copyButton = actions.querySelector("[data-copy-url]");
if (copyButton) {
copyButton.disabled = true;
copyButton.dataset.copyUrl = "";
}
const keepButton = actions.querySelector("[data-keep-button]");
if (keepButton) {
keepButton.disabled = true;
keepButton.classList.remove("kept");
keepButton.textContent = "Keep";
}
actions.querySelector("[data-details-toggle]").disabled = true;
const panel = document.querySelector("[data-details-panel]");
if (panel) panel.hidden = true;
};
const markRead = async (row) => {
if (!row.classList.contains("unread")) return;
row.classList.remove("unread");
decrementSourceCount(row.querySelector(".artifact-source")?.textContent.trim());
try {
await fetch(`/a/${row.dataset.artifactId}/read`, {
method: "POST",
headers: {"X-CSRFToken": csrfToken},
});
} catch {
// Non-critical: the row stays visually read until the next page load.
}
};
const decrementSourceCount = (sourceName) => {
document.querySelectorAll(".source-link").forEach((link) => {
const name = link.querySelector(".source-name")?.textContent.trim();
if (name !== sourceName && name !== "All sources") return;
const badge = link.querySelector(".source-count");
if (!badge) return;
const next = Number(badge.textContent) - 1;
if (next > 0) {
badge.textContent = String(next);
} else {
badge.remove();
}
});
};
const renderPreview = (row, pushState = true) => {
navigationMode = "artifacts";
markRead(row);
allRows().forEach((candidate) => candidate.classList.toggle("selected", candidate === row));
const source = urlFor(row, selectedView);
const title = escapeHtml(row.dataset.title);
const kind = row.dataset.kind;
const content = selectedView === "raw"
? `<iframe class="preview-frame" src="${source}" title="${title} raw source"></iframe>`
: kind === "image"
? `<img class="preview-image" src="${source}" alt="${title}">`
: kind === "binary"
? `<div class="empty preview-empty"><h2>Binary Artifact</h2><p>This artifact type cannot be previewed inline yet.</p><p><a href="${source}">Open or download it</a>.</p></div>`
: `<iframe class="preview-frame" src="${source}" title="${title}"></iframe>`;
previewBody.innerHTML = `<div class="preview-frame-wrap">${content}</div>`;
updateActions(row);
if (pushState) window.history.pushState({}, "", pageUrlFor(row, selectedView));
};
const clearPreview = () => {
navigationMode = "sources";
if (document.activeElement && document.activeElement.classList.contains("artifact-row")) {
document.activeElement.blur();
}
allRows().forEach((candidate) => candidate.classList.remove("selected"));
disableActions();
previewBody.innerHTML = `<div class="empty preview-empty"><h2>No artifact loaded</h2><p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p></div>`;
window.history.pushState({}, "", noPreviewUrl());
const currentSource = document.querySelector(".source-link.selected");
if (currentSource) currentSource.focus({preventScroll: true});
};
// Delegated so rows appended by infinite scroll work without rebinding.
artifactList.addEventListener("click", (event) => {
const row = event.target.closest(".artifact-row");
if (!row) return;
event.preventDefault();
renderPreview(row);
});
const moveSelection = (direction) => {
const rows = allRows();
if (!rows.length) return;
navigationMode = "artifacts";
const current = selectedRow();
const currentIndex = current ? rows.indexOf(current) : -1;
const nextIndex = Math.max(0, Math.min(rows.length - 1, currentIndex + direction));
const nextRow = rows[nextIndex === -1 ? 0 : nextIndex];
renderPreview(nextRow);
nextRow.focus({preventScroll: true});
nextRow.scrollIntoView({block: "nearest"});
};
const moveSource = (direction) => {
if (!sourceLinks.length) return;
navigationMode = "sources";
const current = document.querySelector(".source-link.selected");
const currentIndex = current ? sourceLinks.indexOf(current) : 0;
const nextIndex = Math.max(0, Math.min(sourceLinks.length - 1, currentIndex + direction));
const nextSource = sourceLinks[nextIndex];
if (nextSource && nextSource !== current) window.location.href = nextSource.href;
};
document.addEventListener("keydown", (event) => {
if (event.ctrlKey || event.metaKey || event.shiftKey) return;
if (["INPUT", "SELECT", "TEXTAREA", "BUTTON"].includes(event.target.tagName)) return;
if (event.altKey && event.key === "ArrowDown") {
event.preventDefault();
moveSource(1);
} else if (event.altKey && event.key === "ArrowUp") {
event.preventDefault();
moveSource(-1);
} else if (event.altKey) {
return;
} else if (event.key === "ArrowDown") {
event.preventDefault();
if (navigationMode === "sources") {
moveSource(1);
} else {
moveSelection(1);
}
} else if (event.key === "ArrowUp") {
event.preventDefault();
if (navigationMode === "sources") {
moveSource(-1);
} else {
moveSelection(-1);
}
} else if (event.key === "PageDown") {
if (navigationMode === "artifacts") {
event.preventDefault();
moveSelection(5);
}
} else if (event.key === "PageUp") {
if (navigationMode === "artifacts") {
event.preventDefault();
moveSelection(-5);
}
} else if (event.key === "ArrowRight") {
const rows = allRows();
if (!selectedRow() && rows.length) {
event.preventDefault();
renderPreview(rows[0]);
rows[0].focus({preventScroll: true});
rows[0].scrollIntoView({block: "nearest"});
} else if (selectedRow()) {
navigationMode = "artifacts";
}
} else if (event.key === "ArrowLeft") {
if (selectedRow()) {
event.preventDefault();
clearPreview();
} else {
navigationMode = "sources";
const currentSource = document.querySelector(".source-link.selected");
if (currentSource) currentSource.focus({preventScroll: true});
}
}
});
// Infinite scroll: when the sentinel at the end of the list scrolls into
// view, fetch the next page of older day-groups and append them.
let loadingOlder = false;
const loadOlder = async (sentinel) => {
if (loadingOlder) return;
loadingOlder = true;
sentinel.removeAttribute("data-feed-more");
try {
const url = new URL("{% url 'feed-older' %}", window.location.origin);
const params = url.searchParams;
params.set("before", sentinel.dataset.nextBefore);
params.set("view", feed.dataset.selectedView || "rendered");
if (feed.dataset.selectedSource) params.set("source", feed.dataset.selectedSource);
if (feed.dataset.selectedLabel) params.set("label", feed.dataset.selectedLabel);
if (feed.dataset.selectedKind) params.set("kind", feed.dataset.selectedKind);
if (feed.dataset.titleQuery) params.set("q", feed.dataset.titleQuery);
const response = await fetch(url, {headers: {"X-Requested-With": "fetch"}});
if (!response.ok) return;
const markup = (await response.text()).trim();
sentinel.remove();
if (markup) {
artifactList.insertAdjacentHTML("beforeend", markup);
observeSentinel();
}
} catch {
// Leave the list as-is; the user can scroll again to retry.
} finally {
loadingOlder = false;
}
};
const olderObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
olderObserver.unobserve(entry.target);
loadOlder(entry.target);
}
});
}, {root: artifactList, rootMargin: "200px"});
const observeSentinel = () => {
const sentinel = artifactList.querySelector("[data-feed-more]");
if (sentinel) olderObserver.observe(sentinel);
};
observeSentinel();
// Populate the details panel for a server-preselected artifact so the first
// "Details" click shows content immediately.
const preselected = selectedRow();
if (preselected) renderDetails(preselected);
const toggleKeep = async (button) => {
const artifactId = button.dataset.artifactId;
if (!artifactId) return;
button.disabled = true;
try {
const response = await fetch(`/a/${artifactId}/keep`, {
method: "POST",
headers: {"X-CSRFToken": csrfToken},
});
const data = await response.json();
syncKeepButton(data.is_kept);
const row = document.querySelector(`.artifact-row[data-artifact-id="${artifactId}"]`);
if (row) {
row.dataset.kept = data.is_kept ? "1" : "0";
row.classList.toggle("kept", data.is_kept);
const badge = row.querySelector("[data-kept-badge]");
if (badge) badge.hidden = !data.is_kept;
}
} catch {
button.disabled = false;
}
};
previewPane.addEventListener("click", (event) => {
const copyButton = event.target.closest("[data-copy-url]");
if (copyButton) {
event.preventDefault();
copyLink(copyButton);
return;
}
const keepButton = event.target.closest("[data-keep-button]");
if (keepButton && !keepButton.disabled) {
event.preventDefault();
toggleKeep(keepButton);
return;
}
const detailsToggle = event.target.closest("[data-details-toggle]");
if (detailsToggle && !detailsToggle.disabled) {
event.preventDefault();
const panel = document.querySelector("[data-details-panel]");
if (panel) {
panel.hidden = !panel.hidden;
detailsToggle.classList.toggle("selected", !panel.hidden);
}
return;
}
const toggle = event.target.closest("[data-view]");
if (!toggle) return;
if (toggle.classList.contains("disabled")) {
event.preventDefault();
return;
}
const row = selectedRow();
if (!row) return;
event.preventDefault();
selectedView = toggle.dataset.view;
renderPreview(row);
});
})();
</script>
</body>
</html>

View file

@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Log in — AgentFeed</title>
<link rel="stylesheet" href="/static/daybook.css">
</head>
<body>
<main class="login-page">
<form method="post" class="login-card">
{% csrf_token %}
<h1>AgentFeed</h1>
{% if form.non_field_errors %}
<p class="login-error">{{ form.non_field_errors|join:" " }}</p>
{% endif %}
<label>
<span>Username</span>
{{ form.username }}
</label>
<label>
<span>Password</span>
{{ form.password }}
</label>
<label class="login-remember">
{{ form.remember_me }}
<span>Remember me</span>
</label>
<button type="submit">Log in</button>
<input type="hidden" name="next" value="{{ next }}">
</form>
</main>
</body>
</html>

View file

@ -0,0 +1,15 @@
{% extends "artifacts/settings_base.html" %}
{% block settings_content %}
<h2>Account</h2>
<p><strong>Username:</strong> {{ request.user.username }}</p>
<h3>API Token</h3>
<p>Use this token as <code>Authorization: Bearer &lt;token&gt;</code> when posting artifacts.</p>
<p><code class="token-value">{{ token.key }}</code></p>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="regenerate">
<button type="submit">Regenerate Token</button>
</form>
{% endblock %}

View file

@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Settings — AgentFeed</title>
<link rel="stylesheet" href="/static/daybook.css">
</head>
<body>
{% include "artifacts/_topbar.html" %}
<main class="settings-layout">
<nav class="settings-nav">
<a class="settings-nav-back" href="{% url 'feed' %}">&larr; Back to Feed</a>
<a class="{% if active_section == 'account' %}selected{% endif %}" href="{% url 'settings-account' %}">Account</a>
<a class="{% if active_section == 'storage' %}selected{% endif %}" href="{% url 'settings-storage' %}">Storage</a>
<a class="{% if active_section == 'demo-data' %}selected{% endif %}" href="{% url 'settings-demo-data' %}">Demo Data</a>
</nav>
<section class="settings-panel">
{% if messages %}
<ul class="settings-messages">
{% for message in messages %}<li>{{ message }}</li>{% endfor %}
</ul>
{% endif %}
{% block settings_content %}{% endblock %}
</section>
</main>
</body>
</html>

View file

@ -0,0 +1,10 @@
{% extends "artifacts/settings_base.html" %}
{% block settings_content %}
<h2>Demo Data</h2>
<p>Generate one sample artifact of every supported kind (markdown, HTML fragment, full HTML, deck, image, PDF, binary), tagged "demo" and dated today.</p>
<form method="post">
{% csrf_token %}
<button type="submit">Generate Demo Data</button>
</form>
{% endblock %}

View file

@ -0,0 +1,39 @@
{% extends "artifacts/settings_base.html" %}
{% block settings_content %}
<h2>Storage</h2>
<div class="usage-bar" aria-label="Storage used">
<div class="usage-bar-fill {% if percent_used >= 90 %}usage-bar-fill--full{% elif percent_used >= 75 %}usage-bar-fill--warn{% endif %}" style="width: {{ percent_used }}%"></div>
</div>
<p class="usage-summary">
<strong>{{ used_bytes|filesizeformat }}</strong> of {{ quota_bytes|filesizeformat }} used ({{ percent_used }}%)
</p>
<ul class="storage-facts">
<li>{{ artifact_count }} artifact{{ artifact_count|pluralize }} total</li>
<li>{{ kept_count }} kept ({{ kept_bytes|filesizeformat }}) &mdash; never auto-deleted</li>
</ul>
<p class="storage-note">
When your quota fills, the oldest <strong>unkept</strong> artifacts are deleted
automatically to make room for new ones. Click <strong>Keep</strong> on an artifact
to protect it. If the quota is full of kept artifacts, new posts are rejected until
you free space.
</p>
{% if is_superuser %}
<h3>Admin</h3>
<ul class="storage-facts">
<li>Total quota allocated across all users: <strong>{{ total_allocated|filesizeformat }}</strong></li>
<li>Free space on the artifact disk: <strong>{{ disk_free|filesizeformat }}</strong></li>
</ul>
{% if total_allocated > disk_free %}
<p class="storage-warning">
⚠ Allocated quota exceeds free disk space. Grow the volume before raising quotas,
or usage could fill the actual disk.
</p>
{% endif %}
<p class="storage-note">Per-user quotas are set in the <a href="/admin/artifacts/userprofile/">Django admin</a>.</p>
{% endif %}
{% endblock %}

0
tests/__init__.py Normal file
View file

0
tests/api/__init__.py Normal file
View file

276
tests/api/test_artifacts.py Normal file
View file

@ -0,0 +1,276 @@
import shutil
import tempfile
from pathlib import Path
from django.contrib.auth import get_user_model
from django.test import override_settings
from django.urls import reverse
from django.utils import timezone
from rest_framework.authtoken.models import Token
from rest_framework.test import APITestCase
from artifacts.models import Artifact
User = get_user_model()
class ArtifactAPITestCase(APITestCase):
"""Exercises the ingest/list/detail API against a real (temp) filesystem
and a real (test) database, the way an agent posting over HTTP would."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="daybook-test-artifacts-"))
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
cls.settings_override.enable()
@classmethod
def tearDownClass(cls):
cls.settings_override.disable()
shutil.rmtree(cls.artifacts_root, ignore_errors=True)
super().tearDownClass()
def setUp(self):
self.user = User.objects.create_user(username="alice", password="pw")
self.token = Token.objects.create(user=self.user)
self.other_user = User.objects.create_user(username="bob", password="pw")
self.other_token = Token.objects.create(user=self.other_user)
def auth_headers(self, token):
return {"HTTP_AUTHORIZATION": f"Bearer {token.key}"}
def create_artifact(self, token=None, **overrides):
token = token or self.token
payload = {
"source": "test-agent",
"title": "Hello World",
"kind": "markdown",
"content": "# Hello\n\nBody text.",
}
payload.update(overrides)
return self.client.post(
reverse("artifact-api"), payload, format="json", **self.auth_headers(token)
)
# -- capabilities -------------------------------------------------
def test_capabilities_requires_auth(self):
response = self.client.get(reverse("capabilities-api"))
self.assertEqual(response.status_code, 403)
def test_capabilities_with_token(self):
response = self.client.get(reverse("capabilities-api"), **self.auth_headers(self.token))
self.assertEqual(response.status_code, 200)
self.assertIn("markdown", response.data["artifact_kinds"])
def test_capabilities_lists_supported_methods(self):
response = self.client.get(reverse("capabilities-api"), **self.auth_headers(self.token))
methods = response.data["methods"]
self.assertEqual(methods["/api/artifacts"], ["GET", "POST"])
self.assertEqual(methods["/api/artifacts/{id}"], ["GET", "PATCH", "DELETE"])
def test_capabilities_with_session(self):
self.client.force_login(self.user)
response = self.client.get(reverse("capabilities-api"))
self.assertEqual(response.status_code, 200)
# -- create (POST) --------------------------------------------------
def test_create_markdown_artifact(self):
response = self.create_artifact(tags=["demo", "weekly"])
self.assertEqual(response.status_code, 201)
body = response.data
self.assertEqual(body["source"], "test-agent")
self.assertEqual(body["kind"], "markdown")
self.assertEqual(body["title"], "Hello World")
self.assertEqual([tag["name"] for tag in body["tags"]], ["demo", "weekly"])
artifact = Artifact.objects.get(pk=body["id"])
self.assertEqual(artifact.owner, self.user)
def test_create_artifact_writes_files_to_disk(self):
response = self.create_artifact()
artifact = Artifact.objects.get(pk=response.data["id"])
stored_path = self.artifacts_root / artifact.path
self.assertTrue(stored_path.exists())
# Markdown artifacts also preserve the original .md alongside the rendered .html
self.assertTrue(stored_path.with_suffix(".md").exists())
def test_create_artifact_requires_token(self):
response = self.client.post(
reverse("artifact-api"),
{"source": "test-agent", "title": "x", "kind": "markdown", "content": "hi"},
format="json",
)
self.assertEqual(response.status_code, 403)
def test_create_artifact_rejects_invalid_kind(self):
response = self.create_artifact(kind="pdf")
self.assertEqual(response.status_code, 400)
def test_create_artifact_rejects_missing_fields(self):
response = self.client.post(
reverse("artifact-api"), {"kind": "markdown"}, format="json", **self.auth_headers(self.token)
)
self.assertEqual(response.status_code, 400)
# -- list (GET) -------------------------------------------------------
def test_list_requires_session_login(self):
response = self.client.get(reverse("artifact-api"))
self.assertEqual(response.status_code, 302) # redirected to login
def test_list_scoped_to_owner(self):
self.create_artifact()
self.create_artifact(token=self.other_token, title="Not mine")
self.client.force_login(self.user)
response = self.client.get(reverse("artifact-api"))
self.assertEqual(response.status_code, 200)
self.assertEqual([a["title"] for a in response.data], ["Hello World"])
def test_list_filter_by_label(self):
self.create_artifact(tags=["urgent"])
self.create_artifact(title="Second", tags=["later"])
self.client.force_login(self.user)
response = self.client.get(reverse("artifact-api"), {"label": "urgent"})
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]["title"], "Hello World")
def test_list_filter_by_kind(self):
self.create_artifact(kind="markdown")
self.create_artifact(title="Frag", kind="html_fragment", content="<p>hi</p>")
self.client.force_login(self.user)
response = self.client.get(reverse("artifact-api"), {"kind": "html_fragment"})
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]["title"], "Frag")
def test_list_filter_by_query(self):
self.create_artifact(title="Weekly Report")
self.create_artifact(title="Random Notes")
self.client.force_login(self.user)
response = self.client.get(reverse("artifact-api"), {"q": "weekly"})
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]["title"], "Weekly Report")
def test_list_filter_by_day(self):
self.create_artifact()
today = timezone.localdate().isoformat()
self.client.force_login(self.user)
response = self.client.get(reverse("artifact-api"), {"day": today})
self.assertEqual(len(response.data), 1)
response = self.client.get(reverse("artifact-api"), {"day": "2000-01-01"})
self.assertEqual(len(response.data), 0)
# -- detail (GET/PATCH) -----------------------------------------------
def test_detail_get_scoped_to_owner(self):
artifact_id = self.create_artifact().data["id"]
self.client.force_login(self.other_user)
response = self.client.get(reverse("artifact-detail-api", args=[artifact_id]))
self.assertEqual(response.status_code, 404)
self.client.force_login(self.user)
response = self.client.get(reverse("artifact-detail-api", args=[artifact_id]))
self.assertEqual(response.status_code, 200)
def test_patch_updates_title_and_tags(self):
artifact_id = self.create_artifact().data["id"]
response = self.client.patch(
reverse("artifact-detail-api", args=[artifact_id]),
{"title": "Updated title", "tags": ["a", "b"]},
format="json",
**self.auth_headers(self.token),
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["title"], "Updated title")
self.assertEqual([t["name"] for t in response.data["tags"]], ["a", "b"])
def test_patch_rejects_blank_title(self):
artifact_id = self.create_artifact().data["id"]
response = self.client.patch(
reverse("artifact-detail-api", args=[artifact_id]),
{"title": ""},
format="json",
**self.auth_headers(self.token),
)
self.assertEqual(response.status_code, 400)
def test_patch_requires_token(self):
artifact_id = self.create_artifact().data["id"]
response = self.client.patch(
reverse("artifact-detail-api", args=[artifact_id]), {"title": "No token"}, format="json"
)
self.assertEqual(response.status_code, 403)
def test_patch_cannot_touch_other_users_artifact(self):
artifact_id = self.create_artifact().data["id"]
response = self.client.patch(
reverse("artifact-detail-api", args=[artifact_id]),
{"title": "Hijacked"},
format="json",
**self.auth_headers(self.other_token),
)
self.assertEqual(response.status_code, 404)
# -- delete (DELETE) --------------------------------------------------
def test_delete_removes_artifact_and_files(self):
artifact_id = self.create_artifact().data["id"]
artifact = Artifact.objects.get(pk=artifact_id)
stored_path = self.artifacts_root / artifact.path
self.assertTrue(stored_path.exists())
response = self.client.delete(
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
)
self.assertEqual(response.status_code, 204)
self.assertFalse(Artifact.objects.filter(pk=artifact_id).exists())
self.assertFalse(stored_path.exists())
self.assertFalse(stored_path.with_suffix(".md").exists())
def test_delete_requires_token(self):
artifact_id = self.create_artifact().data["id"]
response = self.client.delete(reverse("artifact-detail-api", args=[artifact_id]))
self.assertEqual(response.status_code, 403)
self.assertTrue(Artifact.objects.filter(pk=artifact_id).exists())
def test_delete_cannot_touch_other_users_artifact(self):
artifact_id = self.create_artifact().data["id"]
response = self.client.delete(
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.other_token)
)
self.assertEqual(response.status_code, 404)
self.assertTrue(Artifact.objects.filter(pk=artifact_id).exists())
def test_delete_twice_second_call_404s(self):
artifact_id = self.create_artifact().data["id"]
first = self.client.delete(
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
)
self.assertEqual(first.status_code, 204)
second = self.client.delete(
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
)
self.assertEqual(second.status_code, 404)
def test_deleted_artifact_is_gone_from_list_and_detail(self):
artifact_id = self.create_artifact().data["id"]
self.client.delete(
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
)
self.client.force_login(self.user)
list_response = self.client.get(reverse("artifact-api"))
self.assertEqual(list_response.data, [])
detail_response = self.client.get(reverse("artifact-detail-api", args=[artifact_id]))
self.assertEqual(detail_response.status_code, 404)