Compare commits

..

2 commits
v0.7.0 ... 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
19 changed files with 582 additions and 43 deletions

View file

@ -9,6 +9,24 @@ former name, "Daybook".)
_Nothing yet._ _Nothing yet._
## [0.7.1] — 2026-09-08
### Added
- **Custom labels.** Curate a personal palette of coloured labels in
**Settings → Labels**, then apply them to any message from its ⋯ menu (or
right-click) — the menu stays open so you can apply several at once, with a
checkmark showing which are applied. Labels are unified with tags: applying
one adds it to the message's tags and renders as a chip. Backed by
`POST /a/{id}/label` (session-auth, owner-scoped, palette-validated); chip
colours sync to the global `TagColor`.
- **Change your password in-app** from **Settings → Account**: enter the current
password, then a new one. Uses Django's `PasswordChangeForm` and keeps the
session logged in after the change.
### Changed
- Sidebar: the **Keep** and **Deleted** folders are now always shown (even when
empty); the count badge only appears when the folder is non-empty.
## [0.7.0] — 2026-09-07 ## [0.7.0] — 2026-09-07
### Added ### Added

View file

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

View file

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

View file

@ -10,6 +10,7 @@ urlpatterns = [
path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"), path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"),
path("a/<str:artifact_id>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"), path("a/<str:artifact_id>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"),
path("a/<str:artifact_id>/share", views.toggle_artifact_share, name="artifact-toggle-share"), path("a/<str:artifact_id>/share", views.toggle_artifact_share, name="artifact-toggle-share"),
path("a/<str:artifact_id>/label", views.toggle_artifact_label, name="artifact-toggle-label"),
path("a/<str:artifact_id>/delete", views.delete_artifact, name="artifact-delete"), path("a/<str:artifact_id>/delete", views.delete_artifact, name="artifact-delete"),
path("a/<str:artifact_id>/undelete", views.undelete_artifact, name="artifact-undelete"), path("a/<str:artifact_id>/undelete", views.undelete_artifact, name="artifact-undelete"),
path("feed/empty-trash", views.empty_trash, name="feed-empty-trash"), path("feed/empty-trash", views.empty_trash, name="feed-empty-trash"),
@ -28,6 +29,7 @@ urlpatterns = [
path("settings/", views.settings_home, name="settings-home"), path("settings/", views.settings_home, name="settings-home"),
path("settings/account/", views.settings_account, name="settings-account"), path("settings/account/", views.settings_account, name="settings-account"),
path("settings/api-keys/", views.settings_api_keys, name="settings-api-keys"), path("settings/api-keys/", views.settings_api_keys, name="settings-api-keys"),
path("settings/labels/", views.settings_labels, name="settings-labels"),
path("settings/users/", views.settings_users, name="settings-users"), path("settings/users/", views.settings_users, name="settings-users"),
path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"), path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"),
path("settings/storage/", views.settings_storage, name="settings-storage"), path("settings/storage/", views.settings_storage, name="settings-storage"),

View file

@ -15,8 +15,9 @@ import nh3
import openpyxl import openpyxl
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import 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.decorators import login_required
from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import OperationalError, transaction from django.db import OperationalError, transaction
from django.db.models import Count, Max, Q, Sum from django.db.models import Count, Max, Q, Sum
@ -36,7 +37,7 @@ from rest_framework.views import APIView
from .demo_data import create_demo_artifacts from .demo_data import create_demo_artifacts
from .forms import AgentFeedLoginForm from .forms import AgentFeedLoginForm
from .models import ApiKey, Artifact, Asset, TagColor from .models import ApiKey, Artifact, Asset, Label, TagColor
from .storage import ( from .storage import (
QuotaExceeded, QuotaExceeded,
SIDECAR_SUFFIXES, SIDECAR_SUFFIXES,
@ -625,6 +626,7 @@ def feed(request):
"today": today, "today": today,
"title_query": title_query, "title_query": title_query,
"kind_choices": Artifact.KIND_CHOICES, "kind_choices": Artifact.KIND_CHOICES,
"label_palette": request.user.labels.all(),
}, },
) )
@ -639,6 +641,31 @@ def mark_artifact_read(request, artifact_id):
return JsonResponse({"id": artifact.id, "read_at": artifact.read_at.isoformat()}) return JsonResponse({"id": artifact.id, "read_at": artifact.read_at.isoformat()})
@login_required
@require_POST
def toggle_artifact_label(request, artifact_id):
"""Toggle a palette label on/off for one of the user's own messages. The
label name is written into/removed from the artifact's `tags` list (labels
are unified with tags). Only names in the user's palette are accepted."""
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user, deleted_at__isnull=True)
name = (request.POST.get("name") or "").strip()
label = Label.objects.filter(user=request.user, name=name).first()
if label is None:
return JsonResponse({"error": "No such label in your palette."}, status=400)
tags = list(artifact.tags)
if name in tags:
tags.remove(name)
applied = False
else:
tags.append(name)
applied = True
artifact.tags = tags
artifact.save(update_fields=["tags"])
# Keep the chip colour in sync with the palette (TagColor is global).
TagColor.objects.update_or_create(name=name, defaults={"color": label.color})
return JsonResponse({"applied": applied, "name": name, "color": label.color, "tags": tags})
@login_required @login_required
@require_POST @require_POST
def toggle_artifact_keep(request, artifact_id): def toggle_artifact_keep(request, artifact_id):
@ -1507,10 +1534,25 @@ def settings_home(request):
@login_required @login_required
def settings_account(request): def settings_account(request):
if request.method == "POST":
password_form = PasswordChangeForm(request.user, request.POST)
if password_form.is_valid():
user = password_form.save()
# Keep the current session valid — changing the password rotates the
# session auth hash, which would otherwise log the user out.
update_session_auth_hash(request, user)
messages.success(request, "Password changed.")
return redirect("settings-account")
else:
password_form = PasswordChangeForm(request.user)
return render( return render(
request, request,
"artifacts/settings_account.html", "artifacts/settings_account.html",
{"active_section": "account", "key_count": request.user.api_keys.count()}, {
"active_section": "account",
"key_count": request.user.api_keys.count(),
"password_form": password_form,
},
) )
@ -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 @login_required
def settings_users(request): def settings_users(request):
if not request.user.is_superuser: if not request.user.is_superuser:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1144,6 +1144,48 @@ kbd {
color: var(--danger-text-plain); color: var(--danger-text-plain);
} }
.row-menu-divider {
border-top: 1px solid var(--grid-line);
margin: 0.25rem 0;
}
.row-menu-labels {
display: flex;
flex-direction: column;
max-height: 13rem;
overflow-y: auto;
}
.row-menu-label {
align-items: center;
display: flex;
gap: 0.45rem;
}
.row-menu-dot {
border-radius: 50%;
flex: 0 0 auto;
height: 0.7rem;
width: 0.7rem;
}
.row-menu-label-name {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Checkmark only visible once the label is applied to the row. */
.row-menu-check {
flex: 0 0 auto;
opacity: 0;
}
.row-menu-label.applied .row-menu-check {
opacity: 1;
}
/* System folders (Keep / Deleted): pinned at the bottom of the sidebar, /* System folders (Keep / Deleted): pinned at the bottom of the sidebar,
below the scrolling source list. */ below the scrolling source list. */
.system-folders { .system-folders {
@ -1198,6 +1240,32 @@ kbd {
gap: 0.3rem; gap: 0.3rem;
} }
.password-change {
display: flex;
flex-direction: column;
gap: 0.85rem;
max-width: 22rem;
}
.password-change .form-field {
display: flex;
flex-direction: column;
font-weight: 600;
gap: 0.3rem;
}
.password-change button {
align-self: start;
}
.form-errors {
color: var(--danger, #d1453b);
font-weight: 400;
list-style: none;
margin: 0.15rem 0 0;
padding: 0;
}
.api-key-table { .api-key-table {
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;

View file

@ -47,22 +47,16 @@
{% endfor %} {% endfor %}
</nav> </nav>
{% if keep_count or trash_count %} <nav class="system-folders" aria-label="Folders">
<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">
{% if keep_count %} <span class="source-name">&#9733; Keep</span>
<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"> {% if keep_count %}<span class="source-count">{{ keep_count }}</span>{% endif %}
<span class="source-name">&#9733; Keep</span> </a>
<span class="source-count">{{ keep_count }}</span> <a class="source-link folder-link {% if selected_folder == 'deleted' %}selected{% endif %}" href="?folder=deleted&amp;view={{ selected_view }}&amp;preview=none" data-folder-link="deleted">
</a> <span class="source-name">&#128465; Deleted</span>
{% endif %} {% if trash_count %}<span class="source-count">{{ trash_count }}</span>{% endif %}
{% if trash_count %} </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"> </nav>
<span class="source-name">&#128465; Deleted</span>
<span class="source-count">{{ trash_count }}</span>
</a>
{% endif %}
</nav>
{% endif %}
</aside> </aside>
<div class="splitter" data-splitter="days" role="separator" aria-label="Resize sources sidebar" aria-orientation="vertical"></div> <div class="splitter" data-splitter="days" role="separator" aria-label="Resize sources sidebar" aria-orientation="vertical"></div>
@ -152,6 +146,18 @@
<button type="button" data-menu-action="open">Open</button> <button type="button" data-menu-action="open">Open</button>
<button type="button" data-menu-action="copy">Copy link</button> <button type="button" data-menu-action="copy">Copy link</button>
<button type="button" data-menu-action="delete" class="danger">Delete</button> <button type="button" data-menu-action="delete" class="danger">Delete</button>
{% if label_palette %}
<div class="row-menu-divider" role="separator"></div>
<div class="row-menu-labels">
{% for label in label_palette %}
<button type="button" class="row-menu-label" data-label-name="{{ label.name }}" data-label-color="{{ label.color }}">
<span class="row-menu-dot" style="background:{{ label.color }}"></span>
<span class="row-menu-label-name">{{ label.name }}</span>
<span class="row-menu-check" aria-hidden="true">&#10003;</span>
</button>
{% endfor %}
</div>
{% endif %}
</div> </div>
<script> <script>
@ -418,13 +424,17 @@
const bumpSourceBadge = (link, delta) => { const bumpSourceBadge = (link, delta) => {
let badge = link.querySelector(".source-count"); let badge = link.querySelector(".source-count");
const next = (badge ? Number(badge.textContent) || 0 : 0) + delta;
if (next <= 0) {
if (badge) badge.remove(); // empty folder/source shows no count badge
return;
}
if (!badge) { if (!badge) {
badge = document.createElement("span"); badge = document.createElement("span");
badge.className = "source-count"; badge.className = "source-count";
badge.textContent = "0";
link.appendChild(badge); link.appendChild(badge);
} }
badge.textContent = String((Number(badge.textContent) || 0) + delta); badge.textContent = String(next);
}; };
const addSourceLink = (name, count) => { const addSourceLink = (name, count) => {
@ -553,11 +563,22 @@
menuRowId = null; menuRowId = null;
}; };
const parseRowTags = (row) =>
(row.dataset.tags || "").split(",").map((t) => t.trim()).filter(Boolean);
const refreshMenuLabelState = (row) => {
const applied = new Set(parseRowTags(row));
rowMenu.querySelectorAll(".row-menu-label").forEach((btn) => {
btn.classList.toggle("applied", applied.has(btn.dataset.labelName));
});
};
const openRowMenu = (row, x, y) => { const openRowMenu = (row, x, y) => {
if (!rowMenu || !row) return; if (!rowMenu || !row) return;
menuRowId = row.dataset.artifactId; menuRowId = row.dataset.artifactId;
const keepBtn = rowMenu.querySelector('[data-menu-action="keep"]'); const keepBtn = rowMenu.querySelector('[data-menu-action="keep"]');
if (keepBtn) keepBtn.textContent = row.dataset.kept === "1" ? "Remove from Keep" : "Keep"; if (keepBtn) keepBtn.textContent = row.dataset.kept === "1" ? "Remove from Keep" : "Keep";
refreshMenuLabelState(row);
rowMenu.hidden = false; rowMenu.hidden = false;
const rect = rowMenu.getBoundingClientRect(); const rect = rowMenu.getBoundingClientRect();
const left = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8)); const left = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8));
@ -589,8 +610,62 @@
} catch (e) { /* leave as-is */ } } catch (e) { /* leave as-is */ }
}; };
const addRowChip = (row, name, color) => {
const main = row.querySelector(".artifact-row-main");
if (!main) return;
let chips = main.querySelector(".tag-chips");
if (!chips) {
chips = document.createElement("span");
chips.className = "tag-chips";
main.appendChild(chips);
}
if ([...chips.querySelectorAll(".tag-chip")].some((c) => c.textContent === name)) return;
const chip = document.createElement("span");
chip.className = "tag-chip";
chip.style.background = color;
chip.textContent = name;
chips.appendChild(chip);
};
const removeRowChip = (row, name) => {
const chips = row.querySelector(".tag-chips");
if (!chips) return;
chips.querySelectorAll(".tag-chip").forEach((c) => { if (c.textContent === name) c.remove(); });
if (!chips.querySelector(".tag-chip")) chips.remove();
};
// Toggle a palette label on the current menu row. Leaves the menu open so
// several labels can be applied in one go.
const toggleLabelFromMenu = async (btn) => {
const row = currentMenuRow();
const id = menuRowId;
if (!row || !id) return;
const name = btn.dataset.labelName;
const body = new URLSearchParams();
body.set("name", name);
let data;
try {
const response = await fetch(`/a/${id}/label`, {
method: "POST",
headers: {"X-CSRFToken": csrfToken, "Content-Type": "application/x-www-form-urlencoded"},
body: body.toString(),
});
if (!response.ok) return;
data = await response.json();
} catch (e) { return; }
row.dataset.tags = data.tags.join(", ");
btn.classList.toggle("applied", data.applied);
if (data.applied) addRowChip(row, data.name, data.color);
else removeRowChip(row, data.name);
};
if (rowMenu) { if (rowMenu) {
rowMenu.addEventListener("click", (event) => { rowMenu.addEventListener("click", (event) => {
const labelBtn = event.target.closest(".row-menu-label");
if (labelBtn) {
toggleLabelFromMenu(labelBtn);
return; // keep the menu open for applying multiple labels
}
const btn = event.target.closest("[data-menu-action]"); const btn = event.target.closest("[data-menu-action]");
if (!btn) return; if (!btn) return;
const row = currentMenuRow(); const row = currentMenuRow();

View file

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

View file

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

View file

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

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