Compare commits

..

No commits in common. "main" and "v0.6.2" have entirely different histories.
main ... v0.6.2

23 changed files with 77 additions and 754 deletions

View file

@ -9,46 +9,6 @@ former name, "Daybook".)
_Nothing yet._ _Nothing yet._
## [0.7.1] — 2026-09-08
### Added
- **Custom labels.** Curate a personal palette of coloured labels in
**Settings → Labels**, then apply them to any message from its ⋯ menu (or
right-click) — the menu stays open so you can apply several at once, with a
checkmark showing which are applied. Labels are unified with tags: applying
one adds it to the message's tags and renders as a chip. Backed by
`POST /a/{id}/label` (session-auth, owner-scoped, palette-validated); chip
colours sync to the global `TagColor`.
- **Change your password in-app** from **Settings → Account**: enter the current
password, then a new one. Uses Django's `PasswordChangeForm` and keeps the
session logged in after the change.
### Changed
- Sidebar: the **Keep** and **Deleted** folders are now always shown (even when
empty); the count badge only appears when the folder is non-empty.
## [0.7.0] — 2026-09-07
### Added
- **Mark all as read.** Click a source's unread badge in the sidebar to mark
all of that source's messages read (clears the badge); click the **All
sources** total to mark everything read. Owner-scoped, excludes trashed; any
visible feed rows update too. Backed by `POST /feed/mark-read`.
## [0.6.3] — 2026-09-07
### Changed
- **User-facing wording: "artifact(s)" → "messages"** throughout the UI (feed
count, empty states, preview, About, Storage). The model, API
(`/api/artifacts`), and posting skill keep "artifact" as the internal term —
no contract change for agents.
- Sidebar: the Keep/Deleted folders now have spacing between them and share the
"All sources" bold+blue-tint styling (they're special folders).
### Fixed
- About page now points to Settings → API Keys (was the stale "Settings →
Account / personal API token").
## [0.6.2] — 2026-09-07 ## [0.6.2] — 2026-09-07
### Changed ### Changed
@ -261,9 +221,7 @@ First tagged release.
- Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a - Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a
runbook (`deploy/cloud_agent_deploy.md`) and health check. runbook (`deploy/cloud_agent_deploy.md`) and health check.
[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.7.0...HEAD [Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.6.2...HEAD
[0.7.0]: https://git.cygnus.systems/peter/daybook/compare/v0.6.3...v0.7.0
[0.6.3]: https://git.cygnus.systems/peter/daybook/compare/v0.6.2...v0.6.3
[0.6.2]: https://git.cygnus.systems/peter/daybook/compare/v0.6.1...v0.6.2 [0.6.2]: https://git.cygnus.systems/peter/daybook/compare/v0.6.1...v0.6.2
[0.6.1]: https://git.cygnus.systems/peter/daybook/compare/v0.6.0...v0.6.1 [0.6.1]: https://git.cygnus.systems/peter/daybook/compare/v0.6.0...v0.6.1
[0.6.0]: https://git.cygnus.systems/peter/daybook/compare/v0.5.1...v0.6.0 [0.6.0]: https://git.cygnus.systems/peter/daybook/compare/v0.5.1...v0.6.0

View file

@ -15,7 +15,7 @@ The core product principle is: agents publish content, not files. The server own
- Exception to the above: an artifact can be explicitly opted into public sharing (per-artifact, owner-toggled from the feed UI) via a high-entropy `share_token`, served unauthenticated at `GET /s/{share_token}`. Not exposed via the ingest API yet — UI-only. Turning sharing off immediately invalidates the old link. Images embedded in a shared artifact are also served unauthenticated while it's shared, so inline content doesn't break for a link recipient. - Exception to the above: an artifact can be explicitly opted into public sharing (per-artifact, owner-toggled from the feed UI) via a high-entropy `share_token`, served unauthenticated at `GET /s/{share_token}`. Not exposed via the ingest API yet — UI-only. Turning sharing off immediately invalidates the old link. Images embedded in a shared artifact are also served unauthenticated while it's shared, so inline content doesn't break for a link recipient.
- User accounts can be created from Settings → Users (superuser-only in-app page) or via Django admin. Regular users are not staff and cannot access `/admin/`. Creating a user via Settings → Users also issues their API token immediately, shown once. - User accounts can be created from Settings → Users (superuser-only in-app page) or via Django admin. Regular users are not staff and cannot access `/admin/`. Creating a user via Settings → Users also issues their API token immediately, shown once.
- `TagColor` (label color overrides) stays global/shared across all users. - `TagColor` (label color overrides) stays global/shared across all users.
- Deleting an artifact is a **soft-delete to Trash** (`deleted_at`), not a hard removal — recoverable via the "Deleted" sidebar folder (undelete), off-quota and eviction-protected while trashed, and purged permanently via "Empty Trash". `DELETE /api/artifacts/{id}` is soft too (idempotent); trashed artifacts are hidden from all API reads. A **Keep** sidebar folder is a view over `is_kept`. Both the Keep and Deleted folders are always shown in the sidebar (even when empty); the count badge only appears when the folder is non-empty. Trash auto-purges after a ~3-day retention window via a cron-less background thread triggered by deletes (`AGENTFEED_ASYNC_PURGE`, on by default); "Empty Trash" purges immediately. - Deleting an artifact is a **soft-delete to Trash** (`deleted_at`), not a hard removal — recoverable via the "Deleted" sidebar folder (undelete), off-quota and eviction-protected while trashed, and purged permanently via "Empty Trash". `DELETE /api/artifacts/{id}` is soft too (idempotent); trashed artifacts are hidden from all API reads. A **Keep** sidebar folder is a view over `is_kept`. Both folders hide when empty. Trash auto-purges after a ~3-day retention window via a cron-less background thread triggered by deletes (`AGENTFEED_ASYNC_PURGE`, on by default); "Empty Trash" purges immediately.
- Use random 8-character artifact IDs. - Use random 8-character artifact IDs.
- Start as a plain Django project; add Docker after core ingest and viewer work. - Start as a plain Django project; add Docker after core ingest and viewer work.
- Preserve original markdown as `.md` alongside rendered `.html`. - Preserve original markdown as `.md` alongside rendered `.html`.

View file

@ -1,29 +0,0 @@
# Generated by Django 5.2.15 on 2026-09-08 13:46
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('artifacts', '0016_artifact_deleted_at'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Label',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=40)),
('color', models.CharField(max_length=20)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='labels', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['name'],
'unique_together': {('user', 'name')},
},
),
]

View file

@ -96,23 +96,6 @@ class TagColor(models.Model):
return f"{self.name} ({self.color})" return f"{self.name} ({self.color})"
class Label(models.Model):
"""A user's reusable label in their personal palette. Applying one from the
feed UI just adds its `name` to an artifact's `tags`; the chip colour lives
in the global TagColor table (kept in sync when the palette entry is saved)."""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="labels")
name = models.CharField(max_length=40)
color = models.CharField(max_length=20) # #RRGGBB
class Meta:
unique_together = ("user", "name")
ordering = ["name"]
def __str__(self):
return f"{self.name} ({self.color})"
class UserProfile(models.Model): class UserProfile(models.Model):
DEFAULT_QUOTA_BYTES = 1024 * 1024 * 1024 # 1 GB DEFAULT_QUOTA_BYTES = 1024 * 1024 * 1024 # 1 GB

View file

@ -10,11 +10,9 @@ urlpatterns = [
path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"), 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>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"),
path("a/<str:artifact_id>/share", views.toggle_artifact_share, name="artifact-toggle-share"), path("a/<str:artifact_id>/share", views.toggle_artifact_share, name="artifact-toggle-share"),
path("a/<str:artifact_id>/label", views.toggle_artifact_label, name="artifact-toggle-label"),
path("a/<str:artifact_id>/delete", views.delete_artifact, name="artifact-delete"), path("a/<str:artifact_id>/delete", views.delete_artifact, name="artifact-delete"),
path("a/<str:artifact_id>/undelete", views.undelete_artifact, name="artifact-undelete"), path("a/<str:artifact_id>/undelete", views.undelete_artifact, name="artifact-undelete"),
path("feed/empty-trash", views.empty_trash, name="feed-empty-trash"), path("feed/empty-trash", views.empty_trash, name="feed-empty-trash"),
path("feed/mark-read", views.mark_all_read, name="feed-mark-read"),
path("a/<str:artifact_id>", views.artifact_view, name="artifact"), path("a/<str:artifact_id>", views.artifact_view, name="artifact"),
path("s/<str:share_token>", views.shared_artifact_view, name="artifact-shared"), path("s/<str:share_token>", views.shared_artifact_view, name="artifact-shared"),
path("assets/<str:asset_id>", views.asset_view, name="asset"), path("assets/<str:asset_id>", views.asset_view, name="asset"),
@ -29,7 +27,6 @@ urlpatterns = [
path("settings/", views.settings_home, name="settings-home"), path("settings/", views.settings_home, name="settings-home"),
path("settings/account/", views.settings_account, name="settings-account"), path("settings/account/", views.settings_account, name="settings-account"),
path("settings/api-keys/", views.settings_api_keys, name="settings-api-keys"), path("settings/api-keys/", views.settings_api_keys, name="settings-api-keys"),
path("settings/labels/", views.settings_labels, name="settings-labels"),
path("settings/users/", views.settings_users, name="settings-users"), path("settings/users/", views.settings_users, name="settings-users"),
path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"), path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"),
path("settings/storage/", views.settings_storage, name="settings-storage"), path("settings/storage/", views.settings_storage, name="settings-storage"),

View file

@ -15,9 +15,8 @@ import nh3
import openpyxl import openpyxl
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import update_session_auth_hash, views as auth_views from django.contrib.auth import views as auth_views
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import OperationalError, transaction from django.db import OperationalError, transaction
from django.db.models import Count, Max, Q, Sum from django.db.models import Count, Max, Q, Sum
@ -37,7 +36,7 @@ from rest_framework.views import APIView
from .demo_data import create_demo_artifacts from .demo_data import create_demo_artifacts
from .forms import AgentFeedLoginForm from .forms import AgentFeedLoginForm
from .models import ApiKey, Artifact, Asset, Label, TagColor from .models import ApiKey, Artifact, Asset, TagColor
from .storage import ( from .storage import (
QuotaExceeded, QuotaExceeded,
SIDECAR_SUFFIXES, SIDECAR_SUFFIXES,
@ -626,7 +625,6 @@ def feed(request):
"today": today, "today": today,
"title_query": title_query, "title_query": title_query,
"kind_choices": Artifact.KIND_CHOICES, "kind_choices": Artifact.KIND_CHOICES,
"label_palette": request.user.labels.all(),
}, },
) )
@ -641,31 +639,6 @@ def mark_artifact_read(request, artifact_id):
return JsonResponse({"id": artifact.id, "read_at": artifact.read_at.isoformat()}) return JsonResponse({"id": artifact.id, "read_at": artifact.read_at.isoformat()})
@login_required
@require_POST
def toggle_artifact_label(request, artifact_id):
"""Toggle a palette label on/off for one of the user's own messages. The
label name is written into/removed from the artifact's `tags` list (labels
are unified with tags). Only names in the user's palette are accepted."""
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user, deleted_at__isnull=True)
name = (request.POST.get("name") or "").strip()
label = Label.objects.filter(user=request.user, name=name).first()
if label is None:
return JsonResponse({"error": "No such label in your palette."}, status=400)
tags = list(artifact.tags)
if name in tags:
tags.remove(name)
applied = False
else:
tags.append(name)
applied = True
artifact.tags = tags
artifact.save(update_fields=["tags"])
# Keep the chip colour in sync with the palette (TagColor is global).
TagColor.objects.update_or_create(name=name, defaults={"color": label.color})
return JsonResponse({"applied": applied, "name": name, "color": label.color, "tags": tags})
@login_required @login_required
@require_POST @require_POST
def toggle_artifact_keep(request, artifact_id): def toggle_artifact_keep(request, artifact_id):
@ -723,21 +696,6 @@ def empty_trash(request):
return JsonResponse({"purged": count}) return JsonResponse({"purged": count})
@login_required
@require_POST
def mark_all_read(request):
"""Mark all of the user's unread (non-trashed) messages as read, optionally
scoped to one source. Backs the sidebar 'Mark all as read' action."""
unread = Artifact.objects.filter(
owner=request.user, deleted_at__isnull=True, read_at__isnull=True
)
source = request.POST.get("source", "")
if source:
unread = unread.filter(source=source)
marked = unread.update(read_at=timezone.now())
return JsonResponse({"marked": marked, "source": source})
@login_required @login_required
def feed_older(request): def feed_older(request):
"""Return the next page of older day-groups as an HTML fragment for infinite scroll.""" """Return the next page of older day-groups as an HTML fragment for infinite scroll."""
@ -1534,25 +1492,10 @@ def settings_home(request):
@login_required @login_required
def settings_account(request): def settings_account(request):
if request.method == "POST":
password_form = PasswordChangeForm(request.user, request.POST)
if password_form.is_valid():
user = password_form.save()
# Keep the current session valid — changing the password rotates the
# session auth hash, which would otherwise log the user out.
update_session_auth_hash(request, user)
messages.success(request, "Password changed.")
return redirect("settings-account")
else:
password_form = PasswordChangeForm(request.user)
return render( return render(
request, request,
"artifacts/settings_account.html", "artifacts/settings_account.html",
{ {"active_section": "account", "key_count": request.user.api_keys.count()},
"active_section": "account",
"key_count": request.user.api_keys.count(),
"password_form": password_form,
},
) )
@ -1582,49 +1525,6 @@ def settings_api_keys(request):
) )
LABEL_HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
@login_required
def settings_labels(request):
if request.method == "POST":
action = request.POST.get("action")
if action == "create":
name = (request.POST.get("name") or "").strip()
color = (request.POST.get("color") or "").strip()
if not name:
messages.error(request, "Give the label a name.")
elif not LABEL_HEX_RE.match(color):
messages.error(request, "Pick a colour (#RRGGBB).")
elif Label.objects.filter(user=request.user, name=name).exists():
messages.error(request, f"You already have a label called '{name}'.")
else:
Label.objects.create(user=request.user, name=name, color=color)
TagColor.objects.update_or_create(name=name, defaults={"color": color})
messages.success(request, f"Created label '{name}'.")
elif action == "update":
color = (request.POST.get("color") or "").strip()
label = Label.objects.filter(pk=request.POST.get("label_id") or "", user=request.user).first()
if label is None:
messages.error(request, "No such label.")
elif not LABEL_HEX_RE.match(color):
messages.error(request, "Pick a colour (#RRGGBB).")
else:
label.color = color
label.save(update_fields=["color"])
TagColor.objects.update_or_create(name=label.name, defaults={"color": color})
messages.success(request, f"Updated '{label.name}'.")
elif action == "delete":
deleted, _ = Label.objects.filter(pk=request.POST.get("label_id") or "", user=request.user).delete()
messages.success(request, "Label removed from your palette.") if deleted else messages.error(request, "No such label.")
return redirect("settings-labels")
return render(
request,
"artifacts/settings_labels.html",
{"active_section": "labels", "labels": request.user.labels.all()},
)
@login_required @login_required
def settings_users(request): def settings_users(request):
if not request.user.is_superuser: if not request.user.is_superuser:

View file

@ -1,4 +1,4 @@
# Single source of truth for the app version. Bump this, commit, then tag the # Single source of truth for the app version. Bump this, commit, then tag the
# commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in # commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in
# GET /api/capabilities ("version"). # GET /api/capabilities ("version").
__version__ = "0.7.1" __version__ = "0.6.2"

View file

@ -15,7 +15,7 @@ Also confirms your token is valid:
```powershell ```powershell
Invoke-RestMethod "http://127.0.0.1:8001/api/capabilities" ` Invoke-RestMethod "http://127.0.0.1:8001/api/capabilities" `
-Headers @{ Authorization = "Bearer <your-personal-key>" } -Headers @{ Authorization = "Bearer <your-personal-token>" }
``` ```
## 2. Publish Markdown ## 2. Publish Markdown
@ -39,7 +39,7 @@ $body = @{
$artifact = Invoke-RestMethod ` $artifact = Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts" ` -Uri "http://127.0.0.1:8001/api/artifacts" `
-Method Post ` -Method Post `
-Headers @{ Authorization = "Bearer <your-personal-key>" } ` -Headers @{ Authorization = "Bearer <your-personal-token>" } `
-ContentType "application/json" ` -ContentType "application/json" `
-Body $body -Body $body
@ -69,7 +69,7 @@ $body = @{
Invoke-RestMethod ` Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts" ` -Uri "http://127.0.0.1:8001/api/artifacts" `
-Method Post ` -Method Post `
-Headers @{ Authorization = "Bearer <your-personal-key>" } ` -Headers @{ Authorization = "Bearer <your-personal-token>" } `
-ContentType "application/json" ` -ContentType "application/json" `
-Body $body -Body $body
``` ```
@ -78,7 +78,7 @@ Invoke-RestMethod `
```powershell ```powershell
curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" ` curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
-H "Authorization: Bearer <your-personal-key>" ` -H "Authorization: Bearer <your-personal-token>" `
-F "source=orion" ` -F "source=orion" `
-F "source_type=agent" ` -F "source_type=agent" `
-F "source_name=Orion Agent" ` -F "source_name=Orion Agent" `
@ -120,7 +120,7 @@ $body = @{
Invoke-RestMethod ` Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" ` -Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" `
-Method Patch ` -Method Patch `
-Headers @{ Authorization = "Bearer <your-personal-key>" } ` -Headers @{ Authorization = "Bearer <your-personal-token>" } `
-ContentType "application/json" ` -ContentType "application/json" `
-Body $body -Body $body
``` ```
@ -131,7 +131,7 @@ Invoke-RestMethod `
Invoke-RestMethod ` Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" ` -Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" `
-Method Delete ` -Method Delete `
-Headers @{ Authorization = "Bearer <your-personal-key>" } -Headers @{ Authorization = "Bearer <your-personal-token>" }
``` ```
Deletes the artifact's database row and its files. You can only delete artifacts your own token owns. Deletes the artifact's database row and its files. You can only delete artifacts your own token owns.

View file

@ -108,7 +108,7 @@ Endpoint:
```http ```http
POST /api/artifacts POST /api/artifacts
Content-Type: application/json Content-Type: application/json
Authorization: Bearer <your-personal-key> Authorization: Bearer <your-personal-token>
``` ```
Required fields: Required fields:
@ -188,7 +188,7 @@ $body = @{
Invoke-RestMethod ` Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts" ` -Uri "http://127.0.0.1:8001/api/artifacts" `
-Method Post ` -Method Post `
-Headers @{ Authorization = "Bearer <your-personal-key>" } ` -Headers @{ Authorization = "Bearer <your-personal-token>" } `
-ContentType "application/json" ` -ContentType "application/json" `
-Body $body -Body $body
``` ```
@ -241,7 +241,7 @@ Endpoint:
```http ```http
POST /api/artifacts POST /api/artifacts
Content-Type: multipart/form-data Content-Type: multipart/form-data
Authorization: Bearer <your-personal-key> Authorization: Bearer <your-personal-token>
``` ```
Required fields: Required fields:
@ -288,7 +288,7 @@ PowerShell/curl example:
```powershell ```powershell
curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" ` curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
-H "Authorization: Bearer <your-personal-key>" ` -H "Authorization: Bearer <your-personal-token>" `
-F "source=orion" ` -F "source=orion" `
-F "source_type=agent" ` -F "source_type=agent" `
-F "source_name=Orion Agent" ` -F "source_name=Orion Agent" `
@ -305,7 +305,7 @@ artifact), upload it first and reference the returned URL in your content.
```http ```http
POST /api/assets POST /api/assets
Content-Type: multipart/form-data Content-Type: multipart/form-data
Authorization: Bearer <your-personal-key> Authorization: Bearer <your-personal-token>
``` ```
| Field | Notes | | Field | Notes |
@ -392,7 +392,7 @@ Endpoint:
```http ```http
PATCH /api/artifacts/{id} PATCH /api/artifacts/{id}
Content-Type: application/json Content-Type: application/json
Authorization: Bearer <your-personal-key> Authorization: Bearer <your-personal-token>
``` ```
Editable fields: Editable fields:
@ -438,7 +438,7 @@ $body = @{
Invoke-RestMethod ` Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" ` -Uri "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" `
-Method Patch ` -Method Patch `
-Headers @{ Authorization = "Bearer <your-personal-key>" } ` -Headers @{ Authorization = "Bearer <your-personal-token>" } `
-ContentType "application/json" ` -ContentType "application/json" `
-Body $body -Body $body
``` ```
@ -449,7 +449,7 @@ Endpoint:
```http ```http
DELETE /api/artifacts/{id} DELETE /api/artifacts/{id}
Authorization: Bearer <your-personal-key> Authorization: Bearer <your-personal-token>
``` ```
**Soft-delete:** moves the artifact to Trash rather than removing it — it's **Soft-delete:** moves the artifact to Trash rather than removing it — it's
@ -464,7 +464,7 @@ There is no API endpoint to undelete or purge yet — that's app-UI-only.
```bash ```bash
curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \ curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \
-H "Authorization: Bearer <your-personal-key>" -H "Authorization: Bearer <your-personal-token>"
``` ```
## Rendered And Raw Content ## Rendered And Raw Content

View file

@ -77,7 +77,7 @@ docker compose -f deploy/docker-compose.yml up -d --build
``` ```
Create your first login (superuser) so you have something to log in with and Create your first login (superuser) so you have something to log in with and
a personal API key to post through: a personal API token to post through:
```bash ```bash
docker compose -f deploy/docker-compose.yml exec agentfeed python manage.py createsuperuser docker compose -f deploy/docker-compose.yml exec agentfeed python manage.py createsuperuser
@ -100,7 +100,7 @@ smoke-test post:
```bash ```bash
curl -fsS -X POST http://127.0.0.1:8000/api/artifacts \ curl -fsS -X POST http://127.0.0.1:8000/api/artifacts \
-H "Authorization: Bearer <your-personal-key>" \ -H "Authorization: Bearer <your-personal-token>" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"source": "deploy-smoke", "source": "deploy-smoke",

View file

@ -20,7 +20,7 @@ them, or set them in your agent harness's own env config.
```text ```text
AGENTFEED_URL=http://127.0.0.1:8001 AGENTFEED_URL=http://127.0.0.1:8001
AGENTFEED_TOKEN=your-personal-api-key AGENTFEED_TOKEN=your-personal-api-token
AGENTFEED_SOURCE=agent AGENTFEED_SOURCE=agent
``` ```

View file

@ -27,7 +27,7 @@ DEFAULT_SOURCE = "agent"
def main(): def main():
args = parse_args() args = parse_args()
base_url = os.environ.get("AGENTFEED_URL", DEFAULT_URL).rstrip("/") base_url = os.environ.get("AGENTFEED_URL", DEFAULT_URL).rstrip("/")
# AGENTFEED_TOKEN is your personal per-user API key (Settings -> API Keys in the app); # AGENTFEED_TOKEN is your personal per-user token (Settings -> API Keys in the app);
# there is no shared fallback token, so posting without it cannot work. # there is no shared fallback token, so posting without it cannot work.
token = os.environ.get("AGENTFEED_TOKEN", "") token = os.environ.get("AGENTFEED_TOKEN", "")
source = args.source or os.environ.get("AGENTFEED_SOURCE", DEFAULT_SOURCE) source = args.source or os.environ.get("AGENTFEED_SOURCE", DEFAULT_SOURCE)
@ -43,7 +43,7 @@ def main():
if not token: if not token:
print( print(
"error: AGENTFEED_TOKEN is not set. Get your personal API key from " "error: AGENTFEED_TOKEN is not set. Get your personal API token from "
f"{base_url}/settings/api-keys/ and set AGENTFEED_TOKEN.", f"{base_url}/settings/api-keys/ and set AGENTFEED_TOKEN.",
file=sys.stderr, file=sys.stderr,
) )

View file

@ -514,18 +514,6 @@ h1 {
font-size: 0.7rem; font-size: 0.7rem;
} }
/* The unread badge doubles as a "mark all as read" button. */
.source-count[data-mark-read] {
cursor: pointer;
border-radius: 8px;
padding: 0 0.35rem;
}
.source-count[data-mark-read]:hover {
background: rgba(74, 110, 168, 0.28);
color: var(--text);
}
.pane-heading span, .pane-heading span,
.artifact-time { .artifact-time {
color: var(--muted); color: var(--muted);
@ -1144,48 +1132,6 @@ kbd {
color: var(--danger-text-plain); color: var(--danger-text-plain);
} }
.row-menu-divider {
border-top: 1px solid var(--grid-line);
margin: 0.25rem 0;
}
.row-menu-labels {
display: flex;
flex-direction: column;
max-height: 13rem;
overflow-y: auto;
}
.row-menu-label {
align-items: center;
display: flex;
gap: 0.45rem;
}
.row-menu-dot {
border-radius: 50%;
flex: 0 0 auto;
height: 0.7rem;
width: 0.7rem;
}
.row-menu-label-name {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Checkmark only visible once the label is applied to the row. */
.row-menu-check {
flex: 0 0 auto;
opacity: 0;
}
.row-menu-label.applied .row-menu-check {
opacity: 1;
}
/* System folders (Keep / Deleted): pinned at the bottom of the sidebar, /* System folders (Keep / Deleted): pinned at the bottom of the sidebar,
below the scrolling source list. */ below the scrolling source list. */
.system-folders { .system-folders {
@ -1193,25 +1139,20 @@ kbd {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-shrink: 0; flex-shrink: 0;
gap: 0.25rem; /* space Keep / Deleted apart */ padding: 0.4rem 0.6rem 0.2rem;
padding: 0.4rem 0.6rem 0.3rem;
} }
/* "Special" sidebar entries — "All sources" and the Keep/Deleted folders — /* "All sources" — the primary entry, visually distinct from individual sources:
share a distinct look (bold + subtle blue tint) to set them apart from the bold, with a subtle blue tint (works over both light and dark surfaces). */
individual sources. */ .all-sources-link {
.all-sources-link,
.folder-link {
font-weight: 600; font-weight: 600;
} }
.all-sources-link:not(.selected), .all-sources-link:not(.selected) {
.folder-link:not(.selected) {
background: rgba(74, 110, 168, 0.14); background: rgba(74, 110, 168, 0.14);
} }
.all-sources-link:not(.selected):hover, .all-sources-link:not(.selected):hover {
.folder-link:not(.selected):hover {
background: rgba(74, 110, 168, 0.22); background: rgba(74, 110, 168, 0.22);
} }
@ -1240,32 +1181,6 @@ kbd {
gap: 0.3rem; gap: 0.3rem;
} }
.password-change {
display: flex;
flex-direction: column;
gap: 0.85rem;
max-width: 22rem;
}
.password-change .form-field {
display: flex;
flex-direction: column;
font-weight: 600;
gap: 0.3rem;
}
.password-change button {
align-self: start;
}
.form-errors {
color: var(--danger, #d1453b);
font-weight: 400;
list-style: none;
margin: 0.15rem 0 0;
padding: 0;
}
.api-key-table { .api-key-table {
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;

View file

@ -37,26 +37,32 @@
<nav class="source-list"> <nav class="source-list">
<a class="source-link all-sources-link {% if not selected_source and not selected_folder %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&amp;{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&amp;{% endif %}{% if title_query %}q={{ title_query|urlencode }}&amp;{% endif %}view={{ selected_view }}&amp;preview=none"> <a class="source-link all-sources-link {% if not selected_source and not selected_folder %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&amp;{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&amp;{% endif %}{% if title_query %}q={{ title_query|urlencode }}&amp;{% endif %}view={{ selected_view }}&amp;preview=none">
<span class="source-name">All sources</span> <span class="source-name">All sources</span>
{% if total_count %}<span class="source-count" data-mark-read role="button" tabindex="0" title="Mark all as read">{{ total_count }}</span>{% endif %} {% if total_count %}<span class="source-count">{{ total_count }}</span>{% endif %}
</a> </a>
{% for entry in source_counts %} {% for entry in source_counts %}
<a class="source-link {% if entry.source == selected_source and not selected_folder %}selected{% endif %}" href="?source={{ entry.source|urlencode }}{% if selected_label %}&amp;label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&amp;kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&amp;q={{ title_query|urlencode }}{% endif %}&amp;view={{ selected_view }}&amp;preview=none" data-source="{{ entry.source }}"> <a class="source-link {% if entry.source == selected_source and not selected_folder %}selected{% endif %}" href="?source={{ entry.source|urlencode }}{% if selected_label %}&amp;label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&amp;kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&amp;q={{ title_query|urlencode }}{% endif %}&amp;view={{ selected_view }}&amp;preview=none">
<span class="source-name">{{ entry.source }}</span> <span class="source-name">{{ entry.source }}</span>
{% if entry.count %}<span class="source-count" data-mark-read role="button" tabindex="0" title="Mark all as read">{{ entry.count }}</span>{% endif %} {% if entry.count %}<span class="source-count">{{ entry.count }}</span>{% endif %}
</a> </a>
{% endfor %} {% endfor %}
</nav> </nav>
<nav class="system-folders" aria-label="Folders"> {% if keep_count or trash_count %}
<a class="source-link folder-link {% if selected_folder == 'keep' %}selected{% endif %}" href="?folder=keep&amp;view={{ selected_view }}&amp;preview=none" data-folder-link="keep"> <nav class="system-folders" aria-label="Folders">
<span class="source-name">&#9733; Keep</span> {% if keep_count %}
{% if keep_count %}<span class="source-count">{{ keep_count }}</span>{% endif %} <a class="source-link folder-link {% if selected_folder == 'keep' %}selected{% endif %}" href="?folder=keep&amp;view={{ selected_view }}&amp;preview=none" data-folder-link="keep">
</a> <span class="source-name">&#9733; Keep</span>
<a class="source-link folder-link {% if selected_folder == 'deleted' %}selected{% endif %}" href="?folder=deleted&amp;view={{ selected_view }}&amp;preview=none" data-folder-link="deleted"> <span class="source-count">{{ keep_count }}</span>
<span class="source-name">&#128465; Deleted</span> </a>
{% if trash_count %}<span class="source-count">{{ trash_count }}</span>{% endif %} {% endif %}
</a> {% if trash_count %}
</nav> <a class="source-link folder-link {% if selected_folder == 'deleted' %}selected{% endif %}" href="?folder=deleted&amp;view={{ selected_view }}&amp;preview=none" data-folder-link="deleted">
<span class="source-name">&#128465; Deleted</span>
<span class="source-count">{{ trash_count }}</span>
</a>
{% endif %}
</nav>
{% endif %}
</aside> </aside>
<div class="splitter" data-splitter="days" role="separator" aria-label="Resize sources sidebar" aria-orientation="vertical"></div> <div class="splitter" data-splitter="days" role="separator" aria-label="Resize sources sidebar" aria-orientation="vertical"></div>
@ -67,7 +73,7 @@
{% if selected_folder == 'deleted' %} {% if selected_folder == 'deleted' %}
<button type="button" class="empty-trash-button" data-empty-trash>Empty Trash</button> <button type="button" class="empty-trash-button" data-empty-trash>Empty Trash</button>
{% else %} {% else %}
<span>{{ artifacts|length }} message{{ artifacts|length|pluralize }}</span> <span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
{% endif %} {% endif %}
</div> </div>
@ -76,7 +82,7 @@
{% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %} {% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %}
{% else %} {% else %}
<div class="empty compact"> <div class="empty compact">
<h3>No messages</h3> <h3>No artifacts</h3>
<p>Nothing in the last {{ 7 }} days for these filters.</p> <p>Nothing in the last {{ 7 }} days for these filters.</p>
</div> </div>
{% endif %} {% endif %}
@ -88,7 +94,7 @@
<section class="preview-pane"> <section class="preview-pane">
<header class="pane-bar preview-actions" data-actions> <header class="pane-bar preview-actions" data-actions>
<div class="preview-title" data-preview-title {% if selected_artifact %}title="{{ selected_artifact.title }}"{% endif %}> <div class="preview-title" data-preview-title {% if selected_artifact %}title="{{ selected_artifact.title }}"{% endif %}>
{% if selected_artifact %}{{ selected_artifact.title }}{% else %}No message loaded{% endif %} {% if selected_artifact %}{{ selected_artifact.title }}{% else %}No artifact loaded{% endif %}
</div> </div>
<nav class="view-toggle" aria-label="Preview mode"> <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 == '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>
@ -118,8 +124,8 @@
<img class="preview-image" src="{% url 'artifact' selected_artifact.id %}" alt="{{ selected_artifact.title }}"> <img class="preview-image" src="{% url 'artifact' selected_artifact.id %}" alt="{{ selected_artifact.title }}">
{% elif selected_artifact.kind == "binary" %} {% elif selected_artifact.kind == "binary" %}
<div class="empty preview-empty"> <div class="empty preview-empty">
<h2>Binary Message</h2> <h2>Binary Artifact</h2>
<p>This message type cannot be previewed inline yet.</p> <p>This artifact type cannot be previewed inline yet.</p>
<p><a href="{% url 'artifact' selected_artifact.id %}">Open or download it</a>.</p> <p><a href="{% url 'artifact' selected_artifact.id %}">Open or download it</a>.</p>
</div> </div>
{% else %} {% else %}
@ -129,10 +135,10 @@
{% else %} {% else %}
<div class="empty preview-empty"> <div class="empty preview-empty">
{% if artifacts %} {% if artifacts %}
<h2>No message loaded</h2> <h2>No artifact loaded</h2>
<p>Press <kbd>Right Arrow</kbd> or select a message from the middle column to preview it.</p> <p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p>
{% else %} {% else %}
<h2>No messages for this day</h2> <h2>No artifacts for this day</h2>
<p>This day is ready for new agent output.</p> <p>This day is ready for new agent output.</p>
{% endif %} {% endif %}
</div> </div>
@ -146,18 +152,6 @@
<button type="button" data-menu-action="open">Open</button> <button type="button" data-menu-action="open">Open</button>
<button type="button" data-menu-action="copy">Copy link</button> <button type="button" data-menu-action="copy">Copy link</button>
<button type="button" data-menu-action="delete" class="danger">Delete</button> <button type="button" data-menu-action="delete" class="danger">Delete</button>
{% if label_palette %}
<div class="row-menu-divider" role="separator"></div>
<div class="row-menu-labels">
{% for label in label_palette %}
<button type="button" class="row-menu-label" data-label-name="{{ label.name }}" data-label-color="{{ label.color }}">
<span class="row-menu-dot" style="background:{{ label.color }}"></span>
<span class="row-menu-label-name">{{ label.name }}</span>
<span class="row-menu-check" aria-hidden="true">&#10003;</span>
</button>
{% endfor %}
</div>
{% endif %}
</div> </div>
<script> <script>
@ -245,7 +239,7 @@
try { try {
await navigator.clipboard.writeText(absoluteUrl); await navigator.clipboard.writeText(absoluteUrl);
} catch { } catch {
window.prompt("Copy message link", absoluteUrl); window.prompt("Copy artifact link", absoluteUrl);
} }
const original = button.textContent; const original = button.textContent;
button.textContent = "Copied"; button.textContent = "Copied";
@ -354,7 +348,7 @@
if (!actions) return; if (!actions) return;
const previewTitle = actions.querySelector("[data-preview-title]"); const previewTitle = actions.querySelector("[data-preview-title]");
if (previewTitle) { if (previewTitle) {
previewTitle.textContent = "No message loaded"; previewTitle.textContent = "No artifact loaded";
previewTitle.removeAttribute("title"); previewTitle.removeAttribute("title");
} }
actions.querySelectorAll("[data-view]").forEach((link) => { actions.querySelectorAll("[data-view]").forEach((link) => {
@ -424,17 +418,13 @@
const bumpSourceBadge = (link, delta) => { const bumpSourceBadge = (link, delta) => {
let badge = link.querySelector(".source-count"); let badge = link.querySelector(".source-count");
const next = (badge ? Number(badge.textContent) || 0 : 0) + delta;
if (next <= 0) {
if (badge) badge.remove(); // empty folder/source shows no count badge
return;
}
if (!badge) { if (!badge) {
badge = document.createElement("span"); badge = document.createElement("span");
badge.className = "source-count"; badge.className = "source-count";
badge.textContent = "0";
link.appendChild(badge); link.appendChild(badge);
} }
badge.textContent = String(next); badge.textContent = String((Number(badge.textContent) || 0) + delta);
}; };
const addSourceLink = (name, count) => { const addSourceLink = (name, count) => {
@ -494,7 +484,7 @@
const header = document.querySelector(".artifact-sidebar .pane-heading span"); const header = document.querySelector(".artifact-sidebar .pane-heading span");
if (!header) return; if (!header) return;
const total = Math.max(0, (parseInt(header.textContent, 10) || 0) + delta); const total = Math.max(0, (parseInt(header.textContent, 10) || 0) + delta);
header.textContent = `${total} message${total === 1 ? "" : "s"}`; header.textContent = `${total} artifact${total === 1 ? "" : "s"}`;
}; };
const bumpFolderCount = (folder, delta) => { const bumpFolderCount = (folder, delta) => {
@ -563,22 +553,11 @@
menuRowId = null; menuRowId = null;
}; };
const parseRowTags = (row) =>
(row.dataset.tags || "").split(",").map((t) => t.trim()).filter(Boolean);
const refreshMenuLabelState = (row) => {
const applied = new Set(parseRowTags(row));
rowMenu.querySelectorAll(".row-menu-label").forEach((btn) => {
btn.classList.toggle("applied", applied.has(btn.dataset.labelName));
});
};
const openRowMenu = (row, x, y) => { const openRowMenu = (row, x, y) => {
if (!rowMenu || !row) return; if (!rowMenu || !row) return;
menuRowId = row.dataset.artifactId; menuRowId = row.dataset.artifactId;
const keepBtn = rowMenu.querySelector('[data-menu-action="keep"]'); const keepBtn = rowMenu.querySelector('[data-menu-action="keep"]');
if (keepBtn) keepBtn.textContent = row.dataset.kept === "1" ? "Remove from Keep" : "Keep"; if (keepBtn) keepBtn.textContent = row.dataset.kept === "1" ? "Remove from Keep" : "Keep";
refreshMenuLabelState(row);
rowMenu.hidden = false; rowMenu.hidden = false;
const rect = rowMenu.getBoundingClientRect(); const rect = rowMenu.getBoundingClientRect();
const left = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8)); const left = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8));
@ -610,62 +589,8 @@
} catch (e) { /* leave as-is */ } } catch (e) { /* leave as-is */ }
}; };
const addRowChip = (row, name, color) => {
const main = row.querySelector(".artifact-row-main");
if (!main) return;
let chips = main.querySelector(".tag-chips");
if (!chips) {
chips = document.createElement("span");
chips.className = "tag-chips";
main.appendChild(chips);
}
if ([...chips.querySelectorAll(".tag-chip")].some((c) => c.textContent === name)) return;
const chip = document.createElement("span");
chip.className = "tag-chip";
chip.style.background = color;
chip.textContent = name;
chips.appendChild(chip);
};
const removeRowChip = (row, name) => {
const chips = row.querySelector(".tag-chips");
if (!chips) return;
chips.querySelectorAll(".tag-chip").forEach((c) => { if (c.textContent === name) c.remove(); });
if (!chips.querySelector(".tag-chip")) chips.remove();
};
// Toggle a palette label on the current menu row. Leaves the menu open so
// several labels can be applied in one go.
const toggleLabelFromMenu = async (btn) => {
const row = currentMenuRow();
const id = menuRowId;
if (!row || !id) return;
const name = btn.dataset.labelName;
const body = new URLSearchParams();
body.set("name", name);
let data;
try {
const response = await fetch(`/a/${id}/label`, {
method: "POST",
headers: {"X-CSRFToken": csrfToken, "Content-Type": "application/x-www-form-urlencoded"},
body: body.toString(),
});
if (!response.ok) return;
data = await response.json();
} catch (e) { return; }
row.dataset.tags = data.tags.join(", ");
btn.classList.toggle("applied", data.applied);
if (data.applied) addRowChip(row, data.name, data.color);
else removeRowChip(row, data.name);
};
if (rowMenu) { if (rowMenu) {
rowMenu.addEventListener("click", (event) => { rowMenu.addEventListener("click", (event) => {
const labelBtn = event.target.closest(".row-menu-label");
if (labelBtn) {
toggleLabelFromMenu(labelBtn);
return; // keep the menu open for applying multiple labels
}
const btn = event.target.closest("[data-menu-action]"); const btn = event.target.closest("[data-menu-action]");
if (!btn) return; if (!btn) return;
const row = currentMenuRow(); const row = currentMenuRow();
@ -695,59 +620,6 @@
openRowMenu(row, event.clientX, event.clientY); openRowMenu(row, event.clientX, event.clientY);
}); });
// Click a source's unread badge to mark all of that source's messages read.
const markSourceRead = async (badge) => {
const link = badge.closest(".source-link");
if (!link) return;
const isAll = link.classList.contains("all-sources-link");
const source = link.dataset.source || "";
const body = new URLSearchParams();
if (!isAll && source) body.set("source", source);
try {
const response = await fetch("{% url 'feed-mark-read' %}", {
method: "POST",
headers: {"X-CSRFToken": csrfToken, "Content-Type": "application/x-www-form-urlencoded"},
body: body.toString(),
});
if (!response.ok) return;
} catch (e) { return; }
if (isAll) {
document.querySelectorAll(".source-list .source-count").forEach((b) => b.remove());
artifactList.querySelectorAll(".artifact-row.unread").forEach((r) => r.classList.remove("unread"));
} else {
const n = parseInt(badge.textContent, 10) || 0;
badge.remove();
const allBadge = document.querySelector(".all-sources-link .source-count");
if (allBadge) {
const t = (parseInt(allBadge.textContent, 10) || 0) - n;
if (t > 0) allBadge.textContent = String(t);
else allBadge.remove();
}
artifactList
.querySelectorAll(`.artifact-row.unread[data-source="${CSS.escape(source)}"]`)
.forEach((r) => r.classList.remove("unread"));
}
};
const sourceListEl = document.querySelector(".source-list");
if (sourceListEl) {
const badgeFromEvent = (event) => event.target.closest("[data-mark-read]");
sourceListEl.addEventListener("click", (event) => {
const badge = badgeFromEvent(event);
if (!badge) return;
event.preventDefault();
event.stopPropagation();
markSourceRead(badge);
});
sourceListEl.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
const badge = badgeFromEvent(event);
if (!badge) return;
event.preventDefault();
markSourceRead(badge);
});
}
const renderPreview = (row, pushState = true) => { const renderPreview = (row, pushState = true) => {
navigationMode = "artifacts"; navigationMode = "artifacts";
markRead(row); markRead(row);
@ -760,7 +632,7 @@
: kind === "image" : kind === "image"
? `<img class="preview-image" src="${source}" alt="${title}">` ? `<img class="preview-image" src="${source}" alt="${title}">`
: kind === "binary" : kind === "binary"
? `<div class="empty preview-empty"><h2>Binary Message</h2><p>This message type cannot be previewed inline yet.</p><p><a href="${source}">Open or download it</a>.</p></div>` ? `<div class="empty preview-empty"><h2>Binary Artifact</h2><p>This artifact type cannot be previewed inline yet.</p><p><a href="${source}">Open or download it</a>.</p></div>`
: `<iframe class="preview-frame" src="${source}" title="${title}"></iframe>`; : `<iframe class="preview-frame" src="${source}" title="${title}"></iframe>`;
previewBody.innerHTML = `<div class="preview-frame-wrap">${content}</div>`; previewBody.innerHTML = `<div class="preview-frame-wrap">${content}</div>`;
@ -775,7 +647,7 @@
} }
allRows().forEach((candidate) => candidate.classList.remove("selected")); allRows().forEach((candidate) => candidate.classList.remove("selected"));
disableActions(); disableActions();
previewBody.innerHTML = `<div class="empty preview-empty"><h2>No message loaded</h2><p>Press <kbd>Right Arrow</kbd> or select a message from the middle column to preview it.</p></div>`; 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()); window.history.pushState({}, "", noPreviewUrl());
const currentSource = document.querySelector(".source-link.selected"); const currentSource = document.querySelector(".source-link.selected");
if (currentSource) currentSource.focus({preventScroll: true}); if (currentSource) currentSource.focus({preventScroll: true});
@ -1013,7 +885,7 @@
const header = document.querySelector(".artifact-sidebar .pane-heading span"); const header = document.querySelector(".artifact-sidebar .pane-heading span");
if (header) { if (header) {
const total = (parseInt(header.textContent, 10) || 0) + data.count; const total = (parseInt(header.textContent, 10) || 0) + data.count;
header.textContent = `${total} message${total === 1 ? "" : "s"}`; header.textContent = `${total} artifact${total === 1 ? "" : "s"}`;
} }
} catch { } catch {
// ignore; retry next tick // ignore; retry next tick

View file

@ -10,11 +10,11 @@
</div> </div>
<p>A self-hosted, multi-user journal where AI agents, scripts, or people <p>A self-hosted, multi-user journal where AI agents, scripts, or people
publish messages — reports, HTML, decks, images, PDFs, JSON, CSV — and each publish artifacts — reports, HTML, decks, images, PDFs, JSON, CSV — and each
logged-in user browses their own time-ordered, label-filterable feed. The logged-in user browses their own time-ordered, label-filterable feed. The
server owns IDs, styling, and storage; agents publish content, not files.</p> server owns IDs, styling, and storage; agents publish content, not files.</p>
<p class="storage-note">Post to it with a personal API key from <p class="storage-note">Post to it with a personal API token from
Settings&nbsp;→&nbsp;API&nbsp;Keys; the current API capabilities (including Settings&nbsp;→&nbsp;Account; the current API capabilities (including this
this version) are available at <code>/api/capabilities</code>.</p> version) are available at <code>/api/capabilities</code>.</p>
{% endblock %} {% endblock %}

View file

@ -10,27 +10,4 @@
revocable on its own. You have {{ key_count }} key{{ key_count|pluralize }}. revocable on its own. You have {{ key_count }} key{{ key_count|pluralize }}.
</p> </p>
<p><a href="{% url 'settings-api-keys' %}">Manage API keys &rarr;</a></p> <p><a href="{% url 'settings-api-keys' %}">Manage API keys &rarr;</a></p>
<h3>Change password</h3>
<p>Enter your current password, then choose a new one.</p>
<form method="post" class="password-change">
{% csrf_token %}
{% if password_form.non_field_errors %}
<ul class="form-errors">
{% for error in password_form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
{% for field in password_form %}
<label class="form-field">
<span>{{ field.label }}</span>
{{ field }}
{% if field.errors %}
<ul class="form-errors">
{% for error in field.errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
</label>
{% endfor %}
<button type="submit">Change password</button>
</form>
{% endblock %} {% endblock %}

View file

@ -29,7 +29,6 @@
<span class="settings-nav-heading">You</span> <span class="settings-nav-heading">You</span>
<a class="{% if active_section == 'account' %}selected{% endif %}" href="{% url 'settings-account' %}">Account</a> <a class="{% if active_section == 'account' %}selected{% endif %}" href="{% url 'settings-account' %}">Account</a>
<a class="{% if active_section == 'api-keys' %}selected{% endif %}" href="{% url 'settings-api-keys' %}">API Keys</a> <a class="{% if active_section == 'api-keys' %}selected{% endif %}" href="{% url 'settings-api-keys' %}">API Keys</a>
<a class="{% if active_section == 'labels' %}selected{% endif %}" href="{% url 'settings-labels' %}">Labels</a>
<a class="{% if active_section == 'storage' %}selected{% endif %}" href="{% url 'settings-storage' %}">Storage</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> <a class="{% if active_section == 'demo-data' %}selected{% endif %}" href="{% url 'settings-demo-data' %}">Demo Data</a>
</div> </div>

View file

@ -2,7 +2,7 @@
{% block settings_content %} {% block settings_content %}
<h2>Demo Data</h2> <h2>Demo Data</h2>
<p>Generate one sample message of every supported kind (markdown, HTML fragment, full HTML, deck, image, PDF, binary), tagged "demo" and dated today.</p> <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"> <form method="post">
{% csrf_token %} {% csrf_token %}
<button type="submit">Generate Demo Data</button> <button type="submit">Generate Demo Data</button>

View file

@ -1,54 +0,0 @@
{% extends "artifacts/settings_base.html" %}
{% block settings_content %}
<h2>Labels</h2>
<p>
Build a palette of labels you can apply to any message from its &#8943; menu
in the feed. A label is just a coloured tag — applying one adds it to the
message and it shows as a chip next to the title. Deleting a label here only
removes it from this palette; messages that already carry it keep the chip.
</p>
<h3>Add a label</h3>
<form method="post" class="label-create">
{% csrf_token %}
<input type="hidden" name="action" value="create">
<label>Name <input type="text" name="name" placeholder="e.g. follow-up" required autocomplete="off" maxlength="40"></label>
<label>Colour <input type="color" name="color" value="#4a6ea8"></label>
<button type="submit">Add label</button>
</form>
<h3>Your labels</h3>
{% if labels %}
<table class="label-table">
<thead>
<tr><th>Label</th><th>Colour</th><th></th></tr>
</thead>
<tbody>
{% for label in labels %}
<tr>
<td><span class="tag-chip" style="background:{{ label.color }}">{{ label.name }}</span></td>
<td>
<form method="post" class="label-recolor">
{% csrf_token %}
<input type="hidden" name="action" value="update">
<input type="hidden" name="label_id" value="{{ label.pk }}">
<input type="color" name="color" value="{{ label.color }}" onchange="this.form.submit()" title="Change colour">
</form>
</td>
<td>
<form method="post" onsubmit="return confirm('Remove &quot;{{ label.name|escapejs }}&quot; from your palette? Messages already labelled keep the chip.');">
{% csrf_token %}
<input type="hidden" name="action" value="delete">
<input type="hidden" name="label_id" value="{{ label.pk }}">
<button type="submit" class="label-delete">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No labels yet. Add one above, then pick it from a message's &#8943; menu.</p>
{% endif %}
{% endblock %}

View file

@ -11,14 +11,14 @@
</p> </p>
<ul class="storage-facts"> <ul class="storage-facts">
<li>{{ artifact_count }} message{{ artifact_count|pluralize }} total</li> <li>{{ artifact_count }} artifact{{ artifact_count|pluralize }} total</li>
<li>{{ kept_count }} kept ({{ kept_bytes|filesizeformat }}) &mdash; never auto-deleted</li> <li>{{ kept_count }} kept ({{ kept_bytes|filesizeformat }}) &mdash; never auto-deleted</li>
</ul> </ul>
<p class="storage-note"> <p class="storage-note">
When your quota fills, the oldest <strong>unkept</strong> messages are deleted When your quota fills, the oldest <strong>unkept</strong> artifacts are deleted
automatically to make room for new ones. Click <strong>Keep</strong> on a message automatically to make room for new ones. Click <strong>Keep</strong> on an artifact
to protect it. If the quota is full of kept messages, new posts are rejected until to protect it. If the quota is full of kept artifacts, new posts are rejected until
you free space. you free space.
</p> </p>
@ -26,7 +26,7 @@
<h3>Admin</h3> <h3>Admin</h3>
<ul class="storage-facts"> <ul class="storage-facts">
<li>Total quota allocated across all users: <strong>{{ total_allocated|filesizeformat }}</strong></li> <li>Total quota allocated across all users: <strong>{{ total_allocated|filesizeformat }}</strong></li>
<li>Free space on the storage disk: <strong>{{ disk_free|filesizeformat }}</strong></li> <li>Free space on the artifact disk: <strong>{{ disk_free|filesizeformat }}</strong></li>
</ul> </ul>
{% if total_allocated > disk_free %} {% if total_allocated > disk_free %}
<p class="storage-warning"> <p class="storage-warning">

View file

@ -1,54 +0,0 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
User = get_user_model()
class ChangePasswordViewTestCase(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="oldpassword")
self.client.force_login(self.user)
def _post(self, old, new1, new2):
return self.client.post(
reverse("settings-account"),
{"old_password": old, "new_password1": new1, "new_password2": new2},
)
def test_get_renders_form(self):
response = self.client.get(reverse("settings-account"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Change password")
self.assertContains(response, "old_password")
def test_requires_login(self):
self.client.logout()
response = self.client.get(reverse("settings-account"))
self.assertEqual(response.status_code, 302)
def test_valid_change_updates_password(self):
response = self._post("oldpassword", "brandnewpass", "brandnewpass")
self.assertRedirects(response, reverse("settings-account"))
self.user.refresh_from_db()
self.assertTrue(self.user.check_password("brandnewpass"))
self.assertFalse(self.user.check_password("oldpassword"))
def test_valid_change_keeps_session(self):
self._post("oldpassword", "brandnewpass", "brandnewpass")
# Session must survive the password change (update_session_auth_hash),
# so a follow-up authenticated request still succeeds.
response = self.client.get(reverse("settings-account"))
self.assertEqual(response.status_code, 200)
def test_wrong_current_password_rejected(self):
response = self._post("WRONG", "brandnewpass", "brandnewpass")
self.assertEqual(response.status_code, 200) # re-render with errors, no redirect
self.user.refresh_from_db()
self.assertTrue(self.user.check_password("oldpassword"))
def test_mismatched_new_password_rejected(self):
response = self._post("oldpassword", "brandnewpass", "different")
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db()
self.assertTrue(self.user.check_password("oldpassword"))

View file

@ -1,113 +0,0 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from artifacts.models import Artifact, Label, TagColor
User = get_user_model()
class LabelPaletteTestCase(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="pw")
self.client.force_login(self.user)
def url(self):
return reverse("settings-labels")
def test_requires_login(self):
self.client.logout()
self.assertEqual(self.client.get(self.url()).status_code, 302)
def test_create_label_sets_palette_and_tagcolor(self):
self.client.post(self.url(), {"action": "create", "name": "follow-up", "color": "#112233"})
label = Label.objects.get(user=self.user, name="follow-up")
self.assertEqual(label.color, "#112233")
self.assertEqual(TagColor.objects.get(name="follow-up").color, "#112233")
def test_create_rejects_bad_color(self):
self.client.post(self.url(), {"action": "create", "name": "x", "color": "red"})
self.assertFalse(Label.objects.filter(user=self.user).exists())
def test_create_requires_name(self):
self.client.post(self.url(), {"action": "create", "name": " ", "color": "#112233"})
self.assertFalse(Label.objects.filter(user=self.user).exists())
def test_duplicate_name_same_user_rejected(self):
Label.objects.create(user=self.user, name="dup", color="#111111")
self.client.post(self.url(), {"action": "create", "name": "dup", "color": "#222222"})
self.assertEqual(Label.objects.filter(user=self.user, name="dup").count(), 1)
def test_same_name_different_user_allowed(self):
other = User.objects.create_user(username="bob", password="pw")
Label.objects.create(user=other, name="shared", color="#111111")
self.client.post(self.url(), {"action": "create", "name": "shared", "color": "#222222"})
self.assertTrue(Label.objects.filter(user=self.user, name="shared").exists())
def test_recolor_updates_tagcolor(self):
label = Label.objects.create(user=self.user, name="urgent", color="#111111")
self.client.post(self.url(), {"action": "update", "label_id": label.pk, "color": "#ff0000"})
label.refresh_from_db()
self.assertEqual(label.color, "#ff0000")
self.assertEqual(TagColor.objects.get(name="urgent").color, "#ff0000")
def test_delete_removes_from_palette_only(self):
label = Label.objects.create(user=self.user, name="temp", color="#111111")
art = Artifact.objects.create(
id="lbl00001", owner=self.user, source="agent", title="T", kind=Artifact.MARKDOWN,
day=timezone.localdate(), path="agent/lbl00001.html", original_kind=Artifact.MARKDOWN,
tags=["temp"],
)
self.client.post(self.url(), {"action": "delete", "label_id": label.pk})
self.assertFalse(Label.objects.filter(pk=label.pk).exists())
art.refresh_from_db()
self.assertEqual(art.tags, ["temp"]) # chip stays on the message
class ToggleLabelTestCase(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="pw")
self.other = User.objects.create_user(username="bob", password="pw")
self.client.force_login(self.user)
self.label = Label.objects.create(user=self.user, name="star", color="#abcdef")
self.art = Artifact.objects.create(
id="tog00001", owner=self.user, source="agent", title="T", kind=Artifact.MARKDOWN,
day=timezone.localdate(), path="agent/tog00001.html", original_kind=Artifact.MARKDOWN,
)
def toggle(self, art_id, name):
return self.client.post(reverse("artifact-toggle-label", args=[art_id]), {"name": name})
def test_toggle_on_then_off(self):
r = self.toggle(self.art.id, "star")
self.assertEqual(r.status_code, 200)
self.assertTrue(r.json()["applied"])
self.art.refresh_from_db()
self.assertIn("star", self.art.tags)
r = self.toggle(self.art.id, "star")
self.assertFalse(r.json()["applied"])
self.art.refresh_from_db()
self.assertNotIn("star", self.art.tags)
def test_toggle_syncs_tagcolor(self):
self.toggle(self.art.id, "star")
self.assertEqual(TagColor.objects.get(name="star").color, "#abcdef")
def test_name_not_in_palette_rejected(self):
r = self.toggle(self.art.id, "nope")
self.assertEqual(r.status_code, 400)
self.art.refresh_from_db()
self.assertEqual(self.art.tags, [])
def test_owner_scoped(self):
theirs = Artifact.objects.create(
id="tog00002", owner=self.other, source="agent", title="T", kind=Artifact.MARKDOWN,
day=timezone.localdate(), path="agent/tog00002.html", original_kind=Artifact.MARKDOWN,
)
self.assertEqual(self.toggle(theirs.id, "star").status_code, 404)
def test_requires_login(self):
self.client.logout()
self.assertEqual(self.toggle(self.art.id, "star").status_code, 302)

View file

@ -128,31 +128,3 @@ class TrashKeepViewTestCase(TestCase):
self.client.post(reverse("artifact-delete", args=[fresh.id])) self.client.post(reverse("artifact-delete", args=[fresh.id]))
self.assertFalse(Artifact.objects.filter(pk=old.id).exists()) self.assertFalse(Artifact.objects.filter(pk=old.id).exists())
self.assertTrue(Artifact.objects.filter(pk=fresh.id, deleted_at__isnull=False).exists()) self.assertTrue(Artifact.objects.filter(pk=fresh.id, deleted_at__isnull=False).exists())
def test_mark_all_read_across_sources(self):
self.make(source="a")
self.make(source="b")
r = self.client.post(reverse("feed-mark-read"))
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()["marked"], 2)
self.assertFalse(Artifact.objects.filter(owner=self.user, read_at__isnull=True).exists())
def test_mark_all_read_scoped_to_source(self):
a = self.make(source="hermes")
b = self.make(source="janus")
self.assertEqual(self.client.post(reverse("feed-mark-read"), {"source": "hermes"}).json()["marked"], 1)
a.refresh_from_db()
b.refresh_from_db()
self.assertIsNotNone(a.read_at)
self.assertIsNone(b.read_at)
def test_mark_all_read_owner_scoped(self):
theirs = self.make(owner=self.other)
self.client.post(reverse("feed-mark-read"))
theirs.refresh_from_db()
self.assertIsNone(theirs.read_at)
def test_mark_all_read_excludes_trashed(self):
a = self.make()
Artifact.objects.filter(pk=a.id).update(deleted_at=timezone.now())
self.assertEqual(self.client.post(reverse("feed-mark-read")).json()["marked"], 0)