From fcd7da853ff9631e37dd570ee4079a6ecac9dde2 Mon Sep 17 00:00:00 2001 From: peter Date: Mon, 7 Sep 2026 23:23:53 +0100 Subject: [PATCH 1/2] docs: say 'API key' instead of 'token' in agent-facing wording Sweep human-facing 'personal API token' / '' placeholders to 'API key' / '' across the API docs, quickstart, deployment guide, LLM helper, and posting skill, matching the in-app 'Settings -> API Keys' naming. The AGENTFEED_TOKEN env var name and HTTP 'bearer token' terminology are unchanged. Co-Authored-By: Claude Opus 4.8 --- docs/agent_quickstart.md | 12 ++++++------ docs/api.md | 18 +++++++++--------- docs/deployment.md | 4 ++-- docs/llm_posting_helper.md | 2 +- .../scripts/agentfeed_post.py | 4 ++-- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/agent_quickstart.md b/docs/agent_quickstart.md index 942ccee..29339ac 100644 --- a/docs/agent_quickstart.md +++ b/docs/agent_quickstart.md @@ -15,7 +15,7 @@ Also confirms your token is valid: ```powershell Invoke-RestMethod "http://127.0.0.1:8001/api/capabilities" ` - -Headers @{ Authorization = "Bearer " } + -Headers @{ Authorization = "Bearer " } ``` ## 2. Publish Markdown @@ -39,7 +39,7 @@ $body = @{ $artifact = Invoke-RestMethod ` -Uri "http://127.0.0.1:8001/api/artifacts" ` -Method Post ` - -Headers @{ Authorization = "Bearer " } ` + -Headers @{ Authorization = "Bearer " } ` -ContentType "application/json" ` -Body $body @@ -69,7 +69,7 @@ $body = @{ Invoke-RestMethod ` -Uri "http://127.0.0.1:8001/api/artifacts" ` -Method Post ` - -Headers @{ Authorization = "Bearer " } ` + -Headers @{ Authorization = "Bearer " } ` -ContentType "application/json" ` -Body $body ``` @@ -78,7 +78,7 @@ Invoke-RestMethod ` ```powershell curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" ` - -H "Authorization: Bearer " ` + -H "Authorization: Bearer " ` -F "source=orion" ` -F "source_type=agent" ` -F "source_name=Orion Agent" ` @@ -120,7 +120,7 @@ $body = @{ Invoke-RestMethod ` -Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" ` -Method Patch ` - -Headers @{ Authorization = "Bearer " } ` + -Headers @{ Authorization = "Bearer " } ` -ContentType "application/json" ` -Body $body ``` @@ -131,7 +131,7 @@ Invoke-RestMethod ` Invoke-RestMethod ` -Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" ` -Method Delete ` - -Headers @{ Authorization = "Bearer " } + -Headers @{ Authorization = "Bearer " } ``` Deletes the artifact's database row and its files. You can only delete artifacts your own token owns. diff --git a/docs/api.md b/docs/api.md index 5135f76..413a238 100644 --- a/docs/api.md +++ b/docs/api.md @@ -108,7 +108,7 @@ Endpoint: ```http POST /api/artifacts Content-Type: application/json -Authorization: Bearer +Authorization: Bearer ``` Required fields: @@ -188,7 +188,7 @@ $body = @{ Invoke-RestMethod ` -Uri "http://127.0.0.1:8001/api/artifacts" ` -Method Post ` - -Headers @{ Authorization = "Bearer " } ` + -Headers @{ Authorization = "Bearer " } ` -ContentType "application/json" ` -Body $body ``` @@ -241,7 +241,7 @@ Endpoint: ```http POST /api/artifacts Content-Type: multipart/form-data -Authorization: Bearer +Authorization: Bearer ``` Required fields: @@ -288,7 +288,7 @@ PowerShell/curl example: ```powershell curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" ` - -H "Authorization: Bearer " ` + -H "Authorization: Bearer " ` -F "source=orion" ` -F "source_type=agent" ` -F "source_name=Orion Agent" ` @@ -305,7 +305,7 @@ artifact), upload it first and reference the returned URL in your content. ```http POST /api/assets Content-Type: multipart/form-data -Authorization: Bearer +Authorization: Bearer ``` | Field | Notes | @@ -392,7 +392,7 @@ Endpoint: ```http PATCH /api/artifacts/{id} Content-Type: application/json -Authorization: Bearer +Authorization: Bearer ``` Editable fields: @@ -438,7 +438,7 @@ $body = @{ Invoke-RestMethod ` -Uri "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" ` -Method Patch ` - -Headers @{ Authorization = "Bearer " } ` + -Headers @{ Authorization = "Bearer " } ` -ContentType "application/json" ` -Body $body ``` @@ -449,7 +449,7 @@ Endpoint: ```http DELETE /api/artifacts/{id} -Authorization: Bearer +Authorization: Bearer ``` **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 curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \ - -H "Authorization: Bearer " + -H "Authorization: Bearer " ``` ## Rendered And Raw Content diff --git a/docs/deployment.md b/docs/deployment.md index ad22fce..27d1fad 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 -a personal API token to post through: +a personal API key to post through: ```bash docker compose -f deploy/docker-compose.yml exec agentfeed python manage.py createsuperuser @@ -100,7 +100,7 @@ smoke-test post: ```bash curl -fsS -X POST http://127.0.0.1:8000/api/artifacts \ - -H "Authorization: Bearer " \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "source": "deploy-smoke", diff --git a/docs/llm_posting_helper.md b/docs/llm_posting_helper.md index 90897d9..75b4508 100644 --- a/docs/llm_posting_helper.md +++ b/docs/llm_posting_helper.md @@ -20,7 +20,7 @@ them, or set them in your agent harness's own env config. ```text AGENTFEED_URL=http://127.0.0.1:8001 -AGENTFEED_TOKEN=your-personal-api-token +AGENTFEED_TOKEN=your-personal-api-key AGENTFEED_SOURCE=agent ``` diff --git a/skills/agentfeed-posting/scripts/agentfeed_post.py b/skills/agentfeed-posting/scripts/agentfeed_post.py index 4d676de..049f78f 100644 --- a/skills/agentfeed-posting/scripts/agentfeed_post.py +++ b/skills/agentfeed-posting/scripts/agentfeed_post.py @@ -27,7 +27,7 @@ DEFAULT_SOURCE = "agent" def main(): args = parse_args() base_url = os.environ.get("AGENTFEED_URL", DEFAULT_URL).rstrip("/") - # AGENTFEED_TOKEN is your personal per-user token (Settings -> API Keys in the app); + # AGENTFEED_TOKEN is your personal per-user API key (Settings -> API Keys in the app); # there is no shared fallback token, so posting without it cannot work. token = os.environ.get("AGENTFEED_TOKEN", "") source = args.source or os.environ.get("AGENTFEED_SOURCE", DEFAULT_SOURCE) @@ -43,7 +43,7 @@ def main(): if not token: print( - "error: AGENTFEED_TOKEN is not set. Get your personal API token from " + "error: AGENTFEED_TOKEN is not set. Get your personal API key from " f"{base_url}/settings/api-keys/ and set AGENTFEED_TOKEN.", file=sys.stderr, ) From c2a354809a13fbadb0ec0e846008b57776983b4a Mon Sep 17 00:00:00 2001 From: peter Date: Tue, 8 Sep 2026 15:19:54 +0100 Subject: [PATCH 2/2] v0.7.1: in-app password change, custom labels, always-on folders - Settings -> Account: change your own password (current + new), session preserved via update_session_auth_hash. - Custom labels: per-user palette in Settings -> Labels; apply/remove from a message's ... menu (menu stays open, checkmark = applied). Labels are unified with tags; chip colours sync to the global TagColor. New Label model + migration 0017; POST /a/{id}/label (session, owner-scoped, palette-validated). - Sidebar Keep/Deleted folders are now always shown; count badge only when non-empty. Updates the stale 'hide when empty' note in CLAUDE.md. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 18 ++++ CLAUDE.md | 2 +- artifacts/migrations/0017_label.py | 29 ++++++ artifacts/models.py | 17 ++++ artifacts/urls.py | 2 + artifacts/views.py | 91 ++++++++++++++++- daybook_project/__init__.py | 2 +- static/agentfeed.css | 68 +++++++++++++ templates/artifacts/feed.html | 111 +++++++++++++++++---- templates/artifacts/settings_account.html | 23 +++++ templates/artifacts/settings_base.html | 1 + templates/artifacts/settings_labels.html | 54 +++++++++++ tests/views/test_settings_account.py | 54 +++++++++++ tests/views/test_settings_labels.py | 113 ++++++++++++++++++++++ 14 files changed, 562 insertions(+), 23 deletions(-) create mode 100644 artifacts/migrations/0017_label.py create mode 100644 templates/artifacts/settings_labels.html create mode 100644 tests/views/test_settings_account.py create mode 100644 tests/views/test_settings_labels.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ff33af..2cb6ed3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,24 @@ former name, "Daybook".) _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 diff --git a/CLAUDE.md b/CLAUDE.md index f38dbbf..86baea8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. - 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. -- 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. +- 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. - 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`. diff --git a/artifacts/migrations/0017_label.py b/artifacts/migrations/0017_label.py new file mode 100644 index 0000000..aedcc1a --- /dev/null +++ b/artifacts/migrations/0017_label.py @@ -0,0 +1,29 @@ +# 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')}, + }, + ), + ] diff --git a/artifacts/models.py b/artifacts/models.py index dd87793..dcba524 100644 --- a/artifacts/models.py +++ b/artifacts/models.py @@ -96,6 +96,23 @@ class TagColor(models.Model): 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): DEFAULT_QUOTA_BYTES = 1024 * 1024 * 1024 # 1 GB diff --git a/artifacts/urls.py b/artifacts/urls.py index 44bab7f..d7adc5d 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -10,6 +10,7 @@ urlpatterns = [ path("a//read", views.mark_artifact_read, name="artifact-mark-read"), path("a//keep", views.toggle_artifact_keep, name="artifact-toggle-keep"), path("a//share", views.toggle_artifact_share, name="artifact-toggle-share"), + path("a//label", views.toggle_artifact_label, name="artifact-toggle-label"), path("a//delete", views.delete_artifact, name="artifact-delete"), path("a//undelete", views.undelete_artifact, name="artifact-undelete"), path("feed/empty-trash", views.empty_trash, name="feed-empty-trash"), @@ -28,6 +29,7 @@ urlpatterns = [ path("settings/", views.settings_home, name="settings-home"), path("settings/account/", views.settings_account, name="settings-account"), 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/demo-data/", views.settings_demo_data, name="settings-demo-data"), path("settings/storage/", views.settings_storage, name="settings-storage"), diff --git a/artifacts/views.py b/artifacts/views.py index 172b8c7..bd2fccc 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -15,8 +15,9 @@ import nh3 import openpyxl from django.conf import settings from django.contrib import messages -from django.contrib.auth import views as auth_views +from django.contrib.auth import update_session_auth_hash, views as auth_views from django.contrib.auth.decorators import login_required +from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.models import User from django.db import OperationalError, transaction from django.db.models import Count, Max, Q, Sum @@ -36,7 +37,7 @@ from rest_framework.views import APIView from .demo_data import create_demo_artifacts from .forms import AgentFeedLoginForm -from .models import ApiKey, Artifact, Asset, TagColor +from .models import ApiKey, Artifact, Asset, Label, TagColor from .storage import ( QuotaExceeded, SIDECAR_SUFFIXES, @@ -625,6 +626,7 @@ def feed(request): "today": today, "title_query": title_query, "kind_choices": Artifact.KIND_CHOICES, + "label_palette": request.user.labels.all(), }, ) @@ -639,6 +641,31 @@ def mark_artifact_read(request, artifact_id): 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 @require_POST def toggle_artifact_keep(request, artifact_id): @@ -1507,10 +1534,25 @@ def settings_home(request): @login_required 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( request, "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, + }, ) @@ -1540,6 +1582,49 @@ 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 def settings_users(request): if not request.user.is_superuser: diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index f00cfaa..a5bfd5e 100644 --- a/daybook_project/__init__.py +++ b/daybook_project/__init__.py @@ -1,4 +1,4 @@ # 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 # GET /api/capabilities ("version"). -__version__ = "0.7.0" +__version__ = "0.7.1" diff --git a/static/agentfeed.css b/static/agentfeed.css index 7c8110d..788b473 100644 --- a/static/agentfeed.css +++ b/static/agentfeed.css @@ -1144,6 +1144,48 @@ kbd { 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, below the scrolling source list. */ .system-folders { @@ -1198,6 +1240,32 @@ kbd { 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 { border-collapse: collapse; width: 100%; diff --git a/templates/artifacts/feed.html b/templates/artifacts/feed.html index 4dfe047..eedd25d 100644 --- a/templates/artifacts/feed.html +++ b/templates/artifacts/feed.html @@ -47,22 +47,16 @@ {% endfor %} - {% if keep_count or trash_count %} - - {% endif %} + @@ -152,6 +146,18 @@ + {% if label_palette %} + +
+ {% for label in label_palette %} + + {% endfor %} +
+ {% endif %}