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/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Internal-only planning/ops docs (home infrastructure details)
|
||||
_archive_/
|
||||
planning/
|
||||
|
||||
# Local machine-specific tool config (MCP servers, absolute local paths)
|
||||
opencode.json
|
||||
|
|
|
|||
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 .models import Artifact
|
||||
from .models import Artifact, Asset, UserProfile
|
||||
|
||||
|
||||
@admin.register(Artifact)
|
||||
class ArtifactAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "title", "persona", "kind", "day", "created_at")
|
||||
list_filter = ("persona", "kind", "day")
|
||||
search_fields = ("id", "title", "persona")
|
||||
list_display = ("id", "title", "owner", "source", "kind", "is_kept", "size_bytes", "day", "created_at")
|
||||
list_filter = ("owner", "source", "source_type", "kind", "is_kept", "day")
|
||||
search_fields = ("id", "title", "source", "source_name", "source_id")
|
||||
|
||||
|
||||
@admin.register(UserProfile)
|
||||
class UserProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "quota_bytes")
|
||||
search_fields = ("user__username",)
|
||||
|
||||
|
||||
@admin.register(Asset)
|
||||
class AssetAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "owner", "artifact", "content_type", "size_bytes", "created_at")
|
||||
list_filter = ("owner", "content_type")
|
||||
search_fields = ("id",)
|
||||
|
|
|
|||
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
|
||||
|
||||
|
||||
|
|
@ -10,6 +11,10 @@ class Artifact(models.Model):
|
|||
PDF = "pdf"
|
||||
BINARY = "binary"
|
||||
|
||||
AGENT = "agent"
|
||||
PERSON = "person"
|
||||
SYSTEM = "system"
|
||||
|
||||
KIND_CHOICES = [
|
||||
(HTML_FRAGMENT, "HTML fragment"),
|
||||
(HTML_FULL, "Full HTML"),
|
||||
|
|
@ -20,8 +25,15 @@ class Artifact(models.Model):
|
|||
(BINARY, "Binary"),
|
||||
]
|
||||
|
||||
SOURCE_TYPE_CHOICES = [
|
||||
(AGENT, "Agent"),
|
||||
(PERSON, "Person"),
|
||||
(SYSTEM, "System"),
|
||||
]
|
||||
|
||||
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)
|
||||
kind = models.CharField(max_length=20, choices=KIND_CHOICES)
|
||||
theme = models.CharField(max_length=40, blank=True)
|
||||
|
|
@ -31,9 +43,63 @@ class Artifact(models.Model):
|
|||
original_kind = models.CharField(max_length=20)
|
||||
tags = models.JSONField(default=list, blank=True)
|
||||
source_prompt = models.TextField(blank=True)
|
||||
source_type = models.CharField(max_length=20, choices=SOURCE_TYPE_CHOICES, default=AGENT)
|
||||
source_name = models.CharField(max_length=120, blank=True)
|
||||
source_id = models.CharField(max_length=120, blank=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
read_at = models.DateTimeField(null=True, blank=True)
|
||||
is_kept = models.BooleanField(default=False)
|
||||
size_bytes = models.BigIntegerField(default=0)
|
||||
|
||||
@property
|
||||
def is_unread(self):
|
||||
return self.read_at is None
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} ({self.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 = [
|
||||
path("", views.feed, name="feed"),
|
||||
path("feed/older", views.feed_older, name="feed-older"),
|
||||
path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"),
|
||||
path("a/<str:artifact_id>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"),
|
||||
path("a/<str:artifact_id>", views.artifact_view, name="artifact"),
|
||||
path("assets/<str:asset_id>", views.asset_view, name="asset"),
|
||||
path("api/assets", views.AssetUploadView.as_view(), name="asset-upload-api"),
|
||||
path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"),
|
||||
path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"),
|
||||
path("api/artifacts/<str:artifact_id>", views.ArtifactDetailView.as_view(), name="artifact-detail-api"),
|
||||
path("settings/", views.settings_home, name="settings-home"),
|
||||
path("settings/account/", views.settings_account, name="settings-account"),
|
||||
path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"),
|
||||
path("settings/storage/", views.settings_storage, name="settings-storage"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,26 +1,64 @@
|
|||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import mimetypes
|
||||
import re
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import markdown
|
||||
from django.conf import settings
|
||||
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Count, Q, Sum
|
||||
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.template.defaultfilters import slugify
|
||||
from django.urls import reverse
|
||||
from django.utils.dateparse import parse_date
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||
from django.views.decorators.http import require_POST
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .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}
|
||||
|
||||
REMEMBER_ME_SESSION_AGE = 60 * 60 * 24 * 30 # 30 days
|
||||
|
||||
MAX_ASSET_BYTES = 50 * 1024 * 1024 # 50 MB per uploaded image
|
||||
|
||||
# Matches the asset id in a Daybook asset URL, e.g. /assets/ab12cd34ef56
|
||||
ASSET_URL_RE = re.compile(r"/assets/([0-9a-f]{12})")
|
||||
|
||||
|
||||
def filter_by_label(queryset, label):
|
||||
matching_ids = [artifact.id for artifact in queryset.only("id", "tags") if label in artifact.tags]
|
||||
return queryset.filter(pk__in=matching_ids)
|
||||
|
||||
|
||||
DEFAULT_THEMES = {
|
||||
Artifact.HTML_FRAGMENT: "report",
|
||||
Artifact.MARKDOWN: "report",
|
||||
|
|
@ -28,70 +66,283 @@ DEFAULT_THEMES = {
|
|||
}
|
||||
|
||||
|
||||
@method_decorator(login_required, name="get")
|
||||
class ArtifactListCreateView(APIView):
|
||||
parser_classes = [JSONParser, FormParser, MultiPartParser]
|
||||
|
||||
def get(self, request):
|
||||
artifacts = Artifact.objects.all()
|
||||
if persona := request.query_params.get("persona"):
|
||||
artifacts = artifacts.filter(persona=persona)
|
||||
artifacts = Artifact.objects.filter(owner=request.user)
|
||||
if source := request.query_params.get("source"):
|
||||
artifacts = artifacts.filter(source=source)
|
||||
if label := request.query_params.get("label"):
|
||||
artifacts = filter_by_label(artifacts, label)
|
||||
if day := request.query_params.get("day"):
|
||||
artifacts = artifacts.filter(day=day)
|
||||
if tag := request.query_params.get("tag"):
|
||||
artifacts = artifacts.filter(tags__contains=[tag])
|
||||
if kind := request.query_params.get("kind"):
|
||||
artifacts = artifacts.filter(kind=kind)
|
||||
if title_query := request.query_params.get("q"):
|
||||
artifacts = artifacts.filter(title__icontains=title_query)
|
||||
|
||||
return Response([serialize_artifact(artifact, request) for artifact in artifacts])
|
||||
|
||||
def post(self, request):
|
||||
if not has_valid_token(request):
|
||||
user = resolve_token_user(request)
|
||||
if user is None:
|
||||
return HttpResponseForbidden("Invalid or missing API token")
|
||||
|
||||
if request.content_type and request.content_type.startswith("multipart/"):
|
||||
artifact = create_binary_artifact(request.data)
|
||||
artifact = create_binary_artifact(request.data, user)
|
||||
if isinstance(artifact, Response):
|
||||
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
|
||||
kind = data.get("kind")
|
||||
if kind not in TEXT_KINDS:
|
||||
return Response({"detail": "kind must be html_fragment, html_full, markdown, or deck"}, status=400)
|
||||
|
||||
required = ["persona", "title", "content"]
|
||||
required = ["source", "title", "content"]
|
||||
missing = [field for field in required if not data.get(field)]
|
||||
if missing:
|
||||
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400)
|
||||
|
||||
artifact = create_text_artifact(data, request)
|
||||
return Response({"id": artifact.id, "url": reverse("artifact", args=[artifact.id])}, status=201)
|
||||
source_error = validate_source_fields(data)
|
||||
if source_error:
|
||||
return source_error
|
||||
|
||||
artifact = create_text_artifact(data, request, user)
|
||||
if isinstance(artifact, Response):
|
||||
return artifact
|
||||
apply_tag_colors(data)
|
||||
return Response(serialize_artifact(artifact, request), status=201)
|
||||
|
||||
|
||||
class AssetUploadView(APIView):
|
||||
"""Upload an image for embedding inside artifact content.
|
||||
|
||||
Returns a stable URL the agent puts in its markdown/fragment. The asset is
|
||||
attached to whichever artifact later references that URL.
|
||||
"""
|
||||
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def post(self, request):
|
||||
user = resolve_token_user(request)
|
||||
if user is None:
|
||||
return HttpResponseForbidden("Invalid or missing API token")
|
||||
|
||||
uploaded = request.data.get("file")
|
||||
if not uploaded:
|
||||
return Response({"detail": "Missing required field: file"}, status=400)
|
||||
|
||||
content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or ""
|
||||
if not content_type.startswith("image/"):
|
||||
return Response({"detail": "Only image uploads are supported."}, status=400)
|
||||
if uploaded.size > MAX_ASSET_BYTES:
|
||||
return Response(
|
||||
{"detail": f"Image exceeds the {MAX_ASSET_BYTES // (1024 * 1024)} MB limit."},
|
||||
status=413,
|
||||
)
|
||||
|
||||
sweep_orphan_assets(user)
|
||||
|
||||
asset_id = generate_asset_id()
|
||||
suffix = safe_suffix(uploaded.name, content_type)
|
||||
directory = Path("_assets") / timezone.localdate().isoformat()
|
||||
(settings.ARTIFACTS_ROOT / directory).mkdir(parents=True, exist_ok=True)
|
||||
stored_path = directory / f"{asset_id}{suffix}"
|
||||
|
||||
with (settings.ARTIFACTS_ROOT / stored_path).open("wb") as target:
|
||||
for chunk in uploaded.chunks():
|
||||
target.write(chunk)
|
||||
|
||||
size_bytes = (settings.ARTIFACTS_ROOT / stored_path).stat().st_size
|
||||
try:
|
||||
make_room_for(user, size_bytes)
|
||||
except QuotaExceeded as exc:
|
||||
(settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True)
|
||||
return Response({"detail": str(exc)}, status=413)
|
||||
|
||||
asset = Asset.objects.create(
|
||||
id=asset_id,
|
||||
owner=user,
|
||||
path=stored_path.as_posix(),
|
||||
content_type=content_type,
|
||||
size_bytes=size_bytes,
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"id": asset.id,
|
||||
"url": request.build_absolute_uri(reverse("asset", args=[asset.id])),
|
||||
"content_type": asset.content_type,
|
||||
"size_bytes": asset.size_bytes,
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@xframe_options_sameorigin
|
||||
def asset_view(request, asset_id):
|
||||
asset = get_object_or_404(Asset, pk=asset_id, owner=request.user)
|
||||
path = safe_artifact_path(asset.path)
|
||||
if not path.exists():
|
||||
raise Http404("Asset file not found")
|
||||
return FileResponse(path.open("rb"), content_type=asset.content_type or "application/octet-stream")
|
||||
|
||||
|
||||
@method_decorator(login_required, name="get")
|
||||
class ArtifactDetailView(APIView):
|
||||
def get(self, request, artifact_id):
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
|
||||
return Response(serialize_artifact(artifact, request))
|
||||
|
||||
def patch(self, request, artifact_id):
|
||||
user = resolve_token_user(request)
|
||||
if user is None:
|
||||
return HttpResponseForbidden("Invalid or missing API token")
|
||||
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
|
||||
data = request.data
|
||||
if "title" in data:
|
||||
if not data["title"]:
|
||||
return Response({"detail": "title cannot be blank"}, status=400)
|
||||
artifact.title = data["title"]
|
||||
if "tags" in data:
|
||||
artifact.tags = normalize_tags(data["tags"])
|
||||
if "source_prompt" in data:
|
||||
artifact.source_prompt = data["source_prompt"] or ""
|
||||
if "source_type" in data:
|
||||
if data["source_type"] not in {choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES}:
|
||||
return Response({"detail": "source_type must be agent, person, or system"}, status=400)
|
||||
artifact.source_type = data["source_type"]
|
||||
if "source_name" in data:
|
||||
artifact.source_name = data["source_name"] or ""
|
||||
if "source_id" in data:
|
||||
artifact.source_id = data["source_id"] or ""
|
||||
if "metadata" in data:
|
||||
if not isinstance(data["metadata"], dict):
|
||||
return Response({"detail": "metadata must be an object"}, status=400)
|
||||
artifact.metadata = data["metadata"]
|
||||
if "tag_colors" in data:
|
||||
try:
|
||||
apply_tag_colors(data)
|
||||
except ValueError as exc:
|
||||
return Response({"detail": str(exc)}, status=400)
|
||||
artifact.save(
|
||||
update_fields=[
|
||||
"title",
|
||||
"tags",
|
||||
"source_prompt",
|
||||
"source_type",
|
||||
"source_name",
|
||||
"source_id",
|
||||
"metadata",
|
||||
]
|
||||
)
|
||||
return Response(serialize_artifact(artifact, request))
|
||||
|
||||
def delete(self, request, artifact_id):
|
||||
user = resolve_token_user(request)
|
||||
if user is None:
|
||||
return HttpResponseForbidden("Invalid or missing API token")
|
||||
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
|
||||
delete_artifact_files(artifact)
|
||||
artifact.delete()
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
class CapabilitiesView(APIView):
|
||||
"""Static capability info. Reachable by a logged-in session or a valid API token,
|
||||
so agents can check connectivity before posting. Exposes no user data."""
|
||||
|
||||
def get(self, request):
|
||||
if not request.user.is_authenticated and resolve_token_user(request) is None:
|
||||
return HttpResponseForbidden("Login or a valid API token is required")
|
||||
return Response(
|
||||
{
|
||||
"artifact_kinds": [choice[0] for choice in Artifact.KIND_CHOICES],
|
||||
"filters": ["source", "label", "day", "kind", "q"],
|
||||
"views": ["rendered", "raw"],
|
||||
"raw_supported_for": [Artifact.MARKDOWN, Artifact.HTML_FULL],
|
||||
"source_types": [choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES],
|
||||
"auth": "bearer_token",
|
||||
"methods": {
|
||||
"/api/artifacts": ["GET", "POST"],
|
||||
"/api/artifacts/{id}": ["GET", "PATCH", "DELETE"],
|
||||
"/api/assets": ["POST"],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
FEED_WINDOW_DAYS = 7
|
||||
|
||||
|
||||
def apply_feed_filters(queryset, source="", label="", kind="", title_query=""):
|
||||
if source:
|
||||
queryset = queryset.filter(source=source)
|
||||
if label:
|
||||
queryset = filter_by_label(queryset, label)
|
||||
if kind:
|
||||
queryset = queryset.filter(kind=kind)
|
||||
if title_query:
|
||||
queryset = queryset.filter(title__icontains=title_query)
|
||||
return queryset
|
||||
|
||||
|
||||
def group_artifacts_by_day(artifacts, overrides):
|
||||
"""Turn a flat, reverse-chronological list into [{day, artifacts}] groups."""
|
||||
groups = []
|
||||
for artifact in artifacts:
|
||||
# Source has its own column in the row, so chips are the free-form tags only.
|
||||
artifact.label_chips = tags_with_colors(artifact.tags, overrides)
|
||||
if not groups or groups[-1]["day"] != artifact.day:
|
||||
groups.append({"day": artifact.day, "artifacts": []})
|
||||
groups[-1]["artifacts"].append(artifact)
|
||||
return groups
|
||||
|
||||
|
||||
@login_required
|
||||
def feed(request):
|
||||
persona = request.GET.get("persona", "")
|
||||
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_day = parse_date(request.GET.get("day", "")) or timezone.localdate()
|
||||
today = timezone.localdate()
|
||||
selected_artifact_id = request.GET.get("artifact", "")
|
||||
preview_none = request.GET.get("preview") == "none"
|
||||
selected_view = "raw" if request.GET.get("view") == "raw" else "rendered"
|
||||
all_artifacts = Artifact.objects.all()
|
||||
artifacts = all_artifacts
|
||||
if persona:
|
||||
artifacts = artifacts.filter(persona=persona)
|
||||
if selected_kind:
|
||||
artifacts = artifacts.filter(kind=selected_kind)
|
||||
if title_query:
|
||||
artifacts = artifacts.filter(title__icontains=title_query)
|
||||
|
||||
days = list(artifacts.dates("day", "day", order="DESC"))
|
||||
if selected_day not in days:
|
||||
days.append(selected_day)
|
||||
days.sort(reverse=True)
|
||||
all_artifacts = Artifact.objects.filter(owner=request.user)
|
||||
artifacts = apply_feed_filters(
|
||||
all_artifacts, selected_source, selected_label, selected_kind, title_query
|
||||
)
|
||||
|
||||
day_artifacts = artifacts.filter(day=selected_day).order_by("-created_at")
|
||||
selected_artifact = day_artifacts.filter(pk=selected_artifact_id).first()
|
||||
toggle_query = {"day": selected_day.isoformat(), "view": "rendered"}
|
||||
if persona:
|
||||
toggle_query["persona"] = persona
|
||||
window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1)
|
||||
if not artifacts.filter(day__gte=window_start).exists():
|
||||
# Nothing recent for these filters: fall back to the most recent days that
|
||||
# do match, so selecting a source is never met with a blank list.
|
||||
recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS])
|
||||
if recent_days:
|
||||
window_start = recent_days[-1]
|
||||
window_artifacts = list(artifacts.filter(day__gte=window_start).order_by("-created_at"))
|
||||
older_count = artifacts.filter(day__lt=window_start).count()
|
||||
|
||||
overrides = tag_color_overrides()
|
||||
day_groups = group_artifacts_by_day(window_artifacts, overrides)
|
||||
|
||||
selected_artifact = None
|
||||
if not preview_none:
|
||||
selected_artifact = next((a for a in window_artifacts if a.id == selected_artifact_id), None)
|
||||
|
||||
toggle_query = {"view": "rendered"}
|
||||
if selected_source:
|
||||
toggle_query["source"] = selected_source
|
||||
if selected_label:
|
||||
toggle_query["label"] = selected_label
|
||||
if selected_kind:
|
||||
toggle_query["kind"] = selected_kind
|
||||
if title_query:
|
||||
|
|
@ -101,30 +352,107 @@ def feed(request):
|
|||
rendered_url = f"?{urlencode(toggle_query)}"
|
||||
toggle_query["view"] = "raw"
|
||||
raw_url = f"?{urlencode(toggle_query)}"
|
||||
personas = all_artifacts.order_by("persona").values_list("persona", flat=True).distinct()
|
||||
|
||||
# Source counts come from the other filters only, so the sidebar still shows
|
||||
# every source available under the current label/type/name filters.
|
||||
source_scoped = apply_feed_filters(
|
||||
all_artifacts, "", selected_label, selected_kind, title_query
|
||||
)
|
||||
source_counts = (
|
||||
source_scoped.values("source")
|
||||
.annotate(count=Count("id", filter=Q(read_at__isnull=True)))
|
||||
.order_by("source")
|
||||
)
|
||||
|
||||
all_labels = sorted({tag for tags in all_artifacts.values_list("tags", flat=True) for tag in tags})
|
||||
return render(
|
||||
request,
|
||||
"artifacts/feed.html",
|
||||
{
|
||||
"artifacts": day_artifacts,
|
||||
"days": days,
|
||||
"personas": personas,
|
||||
"artifacts": window_artifacts,
|
||||
"day_groups": day_groups,
|
||||
"older_count": older_count,
|
||||
"window_start": window_start,
|
||||
"source_counts": source_counts,
|
||||
"total_count": source_scoped.filter(read_at__isnull=True).count(),
|
||||
"all_labels": labels_with_colors_list(all_labels, overrides),
|
||||
"raw_url": raw_url,
|
||||
"rendered_url": rendered_url,
|
||||
"selected_artifact": selected_artifact,
|
||||
"selected_day": selected_day,
|
||||
"selected_kind": selected_kind,
|
||||
"selected_persona": persona,
|
||||
"selected_label": selected_label,
|
||||
"selected_source": selected_source,
|
||||
"selected_view": selected_view,
|
||||
"preview_none": preview_none,
|
||||
"today": today,
|
||||
"title_query": title_query,
|
||||
"kind_choices": Artifact.KIND_CHOICES,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def mark_artifact_read(request, artifact_id):
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
|
||||
if artifact.read_at is None:
|
||||
artifact.read_at = timezone.now()
|
||||
artifact.save(update_fields=["read_at"])
|
||||
return JsonResponse({"id": artifact.id, "read_at": artifact.read_at.isoformat()})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def toggle_artifact_keep(request, artifact_id):
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
|
||||
artifact.is_kept = not artifact.is_kept
|
||||
artifact.save(update_fields=["is_kept"])
|
||||
return JsonResponse({"id": artifact.id, "is_kept": artifact.is_kept})
|
||||
|
||||
|
||||
@login_required
|
||||
def feed_older(request):
|
||||
"""Return the next page of older day-groups as an HTML fragment for infinite scroll."""
|
||||
before = parse_date(request.GET.get("before", "")) or timezone.localdate()
|
||||
artifacts = apply_feed_filters(
|
||||
Artifact.objects.filter(owner=request.user),
|
||||
request.GET.get("source", ""),
|
||||
request.GET.get("label", ""),
|
||||
request.GET.get("kind", ""),
|
||||
request.GET.get("q", ""),
|
||||
).filter(day__lt=before).order_by("-created_at")
|
||||
|
||||
# Take a whole page of days at a time so a day is never split across pages.
|
||||
page_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS])
|
||||
if not page_days:
|
||||
return HttpResponse("")
|
||||
|
||||
page_artifacts = list(artifacts.filter(day__gte=page_days[-1]))
|
||||
day_groups = group_artifacts_by_day(page_artifacts, tag_color_overrides())
|
||||
remaining = artifacts.filter(day__lt=page_days[-1]).count()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"artifacts/_day_groups.html",
|
||||
{
|
||||
"day_groups": day_groups,
|
||||
"next_before": page_days[-1].isoformat(),
|
||||
"has_more": remaining > 0,
|
||||
"selected_artifact": None,
|
||||
"selected_source": request.GET.get("source", ""),
|
||||
"selected_label": request.GET.get("label", ""),
|
||||
"selected_kind": request.GET.get("kind", ""),
|
||||
"title_query": request.GET.get("q", ""),
|
||||
"selected_view": "raw" if request.GET.get("view") == "raw" else "rendered",
|
||||
"today": timezone.localdate(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@xframe_options_sameorigin
|
||||
def artifact_view(request, artifact_id):
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id)
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
|
||||
if request.GET.get("raw") == "1":
|
||||
raw_path = raw_artifact_path(artifact)
|
||||
if raw_path and raw_path.exists():
|
||||
|
|
@ -139,16 +467,16 @@ def artifact_view(request, artifact_id):
|
|||
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()
|
||||
artifact_id = generate_id()
|
||||
persona = slugify(data["persona"]) or "unknown"
|
||||
source = slugify(data["source"]) or "unknown"
|
||||
kind = data["kind"]
|
||||
theme = data.get("theme") or DEFAULT_THEMES.get(kind, "base")
|
||||
title = data["title"]
|
||||
content = data["content"]
|
||||
|
||||
directory = Path(persona) / now.date().isoformat()
|
||||
directory = Path(source) / now.date().isoformat()
|
||||
target_dir = settings.ARTIFACTS_ROOT / directory
|
||||
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"
|
||||
(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,
|
||||
persona=persona,
|
||||
owner=user,
|
||||
source=source,
|
||||
title=title,
|
||||
kind=kind,
|
||||
theme=theme,
|
||||
|
|
@ -176,26 +514,33 @@ def create_text_artifact(data, request):
|
|||
original_kind=kind,
|
||||
tags=normalize_tags(data.get("tags", [])),
|
||||
source_prompt=data.get("source_prompt", ""),
|
||||
size_bytes=size_bytes,
|
||||
**source_fields(data, source),
|
||||
)
|
||||
attach_referenced_assets(artifact, content)
|
||||
return artifact
|
||||
|
||||
|
||||
def create_binary_artifact(data):
|
||||
def create_binary_artifact(data, user):
|
||||
uploaded = data.get("file")
|
||||
required = ["persona", "title"]
|
||||
required = ["source", "title"]
|
||||
missing = [field for field in required if not data.get(field)]
|
||||
if not uploaded:
|
||||
missing.append("file")
|
||||
if missing:
|
||||
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400)
|
||||
source_error = validate_source_fields(data)
|
||||
if source_error:
|
||||
return source_error
|
||||
|
||||
now = timezone.localtime()
|
||||
artifact_id = generate_id()
|
||||
persona = slugify(data["persona"]) or "unknown"
|
||||
source = slugify(data["source"]) or "unknown"
|
||||
title = data["title"]
|
||||
content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or ""
|
||||
kind = binary_kind(content_type)
|
||||
|
||||
directory = Path(persona) / now.date().isoformat()
|
||||
directory = Path(source) / now.date().isoformat()
|
||||
target_dir = settings.ARTIFACTS_ROOT / directory
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
|
@ -205,9 +550,17 @@ def create_binary_artifact(data):
|
|||
for chunk in uploaded.chunks():
|
||||
target.write(chunk)
|
||||
|
||||
size_bytes = artifact_disk_size(stored_path.as_posix(), kind)
|
||||
try:
|
||||
make_room_for(user, size_bytes)
|
||||
except QuotaExceeded as exc:
|
||||
(settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True)
|
||||
return Response({"detail": str(exc)}, status=413)
|
||||
|
||||
return Artifact.objects.create(
|
||||
id=artifact_id,
|
||||
persona=persona,
|
||||
owner=user,
|
||||
source=source,
|
||||
title=title,
|
||||
kind=kind,
|
||||
theme="",
|
||||
|
|
@ -216,6 +569,8 @@ def create_binary_artifact(data):
|
|||
original_kind=kind,
|
||||
tags=normalize_tags(data.get("tags", [])),
|
||||
source_prompt=data.get("source_prompt", ""),
|
||||
size_bytes=size_bytes,
|
||||
**source_fields(data, source),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -260,10 +615,38 @@ def generate_id():
|
|||
return artifact_id
|
||||
|
||||
|
||||
def has_valid_token(request):
|
||||
expected = getattr(settings, "DAYBOOK_API_TOKEN", "")
|
||||
def generate_asset_id():
|
||||
while True:
|
||||
asset_id = secrets.token_hex(6)
|
||||
if not Asset.objects.filter(pk=asset_id).exists():
|
||||
return asset_id
|
||||
|
||||
|
||||
def attach_referenced_assets(artifact, content):
|
||||
"""Link assets whose URLs appear in the artifact's content.
|
||||
|
||||
Assets are uploaded before the artifact exists, so this runs at creation
|
||||
time and ties each referenced asset to the artifact that embeds it.
|
||||
"""
|
||||
referenced = set(ASSET_URL_RE.findall(content or ""))
|
||||
if not referenced:
|
||||
return 0
|
||||
return Asset.objects.filter(
|
||||
owner=artifact.owner, id__in=referenced, artifact__isnull=True
|
||||
).update(artifact=artifact)
|
||||
|
||||
|
||||
def resolve_token_user(request):
|
||||
auth = request.headers.get("Authorization", "")
|
||||
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):
|
||||
|
|
@ -274,6 +657,100 @@ def normalize_tags(tags):
|
|||
return []
|
||||
|
||||
|
||||
HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||
|
||||
TAG_PALETTE = [
|
||||
"#4f6df5", "#e0578c", "#2fa88f", "#d98b2b",
|
||||
"#8c5fd6", "#3fa1d8", "#c14f4f", "#5aa552",
|
||||
]
|
||||
|
||||
|
||||
def tag_color(name, overrides):
|
||||
if override := overrides.get(name):
|
||||
return override
|
||||
index = int(hashlib.sha256(name.encode("utf-8")).hexdigest(), 16) % len(TAG_PALETTE)
|
||||
return TAG_PALETTE[index]
|
||||
|
||||
|
||||
def tag_color_overrides():
|
||||
return dict(TagColor.objects.values_list("name", "color"))
|
||||
|
||||
|
||||
def tags_with_colors(tags, overrides=None):
|
||||
overrides = overrides if overrides is not None else tag_color_overrides()
|
||||
return [{"name": tag, "color": tag_color(tag, overrides)} for tag in tags]
|
||||
|
||||
|
||||
def labels_with_colors_list(labels, overrides=None):
|
||||
overrides = overrides if overrides is not None else tag_color_overrides()
|
||||
return [{"name": label, "color": tag_color(label, overrides)} for label in labels]
|
||||
|
||||
|
||||
def labels_with_colors(artifact, overrides=None):
|
||||
return labels_with_colors_list([artifact.source, *artifact.tags], overrides)
|
||||
|
||||
|
||||
def validate_source_fields(data):
|
||||
if source_type := data.get("source_type"):
|
||||
if source_type not in {choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES}:
|
||||
return Response({"detail": "source_type must be agent, person, or system"}, status=400)
|
||||
try:
|
||||
parse_metadata_value(data.get("metadata", {}))
|
||||
except ValueError as exc:
|
||||
return Response({"detail": str(exc)}, status=400)
|
||||
try:
|
||||
parse_tag_colors_value(data.get("tag_colors", {}))
|
||||
except ValueError as exc:
|
||||
return Response({"detail": str(exc)}, status=400)
|
||||
return None
|
||||
|
||||
|
||||
def parse_tag_colors_value(value):
|
||||
if value in (None, ""):
|
||||
return {}
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("tag_colors must be an object") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("tag_colors must be an object")
|
||||
for tag, color in value.items():
|
||||
if not HEX_COLOR_RE.match(str(color)):
|
||||
raise ValueError(f"tag_colors[{tag}] must be a hex color like #RRGGBB")
|
||||
return value
|
||||
|
||||
|
||||
def apply_tag_colors(data):
|
||||
tag_colors = parse_tag_colors_value(data.get("tag_colors", {}))
|
||||
for tag, color in tag_colors.items():
|
||||
TagColor.objects.update_or_create(name=tag, defaults={"color": color})
|
||||
|
||||
|
||||
def source_fields(data, source):
|
||||
return {
|
||||
"source_type": data.get("source_type") or Artifact.AGENT,
|
||||
"source_name": data.get("source_name") or source,
|
||||
"source_id": data.get("source_id") or "",
|
||||
"metadata": parse_metadata_value(data.get("metadata", {})),
|
||||
}
|
||||
|
||||
|
||||
def parse_metadata_value(value):
|
||||
if value in (None, ""):
|
||||
return {}
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("metadata must be an object") from exc
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
raise ValueError("metadata must be an object")
|
||||
|
||||
|
||||
def binary_kind(content_type):
|
||||
if content_type.startswith("image/"):
|
||||
return Artifact.IMAGE
|
||||
|
|
@ -293,14 +770,25 @@ def serialize_artifact(artifact, request):
|
|||
return {
|
||||
"id": 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,
|
||||
"kind": artifact.kind,
|
||||
"theme": artifact.theme,
|
||||
"day": artifact.day.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_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
|
||||
|
||||
|
||||
class DaybookLoginView(auth_views.LoginView):
|
||||
template_name = "artifacts/login.html"
|
||||
authentication_form = DaybookLoginForm
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
if form.cleaned_data.get("remember_me"):
|
||||
self.request.session.set_expiry(REMEMBER_ME_SESSION_AGE)
|
||||
else:
|
||||
self.request.session.set_expiry(0)
|
||||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
def settings_home(request):
|
||||
return redirect("settings-account")
|
||||
|
||||
|
||||
@login_required
|
||||
def settings_account(request):
|
||||
token, _ = Token.objects.get_or_create(user=request.user)
|
||||
if request.method == "POST" and request.POST.get("action") == "regenerate":
|
||||
token.delete()
|
||||
token = Token.objects.create(user=request.user)
|
||||
messages.success(request, "API token regenerated.")
|
||||
return redirect("settings-account")
|
||||
return render(
|
||||
request,
|
||||
"artifacts/settings_account.html",
|
||||
{"token": token, "active_section": "account"},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def settings_demo_data(request):
|
||||
if request.method == "POST":
|
||||
count = create_demo_artifacts(request.user, request)
|
||||
messages.success(request, f"Created {count} demo artifacts.")
|
||||
return redirect("settings-demo-data")
|
||||
return render(
|
||||
request,
|
||||
"artifacts/settings_demo_data.html",
|
||||
{"active_section": "demo-data"},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def settings_storage(request):
|
||||
used = user_usage_bytes(request.user)
|
||||
quota = user_quota_bytes(request.user)
|
||||
kept_bytes = (
|
||||
Artifact.objects.filter(owner=request.user, is_kept=True)
|
||||
.aggregate(total=Sum("size_bytes"))
|
||||
.get("total")
|
||||
or 0
|
||||
)
|
||||
context = {
|
||||
"active_section": "storage",
|
||||
"used_bytes": used,
|
||||
"quota_bytes": quota,
|
||||
"kept_bytes": kept_bytes,
|
||||
"percent_used": min(100, round(used / quota * 100)) if quota else 0,
|
||||
"artifact_count": Artifact.objects.filter(owner=request.user).count(),
|
||||
"kept_count": Artifact.objects.filter(owner=request.user, is_kept=True).count(),
|
||||
}
|
||||
if request.user.is_superuser:
|
||||
context["is_superuser"] = True
|
||||
context["total_allocated"] = total_allocated_bytes()
|
||||
context["disk_free"] = disk_free_bytes()
|
||||
return render(request, "artifacts/settings_storage.html", context)
|
||||
|
||||
|
||||
def raw_unavailable_html(artifact):
|
||||
return f"""<!doctype html>
|
||||
<html>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
DATA_DIR = Path(os.environ.get("DAYBOOK_DATA_DIR", BASE_DIR)).resolve()
|
||||
|
||||
SECRET_KEY = "dev-only-change-me"
|
||||
DEBUG = True
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-change-me")
|
||||
DEBUG = os.environ.get("DJANGO_DEBUG", "1").lower() in {"1", "true", "yes", "on"}
|
||||
ALLOWED_HOSTS = [host.strip() for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",") if host.strip()]
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
|
|
@ -15,11 +17,13 @@ INSTALLED_APPS = [
|
|||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"rest_framework",
|
||||
"rest_framework.authtoken",
|
||||
"artifacts",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
|
|
@ -50,12 +54,15 @@ WSGI_APPLICATION = "daybook_project.wsgi.application"
|
|||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
"NAME": os.environ.get("DATABASE_PATH", DATA_DIR / "db.sqlite3"),
|
||||
}
|
||||
}
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = []
|
||||
|
||||
LOGIN_URL = "login"
|
||||
LOGIN_REDIRECT_URL = "feed"
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "UTC"
|
||||
USE_I18N = True
|
||||
|
|
@ -63,8 +70,13 @@ USE_TZ = True
|
|||
|
||||
STATIC_URL = "static/"
|
||||
STATICFILES_DIRS = [BASE_DIR / "static"]
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
STORAGES = {
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
ARTIFACTS_ROOT = BASE_DIR / "artifacts_store"
|
||||
DAYBOOK_API_TOKEN = "dev-token"
|
||||
ARTIFACTS_ROOT = Path(os.environ.get("ARTIFACTS_ROOT", DATA_DIR / "artifacts_store"))
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
from django.contrib import admin
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.urls import include, path
|
||||
|
||||
from artifacts.views import DaybookLoginView
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("accounts/login/", DaybookLoginView.as_view(), name="login"),
|
||||
path("accounts/logout/", auth_views.LogoutView.as_view(next_page="login"), name="logout"),
|
||||
path("", include("artifacts.urls")),
|
||||
]
|
||||
|
|
|
|||
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
|
||||
djangorestframework>=3.15,<4.0
|
||||
Markdown>=3.6,<4.0
|
||||
gunicorn>=23.0,<24.0
|
||||
whitenoise>=6.7,<7.0
|
||||
|
|
|
|||
28
scripts/README.md
Normal file
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>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Daybook</title>
|
||||
<title>AgentFeed</title>
|
||||
<link rel="stylesheet" href="/static/daybook.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<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>
|
||||
{% include "artifacts/_topbar.html" with topbar_toolbar="artifacts/_feed_toolbar.html" %}
|
||||
|
||||
<main class="feed">
|
||||
<aside class="day-sidebar" aria-label="Days">
|
||||
<h2>Days</h2>
|
||||
<nav class="day-list">
|
||||
{% for day in days %}
|
||||
<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 }}">
|
||||
<span>{{ day|date:"D" }}</span>
|
||||
<strong>{{ day|date:"Y-m-d" }}</strong>
|
||||
<main class="feed"
|
||||
data-selected-source="{{ selected_source }}"
|
||||
data-selected-label="{{ selected_label }}"
|
||||
data-selected-kind="{{ selected_kind }}"
|
||||
data-title-query="{{ title_query }}"
|
||||
data-selected-view="{{ selected_view }}"
|
||||
data-window-start="{{ window_start|date:'Y-m-d' }}"
|
||||
data-older-count="{{ older_count }}">
|
||||
<aside class="source-sidebar" aria-label="Sources">
|
||||
<div class="pane-bar">
|
||||
<h2>Sources</h2>
|
||||
</div>
|
||||
<nav class="source-list">
|
||||
<a class="source-link {% if not selected_source %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&{% 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>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</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">
|
||||
<div class="pane-heading">
|
||||
<h2>{{ selected_day|date:"Y-m-d" }}</h2>
|
||||
<aside class="artifact-sidebar" aria-label="Artifact feed">
|
||||
<div class="pane-bar pane-heading">
|
||||
<h2>{% if selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
|
||||
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
|
||||
</div>
|
||||
|
||||
<div class="artifact-list">
|
||||
{% for artifact in artifacts %}
|
||||
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %}"
|
||||
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 }}"
|
||||
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="artifact-list" data-artifact-list>
|
||||
{% if day_groups %}
|
||||
{% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %}
|
||||
{% else %}
|
||||
<div class="empty compact">
|
||||
<h3>No artifacts</h3>
|
||||
<p>This day is ready for new agent output.</p>
|
||||
<p>Nothing in the last {{ 7 }} days for these filters.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="splitter" data-splitter="artifacts" role="separator" aria-label="Resize artifacts sidebar" aria-orientation="vertical"></div>
|
||||
|
||||
<section class="preview-pane">
|
||||
<header class="pane-bar preview-actions" data-actions>
|
||||
<div class="preview-title" data-preview-title {% if selected_artifact %}title="{{ selected_artifact.title }}"{% endif %}>
|
||||
{% if selected_artifact %}{{ selected_artifact.title }}{% else %}No artifact loaded{% endif %}
|
||||
</div>
|
||||
<nav class="view-toggle" aria-label="Preview mode">
|
||||
<a class="{% if selected_view == 'rendered' %}selected{% endif %}{% if not selected_artifact %} disabled{% endif %}" href="{% if selected_artifact %}{{ rendered_url }}{% else %}#{% endif %}" data-view="rendered" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Rendered</a>
|
||||
<a class="{% if selected_view == 'raw' %}selected{% endif %}{% if not selected_artifact %} disabled{% endif %}" href="{% if selected_artifact %}{{ raw_url }}{% else %}#{% endif %}" data-view="raw" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Raw</a>
|
||||
</nav>
|
||||
<div class="artifact-action-links">
|
||||
<button type="button" class="keep-button {% if selected_artifact.is_kept %}kept{% endif %}" data-keep-button data-artifact-id="{% if selected_artifact %}{{ selected_artifact.id }}{% endif %}" {% if not selected_artifact %}disabled{% endif %}>{% if selected_artifact.is_kept %}✓ Kept{% else %}Keep{% endif %}</button>
|
||||
<button type="button" data-details-toggle {% if not selected_artifact %}disabled{% endif %}>Details</button>
|
||||
<a class="{% if not selected_artifact %}disabled{% endif %}" href="{% if selected_artifact %}{% url 'artifact' selected_artifact.id %}{% else %}#{% endif %}" target="_blank" rel="noopener" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Open</a>
|
||||
<a class="{% if not selected_artifact %}disabled{% endif %}" href="{% if selected_artifact %}{% url 'artifact' selected_artifact.id %}{% else %}#{% endif %}" download {% if not selected_artifact %}aria-disabled="true"{% endif %}>Download</a>
|
||||
<button type="button" data-copy-url="{% if selected_artifact %}{% url 'artifact' selected_artifact.id %}{% endif %}" {% if not selected_artifact %}disabled{% endif %}>Copy Link</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="details-panel" hidden data-details-panel></div>
|
||||
<div class="preview-body">
|
||||
{% if selected_artifact %}
|
||||
<div class="preview-frame-wrap">
|
||||
{% if selected_view == "raw" %}
|
||||
|
|
@ -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>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
<div class="empty preview-empty">
|
||||
<h2>Select an artifact</h2>
|
||||
<p>Choose an item from the middle column to preview it here.</p>
|
||||
<p>POST to <code>/api/artifacts</code> with <code>Authorization: Bearer dev-token</code>.</p>
|
||||
{% if artifacts %}
|
||||
<h2>No artifact loaded</h2>
|
||||
<p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p>
|
||||
{% else %}
|
||||
<h2>No artifacts for this day</h2>
|
||||
<p>This day is ready for new agent output.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
|
@ -150,16 +126,24 @@
|
|||
|
||||
applySizes();
|
||||
|
||||
// Per-pane resize bounds. The artifact list may grow much wider than the
|
||||
// sources sidebar, but is still capped so it can't swallow the preview.
|
||||
const sizeBounds = {
|
||||
days: {min: 96, max: 320},
|
||||
artifacts: {min: 200, max: 720},
|
||||
};
|
||||
|
||||
document.querySelectorAll(".splitter").forEach((splitter) => {
|
||||
splitter.addEventListener("pointerdown", (event) => {
|
||||
const target = splitter.dataset.splitter;
|
||||
const startX = event.clientX;
|
||||
const startWidth = sizes[target];
|
||||
const bounds = sizeBounds[target];
|
||||
splitter.setPointerCapture(event.pointerId);
|
||||
document.body.classList.add("is-resizing");
|
||||
|
||||
const move = (moveEvent) => {
|
||||
sizes[target] = Math.max(96, Math.min(420, startWidth + moveEvent.clientX - startX));
|
||||
sizes[target] = Math.max(bounds.min, Math.min(bounds.max, startWidth + moveEvent.clientX - startX));
|
||||
applySizes();
|
||||
};
|
||||
|
||||
|
|
@ -178,7 +162,12 @@
|
|||
});
|
||||
|
||||
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) => ({
|
||||
"&": "&",
|
||||
|
|
@ -200,31 +189,147 @@
|
|||
}, 1200);
|
||||
};
|
||||
const selectedRow = () => document.querySelector(".artifact-row.selected");
|
||||
let navigationMode = selectedRow() ? "artifacts" : "sources";
|
||||
const urlFor = (row, view) => view === "raw" ? row.dataset.rawUrl : row.dataset.renderedUrl;
|
||||
const pageUrlFor = (row, view) => {
|
||||
const url = new URL(row.href, window.location.origin);
|
||||
url.searchParams.set("artifact", row.dataset.artifactId);
|
||||
url.searchParams.set("view", view);
|
||||
url.searchParams.delete("preview");
|
||||
return url;
|
||||
};
|
||||
|
||||
const noPreviewUrl = () => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("artifact");
|
||||
url.searchParams.set("preview", "none");
|
||||
url.searchParams.set("view", selectedView);
|
||||
return url;
|
||||
};
|
||||
|
||||
const updateActions = (row) => {
|
||||
const actions = document.querySelector("[data-actions]");
|
||||
if (!actions) return;
|
||||
const previewTitle = actions.querySelector("[data-preview-title]");
|
||||
if (previewTitle) {
|
||||
previewTitle.textContent = row.dataset.title;
|
||||
previewTitle.title = row.dataset.title;
|
||||
}
|
||||
actions.querySelectorAll("[data-view]").forEach((link) => {
|
||||
const view = link.dataset.view;
|
||||
link.classList.toggle("selected", view === selectedView);
|
||||
link.classList.remove("disabled");
|
||||
link.removeAttribute("aria-disabled");
|
||||
link.href = pageUrlFor(row, view).toString();
|
||||
});
|
||||
actions.querySelectorAll(".artifact-action-links a").forEach((link) => {
|
||||
link.classList.remove("disabled");
|
||||
link.removeAttribute("aria-disabled");
|
||||
link.href = row.dataset.renderedUrl;
|
||||
});
|
||||
const copyButton = actions.querySelector("[data-copy-url]");
|
||||
if (copyButton) copyButton.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) => {
|
||||
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 title = escapeHtml(row.dataset.title);
|
||||
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>`
|
||||
: `<iframe class="preview-frame" src="${source}" title="${title}"></iframe>`;
|
||||
|
||||
let frameWrap = previewPane.querySelector(".preview-frame-wrap");
|
||||
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;
|
||||
previewBody.innerHTML = `<div class="preview-frame-wrap">${content}</div>`;
|
||||
updateActions(row);
|
||||
if (pushState) window.history.pushState({}, "", pageUrlFor(row, selectedView));
|
||||
};
|
||||
|
||||
rows.forEach((row) => {
|
||||
row.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
renderPreview(row);
|
||||
});
|
||||
const clearPreview = () => {
|
||||
navigationMode = "sources";
|
||||
if (document.activeElement && document.activeElement.classList.contains("artifact-row")) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
allRows().forEach((candidate) => candidate.classList.remove("selected"));
|
||||
disableActions();
|
||||
previewBody.innerHTML = `<div class="empty preview-empty"><h2>No artifact loaded</h2><p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p></div>`;
|
||||
window.history.pushState({}, "", noPreviewUrl());
|
||||
const currentSource = document.querySelector(".source-link.selected");
|
||||
if (currentSource) currentSource.focus({preventScroll: true});
|
||||
};
|
||||
|
||||
// Delegated so rows appended by infinite scroll work without rebinding.
|
||||
artifactList.addEventListener("click", (event) => {
|
||||
const row = event.target.closest(".artifact-row");
|
||||
if (!row) return;
|
||||
event.preventDefault();
|
||||
renderPreview(row);
|
||||
});
|
||||
|
||||
const moveSelection = (direction) => {
|
||||
const rows = allRows();
|
||||
if (!rows.length) return;
|
||||
navigationMode = "artifacts";
|
||||
const current = selectedRow();
|
||||
const currentIndex = current ? rows.indexOf(current) : -1;
|
||||
const nextIndex = Math.max(0, Math.min(rows.length - 1, currentIndex + direction));
|
||||
const nextRow = rows[nextIndex === -1 ? 0 : nextIndex];
|
||||
renderPreview(nextRow);
|
||||
nextRow.focus({preventScroll: true});
|
||||
nextRow.scrollIntoView({block: "nearest"});
|
||||
};
|
||||
|
||||
const moveSource = (direction) => {
|
||||
if (!sourceLinks.length) return;
|
||||
navigationMode = "sources";
|
||||
const current = document.querySelector(".source-link.selected");
|
||||
const currentIndex = current ? sourceLinks.indexOf(current) : 0;
|
||||
const nextIndex = Math.max(0, Math.min(sourceLinks.length - 1, currentIndex + direction));
|
||||
const nextSource = sourceLinks[nextIndex];
|
||||
if (nextSource && nextSource !== current) window.location.href = nextSource.href;
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) return;
|
||||
if (["INPUT", "SELECT", "TEXTAREA", "BUTTON"].includes(event.target.tagName)) return;
|
||||
|
||||
if (event.altKey && event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
moveSource(1);
|
||||
} else if (event.altKey && event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
moveSource(-1);
|
||||
} else if (event.altKey) {
|
||||
return;
|
||||
} else if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
if (navigationMode === "sources") {
|
||||
moveSource(1);
|
||||
} else {
|
||||
moveSelection(1);
|
||||
}
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (navigationMode === "sources") {
|
||||
moveSource(-1);
|
||||
} else {
|
||||
moveSelection(-1);
|
||||
}
|
||||
} else if (event.key === "PageDown") {
|
||||
if (navigationMode === "artifacts") {
|
||||
event.preventDefault();
|
||||
moveSelection(5);
|
||||
}
|
||||
} else if (event.key === "PageUp") {
|
||||
if (navigationMode === "artifacts") {
|
||||
event.preventDefault();
|
||||
moveSelection(-5);
|
||||
}
|
||||
} else if (event.key === "ArrowRight") {
|
||||
const rows = allRows();
|
||||
if (!selectedRow() && rows.length) {
|
||||
event.preventDefault();
|
||||
renderPreview(rows[0]);
|
||||
rows[0].focus({preventScroll: true});
|
||||
rows[0].scrollIntoView({block: "nearest"});
|
||||
} else if (selectedRow()) {
|
||||
navigationMode = "artifacts";
|
||||
}
|
||||
} else if (event.key === "ArrowLeft") {
|
||||
if (selectedRow()) {
|
||||
event.preventDefault();
|
||||
clearPreview();
|
||||
} else {
|
||||
navigationMode = "sources";
|
||||
const currentSource = document.querySelector(".source-link.selected");
|
||||
if (currentSource) currentSource.focus({preventScroll: true});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Infinite scroll: when the sentinel at the end of the list scrolls into
|
||||
// view, fetch the next page of older day-groups and append them.
|
||||
let loadingOlder = false;
|
||||
const loadOlder = async (sentinel) => {
|
||||
if (loadingOlder) return;
|
||||
loadingOlder = true;
|
||||
sentinel.removeAttribute("data-feed-more");
|
||||
try {
|
||||
const url = new URL("{% url 'feed-older' %}", window.location.origin);
|
||||
const params = url.searchParams;
|
||||
params.set("before", sentinel.dataset.nextBefore);
|
||||
params.set("view", feed.dataset.selectedView || "rendered");
|
||||
if (feed.dataset.selectedSource) params.set("source", feed.dataset.selectedSource);
|
||||
if (feed.dataset.selectedLabel) params.set("label", feed.dataset.selectedLabel);
|
||||
if (feed.dataset.selectedKind) params.set("kind", feed.dataset.selectedKind);
|
||||
if (feed.dataset.titleQuery) params.set("q", feed.dataset.titleQuery);
|
||||
const response = await fetch(url, {headers: {"X-Requested-With": "fetch"}});
|
||||
if (!response.ok) return;
|
||||
const markup = (await response.text()).trim();
|
||||
sentinel.remove();
|
||||
if (markup) {
|
||||
artifactList.insertAdjacentHTML("beforeend", markup);
|
||||
observeSentinel();
|
||||
}
|
||||
} catch {
|
||||
// Leave the list as-is; the user can scroll again to retry.
|
||||
} finally {
|
||||
loadingOlder = false;
|
||||
}
|
||||
};
|
||||
|
||||
const olderObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
olderObserver.unobserve(entry.target);
|
||||
loadOlder(entry.target);
|
||||
}
|
||||
});
|
||||
}, {root: artifactList, rootMargin: "200px"});
|
||||
|
||||
const observeSentinel = () => {
|
||||
const sentinel = artifactList.querySelector("[data-feed-more]");
|
||||
if (sentinel) olderObserver.observe(sentinel);
|
||||
};
|
||||
|
||||
observeSentinel();
|
||||
|
||||
// Populate the details panel for a server-preselected artifact so the first
|
||||
// "Details" click shows content immediately.
|
||||
const preselected = selectedRow();
|
||||
if (preselected) renderDetails(preselected);
|
||||
|
||||
const toggleKeep = async (button) => {
|
||||
const artifactId = button.dataset.artifactId;
|
||||
if (!artifactId) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
const response = await fetch(`/a/${artifactId}/keep`, {
|
||||
method: "POST",
|
||||
headers: {"X-CSRFToken": csrfToken},
|
||||
});
|
||||
const data = await response.json();
|
||||
syncKeepButton(data.is_kept);
|
||||
const row = document.querySelector(`.artifact-row[data-artifact-id="${artifactId}"]`);
|
||||
if (row) {
|
||||
row.dataset.kept = data.is_kept ? "1" : "0";
|
||||
row.classList.toggle("kept", data.is_kept);
|
||||
const badge = row.querySelector("[data-kept-badge]");
|
||||
if (badge) badge.hidden = !data.is_kept;
|
||||
}
|
||||
} catch {
|
||||
button.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
previewPane.addEventListener("click", (event) => {
|
||||
const copyButton = event.target.closest("[data-copy-url]");
|
||||
if (copyButton) {
|
||||
|
|
@ -261,8 +531,30 @@
|
|||
return;
|
||||
}
|
||||
|
||||
const keepButton = event.target.closest("[data-keep-button]");
|
||||
if (keepButton && !keepButton.disabled) {
|
||||
event.preventDefault();
|
||||
toggleKeep(keepButton);
|
||||
return;
|
||||
}
|
||||
|
||||
const detailsToggle = event.target.closest("[data-details-toggle]");
|
||||
if (detailsToggle && !detailsToggle.disabled) {
|
||||
event.preventDefault();
|
||||
const panel = document.querySelector("[data-details-panel]");
|
||||
if (panel) {
|
||||
panel.hidden = !panel.hidden;
|
||||
detailsToggle.classList.toggle("selected", !panel.hidden);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const toggle = event.target.closest("[data-view]");
|
||||
if (!toggle) return;
|
||||
if (toggle.classList.contains("disabled")) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const row = selectedRow();
|
||||
if (!row) return;
|
||||
event.preventDefault();
|
||||
|
|
|
|||
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