Initial public commit
Daybook: a self-hosted, multi-user Django + DRF app where agents, scripts, or people publish artifacts and each logged-in user browses their own time-ordered, label-filterable feed. This squashes prior local development history (kept privately on a separate branch) into a single clean starting point for the public repository. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
940a703cd7
62 changed files with 5755 additions and 0 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/
|
||||||
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
|
||||||
|
# Virtualenvs
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Django/local runtime
|
||||||
|
db.sqlite3
|
||||||
|
artifacts_store/
|
||||||
|
django-runserver.log
|
||||||
|
django-runserver.err.log
|
||||||
|
|
||||||
|
# Static/build/cache
|
||||||
|
staticfiles/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Secrets/env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# IDE/OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Internal-only planning/ops docs (home infrastructure details)
|
||||||
|
_archive_/
|
||||||
|
planning/
|
||||||
|
|
||||||
|
# Local machine-specific tool config (MCP servers, absolute local paths)
|
||||||
|
opencode.json
|
||||||
75
CLAUDE.md
Normal file
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.
|
||||||
0
artifacts/__init__.py
Normal file
0
artifacts/__init__.py
Normal file
23
artifacts/admin.py
Normal file
23
artifacts/admin.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Artifact, Asset, UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Artifact)
|
||||||
|
class ArtifactAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("id", "title", "owner", "source", "kind", "is_kept", "size_bytes", "day", "created_at")
|
||||||
|
list_filter = ("owner", "source", "source_type", "kind", "is_kept", "day")
|
||||||
|
search_fields = ("id", "title", "source", "source_name", "source_id")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserProfile)
|
||||||
|
class UserProfileAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "quota_bytes")
|
||||||
|
search_fields = ("user__username",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Asset)
|
||||||
|
class AssetAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("id", "owner", "artifact", "content_type", "size_bytes", "created_at")
|
||||||
|
list_filter = ("owner", "content_type")
|
||||||
|
search_fields = ("id",)
|
||||||
6
artifacts/apps.py
Normal file
6
artifacts/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "artifacts"
|
||||||
87
artifacts/demo_data.py
Normal file
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)
|
||||||
27
artifacts/migrations/0001_initial.py
Normal file
27
artifacts/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Artifact",
|
||||||
|
fields=[
|
||||||
|
("id", models.CharField(max_length=8, primary_key=True, serialize=False)),
|
||||||
|
("persona", models.CharField(db_index=True, max_length=80)),
|
||||||
|
("title", models.CharField(max_length=200)),
|
||||||
|
("kind", models.CharField(choices=[("html_fragment", "HTML fragment"), ("html_full", "Full HTML"), ("markdown", "Markdown"), ("deck", "Deck"), ("image", "Image"), ("pdf", "PDF"), ("binary", "Binary")], max_length=20)),
|
||||||
|
("theme", models.CharField(blank=True, max_length=40)),
|
||||||
|
("day", models.DateField(db_index=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("path", models.CharField(max_length=500)),
|
||||||
|
("original_kind", models.CharField(max_length=20)),
|
||||||
|
("tags", models.JSONField(blank=True, default=list)),
|
||||||
|
("source_prompt", models.TextField(blank=True)),
|
||||||
|
],
|
||||||
|
options={"ordering": ["-created_at"]},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
artifacts/migrations/__init__.py
Normal file
0
artifacts/migrations/__init__.py
Normal file
105
artifacts/models.py
Normal file
105
artifacts/models.py
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Artifact(models.Model):
|
||||||
|
HTML_FRAGMENT = "html_fragment"
|
||||||
|
HTML_FULL = "html_full"
|
||||||
|
MARKDOWN = "markdown"
|
||||||
|
DECK = "deck"
|
||||||
|
IMAGE = "image"
|
||||||
|
PDF = "pdf"
|
||||||
|
BINARY = "binary"
|
||||||
|
|
||||||
|
AGENT = "agent"
|
||||||
|
PERSON = "person"
|
||||||
|
SYSTEM = "system"
|
||||||
|
|
||||||
|
KIND_CHOICES = [
|
||||||
|
(HTML_FRAGMENT, "HTML fragment"),
|
||||||
|
(HTML_FULL, "Full HTML"),
|
||||||
|
(MARKDOWN, "Markdown"),
|
||||||
|
(DECK, "Deck"),
|
||||||
|
(IMAGE, "Image"),
|
||||||
|
(PDF, "PDF"),
|
||||||
|
(BINARY, "Binary"),
|
||||||
|
]
|
||||||
|
|
||||||
|
SOURCE_TYPE_CHOICES = [
|
||||||
|
(AGENT, "Agent"),
|
||||||
|
(PERSON, "Person"),
|
||||||
|
(SYSTEM, "System"),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = models.CharField(max_length=8, primary_key=True)
|
||||||
|
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="artifacts")
|
||||||
|
source = models.CharField(max_length=80, db_index=True)
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
kind = models.CharField(max_length=20, choices=KIND_CHOICES)
|
||||||
|
theme = models.CharField(max_length=40, blank=True)
|
||||||
|
day = models.DateField(db_index=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
path = models.CharField(max_length=500)
|
||||||
|
original_kind = models.CharField(max_length=20)
|
||||||
|
tags = models.JSONField(default=list, blank=True)
|
||||||
|
source_prompt = models.TextField(blank=True)
|
||||||
|
source_type = models.CharField(max_length=20, choices=SOURCE_TYPE_CHOICES, default=AGENT)
|
||||||
|
source_name = models.CharField(max_length=120, blank=True)
|
||||||
|
source_id = models.CharField(max_length=120, blank=True)
|
||||||
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
read_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
is_kept = models.BooleanField(default=False)
|
||||||
|
size_bytes = models.BigIntegerField(default=0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_unread(self):
|
||||||
|
return self.read_at is None
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.title} ({self.source})"
|
||||||
|
|
||||||
|
|
||||||
|
class TagColor(models.Model):
|
||||||
|
name = models.CharField(max_length=40, primary_key=True)
|
||||||
|
color = models.CharField(max_length=20)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.color})"
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(models.Model):
|
||||||
|
DEFAULT_QUOTA_BYTES = 1024 * 1024 * 1024 # 1 GB
|
||||||
|
|
||||||
|
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile")
|
||||||
|
quota_bytes = models.BigIntegerField(default=DEFAULT_QUOTA_BYTES)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} quota={self.quota_bytes}"
|
||||||
|
|
||||||
|
|
||||||
|
class Asset(models.Model):
|
||||||
|
"""An image uploaded for embedding inside an artifact's content.
|
||||||
|
|
||||||
|
Uploaded before the artifact exists, so `artifact` is null until the
|
||||||
|
artifact referencing its URL is created. Unattached assets are swept
|
||||||
|
after ASSET_ORPHAN_GRACE; attached ones die with their artifact.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = models.CharField(max_length=12, primary_key=True)
|
||||||
|
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="assets")
|
||||||
|
artifact = models.ForeignKey(
|
||||||
|
Artifact, on_delete=models.CASCADE, related_name="assets", null=True, blank=True
|
||||||
|
)
|
||||||
|
path = models.CharField(max_length=500)
|
||||||
|
content_type = models.CharField(max_length=100)
|
||||||
|
size_bytes = models.BigIntegerField(default=0)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.id} ({self.content_type})"
|
||||||
135
artifacts/storage.py
Normal file
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
|
||||||
21
artifacts/urls.py
Normal file
21
artifacts/urls.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", views.feed, name="feed"),
|
||||||
|
path("feed/older", views.feed_older, name="feed-older"),
|
||||||
|
path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"),
|
||||||
|
path("a/<str:artifact_id>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"),
|
||||||
|
path("a/<str:artifact_id>", views.artifact_view, name="artifact"),
|
||||||
|
path("assets/<str:asset_id>", views.asset_view, name="asset"),
|
||||||
|
path("api/assets", views.AssetUploadView.as_view(), name="asset-upload-api"),
|
||||||
|
path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"),
|
||||||
|
path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"),
|
||||||
|
path("api/artifacts/<str:artifact_id>", views.ArtifactDetailView.as_view(), name="artifact-detail-api"),
|
||||||
|
path("settings/", views.settings_home, name="settings-home"),
|
||||||
|
path("settings/account/", views.settings_account, name="settings-account"),
|
||||||
|
path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"),
|
||||||
|
path("settings/storage/", views.settings_storage, name="settings-storage"),
|
||||||
|
]
|
||||||
903
artifacts/views.py
Normal file
903
artifacts/views.py
Normal file
|
|
@ -0,0 +1,903 @@
|
||||||
|
import hashlib
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import markdown
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth import views as auth_views
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.db.models import Count, Q, Sum
|
||||||
|
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.template.defaultfilters import slugify
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.dateparse import parse_date
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
from rest_framework.authtoken.models import Token
|
||||||
|
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from .demo_data import create_demo_artifacts
|
||||||
|
from .forms import DaybookLoginForm
|
||||||
|
from .models import Artifact, Asset, TagColor
|
||||||
|
from .storage import (
|
||||||
|
QuotaExceeded,
|
||||||
|
artifact_disk_size,
|
||||||
|
delete_artifact_files,
|
||||||
|
disk_free_bytes,
|
||||||
|
make_room_for,
|
||||||
|
sweep_orphan_assets,
|
||||||
|
total_allocated_bytes,
|
||||||
|
user_quota_bytes,
|
||||||
|
user_usage_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TEXT_KINDS = {Artifact.HTML_FRAGMENT, Artifact.HTML_FULL, Artifact.MARKDOWN, Artifact.DECK}
|
||||||
|
|
||||||
|
REMEMBER_ME_SESSION_AGE = 60 * 60 * 24 * 30 # 30 days
|
||||||
|
|
||||||
|
MAX_ASSET_BYTES = 50 * 1024 * 1024 # 50 MB per uploaded image
|
||||||
|
|
||||||
|
# Matches the asset id in a Daybook asset URL, e.g. /assets/ab12cd34ef56
|
||||||
|
ASSET_URL_RE = re.compile(r"/assets/([0-9a-f]{12})")
|
||||||
|
|
||||||
|
|
||||||
|
def filter_by_label(queryset, label):
|
||||||
|
matching_ids = [artifact.id for artifact in queryset.only("id", "tags") if label in artifact.tags]
|
||||||
|
return queryset.filter(pk__in=matching_ids)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_THEMES = {
|
||||||
|
Artifact.HTML_FRAGMENT: "report",
|
||||||
|
Artifact.MARKDOWN: "report",
|
||||||
|
Artifact.DECK: "deck",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(login_required, name="get")
|
||||||
|
class ArtifactListCreateView(APIView):
|
||||||
|
parser_classes = [JSONParser, FormParser, MultiPartParser]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
artifacts = Artifact.objects.filter(owner=request.user)
|
||||||
|
if source := request.query_params.get("source"):
|
||||||
|
artifacts = artifacts.filter(source=source)
|
||||||
|
if label := request.query_params.get("label"):
|
||||||
|
artifacts = filter_by_label(artifacts, label)
|
||||||
|
if day := request.query_params.get("day"):
|
||||||
|
artifacts = artifacts.filter(day=day)
|
||||||
|
if kind := request.query_params.get("kind"):
|
||||||
|
artifacts = artifacts.filter(kind=kind)
|
||||||
|
if title_query := request.query_params.get("q"):
|
||||||
|
artifacts = artifacts.filter(title__icontains=title_query)
|
||||||
|
|
||||||
|
return Response([serialize_artifact(artifact, request) for artifact in artifacts])
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
user = resolve_token_user(request)
|
||||||
|
if user is None:
|
||||||
|
return HttpResponseForbidden("Invalid or missing API token")
|
||||||
|
|
||||||
|
if request.content_type and request.content_type.startswith("multipart/"):
|
||||||
|
artifact = create_binary_artifact(request.data, user)
|
||||||
|
if isinstance(artifact, Response):
|
||||||
|
return artifact
|
||||||
|
apply_tag_colors(request.data)
|
||||||
|
return Response(serialize_artifact(artifact, request), status=201)
|
||||||
|
|
||||||
|
data = request.data
|
||||||
|
kind = data.get("kind")
|
||||||
|
if kind not in TEXT_KINDS:
|
||||||
|
return Response({"detail": "kind must be html_fragment, html_full, markdown, or deck"}, status=400)
|
||||||
|
|
||||||
|
required = ["source", "title", "content"]
|
||||||
|
missing = [field for field in required if not data.get(field)]
|
||||||
|
if missing:
|
||||||
|
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400)
|
||||||
|
|
||||||
|
source_error = validate_source_fields(data)
|
||||||
|
if source_error:
|
||||||
|
return source_error
|
||||||
|
|
||||||
|
artifact = create_text_artifact(data, request, user)
|
||||||
|
if isinstance(artifact, Response):
|
||||||
|
return artifact
|
||||||
|
apply_tag_colors(data)
|
||||||
|
return Response(serialize_artifact(artifact, request), status=201)
|
||||||
|
|
||||||
|
|
||||||
|
class AssetUploadView(APIView):
|
||||||
|
"""Upload an image for embedding inside artifact content.
|
||||||
|
|
||||||
|
Returns a stable URL the agent puts in its markdown/fragment. The asset is
|
||||||
|
attached to whichever artifact later references that URL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
parser_classes = [MultiPartParser, FormParser]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
user = resolve_token_user(request)
|
||||||
|
if user is None:
|
||||||
|
return HttpResponseForbidden("Invalid or missing API token")
|
||||||
|
|
||||||
|
uploaded = request.data.get("file")
|
||||||
|
if not uploaded:
|
||||||
|
return Response({"detail": "Missing required field: file"}, status=400)
|
||||||
|
|
||||||
|
content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or ""
|
||||||
|
if not content_type.startswith("image/"):
|
||||||
|
return Response({"detail": "Only image uploads are supported."}, status=400)
|
||||||
|
if uploaded.size > MAX_ASSET_BYTES:
|
||||||
|
return Response(
|
||||||
|
{"detail": f"Image exceeds the {MAX_ASSET_BYTES // (1024 * 1024)} MB limit."},
|
||||||
|
status=413,
|
||||||
|
)
|
||||||
|
|
||||||
|
sweep_orphan_assets(user)
|
||||||
|
|
||||||
|
asset_id = generate_asset_id()
|
||||||
|
suffix = safe_suffix(uploaded.name, content_type)
|
||||||
|
directory = Path("_assets") / timezone.localdate().isoformat()
|
||||||
|
(settings.ARTIFACTS_ROOT / directory).mkdir(parents=True, exist_ok=True)
|
||||||
|
stored_path = directory / f"{asset_id}{suffix}"
|
||||||
|
|
||||||
|
with (settings.ARTIFACTS_ROOT / stored_path).open("wb") as target:
|
||||||
|
for chunk in uploaded.chunks():
|
||||||
|
target.write(chunk)
|
||||||
|
|
||||||
|
size_bytes = (settings.ARTIFACTS_ROOT / stored_path).stat().st_size
|
||||||
|
try:
|
||||||
|
make_room_for(user, size_bytes)
|
||||||
|
except QuotaExceeded as exc:
|
||||||
|
(settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True)
|
||||||
|
return Response({"detail": str(exc)}, status=413)
|
||||||
|
|
||||||
|
asset = Asset.objects.create(
|
||||||
|
id=asset_id,
|
||||||
|
owner=user,
|
||||||
|
path=stored_path.as_posix(),
|
||||||
|
content_type=content_type,
|
||||||
|
size_bytes=size_bytes,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"id": asset.id,
|
||||||
|
"url": request.build_absolute_uri(reverse("asset", args=[asset.id])),
|
||||||
|
"content_type": asset.content_type,
|
||||||
|
"size_bytes": asset.size_bytes,
|
||||||
|
},
|
||||||
|
status=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@xframe_options_sameorigin
|
||||||
|
def asset_view(request, asset_id):
|
||||||
|
asset = get_object_or_404(Asset, pk=asset_id, owner=request.user)
|
||||||
|
path = safe_artifact_path(asset.path)
|
||||||
|
if not path.exists():
|
||||||
|
raise Http404("Asset file not found")
|
||||||
|
return FileResponse(path.open("rb"), content_type=asset.content_type or "application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(login_required, name="get")
|
||||||
|
class ArtifactDetailView(APIView):
|
||||||
|
def get(self, request, artifact_id):
|
||||||
|
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
|
||||||
|
return Response(serialize_artifact(artifact, request))
|
||||||
|
|
||||||
|
def patch(self, request, artifact_id):
|
||||||
|
user = resolve_token_user(request)
|
||||||
|
if user is None:
|
||||||
|
return HttpResponseForbidden("Invalid or missing API token")
|
||||||
|
|
||||||
|
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
|
||||||
|
data = request.data
|
||||||
|
if "title" in data:
|
||||||
|
if not data["title"]:
|
||||||
|
return Response({"detail": "title cannot be blank"}, status=400)
|
||||||
|
artifact.title = data["title"]
|
||||||
|
if "tags" in data:
|
||||||
|
artifact.tags = normalize_tags(data["tags"])
|
||||||
|
if "source_prompt" in data:
|
||||||
|
artifact.source_prompt = data["source_prompt"] or ""
|
||||||
|
if "source_type" in data:
|
||||||
|
if data["source_type"] not in {choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES}:
|
||||||
|
return Response({"detail": "source_type must be agent, person, or system"}, status=400)
|
||||||
|
artifact.source_type = data["source_type"]
|
||||||
|
if "source_name" in data:
|
||||||
|
artifact.source_name = data["source_name"] or ""
|
||||||
|
if "source_id" in data:
|
||||||
|
artifact.source_id = data["source_id"] or ""
|
||||||
|
if "metadata" in data:
|
||||||
|
if not isinstance(data["metadata"], dict):
|
||||||
|
return Response({"detail": "metadata must be an object"}, status=400)
|
||||||
|
artifact.metadata = data["metadata"]
|
||||||
|
if "tag_colors" in data:
|
||||||
|
try:
|
||||||
|
apply_tag_colors(data)
|
||||||
|
except ValueError as exc:
|
||||||
|
return Response({"detail": str(exc)}, status=400)
|
||||||
|
artifact.save(
|
||||||
|
update_fields=[
|
||||||
|
"title",
|
||||||
|
"tags",
|
||||||
|
"source_prompt",
|
||||||
|
"source_type",
|
||||||
|
"source_name",
|
||||||
|
"source_id",
|
||||||
|
"metadata",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return Response(serialize_artifact(artifact, request))
|
||||||
|
|
||||||
|
def delete(self, request, artifact_id):
|
||||||
|
user = resolve_token_user(request)
|
||||||
|
if user is None:
|
||||||
|
return HttpResponseForbidden("Invalid or missing API token")
|
||||||
|
|
||||||
|
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
|
||||||
|
delete_artifact_files(artifact)
|
||||||
|
artifact.delete()
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilitiesView(APIView):
|
||||||
|
"""Static capability info. Reachable by a logged-in session or a valid API token,
|
||||||
|
so agents can check connectivity before posting. Exposes no user data."""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
if not request.user.is_authenticated and resolve_token_user(request) is None:
|
||||||
|
return HttpResponseForbidden("Login or a valid API token is required")
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"artifact_kinds": [choice[0] for choice in Artifact.KIND_CHOICES],
|
||||||
|
"filters": ["source", "label", "day", "kind", "q"],
|
||||||
|
"views": ["rendered", "raw"],
|
||||||
|
"raw_supported_for": [Artifact.MARKDOWN, Artifact.HTML_FULL],
|
||||||
|
"source_types": [choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES],
|
||||||
|
"auth": "bearer_token",
|
||||||
|
"methods": {
|
||||||
|
"/api/artifacts": ["GET", "POST"],
|
||||||
|
"/api/artifacts/{id}": ["GET", "PATCH", "DELETE"],
|
||||||
|
"/api/assets": ["POST"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
FEED_WINDOW_DAYS = 7
|
||||||
|
|
||||||
|
|
||||||
|
def apply_feed_filters(queryset, source="", label="", kind="", title_query=""):
|
||||||
|
if source:
|
||||||
|
queryset = queryset.filter(source=source)
|
||||||
|
if label:
|
||||||
|
queryset = filter_by_label(queryset, label)
|
||||||
|
if kind:
|
||||||
|
queryset = queryset.filter(kind=kind)
|
||||||
|
if title_query:
|
||||||
|
queryset = queryset.filter(title__icontains=title_query)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
def group_artifacts_by_day(artifacts, overrides):
|
||||||
|
"""Turn a flat, reverse-chronological list into [{day, artifacts}] groups."""
|
||||||
|
groups = []
|
||||||
|
for artifact in artifacts:
|
||||||
|
# Source has its own column in the row, so chips are the free-form tags only.
|
||||||
|
artifact.label_chips = tags_with_colors(artifact.tags, overrides)
|
||||||
|
if not groups or groups[-1]["day"] != artifact.day:
|
||||||
|
groups.append({"day": artifact.day, "artifacts": []})
|
||||||
|
groups[-1]["artifacts"].append(artifact)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def feed(request):
|
||||||
|
selected_source = request.GET.get("source", "")
|
||||||
|
selected_label = request.GET.get("label", "")
|
||||||
|
selected_kind = request.GET.get("kind", "")
|
||||||
|
title_query = request.GET.get("q", "")
|
||||||
|
today = timezone.localdate()
|
||||||
|
selected_artifact_id = request.GET.get("artifact", "")
|
||||||
|
preview_none = request.GET.get("preview") == "none"
|
||||||
|
selected_view = "raw" if request.GET.get("view") == "raw" else "rendered"
|
||||||
|
|
||||||
|
all_artifacts = Artifact.objects.filter(owner=request.user)
|
||||||
|
artifacts = apply_feed_filters(
|
||||||
|
all_artifacts, selected_source, selected_label, selected_kind, title_query
|
||||||
|
)
|
||||||
|
|
||||||
|
window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1)
|
||||||
|
if not artifacts.filter(day__gte=window_start).exists():
|
||||||
|
# Nothing recent for these filters: fall back to the most recent days that
|
||||||
|
# do match, so selecting a source is never met with a blank list.
|
||||||
|
recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS])
|
||||||
|
if recent_days:
|
||||||
|
window_start = recent_days[-1]
|
||||||
|
window_artifacts = list(artifacts.filter(day__gte=window_start).order_by("-created_at"))
|
||||||
|
older_count = artifacts.filter(day__lt=window_start).count()
|
||||||
|
|
||||||
|
overrides = tag_color_overrides()
|
||||||
|
day_groups = group_artifacts_by_day(window_artifacts, overrides)
|
||||||
|
|
||||||
|
selected_artifact = None
|
||||||
|
if not preview_none:
|
||||||
|
selected_artifact = next((a for a in window_artifacts if a.id == selected_artifact_id), None)
|
||||||
|
|
||||||
|
toggle_query = {"view": "rendered"}
|
||||||
|
if selected_source:
|
||||||
|
toggle_query["source"] = selected_source
|
||||||
|
if selected_label:
|
||||||
|
toggle_query["label"] = selected_label
|
||||||
|
if selected_kind:
|
||||||
|
toggle_query["kind"] = selected_kind
|
||||||
|
if title_query:
|
||||||
|
toggle_query["q"] = title_query
|
||||||
|
if selected_artifact:
|
||||||
|
toggle_query["artifact"] = selected_artifact.id
|
||||||
|
rendered_url = f"?{urlencode(toggle_query)}"
|
||||||
|
toggle_query["view"] = "raw"
|
||||||
|
raw_url = f"?{urlencode(toggle_query)}"
|
||||||
|
|
||||||
|
# Source counts come from the other filters only, so the sidebar still shows
|
||||||
|
# every source available under the current label/type/name filters.
|
||||||
|
source_scoped = apply_feed_filters(
|
||||||
|
all_artifacts, "", selected_label, selected_kind, title_query
|
||||||
|
)
|
||||||
|
source_counts = (
|
||||||
|
source_scoped.values("source")
|
||||||
|
.annotate(count=Count("id", filter=Q(read_at__isnull=True)))
|
||||||
|
.order_by("source")
|
||||||
|
)
|
||||||
|
|
||||||
|
all_labels = sorted({tag for tags in all_artifacts.values_list("tags", flat=True) for tag in tags})
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"artifacts/feed.html",
|
||||||
|
{
|
||||||
|
"artifacts": window_artifacts,
|
||||||
|
"day_groups": day_groups,
|
||||||
|
"older_count": older_count,
|
||||||
|
"window_start": window_start,
|
||||||
|
"source_counts": source_counts,
|
||||||
|
"total_count": source_scoped.filter(read_at__isnull=True).count(),
|
||||||
|
"all_labels": labels_with_colors_list(all_labels, overrides),
|
||||||
|
"raw_url": raw_url,
|
||||||
|
"rendered_url": rendered_url,
|
||||||
|
"selected_artifact": selected_artifact,
|
||||||
|
"selected_kind": selected_kind,
|
||||||
|
"selected_label": selected_label,
|
||||||
|
"selected_source": selected_source,
|
||||||
|
"selected_view": selected_view,
|
||||||
|
"preview_none": preview_none,
|
||||||
|
"today": today,
|
||||||
|
"title_query": title_query,
|
||||||
|
"kind_choices": Artifact.KIND_CHOICES,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def mark_artifact_read(request, artifact_id):
|
||||||
|
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
|
||||||
|
if artifact.read_at is None:
|
||||||
|
artifact.read_at = timezone.now()
|
||||||
|
artifact.save(update_fields=["read_at"])
|
||||||
|
return JsonResponse({"id": artifact.id, "read_at": artifact.read_at.isoformat()})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def toggle_artifact_keep(request, artifact_id):
|
||||||
|
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
|
||||||
|
artifact.is_kept = not artifact.is_kept
|
||||||
|
artifact.save(update_fields=["is_kept"])
|
||||||
|
return JsonResponse({"id": artifact.id, "is_kept": artifact.is_kept})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def feed_older(request):
|
||||||
|
"""Return the next page of older day-groups as an HTML fragment for infinite scroll."""
|
||||||
|
before = parse_date(request.GET.get("before", "")) or timezone.localdate()
|
||||||
|
artifacts = apply_feed_filters(
|
||||||
|
Artifact.objects.filter(owner=request.user),
|
||||||
|
request.GET.get("source", ""),
|
||||||
|
request.GET.get("label", ""),
|
||||||
|
request.GET.get("kind", ""),
|
||||||
|
request.GET.get("q", ""),
|
||||||
|
).filter(day__lt=before).order_by("-created_at")
|
||||||
|
|
||||||
|
# Take a whole page of days at a time so a day is never split across pages.
|
||||||
|
page_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS])
|
||||||
|
if not page_days:
|
||||||
|
return HttpResponse("")
|
||||||
|
|
||||||
|
page_artifacts = list(artifacts.filter(day__gte=page_days[-1]))
|
||||||
|
day_groups = group_artifacts_by_day(page_artifacts, tag_color_overrides())
|
||||||
|
remaining = artifacts.filter(day__lt=page_days[-1]).count()
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"artifacts/_day_groups.html",
|
||||||
|
{
|
||||||
|
"day_groups": day_groups,
|
||||||
|
"next_before": page_days[-1].isoformat(),
|
||||||
|
"has_more": remaining > 0,
|
||||||
|
"selected_artifact": None,
|
||||||
|
"selected_source": request.GET.get("source", ""),
|
||||||
|
"selected_label": request.GET.get("label", ""),
|
||||||
|
"selected_kind": request.GET.get("kind", ""),
|
||||||
|
"title_query": request.GET.get("q", ""),
|
||||||
|
"selected_view": "raw" if request.GET.get("view") == "raw" else "rendered",
|
||||||
|
"today": timezone.localdate(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@xframe_options_sameorigin
|
||||||
|
def artifact_view(request, artifact_id):
|
||||||
|
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
|
||||||
|
if request.GET.get("raw") == "1":
|
||||||
|
raw_path = raw_artifact_path(artifact)
|
||||||
|
if raw_path and raw_path.exists():
|
||||||
|
return FileResponse(raw_path.open("rb"), content_type="text/plain; charset=utf-8")
|
||||||
|
return HttpResponse(raw_unavailable_html(artifact), content_type="text/html")
|
||||||
|
|
||||||
|
path = safe_artifact_path(artifact.path)
|
||||||
|
if not path.exists():
|
||||||
|
raise Http404("Artifact file not found")
|
||||||
|
|
||||||
|
content_type, _ = mimetypes.guess_type(path.name)
|
||||||
|
return FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
def create_text_artifact(data, request, user):
|
||||||
|
now = timezone.localtime()
|
||||||
|
artifact_id = generate_id()
|
||||||
|
source = slugify(data["source"]) or "unknown"
|
||||||
|
kind = data["kind"]
|
||||||
|
theme = data.get("theme") or DEFAULT_THEMES.get(kind, "base")
|
||||||
|
title = data["title"]
|
||||||
|
content = data["content"]
|
||||||
|
|
||||||
|
directory = Path(source) / now.date().isoformat()
|
||||||
|
target_dir = settings.ARTIFACTS_ROOT / directory
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if kind == Artifact.HTML_FULL:
|
||||||
|
rendered = content
|
||||||
|
elif kind == Artifact.MARKDOWN:
|
||||||
|
rendered = wrap_html(markdown.markdown(content, extensions=["extra", "sane_lists"]), title, theme, request)
|
||||||
|
(target_dir / f"{artifact_id}.md").write_text(content, encoding="utf-8")
|
||||||
|
elif kind == Artifact.DECK:
|
||||||
|
rendered = wrap_deck(content, title, request)
|
||||||
|
else:
|
||||||
|
rendered = wrap_html(content, title, theme, request)
|
||||||
|
|
||||||
|
stored_path = directory / f"{artifact_id}.html"
|
||||||
|
(settings.ARTIFACTS_ROOT / stored_path).write_text(rendered, encoding="utf-8")
|
||||||
|
|
||||||
|
size_bytes = artifact_disk_size(stored_path.as_posix(), kind)
|
||||||
|
try:
|
||||||
|
make_room_for(user, size_bytes)
|
||||||
|
except QuotaExceeded as exc:
|
||||||
|
(settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True)
|
||||||
|
if kind == Artifact.MARKDOWN:
|
||||||
|
(target_dir / f"{artifact_id}.md").unlink(missing_ok=True)
|
||||||
|
return Response({"detail": str(exc)}, status=413)
|
||||||
|
|
||||||
|
artifact = Artifact.objects.create(
|
||||||
|
id=artifact_id,
|
||||||
|
owner=user,
|
||||||
|
source=source,
|
||||||
|
title=title,
|
||||||
|
kind=kind,
|
||||||
|
theme=theme,
|
||||||
|
day=now.date(),
|
||||||
|
path=stored_path.as_posix(),
|
||||||
|
original_kind=kind,
|
||||||
|
tags=normalize_tags(data.get("tags", [])),
|
||||||
|
source_prompt=data.get("source_prompt", ""),
|
||||||
|
size_bytes=size_bytes,
|
||||||
|
**source_fields(data, source),
|
||||||
|
)
|
||||||
|
attach_referenced_assets(artifact, content)
|
||||||
|
return artifact
|
||||||
|
|
||||||
|
|
||||||
|
def create_binary_artifact(data, user):
|
||||||
|
uploaded = data.get("file")
|
||||||
|
required = ["source", "title"]
|
||||||
|
missing = [field for field in required if not data.get(field)]
|
||||||
|
if not uploaded:
|
||||||
|
missing.append("file")
|
||||||
|
if missing:
|
||||||
|
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400)
|
||||||
|
source_error = validate_source_fields(data)
|
||||||
|
if source_error:
|
||||||
|
return source_error
|
||||||
|
|
||||||
|
now = timezone.localtime()
|
||||||
|
artifact_id = generate_id()
|
||||||
|
source = slugify(data["source"]) or "unknown"
|
||||||
|
title = data["title"]
|
||||||
|
content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or ""
|
||||||
|
kind = binary_kind(content_type)
|
||||||
|
|
||||||
|
directory = Path(source) / now.date().isoformat()
|
||||||
|
target_dir = settings.ARTIFACTS_ROOT / directory
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
suffix = safe_suffix(uploaded.name, content_type)
|
||||||
|
stored_path = directory / f"{artifact_id}{suffix}"
|
||||||
|
with (settings.ARTIFACTS_ROOT / stored_path).open("wb") as target:
|
||||||
|
for chunk in uploaded.chunks():
|
||||||
|
target.write(chunk)
|
||||||
|
|
||||||
|
size_bytes = artifact_disk_size(stored_path.as_posix(), kind)
|
||||||
|
try:
|
||||||
|
make_room_for(user, size_bytes)
|
||||||
|
except QuotaExceeded as exc:
|
||||||
|
(settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True)
|
||||||
|
return Response({"detail": str(exc)}, status=413)
|
||||||
|
|
||||||
|
return Artifact.objects.create(
|
||||||
|
id=artifact_id,
|
||||||
|
owner=user,
|
||||||
|
source=source,
|
||||||
|
title=title,
|
||||||
|
kind=kind,
|
||||||
|
theme="",
|
||||||
|
day=now.date(),
|
||||||
|
path=stored_path.as_posix(),
|
||||||
|
original_kind=kind,
|
||||||
|
tags=normalize_tags(data.get("tags", [])),
|
||||||
|
source_prompt=data.get("source_prompt", ""),
|
||||||
|
size_bytes=size_bytes,
|
||||||
|
**source_fields(data, source),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_html(body, title, theme, request):
|
||||||
|
stylesheet = request.build_absolute_uri(f"/static/themes/{theme}.css")
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{html.escape(title)}</title>
|
||||||
|
<link rel="stylesheet" href="{stylesheet}">
|
||||||
|
</head>
|
||||||
|
<body class="artifact">
|
||||||
|
{body}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_deck(body, title, request):
|
||||||
|
stylesheet = request.build_absolute_uri("/static/themes/deck.css")
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{html.escape(title)}</title>
|
||||||
|
<link rel="stylesheet" href="{stylesheet}">
|
||||||
|
</head>
|
||||||
|
<body class="deck">
|
||||||
|
<main class="slides">{body}</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def generate_id():
|
||||||
|
while True:
|
||||||
|
artifact_id = secrets.token_hex(4)
|
||||||
|
if not Artifact.objects.filter(pk=artifact_id).exists():
|
||||||
|
return artifact_id
|
||||||
|
|
||||||
|
|
||||||
|
def generate_asset_id():
|
||||||
|
while True:
|
||||||
|
asset_id = secrets.token_hex(6)
|
||||||
|
if not Asset.objects.filter(pk=asset_id).exists():
|
||||||
|
return asset_id
|
||||||
|
|
||||||
|
|
||||||
|
def attach_referenced_assets(artifact, content):
|
||||||
|
"""Link assets whose URLs appear in the artifact's content.
|
||||||
|
|
||||||
|
Assets are uploaded before the artifact exists, so this runs at creation
|
||||||
|
time and ties each referenced asset to the artifact that embeds it.
|
||||||
|
"""
|
||||||
|
referenced = set(ASSET_URL_RE.findall(content or ""))
|
||||||
|
if not referenced:
|
||||||
|
return 0
|
||||||
|
return Asset.objects.filter(
|
||||||
|
owner=artifact.owner, id__in=referenced, artifact__isnull=True
|
||||||
|
).update(artifact=artifact)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_token_user(request):
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if not auth.startswith("Bearer "):
|
||||||
|
return None
|
||||||
|
key = auth[len("Bearer "):].strip()
|
||||||
|
if not key:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return Token.objects.select_related("user").get(key=key).user
|
||||||
|
except Token.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_tags(tags):
|
||||||
|
if isinstance(tags, str):
|
||||||
|
return [tag.strip() for tag in tags.split(",") if tag.strip()]
|
||||||
|
if isinstance(tags, list):
|
||||||
|
return [str(tag).strip() for tag in tags if str(tag).strip()]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||||
|
|
||||||
|
TAG_PALETTE = [
|
||||||
|
"#4f6df5", "#e0578c", "#2fa88f", "#d98b2b",
|
||||||
|
"#8c5fd6", "#3fa1d8", "#c14f4f", "#5aa552",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def tag_color(name, overrides):
|
||||||
|
if override := overrides.get(name):
|
||||||
|
return override
|
||||||
|
index = int(hashlib.sha256(name.encode("utf-8")).hexdigest(), 16) % len(TAG_PALETTE)
|
||||||
|
return TAG_PALETTE[index]
|
||||||
|
|
||||||
|
|
||||||
|
def tag_color_overrides():
|
||||||
|
return dict(TagColor.objects.values_list("name", "color"))
|
||||||
|
|
||||||
|
|
||||||
|
def tags_with_colors(tags, overrides=None):
|
||||||
|
overrides = overrides if overrides is not None else tag_color_overrides()
|
||||||
|
return [{"name": tag, "color": tag_color(tag, overrides)} for tag in tags]
|
||||||
|
|
||||||
|
|
||||||
|
def labels_with_colors_list(labels, overrides=None):
|
||||||
|
overrides = overrides if overrides is not None else tag_color_overrides()
|
||||||
|
return [{"name": label, "color": tag_color(label, overrides)} for label in labels]
|
||||||
|
|
||||||
|
|
||||||
|
def labels_with_colors(artifact, overrides=None):
|
||||||
|
return labels_with_colors_list([artifact.source, *artifact.tags], overrides)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_source_fields(data):
|
||||||
|
if source_type := data.get("source_type"):
|
||||||
|
if source_type not in {choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES}:
|
||||||
|
return Response({"detail": "source_type must be agent, person, or system"}, status=400)
|
||||||
|
try:
|
||||||
|
parse_metadata_value(data.get("metadata", {}))
|
||||||
|
except ValueError as exc:
|
||||||
|
return Response({"detail": str(exc)}, status=400)
|
||||||
|
try:
|
||||||
|
parse_tag_colors_value(data.get("tag_colors", {}))
|
||||||
|
except ValueError as exc:
|
||||||
|
return Response({"detail": str(exc)}, status=400)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tag_colors_value(value):
|
||||||
|
if value in (None, ""):
|
||||||
|
return {}
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
value = json.loads(value)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError("tag_colors must be an object") from exc
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError("tag_colors must be an object")
|
||||||
|
for tag, color in value.items():
|
||||||
|
if not HEX_COLOR_RE.match(str(color)):
|
||||||
|
raise ValueError(f"tag_colors[{tag}] must be a hex color like #RRGGBB")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def apply_tag_colors(data):
|
||||||
|
tag_colors = parse_tag_colors_value(data.get("tag_colors", {}))
|
||||||
|
for tag, color in tag_colors.items():
|
||||||
|
TagColor.objects.update_or_create(name=tag, defaults={"color": color})
|
||||||
|
|
||||||
|
|
||||||
|
def source_fields(data, source):
|
||||||
|
return {
|
||||||
|
"source_type": data.get("source_type") or Artifact.AGENT,
|
||||||
|
"source_name": data.get("source_name") or source,
|
||||||
|
"source_id": data.get("source_id") or "",
|
||||||
|
"metadata": parse_metadata_value(data.get("metadata", {})),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_metadata_value(value):
|
||||||
|
if value in (None, ""):
|
||||||
|
return {}
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError("metadata must be an object") from exc
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
return parsed
|
||||||
|
raise ValueError("metadata must be an object")
|
||||||
|
|
||||||
|
|
||||||
|
def binary_kind(content_type):
|
||||||
|
if content_type.startswith("image/"):
|
||||||
|
return Artifact.IMAGE
|
||||||
|
if content_type == "application/pdf":
|
||||||
|
return Artifact.PDF
|
||||||
|
return Artifact.BINARY
|
||||||
|
|
||||||
|
|
||||||
|
def safe_suffix(filename, content_type):
|
||||||
|
suffix = Path(filename).suffix.lower()
|
||||||
|
if suffix and suffix[1:].isalnum() and len(suffix) <= 10:
|
||||||
|
return suffix
|
||||||
|
return mimetypes.guess_extension(content_type) or ".bin"
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_artifact(artifact, request):
|
||||||
|
return {
|
||||||
|
"id": artifact.id,
|
||||||
|
"url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])),
|
||||||
|
"api_url": request.build_absolute_uri(reverse("artifact-detail-api", args=[artifact.id])),
|
||||||
|
"raw_url": request.build_absolute_uri(f"{reverse('artifact', args=[artifact.id])}?raw=1"),
|
||||||
|
"raw_available": raw_artifact_path(artifact) is not None,
|
||||||
|
"source": artifact.source,
|
||||||
|
"title": artifact.title,
|
||||||
|
"kind": artifact.kind,
|
||||||
|
"theme": artifact.theme,
|
||||||
|
"day": artifact.day.isoformat(),
|
||||||
|
"created_at": artifact.created_at.isoformat(),
|
||||||
|
"tags": tags_with_colors(artifact.tags),
|
||||||
|
"labels": labels_with_colors(artifact),
|
||||||
|
"source_prompt": artifact.source_prompt,
|
||||||
|
"source_type": artifact.source_type,
|
||||||
|
"source_name": artifact.source_name,
|
||||||
|
"source_id": artifact.source_id,
|
||||||
|
"metadata": artifact.metadata,
|
||||||
|
"read_at": artifact.read_at.isoformat() if artifact.read_at else None,
|
||||||
|
"is_kept": artifact.is_kept,
|
||||||
|
"size_bytes": artifact.size_bytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def safe_artifact_path(relative_path):
|
||||||
|
root = settings.ARTIFACTS_ROOT.resolve()
|
||||||
|
path = (root / relative_path).resolve()
|
||||||
|
if root not in path.parents and path != root:
|
||||||
|
raise Http404("Invalid artifact path")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def raw_artifact_path(artifact):
|
||||||
|
path = safe_artifact_path(artifact.path)
|
||||||
|
if artifact.kind == Artifact.MARKDOWN:
|
||||||
|
return path.with_suffix(".md")
|
||||||
|
if artifact.kind == Artifact.HTML_FULL:
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class DaybookLoginView(auth_views.LoginView):
|
||||||
|
template_name = "artifacts/login.html"
|
||||||
|
authentication_form = DaybookLoginForm
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
response = super().form_valid(form)
|
||||||
|
if form.cleaned_data.get("remember_me"):
|
||||||
|
self.request.session.set_expiry(REMEMBER_ME_SESSION_AGE)
|
||||||
|
else:
|
||||||
|
self.request.session.set_expiry(0)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def settings_home(request):
|
||||||
|
return redirect("settings-account")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def settings_account(request):
|
||||||
|
token, _ = Token.objects.get_or_create(user=request.user)
|
||||||
|
if request.method == "POST" and request.POST.get("action") == "regenerate":
|
||||||
|
token.delete()
|
||||||
|
token = Token.objects.create(user=request.user)
|
||||||
|
messages.success(request, "API token regenerated.")
|
||||||
|
return redirect("settings-account")
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"artifacts/settings_account.html",
|
||||||
|
{"token": token, "active_section": "account"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def settings_demo_data(request):
|
||||||
|
if request.method == "POST":
|
||||||
|
count = create_demo_artifacts(request.user, request)
|
||||||
|
messages.success(request, f"Created {count} demo artifacts.")
|
||||||
|
return redirect("settings-demo-data")
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"artifacts/settings_demo_data.html",
|
||||||
|
{"active_section": "demo-data"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def settings_storage(request):
|
||||||
|
used = user_usage_bytes(request.user)
|
||||||
|
quota = user_quota_bytes(request.user)
|
||||||
|
kept_bytes = (
|
||||||
|
Artifact.objects.filter(owner=request.user, is_kept=True)
|
||||||
|
.aggregate(total=Sum("size_bytes"))
|
||||||
|
.get("total")
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
context = {
|
||||||
|
"active_section": "storage",
|
||||||
|
"used_bytes": used,
|
||||||
|
"quota_bytes": quota,
|
||||||
|
"kept_bytes": kept_bytes,
|
||||||
|
"percent_used": min(100, round(used / quota * 100)) if quota else 0,
|
||||||
|
"artifact_count": Artifact.objects.filter(owner=request.user).count(),
|
||||||
|
"kept_count": Artifact.objects.filter(owner=request.user, is_kept=True).count(),
|
||||||
|
}
|
||||||
|
if request.user.is_superuser:
|
||||||
|
context["is_superuser"] = True
|
||||||
|
context["total_allocated"] = total_allocated_bytes()
|
||||||
|
context["disk_free"] = disk_free_bytes()
|
||||||
|
return render(request, "artifacts/settings_storage.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def raw_unavailable_html(artifact):
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Raw source unavailable</title>
|
||||||
|
<style>
|
||||||
|
body {{ background: #f4f1ea; color: #1d2528; font-family: system-ui, sans-serif; margin: 0; padding: 2rem; }}
|
||||||
|
main {{ background: #fffaf0; border-radius: 16px; max-width: 42rem; padding: 1.4rem; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Raw source unavailable</h1>
|
||||||
|
<p>Daybook does not have preserved raw source for <strong>{html.escape(artifact.title)}</strong>.</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
0
daybook_project/__init__.py
Normal file
0
daybook_project/__init__.py
Normal file
8
daybook_project/asgi.py
Normal file
8
daybook_project/asgi.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "daybook_project.settings")
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
82
daybook_project/settings.py
Normal file
82
daybook_project/settings.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
DATA_DIR = Path(os.environ.get("DAYBOOK_DATA_DIR", BASE_DIR)).resolve()
|
||||||
|
|
||||||
|
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-change-me")
|
||||||
|
DEBUG = os.environ.get("DJANGO_DEBUG", "1").lower() in {"1", "true", "yes", "on"}
|
||||||
|
ALLOWED_HOSTS = [host.strip() for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",") if host.strip()]
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"django.contrib.admin",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"django.contrib.staticfiles",
|
||||||
|
"rest_framework",
|
||||||
|
"rest_framework.authtoken",
|
||||||
|
"artifacts",
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = "daybook_project.urls"
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [BASE_DIR / "templates"],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = "daybook_project.wsgi.application"
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
|
"NAME": os.environ.get("DATABASE_PATH", DATA_DIR / "db.sqlite3"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = []
|
||||||
|
|
||||||
|
LOGIN_URL = "login"
|
||||||
|
LOGIN_REDIRECT_URL = "feed"
|
||||||
|
|
||||||
|
LANGUAGE_CODE = "en-us"
|
||||||
|
TIME_ZONE = "UTC"
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
STATIC_URL = "static/"
|
||||||
|
STATICFILES_DIRS = [BASE_DIR / "static"]
|
||||||
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||||
|
STORAGES = {
|
||||||
|
"staticfiles": {
|
||||||
|
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
|
ARTIFACTS_ROOT = Path(os.environ.get("ARTIFACTS_ROOT", DATA_DIR / "artifacts_store"))
|
||||||
13
daybook_project/urls.py
Normal file
13
daybook_project/urls.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth import views as auth_views
|
||||||
|
from django.urls import include, path
|
||||||
|
|
||||||
|
from artifacts.views import DaybookLoginView
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("admin/", admin.site.urls),
|
||||||
|
path("accounts/login/", DaybookLoginView.as_view(), name="login"),
|
||||||
|
path("accounts/logout/", auth_views.LogoutView.as_view(next_page="login"), name="logout"),
|
||||||
|
path("", include("artifacts.urls")),
|
||||||
|
]
|
||||||
8
daybook_project/wsgi.py
Normal file
8
daybook_project/wsgi.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "daybook_project.settings")
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
24
deploy/Dockerfile
Normal file
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`.
|
||||||
14
manage.py
Normal file
14
manage.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "daybook_project.settings")
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
Django>=5.0,<6.0
|
||||||
|
djangorestframework>=3.15,<4.0
|
||||||
|
Markdown>=3.6,<4.0
|
||||||
|
gunicorn>=23.0,<24.0
|
||||||
|
whitenoise>=6.7,<7.0
|
||||||
28
scripts/README.md
Normal file
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())
|
||||||
965
static/daybook.css
Normal file
965
static/daybook.css
Normal file
|
|
@ -0,0 +1,965 @@
|
||||||
|
:root {
|
||||||
|
--pane-bar-height: 2.75rem;
|
||||||
|
--app-bg: #f6f7f9;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-alt: #f8f9fb;
|
||||||
|
--pane-bar-bg: #ffffff;
|
||||||
|
--grid-line: #dde1e7;
|
||||||
|
--text: #2f343d;
|
||||||
|
--muted: #7b8491;
|
||||||
|
--selected-day-bg: #3d4552;
|
||||||
|
--day-tile-bg: #eef0f4;
|
||||||
|
--selected-artifact-bg: #eaf3ff;
|
||||||
|
--selected-artifact-rail: #dcecff;
|
||||||
|
--control-border: #d9dee6;
|
||||||
|
color-scheme: light;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: var(--app-bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
border-bottom: 1px solid var(--grid-line);
|
||||||
|
color: var(--text);
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 0;
|
||||||
|
grid-template-columns: max-content 1fr max-content;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.32rem 0.8rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
border-color: var(--control-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-dropdown {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--control-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 10rem;
|
||||||
|
position: absolute;
|
||||||
|
right: 0.8rem;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-dropdown[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-dropdown a,
|
||||||
|
.user-menu-dropdown button {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-dropdown a:hover,
|
||||||
|
.user-menu-dropdown button:hover {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-dropdown form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brandline {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.38rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-link {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-link:hover {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
margin: 0 0 0.18rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(1.2rem, 2vw, 1.75rem);
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
align-items: center;
|
||||||
|
align-self: stretch;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.32rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter {
|
||||||
|
align-items: end;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter label {
|
||||||
|
color: var(--muted);
|
||||||
|
display: grid;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
gap: 0.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter select,
|
||||||
|
.filter input {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--control-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.32rem 0.62rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter input {
|
||||||
|
width: 9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter button {
|
||||||
|
background: #3d4552;
|
||||||
|
border: 1px solid #343b46;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.32rem 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle {
|
||||||
|
background: #f0f2f5;
|
||||||
|
border: 1px solid var(--control-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
padding: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle a {
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
padding: 0.3rem 0.58rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle a.selected {
|
||||||
|
background: #3d4552;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed {
|
||||||
|
--artifacts-width: 280px;
|
||||||
|
--days-width: 144px;
|
||||||
|
display: grid;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
grid-template-columns: var(--days-width) 1px var(--artifacts-width) 1px minmax(0, 1fr);
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-sidebar,
|
||||||
|
.artifact-sidebar,
|
||||||
|
.preview-pane {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-sidebar,
|
||||||
|
.artifact-sidebar {
|
||||||
|
min-height: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-color: transparent transparent;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-list,
|
||||||
|
.artifact-list {
|
||||||
|
min-height: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Source sidebar: scrollbar hidden until hover (short list, rarely scrolls). */
|
||||||
|
.source-list {
|
||||||
|
scrollbar-color: transparent transparent;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-sidebar:hover .source-list {
|
||||||
|
scrollbar-color: rgba(61, 69, 82, 0.35) transparent;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-list::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-sidebar:hover .source-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Artifact list: scrollbar always visible so it doesn't jump on hover. */
|
||||||
|
.artifact-list {
|
||||||
|
scrollbar-color: rgba(61, 69, 82, 0.35) transparent;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-list::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-list::-webkit-scrollbar-thumb,
|
||||||
|
.artifact-list::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(61, 69, 82, 0.35);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-list::-webkit-scrollbar-track,
|
||||||
|
.artifact-list::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-sidebar {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-sidebar {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane {
|
||||||
|
align-items: stretch;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitter {
|
||||||
|
background: var(--grid-line);
|
||||||
|
cursor: col-resize;
|
||||||
|
position: relative;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitter::after {
|
||||||
|
background: transparent;
|
||||||
|
bottom: 0;
|
||||||
|
content: "";
|
||||||
|
left: -4px;
|
||||||
|
position: absolute;
|
||||||
|
right: -4px;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitter:hover,
|
||||||
|
.is-resizing .splitter {
|
||||||
|
background: #b8c0cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-resizing {
|
||||||
|
cursor: col-resize;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-sidebar h2,
|
||||||
|
.pane-heading h2 {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
margin: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-list {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-list {
|
||||||
|
align-content: start;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-link,
|
||||||
|
.artifact-row {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-link {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--day-tile-bg);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-link:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-link:focus-visible {
|
||||||
|
outline: 2px solid #8ab4f8;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-link.selected {
|
||||||
|
background: var(--selected-day-bg);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-link.selected .source-name,
|
||||||
|
.source-link.selected .source-count {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-name {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-count {
|
||||||
|
color: var(--muted);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-heading span,
|
||||||
|
.artifact-time {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-separator {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
border-bottom: 1px solid var(--grid-line);
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
justify-content: space-between;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 0.32rem 0.55rem;
|
||||||
|
position: sticky;
|
||||||
|
text-transform: uppercase;
|
||||||
|
top: 0;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-separator-count {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-more {
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-badge {
|
||||||
|
background: #d8f1df;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #245238;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
padding: 0.08rem 0.38rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.pane-bar {
|
||||||
|
align-items: baseline;
|
||||||
|
background: var(--pane-bar-bg);
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 var(--pane-bar-height);
|
||||||
|
gap: 0.55rem;
|
||||||
|
height: var(--pane-bar-height);
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 0;
|
||||||
|
min-height: var(--pane-bar-height);
|
||||||
|
padding: 0 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-heading,
|
||||||
|
.preview-actions {
|
||||||
|
border-bottom: 1px solid var(--grid-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-sidebar .pane-bar,
|
||||||
|
.artifact-sidebar .pane-bar {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-sidebar .pane-heading {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.04rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-list {
|
||||||
|
align-content: start;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-row {
|
||||||
|
align-items: stretch;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--grid-line);
|
||||||
|
border-radius: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
grid-template-columns: 3.15rem minmax(0, 1fr) 7rem;
|
||||||
|
min-height: 2.7rem;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-row:hover {
|
||||||
|
background: #f6f9fd;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-row:focus-visible {
|
||||||
|
outline: 0;
|
||||||
|
box-shadow: inset 0 0 0 1px #8ab4f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-row.selected {
|
||||||
|
background: var(--selected-artifact-bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-row.selected .artifact-time {
|
||||||
|
background: var(--selected-artifact-rail);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-row.selected .artifact-title,
|
||||||
|
.artifact-row.selected .artifact-time {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-row-main {
|
||||||
|
align-items: center;
|
||||||
|
border-right: 1px solid var(--grid-line);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.32rem;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.42rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-row.unread .artifact-title {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-row.unread .artifact-source {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-row-main > .artifact-title {
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
color: var(--text);
|
||||||
|
display: -webkit-box;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-kind-text {
|
||||||
|
background: var(--day-tile-bg);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
padding: 0.1rem 0.34rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kept-badge {
|
||||||
|
align-items: center;
|
||||||
|
background: #eaf7ed;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #28583a;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 700;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.14rem 0.3rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kept-badge[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-source {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0.42rem 0.55rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.24rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.18rem 0.44rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-time {
|
||||||
|
align-items: center;
|
||||||
|
background: #f3f5f7;
|
||||||
|
border-right: 1px solid var(--grid-line);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
justify-self: stretch;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 0.48rem;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty.compact {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty.compact h3,
|
||||||
|
.preview-empty h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty {
|
||||||
|
max-width: 34rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
background: #f0f2f5;
|
||||||
|
border: 1px solid var(--control-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text);
|
||||||
|
font: 0.85em ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
padding: 0.08rem 0.28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-body {
|
||||||
|
align-items: stretch;
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-body > .preview-empty {
|
||||||
|
align-self: center;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-frame-wrap {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-actions {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--pane-bar-bg);
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title {
|
||||||
|
color: var(--text);
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-actions .view-toggle,
|
||||||
|
.preview-actions .artifact-action-links {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-action-links {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-action-links a,
|
||||||
|
.artifact-action-links button {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--control-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.42rem 0.65rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle a.disabled,
|
||||||
|
.artifact-action-links a.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.48;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-action-links button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-frame {
|
||||||
|
background: #fff;
|
||||||
|
border: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
background: #fff;
|
||||||
|
max-height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: auto;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
align-items: stretch;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar,
|
||||||
|
.filter,
|
||||||
|
.preview-actions {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitter {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-sidebar,
|
||||||
|
.artifact-sidebar {
|
||||||
|
border-bottom: 1px solid #d9c9ad;
|
||||||
|
border-right: 0;
|
||||||
|
max-height: 32vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--control-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.8rem;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 20rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card label span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card input[type="text"],
|
||||||
|
.login-card input[type="password"] {
|
||||||
|
border: 1px solid var(--control-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-remember {
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row !important;
|
||||||
|
gap: 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
color: #c14f4f;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-layout {
|
||||||
|
display: grid;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
grid-template-columns: 14rem 1fr;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
border-right: 1px solid var(--grid-line);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav a {
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav a:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav a.selected {
|
||||||
|
background: var(--day-tile-bg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-back {
|
||||||
|
border-bottom: 1px solid var(--grid-line);
|
||||||
|
color: var(--muted) !important;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding-bottom: 0.7rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel {
|
||||||
|
max-width: 42rem;
|
||||||
|
padding: 1.6rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-messages {
|
||||||
|
background: #eaf7ed;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #28583a;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 1.2rem;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-value {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keep-button.kept {
|
||||||
|
background: #eaf7ed;
|
||||||
|
border-color: #9bd4ab;
|
||||||
|
color: #28583a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-panel {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
border-bottom: 1px solid var(--grid-line);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-height: 40%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem 1rem;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-grid dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-grid dd {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-bar {
|
||||||
|
background: var(--day-tile-bg);
|
||||||
|
border-radius: 999px;
|
||||||
|
height: 0.75rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-bar-fill {
|
||||||
|
background: #5aa552;
|
||||||
|
border-radius: 999px;
|
||||||
|
height: 100%;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-bar-fill--warn {
|
||||||
|
background: #d98b2b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-bar-fill--full {
|
||||||
|
background: #c14f4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-summary {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-facts {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-note {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-warning {
|
||||||
|
background: #fdecea;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #a3312a;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
}
|
||||||
18
static/themes/base.css
Normal file
18
static/themes/base.css
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
body.artifact {
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: #1d2528;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
line-height: 1.65;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 900px;
|
||||||
|
padding: 3rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
table {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
30
static/themes/deck.css
Normal file
30
static/themes/deck.css
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
body.deck {
|
||||||
|
background: #111827;
|
||||||
|
color: #f9fafb;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slides {
|
||||||
|
display: grid;
|
||||||
|
gap: 2rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 1100px;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slides section {
|
||||||
|
background: linear-gradient(135deg, #1f2937, #111827);
|
||||||
|
border: 1px solid #374151;
|
||||||
|
border-radius: 28px;
|
||||||
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
|
||||||
|
min-height: 62vh;
|
||||||
|
padding: clamp(2rem, 6vw, 5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slides h1,
|
||||||
|
.slides h2 {
|
||||||
|
color: #fbbf24;
|
||||||
|
font-size: clamp(2rem, 7vw, 5rem);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
41
static/themes/report.css
Normal file
41
static/themes/report.css
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
@import url("/static/themes/base.css");
|
||||||
|
|
||||||
|
body.artifact {
|
||||||
|
background: #fffaf0;
|
||||||
|
color: #1e2b2f;
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
font-size: 1.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
color: #17313b;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #8a4f16;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 4px solid #d5a24c;
|
||||||
|
color: #5b5449;
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
pre {
|
||||||
|
background: #efe5d2;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
20
static/themes/tasks.css
Normal file
20
static/themes/tasks.css
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
@import url("/static/themes/base.css");
|
||||||
|
|
||||||
|
body.artifact {
|
||||||
|
background: #f7fbff;
|
||||||
|
color: #17202a;
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
border-bottom: 3px solid #66a7d8;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin: 0.45rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
transform: translateY(1px) scale(1.1);
|
||||||
|
}
|
||||||
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>
|
||||||
567
templates/artifacts/feed.html
Normal file
567
templates/artifacts/feed.html
Normal file
|
|
@ -0,0 +1,567 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>AgentFeed</title>
|
||||||
|
<link rel="stylesheet" href="/static/daybook.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include "artifacts/_topbar.html" with topbar_toolbar="artifacts/_feed_toolbar.html" %}
|
||||||
|
|
||||||
|
<main class="feed"
|
||||||
|
data-selected-source="{{ selected_source }}"
|
||||||
|
data-selected-label="{{ selected_label }}"
|
||||||
|
data-selected-kind="{{ selected_kind }}"
|
||||||
|
data-title-query="{{ title_query }}"
|
||||||
|
data-selected-view="{{ selected_view }}"
|
||||||
|
data-window-start="{{ window_start|date:'Y-m-d' }}"
|
||||||
|
data-older-count="{{ older_count }}">
|
||||||
|
<aside class="source-sidebar" aria-label="Sources">
|
||||||
|
<div class="pane-bar">
|
||||||
|
<h2>Sources</h2>
|
||||||
|
</div>
|
||||||
|
<nav class="source-list">
|
||||||
|
<a class="source-link {% if not selected_source %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&{% 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 sources sidebar" aria-orientation="vertical"></div>
|
||||||
|
|
||||||
|
<aside class="artifact-sidebar" aria-label="Artifact feed">
|
||||||
|
<div class="pane-bar pane-heading">
|
||||||
|
<h2>{% if selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
|
||||||
|
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="artifact-list" data-artifact-list>
|
||||||
|
{% if day_groups %}
|
||||||
|
{% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %}
|
||||||
|
{% else %}
|
||||||
|
<div class="empty compact">
|
||||||
|
<h3>No artifacts</h3>
|
||||||
|
<p>Nothing in the last {{ 7 }} days for these filters.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="splitter" data-splitter="artifacts" role="separator" aria-label="Resize artifacts sidebar" aria-orientation="vertical"></div>
|
||||||
|
|
||||||
|
<section class="preview-pane">
|
||||||
|
<header class="pane-bar preview-actions" data-actions>
|
||||||
|
<div class="preview-title" data-preview-title {% if selected_artifact %}title="{{ selected_artifact.title }}"{% endif %}>
|
||||||
|
{% if selected_artifact %}{{ selected_artifact.title }}{% else %}No artifact loaded{% endif %}
|
||||||
|
</div>
|
||||||
|
<nav class="view-toggle" aria-label="Preview mode">
|
||||||
|
<a class="{% if selected_view == 'rendered' %}selected{% endif %}{% if not selected_artifact %} disabled{% endif %}" href="{% if selected_artifact %}{{ rendered_url }}{% else %}#{% endif %}" data-view="rendered" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Rendered</a>
|
||||||
|
<a class="{% if selected_view == 'raw' %}selected{% endif %}{% if not selected_artifact %} disabled{% endif %}" href="{% if selected_artifact %}{{ raw_url }}{% else %}#{% endif %}" data-view="raw" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Raw</a>
|
||||||
|
</nav>
|
||||||
|
<div class="artifact-action-links">
|
||||||
|
<button type="button" class="keep-button {% if selected_artifact.is_kept %}kept{% endif %}" data-keep-button data-artifact-id="{% if selected_artifact %}{{ selected_artifact.id }}{% endif %}" {% if not selected_artifact %}disabled{% endif %}>{% if selected_artifact.is_kept %}✓ Kept{% else %}Keep{% endif %}</button>
|
||||||
|
<button type="button" data-details-toggle {% if not selected_artifact %}disabled{% endif %}>Details</button>
|
||||||
|
<a class="{% if not selected_artifact %}disabled{% endif %}" href="{% if selected_artifact %}{% url 'artifact' selected_artifact.id %}{% else %}#{% endif %}" target="_blank" rel="noopener" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Open</a>
|
||||||
|
<a class="{% if not selected_artifact %}disabled{% endif %}" href="{% if selected_artifact %}{% url 'artifact' selected_artifact.id %}{% else %}#{% endif %}" download {% if not selected_artifact %}aria-disabled="true"{% endif %}>Download</a>
|
||||||
|
<button type="button" data-copy-url="{% if selected_artifact %}{% url 'artifact' selected_artifact.id %}{% endif %}" {% if not selected_artifact %}disabled{% endif %}>Copy Link</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="details-panel" hidden data-details-panel></div>
|
||||||
|
<div class="preview-body">
|
||||||
|
{% if selected_artifact %}
|
||||||
|
<div class="preview-frame-wrap">
|
||||||
|
{% if selected_view == "raw" %}
|
||||||
|
<iframe class="preview-frame" src="{% url 'artifact' selected_artifact.id %}?raw=1" title="{{ selected_artifact.title }} raw source"></iframe>
|
||||||
|
{% elif selected_artifact.kind == "image" %}
|
||||||
|
<img class="preview-image" src="{% url 'artifact' selected_artifact.id %}" alt="{{ selected_artifact.title }}">
|
||||||
|
{% elif selected_artifact.kind == "binary" %}
|
||||||
|
<div class="empty preview-empty">
|
||||||
|
<h2>Binary Artifact</h2>
|
||||||
|
<p>This artifact type cannot be previewed inline yet.</p>
|
||||||
|
<p><a href="{% url 'artifact' selected_artifact.id %}">Open or download it</a>.</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<iframe class="preview-frame" src="{% url 'artifact' selected_artifact.id %}{% if selected_view == 'raw' %}?raw=1{% endif %}" title="{{ selected_artifact.title }}"></iframe>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty preview-empty">
|
||||||
|
{% if artifacts %}
|
||||||
|
<h2>No artifact loaded</h2>
|
||||||
|
<p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p>
|
||||||
|
{% else %}
|
||||||
|
<h2>No artifacts for this day</h2>
|
||||||
|
<p>This day is ready for new agent output.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const feed = document.querySelector(".feed");
|
||||||
|
if (!feed) return;
|
||||||
|
let selectedView = "{{ selected_view }}";
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
days: Number(localStorage.getItem("daybook.daysWidth")) || 144,
|
||||||
|
artifacts: Number(localStorage.getItem("daybook.artifactsWidth")) || 280,
|
||||||
|
};
|
||||||
|
|
||||||
|
const applySizes = () => {
|
||||||
|
feed.style.setProperty("--days-width", `${sizes.days}px`);
|
||||||
|
feed.style.setProperty("--artifacts-width", `${sizes.artifacts}px`);
|
||||||
|
};
|
||||||
|
|
||||||
|
applySizes();
|
||||||
|
|
||||||
|
// Per-pane resize bounds. The artifact list may grow much wider than the
|
||||||
|
// sources sidebar, but is still capped so it can't swallow the preview.
|
||||||
|
const sizeBounds = {
|
||||||
|
days: {min: 96, max: 320},
|
||||||
|
artifacts: {min: 200, max: 720},
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelectorAll(".splitter").forEach((splitter) => {
|
||||||
|
splitter.addEventListener("pointerdown", (event) => {
|
||||||
|
const target = splitter.dataset.splitter;
|
||||||
|
const startX = event.clientX;
|
||||||
|
const startWidth = sizes[target];
|
||||||
|
const bounds = sizeBounds[target];
|
||||||
|
splitter.setPointerCapture(event.pointerId);
|
||||||
|
document.body.classList.add("is-resizing");
|
||||||
|
|
||||||
|
const move = (moveEvent) => {
|
||||||
|
sizes[target] = Math.max(bounds.min, Math.min(bounds.max, startWidth + moveEvent.clientX - startX));
|
||||||
|
applySizes();
|
||||||
|
};
|
||||||
|
|
||||||
|
const up = () => {
|
||||||
|
localStorage.setItem(`daybook.${target}Width`, String(sizes[target]));
|
||||||
|
document.body.classList.remove("is-resizing");
|
||||||
|
splitter.removeEventListener("pointermove", move);
|
||||||
|
splitter.removeEventListener("pointerup", up);
|
||||||
|
splitter.removeEventListener("pointercancel", up);
|
||||||
|
};
|
||||||
|
|
||||||
|
splitter.addEventListener("pointermove", move);
|
||||||
|
splitter.addEventListener("pointerup", up);
|
||||||
|
splitter.addEventListener("pointercancel", up);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewPane = document.querySelector(".preview-pane");
|
||||||
|
const previewBody = document.querySelector(".preview-body");
|
||||||
|
const artifactList = document.querySelector("[data-artifact-list]");
|
||||||
|
// Rows grow as older pages load, so always read them live rather than caching an array.
|
||||||
|
const allRows = () => Array.from(document.querySelectorAll(".artifact-row"));
|
||||||
|
const sourceLinks = Array.from(document.querySelectorAll(".source-link"));
|
||||||
|
const csrfToken = document.querySelector("[name=csrfmiddlewaretoken]")?.value || "";
|
||||||
|
|
||||||
|
const escapeHtml = (value) => value.replace(/[&<>"]/g, (character) => ({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
"\"": """,
|
||||||
|
}[character]));
|
||||||
|
const copyLink = async (button) => {
|
||||||
|
const absoluteUrl = new URL(button.dataset.copyUrl, window.location.origin).toString();
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(absoluteUrl);
|
||||||
|
} catch {
|
||||||
|
window.prompt("Copy artifact link", absoluteUrl);
|
||||||
|
}
|
||||||
|
const original = button.textContent;
|
||||||
|
button.textContent = "Copied";
|
||||||
|
setTimeout(() => {
|
||||||
|
button.textContent = original;
|
||||||
|
}, 1200);
|
||||||
|
};
|
||||||
|
const selectedRow = () => document.querySelector(".artifact-row.selected");
|
||||||
|
let navigationMode = selectedRow() ? "artifacts" : "sources";
|
||||||
|
const urlFor = (row, view) => view === "raw" ? row.dataset.rawUrl : row.dataset.renderedUrl;
|
||||||
|
const pageUrlFor = (row, view) => {
|
||||||
|
const url = new URL(row.href, window.location.origin);
|
||||||
|
url.searchParams.set("artifact", row.dataset.artifactId);
|
||||||
|
url.searchParams.set("view", view);
|
||||||
|
url.searchParams.delete("preview");
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
const noPreviewUrl = () => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete("artifact");
|
||||||
|
url.searchParams.set("preview", "none");
|
||||||
|
url.searchParams.set("view", selectedView);
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateActions = (row) => {
|
||||||
|
const actions = document.querySelector("[data-actions]");
|
||||||
|
if (!actions) return;
|
||||||
|
const previewTitle = actions.querySelector("[data-preview-title]");
|
||||||
|
if (previewTitle) {
|
||||||
|
previewTitle.textContent = row.dataset.title;
|
||||||
|
previewTitle.title = row.dataset.title;
|
||||||
|
}
|
||||||
|
actions.querySelectorAll("[data-view]").forEach((link) => {
|
||||||
|
const view = link.dataset.view;
|
||||||
|
link.classList.toggle("selected", view === selectedView);
|
||||||
|
link.classList.remove("disabled");
|
||||||
|
link.removeAttribute("aria-disabled");
|
||||||
|
link.href = pageUrlFor(row, view).toString();
|
||||||
|
});
|
||||||
|
actions.querySelectorAll(".artifact-action-links a").forEach((link) => {
|
||||||
|
link.classList.remove("disabled");
|
||||||
|
link.removeAttribute("aria-disabled");
|
||||||
|
link.href = row.dataset.renderedUrl;
|
||||||
|
});
|
||||||
|
const copyButton = actions.querySelector("[data-copy-url]");
|
||||||
|
if (copyButton) {
|
||||||
|
copyButton.disabled = false;
|
||||||
|
copyButton.dataset.copyUrl = row.dataset.renderedUrl;
|
||||||
|
}
|
||||||
|
syncKeepButton(row.dataset.kept === "1");
|
||||||
|
const keepButton = actions.querySelector("[data-keep-button]");
|
||||||
|
if (keepButton) keepButton.dataset.artifactId = row.dataset.artifactId;
|
||||||
|
actions.querySelector("[data-details-toggle]").disabled = false;
|
||||||
|
renderDetails(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncKeepButton = (isKept) => {
|
||||||
|
const keepButton = document.querySelector("[data-keep-button]");
|
||||||
|
if (!keepButton) return;
|
||||||
|
keepButton.disabled = false;
|
||||||
|
keepButton.classList.toggle("kept", isKept);
|
||||||
|
keepButton.textContent = isKept ? "✓ Kept" : "Keep";
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDetails = (row) => {
|
||||||
|
const panel = document.querySelector("[data-details-panel]");
|
||||||
|
if (!panel) return;
|
||||||
|
const d = row.dataset;
|
||||||
|
const tags = d.tags ? d.tags.split(",").map((t) => t.trim()).filter(Boolean) : [];
|
||||||
|
const rows = [
|
||||||
|
["Source", `${escapeHtml(d.source)} (${escapeHtml(d.sourceType)})`],
|
||||||
|
["Source name", escapeHtml(d.sourceName || "—")],
|
||||||
|
["Type", escapeHtml(d.kind)],
|
||||||
|
["Created", escapeHtml(d.created)],
|
||||||
|
["Size", escapeHtml(d.size)],
|
||||||
|
["Tags", tags.length ? tags.map(escapeHtml).join(", ") : "—"],
|
||||||
|
];
|
||||||
|
panel.innerHTML = `<dl class="details-grid">${rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join("")}</dl>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const disableActions = () => {
|
||||||
|
const actions = document.querySelector("[data-actions]");
|
||||||
|
if (!actions) return;
|
||||||
|
const previewTitle = actions.querySelector("[data-preview-title]");
|
||||||
|
if (previewTitle) {
|
||||||
|
previewTitle.textContent = "No artifact loaded";
|
||||||
|
previewTitle.removeAttribute("title");
|
||||||
|
}
|
||||||
|
actions.querySelectorAll("[data-view]").forEach((link) => {
|
||||||
|
link.classList.add("disabled");
|
||||||
|
link.setAttribute("aria-disabled", "true");
|
||||||
|
link.href = "#";
|
||||||
|
});
|
||||||
|
actions.querySelectorAll(".artifact-action-links a").forEach((link) => {
|
||||||
|
link.classList.add("disabled");
|
||||||
|
link.setAttribute("aria-disabled", "true");
|
||||||
|
link.href = "#";
|
||||||
|
});
|
||||||
|
const copyButton = actions.querySelector("[data-copy-url]");
|
||||||
|
if (copyButton) {
|
||||||
|
copyButton.disabled = true;
|
||||||
|
copyButton.dataset.copyUrl = "";
|
||||||
|
}
|
||||||
|
const keepButton = actions.querySelector("[data-keep-button]");
|
||||||
|
if (keepButton) {
|
||||||
|
keepButton.disabled = true;
|
||||||
|
keepButton.classList.remove("kept");
|
||||||
|
keepButton.textContent = "Keep";
|
||||||
|
}
|
||||||
|
actions.querySelector("[data-details-toggle]").disabled = true;
|
||||||
|
const panel = document.querySelector("[data-details-panel]");
|
||||||
|
if (panel) panel.hidden = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const markRead = async (row) => {
|
||||||
|
if (!row.classList.contains("unread")) return;
|
||||||
|
row.classList.remove("unread");
|
||||||
|
decrementSourceCount(row.querySelector(".artifact-source")?.textContent.trim());
|
||||||
|
try {
|
||||||
|
await fetch(`/a/${row.dataset.artifactId}/read`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"X-CSRFToken": csrfToken},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Non-critical: the row stays visually read until the next page load.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const decrementSourceCount = (sourceName) => {
|
||||||
|
document.querySelectorAll(".source-link").forEach((link) => {
|
||||||
|
const name = link.querySelector(".source-name")?.textContent.trim();
|
||||||
|
if (name !== sourceName && name !== "All sources") return;
|
||||||
|
const badge = link.querySelector(".source-count");
|
||||||
|
if (!badge) return;
|
||||||
|
const next = Number(badge.textContent) - 1;
|
||||||
|
if (next > 0) {
|
||||||
|
badge.textContent = String(next);
|
||||||
|
} else {
|
||||||
|
badge.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPreview = (row, pushState = true) => {
|
||||||
|
navigationMode = "artifacts";
|
||||||
|
markRead(row);
|
||||||
|
allRows().forEach((candidate) => candidate.classList.toggle("selected", candidate === row));
|
||||||
|
const source = urlFor(row, selectedView);
|
||||||
|
const title = escapeHtml(row.dataset.title);
|
||||||
|
const kind = row.dataset.kind;
|
||||||
|
const content = selectedView === "raw"
|
||||||
|
? `<iframe class="preview-frame" src="${source}" title="${title} raw source"></iframe>`
|
||||||
|
: kind === "image"
|
||||||
|
? `<img class="preview-image" src="${source}" alt="${title}">`
|
||||||
|
: kind === "binary"
|
||||||
|
? `<div class="empty preview-empty"><h2>Binary Artifact</h2><p>This artifact type cannot be previewed inline yet.</p><p><a href="${source}">Open or download it</a>.</p></div>`
|
||||||
|
: `<iframe class="preview-frame" src="${source}" title="${title}"></iframe>`;
|
||||||
|
|
||||||
|
previewBody.innerHTML = `<div class="preview-frame-wrap">${content}</div>`;
|
||||||
|
updateActions(row);
|
||||||
|
if (pushState) window.history.pushState({}, "", pageUrlFor(row, selectedView));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearPreview = () => {
|
||||||
|
navigationMode = "sources";
|
||||||
|
if (document.activeElement && document.activeElement.classList.contains("artifact-row")) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
allRows().forEach((candidate) => candidate.classList.remove("selected"));
|
||||||
|
disableActions();
|
||||||
|
previewBody.innerHTML = `<div class="empty preview-empty"><h2>No artifact loaded</h2><p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p></div>`;
|
||||||
|
window.history.pushState({}, "", noPreviewUrl());
|
||||||
|
const currentSource = document.querySelector(".source-link.selected");
|
||||||
|
if (currentSource) currentSource.focus({preventScroll: true});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delegated so rows appended by infinite scroll work without rebinding.
|
||||||
|
artifactList.addEventListener("click", (event) => {
|
||||||
|
const row = event.target.closest(".artifact-row");
|
||||||
|
if (!row) return;
|
||||||
|
event.preventDefault();
|
||||||
|
renderPreview(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveSelection = (direction) => {
|
||||||
|
const rows = allRows();
|
||||||
|
if (!rows.length) return;
|
||||||
|
navigationMode = "artifacts";
|
||||||
|
const current = selectedRow();
|
||||||
|
const currentIndex = current ? rows.indexOf(current) : -1;
|
||||||
|
const nextIndex = Math.max(0, Math.min(rows.length - 1, currentIndex + direction));
|
||||||
|
const nextRow = rows[nextIndex === -1 ? 0 : nextIndex];
|
||||||
|
renderPreview(nextRow);
|
||||||
|
nextRow.focus({preventScroll: true});
|
||||||
|
nextRow.scrollIntoView({block: "nearest"});
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveSource = (direction) => {
|
||||||
|
if (!sourceLinks.length) return;
|
||||||
|
navigationMode = "sources";
|
||||||
|
const current = document.querySelector(".source-link.selected");
|
||||||
|
const currentIndex = current ? sourceLinks.indexOf(current) : 0;
|
||||||
|
const nextIndex = Math.max(0, Math.min(sourceLinks.length - 1, currentIndex + direction));
|
||||||
|
const nextSource = sourceLinks[nextIndex];
|
||||||
|
if (nextSource && nextSource !== current) window.location.href = nextSource.href;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.ctrlKey || event.metaKey || event.shiftKey) return;
|
||||||
|
if (["INPUT", "SELECT", "TEXTAREA", "BUTTON"].includes(event.target.tagName)) return;
|
||||||
|
|
||||||
|
if (event.altKey && event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
moveSource(1);
|
||||||
|
} else if (event.altKey && event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
moveSource(-1);
|
||||||
|
} else if (event.altKey) {
|
||||||
|
return;
|
||||||
|
} else if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (navigationMode === "sources") {
|
||||||
|
moveSource(1);
|
||||||
|
} else {
|
||||||
|
moveSelection(1);
|
||||||
|
}
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (navigationMode === "sources") {
|
||||||
|
moveSource(-1);
|
||||||
|
} else {
|
||||||
|
moveSelection(-1);
|
||||||
|
}
|
||||||
|
} else if (event.key === "PageDown") {
|
||||||
|
if (navigationMode === "artifacts") {
|
||||||
|
event.preventDefault();
|
||||||
|
moveSelection(5);
|
||||||
|
}
|
||||||
|
} else if (event.key === "PageUp") {
|
||||||
|
if (navigationMode === "artifacts") {
|
||||||
|
event.preventDefault();
|
||||||
|
moveSelection(-5);
|
||||||
|
}
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
const rows = allRows();
|
||||||
|
if (!selectedRow() && rows.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
renderPreview(rows[0]);
|
||||||
|
rows[0].focus({preventScroll: true});
|
||||||
|
rows[0].scrollIntoView({block: "nearest"});
|
||||||
|
} else if (selectedRow()) {
|
||||||
|
navigationMode = "artifacts";
|
||||||
|
}
|
||||||
|
} else if (event.key === "ArrowLeft") {
|
||||||
|
if (selectedRow()) {
|
||||||
|
event.preventDefault();
|
||||||
|
clearPreview();
|
||||||
|
} else {
|
||||||
|
navigationMode = "sources";
|
||||||
|
const currentSource = document.querySelector(".source-link.selected");
|
||||||
|
if (currentSource) currentSource.focus({preventScroll: true});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Infinite scroll: when the sentinel at the end of the list scrolls into
|
||||||
|
// view, fetch the next page of older day-groups and append them.
|
||||||
|
let loadingOlder = false;
|
||||||
|
const loadOlder = async (sentinel) => {
|
||||||
|
if (loadingOlder) return;
|
||||||
|
loadingOlder = true;
|
||||||
|
sentinel.removeAttribute("data-feed-more");
|
||||||
|
try {
|
||||||
|
const url = new URL("{% url 'feed-older' %}", window.location.origin);
|
||||||
|
const params = url.searchParams;
|
||||||
|
params.set("before", sentinel.dataset.nextBefore);
|
||||||
|
params.set("view", feed.dataset.selectedView || "rendered");
|
||||||
|
if (feed.dataset.selectedSource) params.set("source", feed.dataset.selectedSource);
|
||||||
|
if (feed.dataset.selectedLabel) params.set("label", feed.dataset.selectedLabel);
|
||||||
|
if (feed.dataset.selectedKind) params.set("kind", feed.dataset.selectedKind);
|
||||||
|
if (feed.dataset.titleQuery) params.set("q", feed.dataset.titleQuery);
|
||||||
|
const response = await fetch(url, {headers: {"X-Requested-With": "fetch"}});
|
||||||
|
if (!response.ok) return;
|
||||||
|
const markup = (await response.text()).trim();
|
||||||
|
sentinel.remove();
|
||||||
|
if (markup) {
|
||||||
|
artifactList.insertAdjacentHTML("beforeend", markup);
|
||||||
|
observeSentinel();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Leave the list as-is; the user can scroll again to retry.
|
||||||
|
} finally {
|
||||||
|
loadingOlder = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const olderObserver = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
olderObserver.unobserve(entry.target);
|
||||||
|
loadOlder(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, {root: artifactList, rootMargin: "200px"});
|
||||||
|
|
||||||
|
const observeSentinel = () => {
|
||||||
|
const sentinel = artifactList.querySelector("[data-feed-more]");
|
||||||
|
if (sentinel) olderObserver.observe(sentinel);
|
||||||
|
};
|
||||||
|
|
||||||
|
observeSentinel();
|
||||||
|
|
||||||
|
// Populate the details panel for a server-preselected artifact so the first
|
||||||
|
// "Details" click shows content immediately.
|
||||||
|
const preselected = selectedRow();
|
||||||
|
if (preselected) renderDetails(preselected);
|
||||||
|
|
||||||
|
const toggleKeep = async (button) => {
|
||||||
|
const artifactId = button.dataset.artifactId;
|
||||||
|
if (!artifactId) return;
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/a/${artifactId}/keep`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"X-CSRFToken": csrfToken},
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
syncKeepButton(data.is_kept);
|
||||||
|
const row = document.querySelector(`.artifact-row[data-artifact-id="${artifactId}"]`);
|
||||||
|
if (row) {
|
||||||
|
row.dataset.kept = data.is_kept ? "1" : "0";
|
||||||
|
row.classList.toggle("kept", data.is_kept);
|
||||||
|
const badge = row.querySelector("[data-kept-badge]");
|
||||||
|
if (badge) badge.hidden = !data.is_kept;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
previewPane.addEventListener("click", (event) => {
|
||||||
|
const copyButton = event.target.closest("[data-copy-url]");
|
||||||
|
if (copyButton) {
|
||||||
|
event.preventDefault();
|
||||||
|
copyLink(copyButton);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepButton = event.target.closest("[data-keep-button]");
|
||||||
|
if (keepButton && !keepButton.disabled) {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleKeep(keepButton);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailsToggle = event.target.closest("[data-details-toggle]");
|
||||||
|
if (detailsToggle && !detailsToggle.disabled) {
|
||||||
|
event.preventDefault();
|
||||||
|
const panel = document.querySelector("[data-details-panel]");
|
||||||
|
if (panel) {
|
||||||
|
panel.hidden = !panel.hidden;
|
||||||
|
detailsToggle.classList.toggle("selected", !panel.hidden);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = event.target.closest("[data-view]");
|
||||||
|
if (!toggle) return;
|
||||||
|
if (toggle.classList.contains("disabled")) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = selectedRow();
|
||||||
|
if (!row) return;
|
||||||
|
event.preventDefault();
|
||||||
|
selectedView = toggle.dataset.view;
|
||||||
|
renderPreview(row);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
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