Compare commits

..

4 commits
v0.6.2 ... main

Author SHA1 Message Date
c2a354809a 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 <noreply@anthropic.com>
2026-09-08 15:19:54 +01:00
fcd7da853f docs: say 'API key' instead of 'token' in agent-facing wording
Sweep human-facing 'personal API token' / '<your-personal-token>'
placeholders to 'API key' / '<your-personal-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 <noreply@anthropic.com>
2026-09-07 23:23:53 +01:00
89936ebf58 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 <noreply@anthropic.com>
2026-09-07 22:26:48 +01:00
7b161f1276 v0.6.3: UI wording -> "messages"; folder spacing + tint
- User-facing "artifact(s)" -> "messages" across the UI (feed count, empty
  states, preview, About, Storage). Model/API/skill keep "artifact" internally
  — no agent-contract change.
- Sidebar: Keep/Deleted folders get spacing between them and share the "All
  sources" bold + blue-tint styling (special folders).
- Fix: About page points to Settings -> API Keys (was stale "Settings ->
  Account / API token").

152 tests pass; inline JS re-validated with node --check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-07 21:37:08 +01:00
23 changed files with 754 additions and 77 deletions

View file

@ -9,6 +9,46 @@ 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
- **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
### Changed
@ -221,7 +261,9 @@ 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.2...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
[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.
- 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`.

View file

@ -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')},
},
),
]

View file

@ -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

View file

@ -10,9 +10,11 @@ urlpatterns = [
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>/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>/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/<str:artifact_id>", views.artifact_view, name="artifact"),
path("s/<str:share_token>", views.shared_artifact_view, name="artifact-shared"),
path("assets/<str:asset_id>", views.asset_view, name="asset"),
@ -27,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"),

View file

@ -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):
@ -696,6 +723,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."""
@ -1492,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,
},
)
@ -1525,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:

View file

@ -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.2"
__version__ = "0.7.1"

View file

@ -15,7 +15,7 @@ Also confirms your token is valid:
```powershell
Invoke-RestMethod "http://127.0.0.1:8001/api/capabilities" `
-Headers @{ Authorization = "Bearer <your-personal-token>" }
-Headers @{ Authorization = "Bearer <your-personal-key>" }
```
## 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 <your-personal-token>" } `
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
-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 <your-personal-token>" } `
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
-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 <your-personal-token>" `
-H "Authorization: Bearer <your-personal-key>" `
-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 <your-personal-token>" } `
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
-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 <your-personal-token>" }
-Headers @{ Authorization = "Bearer <your-personal-key>" }
```
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
POST /api/artifacts
Content-Type: application/json
Authorization: Bearer <your-personal-token>
Authorization: Bearer <your-personal-key>
```
Required fields:
@ -188,7 +188,7 @@ $body = @{
Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts" `
-Method Post `
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
-ContentType "application/json" `
-Body $body
```
@ -241,7 +241,7 @@ Endpoint:
```http
POST /api/artifacts
Content-Type: multipart/form-data
Authorization: Bearer <your-personal-token>
Authorization: Bearer <your-personal-key>
```
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 <your-personal-token>" `
-H "Authorization: Bearer <your-personal-key>" `
-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 <your-personal-token>
Authorization: Bearer <your-personal-key>
```
| Field | Notes |
@ -392,7 +392,7 @@ Endpoint:
```http
PATCH /api/artifacts/{id}
Content-Type: application/json
Authorization: Bearer <your-personal-token>
Authorization: Bearer <your-personal-key>
```
Editable fields:
@ -438,7 +438,7 @@ $body = @{
Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" `
-Method Patch `
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
-ContentType "application/json" `
-Body $body
```
@ -449,7 +449,7 @@ Endpoint:
```http
DELETE /api/artifacts/{id}
Authorization: Bearer <your-personal-token>
Authorization: Bearer <your-personal-key>
```
**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 <your-personal-token>"
-H "Authorization: Bearer <your-personal-key>"
```
## 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
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 <your-personal-token>" \
-H "Authorization: Bearer <your-personal-key>" \
-H "Content-Type: application/json" \
-d '{
"source": "deploy-smoke",

View file

@ -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
```

View file

@ -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,
)

View file

@ -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);
@ -1132,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 {
@ -1139,20 +1193,25 @@ kbd {
display: flex;
flex-direction: column;
flex-shrink: 0;
padding: 0.4rem 0.6rem 0.2rem;
gap: 0.25rem; /* space Keep / Deleted apart */
padding: 0.4rem 0.6rem 0.3rem;
}
/* "All sources" — the primary entry, visually distinct from individual sources:
bold, with a subtle blue tint (works over both light and dark surfaces). */
.all-sources-link {
/* "Special" sidebar entries — "All sources" and the Keep/Deleted folders —
share a distinct look (bold + subtle blue tint) to set them apart from the
individual sources. */
.all-sources-link,
.folder-link {
font-weight: 600;
}
.all-sources-link:not(.selected) {
.all-sources-link:not(.selected),
.folder-link:not(.selected) {
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);
}
@ -1181,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%;

View file

@ -37,32 +37,26 @@
<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">
<span class="source-name">All sources</span>
{% if total_count %}<span class="source-count">{{ total_count }}</span>{% endif %}
{% if total_count %}<span class="source-count" data-mark-read role="button" tabindex="0" title="Mark all as read">{{ total_count }}</span>{% endif %}
</a>
{% 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">
<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 }}">
<span class="source-name">{{ entry.source }}</span>
{% if entry.count %}<span class="source-count">{{ entry.count }}</span>{% endif %}
{% if entry.count %}<span class="source-count" data-mark-read role="button" tabindex="0" title="Mark all as read">{{ entry.count }}</span>{% endif %}
</a>
{% endfor %}
</nav>
{% if keep_count or trash_count %}
<nav class="system-folders" aria-label="Folders">
{% if keep_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">
<span class="source-name">&#9733; Keep</span>
<span class="source-count">{{ keep_count }}</span>
</a>
{% endif %}
{% if trash_count %}
<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 %}
<nav class="system-folders" aria-label="Folders">
<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">
<span class="source-name">&#9733; Keep</span>
{% if keep_count %}<span class="source-count">{{ keep_count }}</span>{% endif %}
</a>
<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>
{% if trash_count %}<span class="source-count">{{ trash_count }}</span>{% endif %}
</a>
</nav>
</aside>
<div class="splitter" data-splitter="days" role="separator" aria-label="Resize sources sidebar" aria-orientation="vertical"></div>
@ -73,7 +67,7 @@
{% if selected_folder == 'deleted' %}
<button type="button" class="empty-trash-button" data-empty-trash>Empty Trash</button>
{% else %}
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
<span>{{ artifacts|length }} message{{ artifacts|length|pluralize }}</span>
{% endif %}
</div>
@ -82,7 +76,7 @@
{% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %}
{% else %}
<div class="empty compact">
<h3>No artifacts</h3>
<h3>No messages</h3>
<p>Nothing in the last {{ 7 }} days for these filters.</p>
</div>
{% endif %}
@ -94,7 +88,7 @@
<section class="preview-pane">
<header class="pane-bar preview-actions" data-actions>
<div class="preview-title" data-preview-title {% if selected_artifact %}title="{{ selected_artifact.title }}"{% endif %}>
{% if selected_artifact %}{{ selected_artifact.title }}{% else %}No artifact loaded{% endif %}
{% if selected_artifact %}{{ selected_artifact.title }}{% else %}No message loaded{% endif %}
</div>
<nav class="view-toggle" aria-label="Preview mode">
<a class="{% if selected_view == 'rendered' %}selected{% endif %}{% if not selected_artifact %} disabled{% endif %}" href="{% if selected_artifact %}{{ rendered_url }}{% else %}#{% endif %}" data-view="rendered" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Rendered</a>
@ -124,8 +118,8 @@
<img class="preview-image" src="{% url 'artifact' selected_artifact.id %}" alt="{{ selected_artifact.title }}">
{% elif selected_artifact.kind == "binary" %}
<div class="empty preview-empty">
<h2>Binary Artifact</h2>
<p>This artifact type cannot be previewed inline yet.</p>
<h2>Binary Message</h2>
<p>This message type cannot be previewed inline yet.</p>
<p><a href="{% url 'artifact' selected_artifact.id %}">Open or download it</a>.</p>
</div>
{% else %}
@ -135,10 +129,10 @@
{% else %}
<div class="empty preview-empty">
{% if artifacts %}
<h2>No artifact loaded</h2>
<p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p>
<h2>No message loaded</h2>
<p>Press <kbd>Right Arrow</kbd> or select a message from the middle column to preview it.</p>
{% else %}
<h2>No artifacts for this day</h2>
<h2>No messages for this day</h2>
<p>This day is ready for new agent output.</p>
{% endif %}
</div>
@ -152,6 +146,18 @@
<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="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>
<script>
@ -239,7 +245,7 @@
try {
await navigator.clipboard.writeText(absoluteUrl);
} catch {
window.prompt("Copy artifact link", absoluteUrl);
window.prompt("Copy message link", absoluteUrl);
}
const original = button.textContent;
button.textContent = "Copied";
@ -348,7 +354,7 @@
if (!actions) return;
const previewTitle = actions.querySelector("[data-preview-title]");
if (previewTitle) {
previewTitle.textContent = "No artifact loaded";
previewTitle.textContent = "No message loaded";
previewTitle.removeAttribute("title");
}
actions.querySelectorAll("[data-view]").forEach((link) => {
@ -418,13 +424,17 @@
const bumpSourceBadge = (link, delta) => {
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) {
badge = document.createElement("span");
badge.className = "source-count";
badge.textContent = "0";
link.appendChild(badge);
}
badge.textContent = String((Number(badge.textContent) || 0) + delta);
badge.textContent = String(next);
};
const addSourceLink = (name, count) => {
@ -484,7 +494,7 @@
const header = document.querySelector(".artifact-sidebar .pane-heading span");
if (!header) return;
const total = Math.max(0, (parseInt(header.textContent, 10) || 0) + delta);
header.textContent = `${total} artifact${total === 1 ? "" : "s"}`;
header.textContent = `${total} message${total === 1 ? "" : "s"}`;
};
const bumpFolderCount = (folder, delta) => {
@ -553,11 +563,22 @@
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) => {
if (!rowMenu || !row) return;
menuRowId = row.dataset.artifactId;
const keepBtn = rowMenu.querySelector('[data-menu-action="keep"]');
if (keepBtn) keepBtn.textContent = row.dataset.kept === "1" ? "Remove from Keep" : "Keep";
refreshMenuLabelState(row);
rowMenu.hidden = false;
const rect = rowMenu.getBoundingClientRect();
const left = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8));
@ -589,8 +610,62 @@
} 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) {
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]");
if (!btn) return;
const row = currentMenuRow();
@ -620,6 +695,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);
@ -632,7 +760,7 @@
: kind === "image"
? `<img class="preview-image" src="${source}" alt="${title}">`
: kind === "binary"
? `<div class="empty preview-empty"><h2>Binary Artifact</h2><p>This artifact type cannot be previewed inline yet.</p><p><a href="${source}">Open or download it</a>.</p></div>`
? `<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>`
: `<iframe class="preview-frame" src="${source}" title="${title}"></iframe>`;
previewBody.innerHTML = `<div class="preview-frame-wrap">${content}</div>`;
@ -647,7 +775,7 @@
}
allRows().forEach((candidate) => candidate.classList.remove("selected"));
disableActions();
previewBody.innerHTML = `<div class="empty preview-empty"><h2>No artifact loaded</h2><p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p></div>`;
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>`;
window.history.pushState({}, "", noPreviewUrl());
const currentSource = document.querySelector(".source-link.selected");
if (currentSource) currentSource.focus({preventScroll: true});
@ -885,7 +1013,7 @@
const header = document.querySelector(".artifact-sidebar .pane-heading span");
if (header) {
const total = (parseInt(header.textContent, 10) || 0) + data.count;
header.textContent = `${total} artifact${total === 1 ? "" : "s"}`;
header.textContent = `${total} message${total === 1 ? "" : "s"}`;
}
} catch {
// ignore; retry next tick

View file

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

View file

@ -10,4 +10,27 @@
revocable on its own. You have {{ key_count }} key{{ key_count|pluralize }}.
</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 %}

View file

@ -29,6 +29,7 @@
<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 == '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 == 'demo-data' %}selected{% endif %}" href="{% url 'settings-demo-data' %}">Demo Data</a>
</div>

View file

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

View file

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

View file

@ -0,0 +1,54 @@
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

@ -0,0 +1,113 @@
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,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)