{{ artifact_count }} artifact{{ artifact_count|pluralize }} total
+
{{ artifact_count }} message{{ artifact_count|pluralize }} total
{{ kept_count }} kept ({{ kept_bytes|filesizeformat }}) — never auto-deleted
- When your quota fills, the oldest unkept artifacts are deleted
- automatically to make room for new ones. Click Keep on an artifact
- to protect it. If the quota is full of kept artifacts, new posts are rejected until
+ When your quota fills, the oldest unkept messages are deleted
+ automatically to make room for new ones. Click Keep on a message
+ to protect it. If the quota is full of kept messages, new posts are rejected until
you free space.
@@ -26,7 +26,7 @@
Admin
Total quota allocated across all users: {{ total_allocated|filesizeformat }}
-
Free space on the artifact disk: {{ disk_free|filesizeformat }}
+
Free space on the storage disk: {{ disk_free|filesizeformat }}
{% if total_allocated > disk_free %}
From 89936ebf5850f19705e21e36e2715ea47e0a78ae Mon Sep 17 00:00:00 2001
From: peter
Date: Mon, 7 Sep 2026 22:26:48 +0100
Subject: [PATCH 3/5] v0.7.0: mark all as read by clicking the unread badge
- Click a source's unread count in the sidebar to mark all of that source's
messages read (clears the badge + reduces the All-sources total); click the
All-sources total to mark everything read.
- POST /feed/mark-read (session, owner-scoped, excludes trashed). Visible feed
rows drop their unread state too. Badge is keyboard-activatable (Enter/Space).
156 tests pass (4 new); inline JS re-validated with node --check.
Co-Authored-By: Claude Opus 4.8
---
CHANGELOG.md | 11 ++++++-
artifacts/urls.py | 1 +
artifacts/views.py | 15 +++++++++
daybook_project/__init__.py | 2 +-
static/agentfeed.css | 12 +++++++
templates/artifacts/feed.html | 59 +++++++++++++++++++++++++++++++++--
tests/views/test_trash.py | 28 +++++++++++++++++
7 files changed, 123 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 318a2a1..6ff33af 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,14 @@ former name, "Daybook".)
_Nothing yet._
+## [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
@@ -235,7 +243,8 @@ First tagged release.
- Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a
runbook (`deploy/cloud_agent_deploy.md`) and health check.
-[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.6.3...HEAD
+[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.7.0...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.1]: https://git.cygnus.systems/peter/daybook/compare/v0.6.0...v0.6.1
diff --git a/artifacts/urls.py b/artifacts/urls.py
index 158b3d1..44bab7f 100644
--- a/artifacts/urls.py
+++ b/artifacts/urls.py
@@ -13,6 +13,7 @@ urlpatterns = [
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"),
+ path("feed/mark-read", views.mark_all_read, name="feed-mark-read"),
path("a/", views.artifact_view, name="artifact"),
path("s/", views.shared_artifact_view, name="artifact-shared"),
path("assets/", views.asset_view, name="asset"),
diff --git a/artifacts/views.py b/artifacts/views.py
index 055c861..172b8c7 100644
--- a/artifacts/views.py
+++ b/artifacts/views.py
@@ -696,6 +696,21 @@ def empty_trash(request):
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
def feed_older(request):
"""Return the next page of older day-groups as an HTML fragment for infinite scroll."""
diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py
index b040cb3..f00cfaa 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.6.3"
+__version__ = "0.7.0"
diff --git a/static/agentfeed.css b/static/agentfeed.css
index ca19527..7c8110d 100644
--- a/static/agentfeed.css
+++ b/static/agentfeed.css
@@ -514,6 +514,18 @@ h1 {
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,
.artifact-time {
color: var(--muted);
diff --git a/templates/artifacts/feed.html b/templates/artifacts/feed.html
index daa2182..4dfe047 100644
--- a/templates/artifacts/feed.html
+++ b/templates/artifacts/feed.html
@@ -37,12 +37,12 @@
@@ -620,6 +620,59 @@
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) => {
navigationMode = "artifacts";
markRead(row);
diff --git a/tests/views/test_trash.py b/tests/views/test_trash.py
index e04601e..28faf70 100644
--- a/tests/views/test_trash.py
+++ b/tests/views/test_trash.py
@@ -128,3 +128,31 @@ class TrashKeepViewTestCase(TestCase):
self.client.post(reverse("artifact-delete", args=[fresh.id]))
self.assertFalse(Artifact.objects.filter(pk=old.id).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)
From fcd7da853ff9631e37dd570ee4079a6ecac9dde2 Mon Sep 17 00:00:00 2001
From: peter
Date: Mon, 7 Sep 2026 23:23:53 +0100
Subject: [PATCH 4/5] 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 5/5] 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 %}
+