Compare commits
10 commits
3974276c9b
...
f92f34dfae
| Author | SHA1 | Date | |
|---|---|---|---|
| f92f34dfae | |||
| bf8d1fe244 | |||
| 13f728bcd6 | |||
| e0f1cce626 | |||
| 37cd96fa7a | |||
| 203f9b578e | |||
| b455fe56bb | |||
| 74654f71dd | |||
| ba9f5eb168 | |||
| 3179f832fc |
53 changed files with 4697 additions and 589 deletions
13
.claude/commands/seed-demo.md
Normal file
13
.claude/commands/seed-demo.md
Normal 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
19
.dockerignore
Normal 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/
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -31,3 +31,10 @@ htmlcov/
|
||||||
.vscode/
|
.vscode/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Internal-only planning/ops docs (home infrastructure details)
|
||||||
|
_archive_/
|
||||||
|
planning/
|
||||||
|
|
||||||
|
# Local machine-specific tool config (MCP servers, absolute local paths)
|
||||||
|
opencode.json
|
||||||
|
|
|
||||||
52
AGENTS.md
52
AGENTS.md
|
|
@ -1,52 +0,0 @@
|
||||||
# AGENTS.md
|
|
||||||
|
|
||||||
## Project
|
|
||||||
|
|
||||||
Daybook is a self-hosted Django + Django REST Framework web app where AI agents publish artifacts and a human browses them in a time-ordered, persona-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
|
|
||||||
|
|
||||||
- Use a single shared ingest API token for now.
|
|
||||||
- Require `persona` in every artifact payload.
|
|
||||||
- Do not add reader authentication in the local/self-hosted MVP.
|
|
||||||
- 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`.
|
|
||||||
- Stored artifact files live under `artifacts_store/{persona}/{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.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
- Install dependencies: `pip install -r requirements.txt`
|
|
||||||
- Run migrations: `python manage.py migrate`
|
|
||||||
- Start dev server: `python manage.py runserver`
|
|
||||||
- Django checks: `python manage.py check`
|
|
||||||
|
|
||||||
## API Defaults
|
|
||||||
|
|
||||||
- MVP ingest endpoint: `POST /api/artifacts`
|
|
||||||
- MVP list endpoint: `GET /api/artifacts?persona=&day=&tag=`
|
|
||||||
- Artifact view: `GET /a/{id}`
|
|
||||||
- Development token: `Authorization: Bearer dev-token`
|
|
||||||
|
|
||||||
## OpenCode
|
|
||||||
|
|
||||||
This project is initialized with `opencode.json`, which loads this file as project instructions. After changing `opencode.json`, `.opencode/`, or this file, restart OpenCode so the updated instructions are loaded.
|
|
||||||
75
CLAUDE.md
Normal file
75
CLAUDE.md
Normal 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.
|
||||||
|
|
@ -1,10 +1,23 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import Artifact
|
from .models import Artifact, Asset, UserProfile
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Artifact)
|
@admin.register(Artifact)
|
||||||
class ArtifactAdmin(admin.ModelAdmin):
|
class ArtifactAdmin(admin.ModelAdmin):
|
||||||
list_display = ("id", "title", "persona", "kind", "day", "created_at")
|
list_display = ("id", "title", "owner", "source", "kind", "is_kept", "size_bytes", "day", "created_at")
|
||||||
list_filter = ("persona", "kind", "day")
|
list_filter = ("owner", "source", "source_type", "kind", "is_kept", "day")
|
||||||
search_fields = ("id", "title", "persona")
|
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",)
|
||||||
|
|
|
||||||
87
artifacts/demo_data.py
Normal file
87
artifacts/demo_data.py
Normal 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
6
artifacts/forms.py
Normal 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)
|
||||||
|
|
@ -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),
|
||||||
|
]
|
||||||
20
artifacts/migrations/0003_tagcolor.py
Normal file
20
artifacts/migrations/0003_tagcolor.py
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
16
artifacts/migrations/0004_rename_persona_to_source.py
Normal file
16
artifacts/migrations/0004_rename_persona_to_source.py
Normal 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",
|
||||||
|
),
|
||||||
|
]
|
||||||
24
artifacts/migrations/0005_add_owner_nullable.py
Normal file
24
artifacts/migrations/0005_add_owner_nullable.py
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
27
artifacts/migrations/0006_backfill_owner.py
Normal file
27
artifacts/migrations/0006_backfill_owner.py
Normal 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),
|
||||||
|
]
|
||||||
23
artifacts/migrations/0007_owner_required.py
Normal file
23
artifacts/migrations/0007_owner_required.py
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
artifacts/migrations/0008_artifact_read_at.py
Normal file
18
artifacts/migrations/0008_artifact_read_at.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
34
artifacts/migrations/0009_quota_and_keep.py
Normal file
34
artifacts/migrations/0009_quota_and_keep.py
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
43
artifacts/migrations/0010_backfill_sizes_and_profiles.py
Normal file
43
artifacts/migrations/0010_backfill_sizes_and_profiles.py
Normal 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),
|
||||||
|
]
|
||||||
31
artifacts/migrations/0011_add_asset.py
Normal file
31
artifacts/migrations/0011_add_asset.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -10,6 +11,10 @@ class Artifact(models.Model):
|
||||||
PDF = "pdf"
|
PDF = "pdf"
|
||||||
BINARY = "binary"
|
BINARY = "binary"
|
||||||
|
|
||||||
|
AGENT = "agent"
|
||||||
|
PERSON = "person"
|
||||||
|
SYSTEM = "system"
|
||||||
|
|
||||||
KIND_CHOICES = [
|
KIND_CHOICES = [
|
||||||
(HTML_FRAGMENT, "HTML fragment"),
|
(HTML_FRAGMENT, "HTML fragment"),
|
||||||
(HTML_FULL, "Full HTML"),
|
(HTML_FULL, "Full HTML"),
|
||||||
|
|
@ -20,8 +25,15 @@ class Artifact(models.Model):
|
||||||
(BINARY, "Binary"),
|
(BINARY, "Binary"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
SOURCE_TYPE_CHOICES = [
|
||||||
|
(AGENT, "Agent"),
|
||||||
|
(PERSON, "Person"),
|
||||||
|
(SYSTEM, "System"),
|
||||||
|
]
|
||||||
|
|
||||||
id = models.CharField(max_length=8, primary_key=True)
|
id = models.CharField(max_length=8, primary_key=True)
|
||||||
persona = models.CharField(max_length=80, db_index=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)
|
title = models.CharField(max_length=200)
|
||||||
kind = models.CharField(max_length=20, choices=KIND_CHOICES)
|
kind = models.CharField(max_length=20, choices=KIND_CHOICES)
|
||||||
theme = models.CharField(max_length=40, blank=True)
|
theme = models.CharField(max_length=40, blank=True)
|
||||||
|
|
@ -31,9 +43,63 @@ class Artifact(models.Model):
|
||||||
original_kind = models.CharField(max_length=20)
|
original_kind = models.CharField(max_length=20)
|
||||||
tags = models.JSONField(default=list, blank=True)
|
tags = models.JSONField(default=list, blank=True)
|
||||||
source_prompt = models.TextField(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:
|
class Meta:
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.title} ({self.persona})"
|
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
135
artifacts/storage.py
Normal 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
|
||||||
|
|
@ -5,6 +5,17 @@ from . import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.feed, name="feed"),
|
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("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", 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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,64 @@
|
||||||
|
import hashlib
|
||||||
import html
|
import html
|
||||||
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import markdown
|
import markdown
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden
|
from django.contrib import messages
|
||||||
from django.shortcuts import get_object_or_404, render
|
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.template.defaultfilters import slugify
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.dateparse import parse_date
|
from django.utils.dateparse import parse_date
|
||||||
from django.utils import timezone
|
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.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.parsers import FormParser, JSONParser, MultiPartParser
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from .models import Artifact
|
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}
|
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 = {
|
DEFAULT_THEMES = {
|
||||||
Artifact.HTML_FRAGMENT: "report",
|
Artifact.HTML_FRAGMENT: "report",
|
||||||
Artifact.MARKDOWN: "report",
|
Artifact.MARKDOWN: "report",
|
||||||
|
|
@ -28,70 +66,283 @@ DEFAULT_THEMES = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(login_required, name="get")
|
||||||
class ArtifactListCreateView(APIView):
|
class ArtifactListCreateView(APIView):
|
||||||
parser_classes = [JSONParser, FormParser, MultiPartParser]
|
parser_classes = [JSONParser, FormParser, MultiPartParser]
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
artifacts = Artifact.objects.all()
|
artifacts = Artifact.objects.filter(owner=request.user)
|
||||||
if persona := request.query_params.get("persona"):
|
if source := request.query_params.get("source"):
|
||||||
artifacts = artifacts.filter(persona=persona)
|
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"):
|
if day := request.query_params.get("day"):
|
||||||
artifacts = artifacts.filter(day=day)
|
artifacts = artifacts.filter(day=day)
|
||||||
if tag := request.query_params.get("tag"):
|
if kind := request.query_params.get("kind"):
|
||||||
artifacts = artifacts.filter(tags__contains=[tag])
|
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])
|
return Response([serialize_artifact(artifact, request) for artifact in artifacts])
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
if not has_valid_token(request):
|
user = resolve_token_user(request)
|
||||||
|
if user is None:
|
||||||
return HttpResponseForbidden("Invalid or missing API token")
|
return HttpResponseForbidden("Invalid or missing API token")
|
||||||
|
|
||||||
if request.content_type and request.content_type.startswith("multipart/"):
|
if request.content_type and request.content_type.startswith("multipart/"):
|
||||||
artifact = create_binary_artifact(request.data)
|
artifact = create_binary_artifact(request.data, user)
|
||||||
if isinstance(artifact, Response):
|
if isinstance(artifact, Response):
|
||||||
return artifact
|
return artifact
|
||||||
return Response({"id": artifact.id, "url": reverse("artifact", args=[artifact.id])}, status=201)
|
apply_tag_colors(request.data)
|
||||||
|
return Response(serialize_artifact(artifact, request), status=201)
|
||||||
|
|
||||||
data = request.data
|
data = request.data
|
||||||
kind = data.get("kind")
|
kind = data.get("kind")
|
||||||
if kind not in TEXT_KINDS:
|
if kind not in TEXT_KINDS:
|
||||||
return Response({"detail": "kind must be html_fragment, html_full, markdown, or deck"}, status=400)
|
return Response({"detail": "kind must be html_fragment, html_full, markdown, or deck"}, status=400)
|
||||||
|
|
||||||
required = ["persona", "title", "content"]
|
required = ["source", "title", "content"]
|
||||||
missing = [field for field in required if not data.get(field)]
|
missing = [field for field in required if not data.get(field)]
|
||||||
if missing:
|
if missing:
|
||||||
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400)
|
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400)
|
||||||
|
|
||||||
artifact = create_text_artifact(data, request)
|
source_error = validate_source_fields(data)
|
||||||
return Response({"id": artifact.id, "url": reverse("artifact", args=[artifact.id])}, status=201)
|
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):
|
def feed(request):
|
||||||
persona = request.GET.get("persona", "")
|
selected_source = request.GET.get("source", "")
|
||||||
|
selected_label = request.GET.get("label", "")
|
||||||
selected_kind = request.GET.get("kind", "")
|
selected_kind = request.GET.get("kind", "")
|
||||||
title_query = request.GET.get("q", "")
|
title_query = request.GET.get("q", "")
|
||||||
selected_day = parse_date(request.GET.get("day", "")) or timezone.localdate()
|
today = timezone.localdate()
|
||||||
selected_artifact_id = request.GET.get("artifact", "")
|
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"
|
selected_view = "raw" if request.GET.get("view") == "raw" else "rendered"
|
||||||
all_artifacts = Artifact.objects.all()
|
|
||||||
artifacts = all_artifacts
|
|
||||||
if persona:
|
|
||||||
artifacts = artifacts.filter(persona=persona)
|
|
||||||
if selected_kind:
|
|
||||||
artifacts = artifacts.filter(kind=selected_kind)
|
|
||||||
if title_query:
|
|
||||||
artifacts = artifacts.filter(title__icontains=title_query)
|
|
||||||
|
|
||||||
days = list(artifacts.dates("day", "day", order="DESC"))
|
all_artifacts = Artifact.objects.filter(owner=request.user)
|
||||||
if selected_day not in days:
|
artifacts = apply_feed_filters(
|
||||||
days.append(selected_day)
|
all_artifacts, selected_source, selected_label, selected_kind, title_query
|
||||||
days.sort(reverse=True)
|
)
|
||||||
|
|
||||||
day_artifacts = artifacts.filter(day=selected_day).order_by("-created_at")
|
window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1)
|
||||||
selected_artifact = day_artifacts.filter(pk=selected_artifact_id).first()
|
if not artifacts.filter(day__gte=window_start).exists():
|
||||||
toggle_query = {"day": selected_day.isoformat(), "view": "rendered"}
|
# Nothing recent for these filters: fall back to the most recent days that
|
||||||
if persona:
|
# do match, so selecting a source is never met with a blank list.
|
||||||
toggle_query["persona"] = persona
|
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:
|
if selected_kind:
|
||||||
toggle_query["kind"] = selected_kind
|
toggle_query["kind"] = selected_kind
|
||||||
if title_query:
|
if title_query:
|
||||||
|
|
@ -101,30 +352,107 @@ def feed(request):
|
||||||
rendered_url = f"?{urlencode(toggle_query)}"
|
rendered_url = f"?{urlencode(toggle_query)}"
|
||||||
toggle_query["view"] = "raw"
|
toggle_query["view"] = "raw"
|
||||||
raw_url = f"?{urlencode(toggle_query)}"
|
raw_url = f"?{urlencode(toggle_query)}"
|
||||||
personas = all_artifacts.order_by("persona").values_list("persona", flat=True).distinct()
|
|
||||||
|
# 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(
|
return render(
|
||||||
request,
|
request,
|
||||||
"artifacts/feed.html",
|
"artifacts/feed.html",
|
||||||
{
|
{
|
||||||
"artifacts": day_artifacts,
|
"artifacts": window_artifacts,
|
||||||
"days": days,
|
"day_groups": day_groups,
|
||||||
"personas": personas,
|
"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,
|
"raw_url": raw_url,
|
||||||
"rendered_url": rendered_url,
|
"rendered_url": rendered_url,
|
||||||
"selected_artifact": selected_artifact,
|
"selected_artifact": selected_artifact,
|
||||||
"selected_day": selected_day,
|
|
||||||
"selected_kind": selected_kind,
|
"selected_kind": selected_kind,
|
||||||
"selected_persona": persona,
|
"selected_label": selected_label,
|
||||||
|
"selected_source": selected_source,
|
||||||
"selected_view": selected_view,
|
"selected_view": selected_view,
|
||||||
|
"preview_none": preview_none,
|
||||||
|
"today": today,
|
||||||
"title_query": title_query,
|
"title_query": title_query,
|
||||||
"kind_choices": Artifact.KIND_CHOICES,
|
"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
|
@xframe_options_sameorigin
|
||||||
def artifact_view(request, artifact_id):
|
def artifact_view(request, artifact_id):
|
||||||
artifact = get_object_or_404(Artifact, pk=artifact_id)
|
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
|
||||||
if request.GET.get("raw") == "1":
|
if request.GET.get("raw") == "1":
|
||||||
raw_path = raw_artifact_path(artifact)
|
raw_path = raw_artifact_path(artifact)
|
||||||
if raw_path and raw_path.exists():
|
if raw_path and raw_path.exists():
|
||||||
|
|
@ -139,16 +467,16 @@ def artifact_view(request, artifact_id):
|
||||||
return FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream")
|
return FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
def create_text_artifact(data, request):
|
def create_text_artifact(data, request, user):
|
||||||
now = timezone.localtime()
|
now = timezone.localtime()
|
||||||
artifact_id = generate_id()
|
artifact_id = generate_id()
|
||||||
persona = slugify(data["persona"]) or "unknown"
|
source = slugify(data["source"]) or "unknown"
|
||||||
kind = data["kind"]
|
kind = data["kind"]
|
||||||
theme = data.get("theme") or DEFAULT_THEMES.get(kind, "base")
|
theme = data.get("theme") or DEFAULT_THEMES.get(kind, "base")
|
||||||
title = data["title"]
|
title = data["title"]
|
||||||
content = data["content"]
|
content = data["content"]
|
||||||
|
|
||||||
directory = Path(persona) / now.date().isoformat()
|
directory = Path(source) / now.date().isoformat()
|
||||||
target_dir = settings.ARTIFACTS_ROOT / directory
|
target_dir = settings.ARTIFACTS_ROOT / directory
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
@ -165,9 +493,19 @@ def create_text_artifact(data, request):
|
||||||
stored_path = directory / f"{artifact_id}.html"
|
stored_path = directory / f"{artifact_id}.html"
|
||||||
(settings.ARTIFACTS_ROOT / stored_path).write_text(rendered, encoding="utf-8")
|
(settings.ARTIFACTS_ROOT / stored_path).write_text(rendered, encoding="utf-8")
|
||||||
|
|
||||||
return Artifact.objects.create(
|
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,
|
id=artifact_id,
|
||||||
persona=persona,
|
owner=user,
|
||||||
|
source=source,
|
||||||
title=title,
|
title=title,
|
||||||
kind=kind,
|
kind=kind,
|
||||||
theme=theme,
|
theme=theme,
|
||||||
|
|
@ -176,26 +514,33 @@ def create_text_artifact(data, request):
|
||||||
original_kind=kind,
|
original_kind=kind,
|
||||||
tags=normalize_tags(data.get("tags", [])),
|
tags=normalize_tags(data.get("tags", [])),
|
||||||
source_prompt=data.get("source_prompt", ""),
|
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):
|
def create_binary_artifact(data, user):
|
||||||
uploaded = data.get("file")
|
uploaded = data.get("file")
|
||||||
required = ["persona", "title"]
|
required = ["source", "title"]
|
||||||
missing = [field for field in required if not data.get(field)]
|
missing = [field for field in required if not data.get(field)]
|
||||||
if not uploaded:
|
if not uploaded:
|
||||||
missing.append("file")
|
missing.append("file")
|
||||||
if missing:
|
if missing:
|
||||||
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400)
|
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()
|
now = timezone.localtime()
|
||||||
artifact_id = generate_id()
|
artifact_id = generate_id()
|
||||||
persona = slugify(data["persona"]) or "unknown"
|
source = slugify(data["source"]) or "unknown"
|
||||||
title = data["title"]
|
title = data["title"]
|
||||||
content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or ""
|
content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or ""
|
||||||
kind = binary_kind(content_type)
|
kind = binary_kind(content_type)
|
||||||
|
|
||||||
directory = Path(persona) / now.date().isoformat()
|
directory = Path(source) / now.date().isoformat()
|
||||||
target_dir = settings.ARTIFACTS_ROOT / directory
|
target_dir = settings.ARTIFACTS_ROOT / directory
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
@ -205,9 +550,17 @@ def create_binary_artifact(data):
|
||||||
for chunk in uploaded.chunks():
|
for chunk in uploaded.chunks():
|
||||||
target.write(chunk)
|
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(
|
return Artifact.objects.create(
|
||||||
id=artifact_id,
|
id=artifact_id,
|
||||||
persona=persona,
|
owner=user,
|
||||||
|
source=source,
|
||||||
title=title,
|
title=title,
|
||||||
kind=kind,
|
kind=kind,
|
||||||
theme="",
|
theme="",
|
||||||
|
|
@ -216,6 +569,8 @@ def create_binary_artifact(data):
|
||||||
original_kind=kind,
|
original_kind=kind,
|
||||||
tags=normalize_tags(data.get("tags", [])),
|
tags=normalize_tags(data.get("tags", [])),
|
||||||
source_prompt=data.get("source_prompt", ""),
|
source_prompt=data.get("source_prompt", ""),
|
||||||
|
size_bytes=size_bytes,
|
||||||
|
**source_fields(data, source),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -260,10 +615,38 @@ def generate_id():
|
||||||
return artifact_id
|
return artifact_id
|
||||||
|
|
||||||
|
|
||||||
def has_valid_token(request):
|
def generate_asset_id():
|
||||||
expected = getattr(settings, "DAYBOOK_API_TOKEN", "")
|
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", "")
|
auth = request.headers.get("Authorization", "")
|
||||||
return bool(expected) and auth == f"Bearer {expected}"
|
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):
|
def normalize_tags(tags):
|
||||||
|
|
@ -274,6 +657,100 @@ def normalize_tags(tags):
|
||||||
return []
|
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):
|
def binary_kind(content_type):
|
||||||
if content_type.startswith("image/"):
|
if content_type.startswith("image/"):
|
||||||
return Artifact.IMAGE
|
return Artifact.IMAGE
|
||||||
|
|
@ -293,14 +770,25 @@ def serialize_artifact(artifact, request):
|
||||||
return {
|
return {
|
||||||
"id": artifact.id,
|
"id": artifact.id,
|
||||||
"url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])),
|
"url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])),
|
||||||
"persona": artifact.persona,
|
"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,
|
"title": artifact.title,
|
||||||
"kind": artifact.kind,
|
"kind": artifact.kind,
|
||||||
"theme": artifact.theme,
|
"theme": artifact.theme,
|
||||||
"day": artifact.day.isoformat(),
|
"day": artifact.day.isoformat(),
|
||||||
"created_at": artifact.created_at.isoformat(),
|
"created_at": artifact.created_at.isoformat(),
|
||||||
"tags": artifact.tags,
|
"tags": tags_with_colors(artifact.tags),
|
||||||
|
"labels": labels_with_colors(artifact),
|
||||||
"source_prompt": artifact.source_prompt,
|
"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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -321,6 +809,78 @@ def raw_artifact_path(artifact):
|
||||||
return None
|
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):
|
def raw_unavailable_html(artifact):
|
||||||
return f"""<!doctype html>
|
return f"""<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
DATA_DIR = Path(os.environ.get("DAYBOOK_DATA_DIR", BASE_DIR)).resolve()
|
||||||
|
|
||||||
SECRET_KEY = "dev-only-change-me"
|
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-change-me")
|
||||||
DEBUG = True
|
DEBUG = os.environ.get("DJANGO_DEBUG", "1").lower() in {"1", "true", "yes", "on"}
|
||||||
ALLOWED_HOSTS = ["*"]
|
ALLOWED_HOSTS = [host.strip() for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",") if host.strip()]
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
"django.contrib.admin",
|
"django.contrib.admin",
|
||||||
|
|
@ -15,11 +17,13 @@ INSTALLED_APPS = [
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
|
"rest_framework.authtoken",
|
||||||
"artifacts",
|
"artifacts",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
|
@ -50,12 +54,15 @@ WSGI_APPLICATION = "daybook_project.wsgi.application"
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
"default": {
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
"NAME": BASE_DIR / "db.sqlite3",
|
"NAME": os.environ.get("DATABASE_PATH", DATA_DIR / "db.sqlite3"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = []
|
AUTH_PASSWORD_VALIDATORS = []
|
||||||
|
|
||||||
|
LOGIN_URL = "login"
|
||||||
|
LOGIN_REDIRECT_URL = "feed"
|
||||||
|
|
||||||
LANGUAGE_CODE = "en-us"
|
LANGUAGE_CODE = "en-us"
|
||||||
TIME_ZONE = "UTC"
|
TIME_ZONE = "UTC"
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
@ -63,8 +70,13 @@ USE_TZ = True
|
||||||
|
|
||||||
STATIC_URL = "static/"
|
STATIC_URL = "static/"
|
||||||
STATICFILES_DIRS = [BASE_DIR / "static"]
|
STATICFILES_DIRS = [BASE_DIR / "static"]
|
||||||
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||||
|
STORAGES = {
|
||||||
|
"staticfiles": {
|
||||||
|
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
ARTIFACTS_ROOT = BASE_DIR / "artifacts_store"
|
ARTIFACTS_ROOT = Path(os.environ.get("ARTIFACTS_ROOT", DATA_DIR / "artifacts_store"))
|
||||||
DAYBOOK_API_TOKEN = "dev-token"
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth import views as auth_views
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
|
from artifacts.views import DaybookLoginView
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
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")),
|
path("", include("artifacts.urls")),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
24
deploy/Dockerfile
Normal file
24
deploy/Dockerfile
Normal 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
19
deploy/docker-compose.yml
Normal 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
9
deploy/entrypoint.sh
Normal 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
7
deploy/env.example
Normal 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
|
||||||
348
deploy/proxmox_llm_deploy.md
Normal file
348
deploy/proxmox_llm_deploy.md
Normal 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
140
docs/agent_quickstart.md
Normal 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
425
docs/api.md
Normal 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
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
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
108
docs/llm_posting_helper.md
Normal 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`.
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://opencode.ai/config.json",
|
|
||||||
"instructions": ["AGENTS.md"],
|
|
||||||
"permission": "allow"
|
|
||||||
}
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
# Daybook — Project Planning
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
**Daybook** is a self-hosted web application where AI agents publish their
|
|
||||||
outputs (task lists, research reports, slide decks, images, etc.) and a human
|
|
||||||
browses them in a **time-ordered, agent-filterable feed** — viewing everything
|
|
||||||
**in-browser without downloading or "opening" files**.
|
|
||||||
|
|
||||||
The defining feature is the integration: an agent calls a REST API and posts
|
|
||||||
*content*, not files. For HTML it can send just a body fragment and Daybook
|
|
||||||
supplies the CSS. The agent never deals with styling, file naming, or storage.
|
|
||||||
|
|
||||||
## Core principles
|
|
||||||
|
|
||||||
- **View, don't download.** Everything renders inline in the browser.
|
|
||||||
- **Agent-agnostic.** Any agent/persona can publish; the human filters by persona.
|
|
||||||
- **Time-ordered.** The primary axis is the day; flipping back to yesterday is trivial.
|
|
||||||
- **Minimal agent burden.** Agents send content + a few metadata fields. The server
|
|
||||||
owns styling, storage layout, IDs, and dates.
|
|
||||||
- **Self-hosted.** A single deployable unit running on the user's own machine/server.
|
|
||||||
- **Dumb viewer, smart ingest.** Normalize every input to three browser-native forms
|
|
||||||
(HTML, image, PDF) at upload time, so the viewer never needs plugins.
|
|
||||||
|
|
||||||
## Actors
|
|
||||||
|
|
||||||
- **Agent (publisher):** an authenticated machine client (e.g. Claude Code, a
|
|
||||||
persona script). POSTs artifacts via REST.
|
|
||||||
- **Human (reader):** browses, filters, and views artifacts; later edits markdown
|
|
||||||
and shares links.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
### MVP
|
|
||||||
- REST ingest endpoint accepting: HTML fragment, full HTML, markdown, image, PDF, binary.
|
|
||||||
- Server-side **named CSS themes**; automatic wrapping of HTML fragments.
|
|
||||||
- Storage in a `persona/day/id` layout plus a metadata table.
|
|
||||||
- Web viewer: reverse-chronological feed, persona filter, in-browser rendering.
|
|
||||||
|
|
||||||
### Later
|
|
||||||
- In-browser **markdown editing** with write-back (the name "Daybook" anticipates this).
|
|
||||||
- Slide decks via a **reveal.js** theme; `pptx`/`docx`/`xlsx` → PDF fallback.
|
|
||||||
- **Share links** (password + expiry) for individual artifacts.
|
|
||||||
- Tags, search, and full-text indexing.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Agent ──POST /api/artifacts──▶ Daybook (Django + DRF)
|
|
||||||
│
|
|
||||||
├─ normalize-on-ingest
|
|
||||||
│ fragment → wrap with theme CSS → .html
|
|
||||||
│ markdown → render → .html
|
|
||||||
│ deck → reveal.js scaffold → .html
|
|
||||||
│ office → LibreOffice headless → .pdf
|
|
||||||
│ image/pdf → store as-is
|
|
||||||
│
|
|
||||||
├─ write file to artifacts/{persona}/{day}/{id}.{ext}
|
|
||||||
└─ insert Artifact row (metadata index)
|
|
||||||
|
|
||||||
Human ──GET / (feed)──▶ list Artifact rows (reverse-chron, persona filter)
|
|
||||||
──GET /a/{id}──▶ render via <iframe> (html/pdf) or <img> (image)
|
|
||||||
```
|
|
||||||
|
|
||||||
The viewer only ever handles **HTML, image, or PDF**, because everything is
|
|
||||||
collapsed into those three at ingest. No client-side office/pptx rendering.
|
|
||||||
|
|
||||||
## Data model
|
|
||||||
|
|
||||||
`Artifact`
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | str (8-char) | short unique id, used in URLs (`/a/{id}`) |
|
|
||||||
| `persona` | str (indexed) | the agent that published it; primary filter |
|
|
||||||
| `title` | str | display title |
|
|
||||||
| `kind` | str | `html_fragment` \| `html_full` \| `markdown` \| `deck` \| `image` \| `pdf` \| `binary` |
|
|
||||||
| `theme` | str | named CSS theme used at render (nullable) |
|
|
||||||
| `day` | date (indexed) | publication day; feed grouping key |
|
|
||||||
| `created_at` | datetime | exact timestamp |
|
|
||||||
| `path` | str | relative path to stored, normalized file |
|
|
||||||
| `original_kind` | str | what the agent sent, pre-normalization (audit) |
|
|
||||||
| `tags` | json/list | later: filtering/search |
|
|
||||||
| `source_prompt` | text | optional: the prompt that produced it |
|
|
||||||
|
|
||||||
Share fields (`share_token`, `share_expires`, `share_password_hash`) added in the
|
|
||||||
share-links milestone, not MVP.
|
|
||||||
|
|
||||||
## REST API (MVP contract)
|
|
||||||
|
|
||||||
All ingest endpoints require an API token (per-persona; see Open Questions).
|
|
||||||
|
|
||||||
### `POST /api/artifacts` (JSON — text content)
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"persona": "hermes",
|
|
||||||
"kind": "html_fragment", // or html_full | markdown | deck
|
|
||||||
"theme": "report", // optional; defaults per kind
|
|
||||||
"title": "Daily Tasks 2026-06-10",
|
|
||||||
"content": "<h1>Today</h1><ul><li>...</li></ul>",
|
|
||||||
"tags": ["tasks"], // optional
|
|
||||||
"source_prompt": "..." // optional
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Response: `{ "id": "a1b2c3d4", "url": "/a/a1b2c3d4" }`
|
|
||||||
|
|
||||||
### `POST /api/artifacts` (multipart — binary)
|
|
||||||
Fields: `persona`, `title`, `tags`; file part `file` (image / pdf / office).
|
|
||||||
Office/deck files are converted to PDF on ingest.
|
|
||||||
|
|
||||||
### `GET /api/artifacts?persona=&day=&tag=`
|
|
||||||
Returns metadata list (reverse-chronological). Drives the feed.
|
|
||||||
|
|
||||||
### `GET /a/{id}`
|
|
||||||
Serves the rendered artifact (HTML/PDF/image) for inline viewing.
|
|
||||||
|
|
||||||
## Theming (server-side CSS)
|
|
||||||
|
|
||||||
The server owns styling so agents can send markup only. Themes live as static
|
|
||||||
files and are selected by name in the POST body:
|
|
||||||
|
|
||||||
```
|
|
||||||
static/themes/report.css
|
|
||||||
static/themes/tasks.css
|
|
||||||
static/themes/deck.css # reveal.js theme
|
|
||||||
static/themes/base.css # default
|
|
||||||
```
|
|
||||||
|
|
||||||
Fragment wrapping (conceptual):
|
|
||||||
|
|
||||||
```python
|
|
||||||
WRAP = """<!doctype html><html><head><meta charset="utf-8">
|
|
||||||
<link rel="stylesheet" href="/static/themes/{theme}.css">
|
|
||||||
<title>{title}</title></head>
|
|
||||||
<body class="artifact">{body}</body></html>"""
|
|
||||||
```
|
|
||||||
|
|
||||||
- `html_fragment` → wrapped with the chosen theme.
|
|
||||||
- `markdown` → rendered to HTML, then wrapped.
|
|
||||||
- `deck` → wrapped in reveal.js scaffold (agent sends `<section>` blocks).
|
|
||||||
- `html_full` → stored verbatim (agent owns everything).
|
|
||||||
|
|
||||||
## Ingest normalization pipeline
|
|
||||||
|
|
||||||
| Agent sends | Step | Stored as | Viewer |
|
|
||||||
|---|---|---|---|
|
|
||||||
| HTML fragment | wrap + theme | `.html` | iframe |
|
|
||||||
| Full HTML | verbatim | `.html` | iframe |
|
|
||||||
| Markdown | render + wrap | `.html` | iframe |
|
|
||||||
| Deck (sections) | reveal.js wrap | `.html` | iframe |
|
|
||||||
| Image | verbatim | image | `<img>` |
|
|
||||||
| PDF | verbatim | `.pdf` | native / pdf.js |
|
|
||||||
| pptx/docx/xlsx | LibreOffice → PDF | `.pdf` | native / pdf.js |
|
|
||||||
|
|
||||||
## Viewer
|
|
||||||
|
|
||||||
- **Feed view (`/`):** reverse-chronological list grouped by day, with a persona
|
|
||||||
filter (dropdown or sidebar) and, later, tag/search. Each entry shows title,
|
|
||||||
persona, time, and kind.
|
|
||||||
- **Artifact view (`/a/{id}`):** renders inline — `<iframe>` for HTML/PDF,
|
|
||||||
`<img>` for images. No download step required (download remains available).
|
|
||||||
- Markdown edit (later): the stored source markdown is editable in-browser and
|
|
||||||
re-rendered on save; keep the original `.md` alongside the rendered `.html`.
|
|
||||||
|
|
||||||
## Storage layout
|
|
||||||
|
|
||||||
```
|
|
||||||
artifacts/
|
|
||||||
{persona}/
|
|
||||||
{YYYY-MM-DD}/
|
|
||||||
{id}.html | {id}.pdf | {id}.png ...
|
|
||||||
{id}.md # kept when source is markdown (for editing)
|
|
||||||
```
|
|
||||||
|
|
||||||
Files on disk stay greppable, portable, and trivially backed up; the DB is an
|
|
||||||
index over them, not the source of truth for content.
|
|
||||||
|
|
||||||
## Tech stack
|
|
||||||
|
|
||||||
- **Django + Django REST Framework** — API, metadata, viewer.
|
|
||||||
- **SQLite** (dev) → **PostgreSQL** (later) for the metadata index.
|
|
||||||
- **Files on disk** for artifact content.
|
|
||||||
- **LibreOffice headless** in the container for office/deck → PDF.
|
|
||||||
- **markdown** (python-markdown) for md rendering; **reveal.js** for decks.
|
|
||||||
- **pdf.js** optional, only if the native PDF viewer is unsatisfactory.
|
|
||||||
- **Docker** — single image, self-hosted.
|
|
||||||
|
|
||||||
## Milestones
|
|
||||||
|
|
||||||
1. **M1 — Core ingest + storage.** `Artifact` model, JSON POST for
|
|
||||||
`html_fragment`/`html_full`/`markdown`, theme wrapping, disk write, `/a/{id}` view.
|
|
||||||
2. **M2 — Viewer.** Feed (`/`) with reverse-chron listing and persona filter;
|
|
||||||
inline iframe/img rendering.
|
|
||||||
3. **M3 — Binary + conversion.** Multipart upload; image/PDF passthrough;
|
|
||||||
LibreOffice headless for office/deck → PDF.
|
|
||||||
4. **M4 — Decks.** reveal.js theme + `deck` kind.
|
|
||||||
5. **M5 — Editing.** In-browser markdown edit + re-render.
|
|
||||||
6. **M6 — Sharing.** Share tokens with password + expiry.
|
|
||||||
7. **M7 — Search/tags.** Tag filter and full-text search.
|
|
||||||
|
|
||||||
## Future features
|
|
||||||
|
|
||||||
- Per-persona dashboards / stats.
|
|
||||||
- Webhooks or "latest artifact" endpoints for chaining agents.
|
|
||||||
- Retention/archival policies per persona.
|
|
||||||
- Multi-user reader auth with per-persona visibility.
|
|
||||||
|
|
||||||
## Open questions / decisions
|
|
||||||
|
|
||||||
- **Output standardization:** push agents toward HTML/markdown/reveal.js
|
|
||||||
(cheap, robust) vs. accept native office files (LibreOffice conversion is the
|
|
||||||
heaviest, flakiest path). Recommendation: prefer the former, keep conversion
|
|
||||||
as a fallback.
|
|
||||||
- **Agent auth:** one API token per persona, or a shared token + persona field?
|
|
||||||
- **Reader auth:** single-user (simplest) vs. multi-user from the start?
|
|
||||||
- **ID scheme:** random short id vs. date-prefixed sortable id.
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
Django>=5.0,<6.0
|
Django>=5.0,<6.0
|
||||||
djangorestframework>=3.15,<4.0
|
djangorestframework>=3.15,<4.0
|
||||||
Markdown>=3.6,<4.0
|
Markdown>=3.6,<4.0
|
||||||
|
gunicorn>=23.0,<24.0
|
||||||
|
whitenoise>=6.7,<7.0
|
||||||
|
|
|
||||||
28
scripts/README.md
Normal file
28
scripts/README.md
Normal 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
16
scripts/daybook_post.py
Normal 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__")
|
||||||
111
scripts/seed_demo_artifacts.py
Normal file
111
scripts/seed_demo_artifacts.py
Normal 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())
|
||||||
73
skills/daybook-posting/SKILL.md
Normal file
73
skills/daybook-posting/SKILL.md
Normal 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
|
||||||
|
# 
|
||||||
|
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.
|
||||||
429
skills/daybook-posting/scripts/daybook_post.py
Normal file
429
skills/daybook-posting/scripts/daybook_post.py
Normal 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())
|
||||||
File diff suppressed because it is too large
Load diff
42
templates/artifacts/_day_groups.html
Normal file
42
templates/artifacts/_day_groups.html
Normal 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 }}&{% endif %}{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}artifact={{ artifact.id }}&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 %}
|
||||||
28
templates/artifacts/_feed_toolbar.html
Normal file
28
templates/artifacts/_feed_toolbar.html
Normal 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>
|
||||||
37
templates/artifacts/_topbar.html
Normal file
37
templates/artifacts/_topbar.html
Normal 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 }}">
|
||||||
|
⚙ {{ 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>
|
||||||
|
|
@ -3,97 +3,80 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Daybook</title>
|
<title>AgentFeed</title>
|
||||||
<link rel="stylesheet" href="/static/daybook.css">
|
<link rel="stylesheet" href="/static/daybook.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
{% include "artifacts/_topbar.html" with topbar_toolbar="artifacts/_feed_toolbar.html" %}
|
||||||
<div class="brandline">
|
|
||||||
<h1>Daybook</h1>
|
|
||||||
<p>Agent Artifacts</p>
|
|
||||||
</div>
|
|
||||||
<div class="toolbar">
|
|
||||||
<form method="get" class="filter">
|
|
||||||
<input type="hidden" name="day" value="{{ selected_day|date:'Y-m-d' }}">
|
|
||||||
<input type="hidden" name="view" value="{{ selected_view }}">
|
|
||||||
{% if selected_artifact %}<input type="hidden" name="artifact" value="{{ selected_artifact.id }}">{% endif %}
|
|
||||||
<label>
|
|
||||||
<span>Persona</span>
|
|
||||||
<select name="persona" onchange="this.form.submit()">
|
|
||||||
<option value="">All personas</option>
|
|
||||||
{% for persona in personas %}
|
|
||||||
<option value="{{ persona }}" {% if persona == selected_persona %}selected{% endif %}>{{ persona }}</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>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="feed">
|
<main class="feed"
|
||||||
<aside class="day-sidebar" aria-label="Days">
|
data-selected-source="{{ selected_source }}"
|
||||||
<h2>Days</h2>
|
data-selected-label="{{ selected_label }}"
|
||||||
<nav class="day-list">
|
data-selected-kind="{{ selected_kind }}"
|
||||||
{% for day in days %}
|
data-title-query="{{ title_query }}"
|
||||||
<a class="day-link {% if day == selected_day %}selected{% endif %}" href="?day={{ day|date:'Y-m-d' }}{% if selected_persona %}&persona={{ selected_persona|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&view={{ selected_view }}">
|
data-selected-view="{{ selected_view }}"
|
||||||
<span>{{ day|date:"D" }}</span>
|
data-window-start="{{ window_start|date:'Y-m-d' }}"
|
||||||
<strong>{{ day|date:"Y-m-d" }}</strong>
|
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 }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}view={{ selected_view }}&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 %}&label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&view={{ selected_view }}&preview=none">
|
||||||
|
<span class="source-name">{{ entry.source }}</span>
|
||||||
|
{% if entry.count %}<span class="source-count">{{ entry.count }}</span>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="splitter" data-splitter="days" role="separator" aria-label="Resize days sidebar" aria-orientation="vertical"></div>
|
<div class="splitter" data-splitter="days" role="separator" aria-label="Resize sources sidebar" aria-orientation="vertical"></div>
|
||||||
|
|
||||||
<aside class="artifact-sidebar" aria-label="Artifacts for selected day">
|
<aside class="artifact-sidebar" aria-label="Artifact feed">
|
||||||
<div class="pane-heading">
|
<div class="pane-bar pane-heading">
|
||||||
<h2>{{ selected_day|date:"Y-m-d" }}</h2>
|
<h2>{% if selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
|
||||||
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
|
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="artifact-list">
|
<div class="artifact-list" data-artifact-list>
|
||||||
{% for artifact in artifacts %}
|
{% if day_groups %}
|
||||||
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %}"
|
{% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %}
|
||||||
href="?day={{ selected_day|date:'Y-m-d' }}{% if selected_persona %}&persona={{ selected_persona|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&artifact={{ artifact.id }}&view={{ selected_view }}"
|
{% else %}
|
||||||
data-artifact-id="{{ artifact.id }}"
|
|
||||||
data-title="{{ artifact.title }}"
|
|
||||||
data-kind="{{ artifact.kind }}"
|
|
||||||
data-rendered-url="{% url 'artifact' artifact.id %}"
|
|
||||||
data-raw-url="{% url 'artifact' artifact.id %}?raw=1">
|
|
||||||
<span class="kind-icon kind-{{ artifact.kind }}" aria-hidden="true">
|
|
||||||
{% if artifact.kind == "image" %}IMG{% elif artifact.kind == "pdf" %}PDF{% elif artifact.kind == "markdown" %}MD{% elif artifact.kind == "deck" %}DECK{% elif artifact.kind == "binary" %}BIN{% else %}HTML{% endif %}
|
|
||||||
</span>
|
|
||||||
<span class="artifact-row-main">
|
|
||||||
<strong>{{ artifact.created_at|date:"H:i" }}</strong>
|
|
||||||
<span>{{ artifact.title }}</span>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
{% empty %}
|
|
||||||
<div class="empty compact">
|
<div class="empty compact">
|
||||||
<h3>No artifacts</h3>
|
<h3>No artifacts</h3>
|
||||||
<p>This day is ready for new agent output.</p>
|
<p>Nothing in the last {{ 7 }} days for these filters.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="splitter" data-splitter="artifacts" role="separator" aria-label="Resize artifacts sidebar" aria-orientation="vertical"></div>
|
<div class="splitter" data-splitter="artifacts" role="separator" aria-label="Resize artifacts sidebar" aria-orientation="vertical"></div>
|
||||||
|
|
||||||
<section class="preview-pane">
|
<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 %}
|
{% if selected_artifact %}
|
||||||
<div class="preview-frame-wrap">
|
<div class="preview-frame-wrap">
|
||||||
{% if selected_view == "raw" %}
|
{% if selected_view == "raw" %}
|
||||||
|
|
@ -110,25 +93,18 @@
|
||||||
<iframe class="preview-frame" src="{% url 'artifact' selected_artifact.id %}{% if selected_view == 'raw' %}?raw=1{% endif %}" title="{{ selected_artifact.title }}"></iframe>
|
<iframe class="preview-frame" src="{% url 'artifact' selected_artifact.id %}{% if selected_view == 'raw' %}?raw=1{% endif %}" title="{{ selected_artifact.title }}"></iframe>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<footer class="artifact-actions" data-actions>
|
|
||||||
<nav class="view-toggle" aria-label="Preview mode">
|
|
||||||
<a class="{% if selected_view == 'rendered' %}selected{% endif %}" href="{{ rendered_url }}" data-view="rendered">Rendered</a>
|
|
||||||
<a class="{% if selected_view == 'raw' %}selected{% endif %}" href="{{ raw_url }}" data-view="raw">Raw</a>
|
|
||||||
</nav>
|
|
||||||
<div class="artifact-action-links">
|
|
||||||
<a href="{% url 'artifact' selected_artifact.id %}" target="_blank" rel="noopener">Open</a>
|
|
||||||
<a href="{% url 'artifact' selected_artifact.id %}" download>Download</a>
|
|
||||||
<button type="button" data-copy-url="{% url 'artifact' selected_artifact.id %}">Copy Link</button>
|
|
||||||
<button type="button" disabled>Send</button>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty preview-empty">
|
<div class="empty preview-empty">
|
||||||
<h2>Select an artifact</h2>
|
{% if artifacts %}
|
||||||
<p>Choose an item from the middle column to preview it here.</p>
|
<h2>No artifact loaded</h2>
|
||||||
<p>POST to <code>/api/artifacts</code> with <code>Authorization: Bearer dev-token</code>.</p>
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
@ -150,16 +126,24 @@
|
||||||
|
|
||||||
applySizes();
|
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) => {
|
document.querySelectorAll(".splitter").forEach((splitter) => {
|
||||||
splitter.addEventListener("pointerdown", (event) => {
|
splitter.addEventListener("pointerdown", (event) => {
|
||||||
const target = splitter.dataset.splitter;
|
const target = splitter.dataset.splitter;
|
||||||
const startX = event.clientX;
|
const startX = event.clientX;
|
||||||
const startWidth = sizes[target];
|
const startWidth = sizes[target];
|
||||||
|
const bounds = sizeBounds[target];
|
||||||
splitter.setPointerCapture(event.pointerId);
|
splitter.setPointerCapture(event.pointerId);
|
||||||
document.body.classList.add("is-resizing");
|
document.body.classList.add("is-resizing");
|
||||||
|
|
||||||
const move = (moveEvent) => {
|
const move = (moveEvent) => {
|
||||||
sizes[target] = Math.max(96, Math.min(420, startWidth + moveEvent.clientX - startX));
|
sizes[target] = Math.max(bounds.min, Math.min(bounds.max, startWidth + moveEvent.clientX - startX));
|
||||||
applySizes();
|
applySizes();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -178,7 +162,12 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
const previewPane = document.querySelector(".preview-pane");
|
const previewPane = document.querySelector(".preview-pane");
|
||||||
const rows = Array.from(document.querySelectorAll(".artifact-row"));
|
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) => ({
|
const escapeHtml = (value) => value.replace(/[&<>"]/g, (character) => ({
|
||||||
"&": "&",
|
"&": "&",
|
||||||
|
|
@ -200,31 +189,147 @@
|
||||||
}, 1200);
|
}, 1200);
|
||||||
};
|
};
|
||||||
const selectedRow = () => document.querySelector(".artifact-row.selected");
|
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 urlFor = (row, view) => view === "raw" ? row.dataset.rawUrl : row.dataset.renderedUrl;
|
||||||
const pageUrlFor = (row, view) => {
|
const pageUrlFor = (row, view) => {
|
||||||
const url = new URL(row.href, window.location.origin);
|
const url = new URL(row.href, window.location.origin);
|
||||||
url.searchParams.set("artifact", row.dataset.artifactId);
|
url.searchParams.set("artifact", row.dataset.artifactId);
|
||||||
url.searchParams.set("view", view);
|
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;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateActions = (row) => {
|
const updateActions = (row) => {
|
||||||
const actions = document.querySelector("[data-actions]");
|
const actions = document.querySelector("[data-actions]");
|
||||||
if (!actions) return;
|
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) => {
|
actions.querySelectorAll("[data-view]").forEach((link) => {
|
||||||
const view = link.dataset.view;
|
const view = link.dataset.view;
|
||||||
link.classList.toggle("selected", view === selectedView);
|
link.classList.toggle("selected", view === selectedView);
|
||||||
|
link.classList.remove("disabled");
|
||||||
|
link.removeAttribute("aria-disabled");
|
||||||
link.href = pageUrlFor(row, view).toString();
|
link.href = pageUrlFor(row, view).toString();
|
||||||
});
|
});
|
||||||
actions.querySelectorAll(".artifact-action-links a").forEach((link) => {
|
actions.querySelectorAll(".artifact-action-links a").forEach((link) => {
|
||||||
|
link.classList.remove("disabled");
|
||||||
|
link.removeAttribute("aria-disabled");
|
||||||
link.href = row.dataset.renderedUrl;
|
link.href = row.dataset.renderedUrl;
|
||||||
});
|
});
|
||||||
const copyButton = actions.querySelector("[data-copy-url]");
|
const copyButton = actions.querySelector("[data-copy-url]");
|
||||||
if (copyButton) copyButton.dataset.copyUrl = row.dataset.renderedUrl;
|
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) => {
|
const renderPreview = (row, pushState = true) => {
|
||||||
rows.forEach((candidate) => candidate.classList.toggle("selected", candidate === row));
|
navigationMode = "artifacts";
|
||||||
|
markRead(row);
|
||||||
|
allRows().forEach((candidate) => candidate.classList.toggle("selected", candidate === row));
|
||||||
const source = urlFor(row, selectedView);
|
const source = urlFor(row, selectedView);
|
||||||
const title = escapeHtml(row.dataset.title);
|
const title = escapeHtml(row.dataset.title);
|
||||||
const kind = row.dataset.kind;
|
const kind = row.dataset.kind;
|
||||||
|
|
@ -236,23 +341,188 @@
|
||||||
? `<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>`
|
? `<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>`;
|
: `<iframe class="preview-frame" src="${source}" title="${title}"></iframe>`;
|
||||||
|
|
||||||
let frameWrap = previewPane.querySelector(".preview-frame-wrap");
|
previewBody.innerHTML = `<div class="preview-frame-wrap">${content}</div>`;
|
||||||
if (!frameWrap) {
|
|
||||||
previewPane.innerHTML = `<div class="preview-frame-wrap"></div><footer class="artifact-actions" data-actions><nav class="view-toggle" aria-label="Preview mode"><a data-view="rendered">Rendered</a><a data-view="raw">Raw</a></nav><div class="artifact-action-links"><a target="_blank" rel="noopener">Open</a><a download>Download</a><button type="button" data-copy-url="">Copy Link</button><button type="button" disabled>Send</button></div></footer>`;
|
|
||||||
frameWrap = previewPane.querySelector(".preview-frame-wrap");
|
|
||||||
}
|
|
||||||
frameWrap.innerHTML = content;
|
|
||||||
updateActions(row);
|
updateActions(row);
|
||||||
if (pushState) window.history.pushState({}, "", pageUrlFor(row, selectedView));
|
if (pushState) window.history.pushState({}, "", pageUrlFor(row, selectedView));
|
||||||
};
|
};
|
||||||
|
|
||||||
rows.forEach((row) => {
|
const clearPreview = () => {
|
||||||
row.addEventListener("click", (event) => {
|
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();
|
event.preventDefault();
|
||||||
renderPreview(row);
|
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) => {
|
previewPane.addEventListener("click", (event) => {
|
||||||
const copyButton = event.target.closest("[data-copy-url]");
|
const copyButton = event.target.closest("[data-copy-url]");
|
||||||
if (copyButton) {
|
if (copyButton) {
|
||||||
|
|
@ -261,8 +531,30 @@
|
||||||
return;
|
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]");
|
const toggle = event.target.closest("[data-view]");
|
||||||
if (!toggle) return;
|
if (!toggle) return;
|
||||||
|
if (toggle.classList.contains("disabled")) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const row = selectedRow();
|
const row = selectedRow();
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
|
||||||
34
templates/artifacts/login.html
Normal file
34
templates/artifacts/login.html
Normal 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>
|
||||||
15
templates/artifacts/settings_account.html
Normal file
15
templates/artifacts/settings_account.html
Normal 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 <token></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 %}
|
||||||
29
templates/artifacts/settings_base.html
Normal file
29
templates/artifacts/settings_base.html
Normal 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' %}">← 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>
|
||||||
10
templates/artifacts/settings_demo_data.html
Normal file
10
templates/artifacts/settings_demo_data.html
Normal 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 %}
|
||||||
39
templates/artifacts/settings_storage.html
Normal file
39
templates/artifacts/settings_storage.html
Normal 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 }}) — 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
0
tests/__init__.py
Normal file
0
tests/api/__init__.py
Normal file
0
tests/api/__init__.py
Normal file
276
tests/api/test_artifacts.py
Normal file
276
tests/api/test_artifacts.py
Normal 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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue