Compare commits

..

8 commits
v0.5.0 ... HEAD

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
c1be2face7 v0.6.2: order sources by most recent message (recency)
- Sidebar sources ordered by Max(created_at) desc instead of alphabetically;
  "All sources" stays pinned at top (rendered separately).
- Live updates move a source to the top when it gets a new message, so the
  recency order holds without a reload.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-07 21:18:49 +01:00
ab3e92268a v0.6.1: pin Keep/Deleted folders to sidebar bottom; highlight All sources
- Move the Keep/Deleted system folders out of the scrolling source list into a
  pinned section at the bottom of the sidebar (only sources scroll now), so the
  folders stay reachable with many sources. Renamed .source-folders ->
  .system-folders; .source-list flexes to fill.
- "All sources" is visually distinct (bold + subtle blue tint) as the primary
  entry.

Template/CSS only. Feed/trash rendering tests pass; feed renders in all views.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-07 21:07:39 +01:00
548ba3a669 v0.6.0: per-artifact actions menu (⋯ + right-click)
A ⋯ button on row hover and right-click on a feed row open the same floating
menu: Keep / Open / Copy link / Delete. Dismisses on click-away/Escape,
edge-flips to stay on-screen. Standalone one-click Delete icon stays; the
Trash view keeps its dedicated Undelete.

- Row markup: actions grouped in a .row-actions container (delete + ⋯ in the
  feed/keep views; undelete in trash). Reuses the existing keep endpoint.
- Menu only in the normal/keep views, not trash. Keep-toggle syncs the row's
  kept badge + the preview Keep button, and (in the Keep folder) drops the row
  when unkept.
- Frontend-only (no backend change); inline JS validated with node --check;
  152 tests pass; feed renders in all views.

Share + Add-label join the menu in later slices.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-07 13:12:27 +01:00
57ad58ff2d v0.5.1: automatic Trash purge (cron-less background thread)
Trashed artifacts past a ~3-day retention window are now purged automatically.

- storage.purge_expired_trash(): removes trash older than TRASH_RETENTION
  (all users), tolerant of already-gone rows (concurrent-safe).
- spawn_trash_purge(): runs it on a daemon thread (manages/closes its own DB
  connection), triggered by each delete so it's cron-less and off the request
  thread. Inline fallback when AGENTFEED_ASYNC_PURGE is off (tests).
- Wired into both delete paths (API + session). Manual Empty Trash unchanged.
- Tests: purge removes expired-only; delete triggers the purge. 152 passing;
  live delete verified with the thread enabled (204, server healthy).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-07 13:01:12 +01:00
27 changed files with 1058 additions and 106 deletions

View file

@ -9,6 +9,82 @@ former name, "Daybook".)
_Nothing yet._ _Nothing yet._
## [0.7.1] — 2026-09-08
### Added
- **Custom labels.** Curate a personal palette of coloured labels in
**Settings → Labels**, then apply them to any message from its ⋯ menu (or
right-click) — the menu stays open so you can apply several at once, with a
checkmark showing which are applied. Labels are unified with tags: applying
one adds it to the message's tags and renders as a chip. Backed by
`POST /a/{id}/label` (session-auth, owner-scoped, palette-validated); chip
colours sync to the global `TagColor`.
- **Change your password in-app** from **Settings → Account**: enter the current
password, then a new one. Uses Django's `PasswordChangeForm` and keeps the
session logged in after the change.
### Changed
- Sidebar: the **Keep** and **Deleted** folders are now always shown (even when
empty); the count badge only appears when the folder is non-empty.
## [0.7.0] — 2026-09-07
### Added
- **Mark all as read.** Click a source's unread badge in the sidebar to mark
all of that source's messages read (clears the badge); click the **All
sources** total to mark everything read. Owner-scoped, excludes trashed; any
visible feed rows update too. Backed by `POST /feed/mark-read`.
## [0.6.3] — 2026-09-07
### Changed
- **User-facing wording: "artifact(s)" → "messages"** throughout the UI (feed
count, empty states, preview, About, Storage). The model, API
(`/api/artifacts`), and posting skill keep "artifact" as the internal term —
no contract change for agents.
- Sidebar: the Keep/Deleted folders now have spacing between them and share the
"All sources" bold+blue-tint styling (they're special folders).
### Fixed
- About page now points to Settings → API Keys (was the stale "Settings →
Account / personal API token").
## [0.6.2] — 2026-09-07
### Changed
- Source sidebar is now ordered by **most recent message** (Telegram-style)
instead of alphabetically; "All sources" stays pinned at the top. Live
updates move a source to the top when it receives a new message.
## [0.6.1] — 2026-09-07
### Changed
- Sidebar: the **Keep / Deleted folders are pinned to the bottom** — only the
source list scrolls, so the folders stay reachable no matter how many sources
you have. **"All sources"** is now visually distinct (bold + subtle blue tint)
as the primary entry. (Internal: `.source-folders` class renamed to
`.system-folders`.)
## [0.6.0] — 2026-09-07
### Added
- **Per-artifact actions menu.** A **⋯** button appears on row hover, and
**right-clicking** a feed row opens the same floating menu — **Keep /
Open / Copy link / Delete**. Dismisses on click-away or Escape, edge-flips
to stay on-screen. The standalone one-click Delete icon stays; the Trash
view keeps its dedicated Undelete. (Share and Add-label will join the menu
in later slices.)
## [0.5.1] — 2026-09-07
### Added
- **Automatic Trash purge.** Trashed artifacts older than a ~3-day retention
window are now permanently removed automatically. It's cron-less: a delete
triggers a sweep that runs on a **background thread** (off the request, so
the click never blocks), tolerant of concurrent runs. Manual "Empty Trash"
still works for immediate reclaim. Controlled by `AGENTFEED_ASYNC_PURGE`
(on by default; tests run it inline).
## [0.5.0] — 2026-09-07 ## [0.5.0] — 2026-09-07
### Added ### Added
@ -185,7 +261,13 @@ First tagged release.
- Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a - Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a
runbook (`deploy/cloud_agent_deploy.md`) and health check. runbook (`deploy/cloud_agent_deploy.md`) and health check.
[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.5.0...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
[0.5.1]: https://git.cygnus.systems/peter/daybook/compare/v0.5.0...v0.5.1
[0.5.0]: https://git.cygnus.systems/peter/daybook/compare/v0.4.1...v0.5.0 [0.5.0]: https://git.cygnus.systems/peter/daybook/compare/v0.4.1...v0.5.0
[0.4.1]: https://git.cygnus.systems/peter/daybook/compare/v0.4.0...v0.4.1 [0.4.1]: https://git.cygnus.systems/peter/daybook/compare/v0.4.0...v0.4.1
[0.4.0]: https://git.cygnus.systems/peter/daybook/compare/v0.3.2...v0.4.0 [0.4.0]: https://git.cygnus.systems/peter/daybook/compare/v0.3.2...v0.4.0

View file

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

@ -7,9 +7,11 @@ SUM rather than a stat of every file.
""" """
import shutil import shutil
import threading
from datetime import timedelta from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.db import connection
from django.db.models import F, Sum from django.db.models import F, Sum
from django.utils import timezone from django.utils import timezone
@ -154,6 +156,45 @@ def purge_trashed(user):
return len(trashed) return len(trashed)
TRASH_RETENTION = timedelta(days=3)
def purge_expired_trash(now=None):
"""Permanently remove trashed artifacts older than the retention window
(all users). Tolerant of already-gone rows so concurrent purges are safe.
Returns the number purged."""
now = now or timezone.now()
cutoff = now - TRASH_RETENTION
expired = list(Artifact.objects.filter(deleted_at__isnull=False, deleted_at__lt=cutoff))
purged = 0
for artifact in expired:
try:
delete_artifact_files(artifact)
artifact.delete()
purged += 1
except Exception:
# Another concurrent purge got there first, or a file's already
# gone — a purge error must never surface to anyone.
pass
return purged
def spawn_trash_purge():
"""Run purge_expired_trash off the request thread (cron-less: triggered by
deletes). Falls back to inline when async is disabled (tests)."""
if not getattr(settings, "AGENTFEED_ASYNC_PURGE", True):
purge_expired_trash()
return
def _run():
try:
purge_expired_trash()
finally:
connection.close() # threads get their own connection; don't leak it
threading.Thread(target=_run, daemon=True).start()
ASSET_ORPHAN_GRACE = timedelta(hours=24) ASSET_ORPHAN_GRACE = timedelta(hours=24)

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>/read", views.mark_artifact_read, name="artifact-mark-read"),
path("a/<str:artifact_id>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"), path("a/<str:artifact_id>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"),
path("a/<str:artifact_id>/share", views.toggle_artifact_share, name="artifact-toggle-share"), path("a/<str:artifact_id>/share", views.toggle_artifact_share, name="artifact-toggle-share"),
path("a/<str:artifact_id>/label", views.toggle_artifact_label, name="artifact-toggle-label"),
path("a/<str:artifact_id>/delete", views.delete_artifact, name="artifact-delete"), path("a/<str:artifact_id>/delete", views.delete_artifact, name="artifact-delete"),
path("a/<str:artifact_id>/undelete", views.undelete_artifact, name="artifact-undelete"), path("a/<str:artifact_id>/undelete", views.undelete_artifact, name="artifact-undelete"),
path("feed/empty-trash", views.empty_trash, name="feed-empty-trash"), path("feed/empty-trash", views.empty_trash, name="feed-empty-trash"),
path("feed/mark-read", views.mark_all_read, name="feed-mark-read"),
path("a/<str:artifact_id>", views.artifact_view, name="artifact"), path("a/<str:artifact_id>", views.artifact_view, name="artifact"),
path("s/<str:share_token>", views.shared_artifact_view, name="artifact-shared"), path("s/<str:share_token>", views.shared_artifact_view, name="artifact-shared"),
path("assets/<str:asset_id>", views.asset_view, name="asset"), path("assets/<str:asset_id>", views.asset_view, name="asset"),
@ -27,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,11 +15,12 @@ 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, Q, Sum from django.db.models import Count, Max, Q, Sum
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.template.defaultfilters import date as date_filter, slugify from django.template.defaultfilters import date as date_filter, slugify
@ -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,
@ -45,6 +46,7 @@ from .storage import (
disk_free_bytes, disk_free_bytes,
make_room_for, make_room_for,
purge_trashed, purge_trashed,
spawn_trash_purge,
sweep_orphan_assets, sweep_orphan_assets,
total_allocated_bytes, total_allocated_bytes,
user_quota_bytes, user_quota_bytes,
@ -305,6 +307,7 @@ class ArtifactDetailView(APIView):
if artifact.deleted_at is None: if artifact.deleted_at is None:
artifact.deleted_at = timezone.now() artifact.deleted_at = timezone.now()
artifact.save(update_fields=["deleted_at"]) artifact.save(update_fields=["deleted_at"])
spawn_trash_purge() # sweep expired trash off the request thread
return Response(status=204) return Response(status=204)
@ -584,10 +587,12 @@ def feed(request):
source_scoped = apply_feed_filters( source_scoped = apply_feed_filters(
live, "", selected_label, selected_kind, title_query live, "", selected_label, selected_kind, title_query
) )
# Sources ordered by most recent message first (Telegram-style); "All
# sources" is rendered separately and stays pinned at the top.
source_counts = ( source_counts = (
source_scoped.values("source") source_scoped.values("source")
.annotate(count=Count("id", filter=Q(read_at__isnull=True))) .annotate(count=Count("id", filter=Q(read_at__isnull=True)), latest=Max("created_at"))
.order_by("source") .order_by("-latest")
) )
keep_count = live.filter(is_kept=True).count() keep_count = live.filter(is_kept=True).count()
trash_count = Artifact.objects.filter(owner=request.user, deleted_at__isnull=False).count() trash_count = Artifact.objects.filter(owner=request.user, deleted_at__isnull=False).count()
@ -621,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(),
}, },
) )
@ -635,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):
@ -669,6 +700,7 @@ def delete_artifact(request, artifact_id):
if artifact.deleted_at is None: if artifact.deleted_at is None:
artifact.deleted_at = timezone.now() artifact.deleted_at = timezone.now()
artifact.save(update_fields=["deleted_at"]) artifact.save(update_fields=["deleted_at"])
spawn_trash_purge() # sweep expired trash off the request thread
return JsonResponse({"id": artifact.id, "deleted": True}) return JsonResponse({"id": artifact.id, "deleted": True})
@ -691,6 +723,21 @@ def empty_trash(request):
return JsonResponse({"purged": count}) return JsonResponse({"purged": count})
@login_required
@require_POST
def mark_all_read(request):
"""Mark all of the user's unread (non-trashed) messages as read, optionally
scoped to one source. Backs the sidebar 'Mark all as read' action."""
unread = Artifact.objects.filter(
owner=request.user, deleted_at__isnull=True, read_at__isnull=True
)
source = request.POST.get("source", "")
if source:
unread = unread.filter(source=source)
marked = unread.update(read_at=timezone.now())
return JsonResponse({"marked": marked, "source": source})
@login_required @login_required
def feed_older(request): def feed_older(request):
"""Return the next page of older day-groups as an HTML fragment for infinite scroll.""" """Return the next page of older day-groups as an HTML fragment for infinite scroll."""
@ -1487,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,
},
) )
@ -1520,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.5.0" __version__ = "0.7.1"

View file

@ -19,6 +19,10 @@ AGENTFEED_KEY_PROVISIONING_API = os.environ.get(
"AGENTFEED_KEY_PROVISIONING_API", "" "AGENTFEED_KEY_PROVISIONING_API", ""
).lower() in {"1", "true", "yes", "on"} ).lower() in {"1", "true", "yes", "on"}
# Purge expired Trash off the request thread (triggered by deletes, no cron).
# Tests override this to run inline so purge behavior is deterministic.
AGENTFEED_ASYNC_PURGE = True
# Needed when running behind a TLS-terminating reverse proxy (Apache/Nginx/etc.): # Needed when running behind a TLS-terminating reverse proxy (Apache/Nginx/etc.):
# Django otherwise thinks every request is plain HTTP, which breaks CSRF checks # Django otherwise thinks every request is plain HTTP, which breaks CSRF checks
# on login/POST. The proxy must forward X-Forwarded-Proto: https. # on login/POST. The proxy must forward X-Forwarded-Proto: https.

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

@ -360,6 +360,7 @@ h1 {
/* Source sidebar: scrollbar hidden until hover (short list, rarely scrolls). */ /* Source sidebar: scrollbar hidden until hover (short list, rarely scrolls). */
.source-list { .source-list {
flex: 1 1 auto; /* fill available height so system folders pin to the bottom */
scrollbar-color: transparent transparent; scrollbar-color: transparent transparent;
scrollbar-width: none; scrollbar-width: none;
} }
@ -513,6 +514,18 @@ h1 {
font-size: 0.7rem; font-size: 0.7rem;
} }
/* The unread badge doubles as a "mark all as read" button. */
.source-count[data-mark-read] {
cursor: pointer;
border-radius: 8px;
padding: 0 0.35rem;
}
.source-count[data-mark-read]:hover {
background: rgba(74, 110, 168, 0.28);
color: var(--text);
}
.pane-heading span, .pane-heading span,
.artifact-time { .artifact-time {
color: var(--muted); color: var(--muted);
@ -1060,12 +1073,22 @@ kbd {
position: relative; position: relative;
} }
.row-action { .row-actions {
position: absolute; position: absolute;
right: 0.5rem; right: 0.4rem;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
display: flex;
gap: 0.15rem;
opacity: 0; opacity: 0;
}
.artifact-row:hover .row-actions,
.artifact-row:focus-within .row-actions {
opacity: 1;
}
.row-action {
cursor: pointer; cursor: pointer;
font-size: 0.95rem; font-size: 0.95rem;
line-height: 1; line-height: 1;
@ -1075,11 +1098,6 @@ kbd {
color: var(--muted); color: var(--muted);
} }
.artifact-row:hover .row-action,
.artifact-row:focus-within .row-action {
opacity: 1;
}
.row-action:hover { .row-action:hover {
background: var(--surface-alt); background: var(--surface-alt);
color: var(--text); color: var(--text);
@ -1089,11 +1107,112 @@ kbd {
color: var(--danger-text-plain); color: var(--danger-text-plain);
} }
/* System folders (Keep / Deleted) at the bottom of the sidebar */ /* Per-row actions menu (opened by the ⋯ trigger or right-click) */
.source-folders { .row-menu {
position: fixed;
z-index: 100;
min-width: 9rem;
background: var(--surface);
border: 1px solid var(--control-border);
border-radius: 8px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.14);
padding: 0.25rem;
display: flex;
flex-direction: column;
}
.row-menu[hidden] {
display: none;
}
.row-menu button {
background: transparent;
border: 0;
border-radius: 5px;
color: var(--text);
cursor: pointer;
font-size: 0.85rem;
padding: 0.4rem 0.6rem;
text-align: left;
}
.row-menu button:hover {
background: var(--surface-alt);
}
.row-menu button.danger {
color: var(--danger-text-plain);
}
.row-menu-divider {
border-top: 1px solid var(--grid-line); border-top: 1px solid var(--grid-line);
margin-top: 0.4rem; margin: 0.25rem 0;
padding-top: 0.4rem; }
.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 {
border-top: 1px solid var(--grid-line);
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: 0.25rem; /* space Keep / Deleted apart */
padding: 0.4rem 0.6rem 0.3rem;
}
/* "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),
.folder-link:not(.selected) {
background: rgba(74, 110, 168, 0.14);
}
.all-sources-link:not(.selected):hover,
.folder-link:not(.selected):hover {
background: rgba(74, 110, 168, 0.22);
} }
.empty-trash-button { .empty-trash-button {
@ -1121,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

@ -30,10 +30,13 @@
{% endif %} {% endif %}
</span> </span>
<span class="artifact-source">{{ artifact.source }}</span> <span class="artifact-source">{{ artifact.source }}</span>
<span class="row-actions">
{% if in_trash %} {% if in_trash %}
<span class="row-action row-undelete" role="button" tabindex="0" data-undelete="{{ artifact.id }}" title="Restore from Trash" aria-label="Restore {{ artifact.title }}">&#8617;</span> <span class="row-action row-undelete" role="button" tabindex="0" data-undelete="{{ artifact.id }}" title="Restore from Trash" aria-label="Restore {{ artifact.title }}">&#8617;</span>
{% else %} {% else %}
<span class="row-action row-delete" role="button" tabindex="0" data-delete="{{ artifact.id }}" title="Delete" aria-label="Delete {{ artifact.title }}">&#128465;</span> <span class="row-action row-delete" role="button" tabindex="0" data-delete="{{ artifact.id }}" title="Delete" aria-label="Delete {{ artifact.title }}">&#128465;</span>
<span class="row-action row-menu-trigger" role="button" tabindex="0" data-menu-trigger title="More actions" aria-label="More actions for {{ artifact.title }}">&#8943;</span>
{% endif %} {% endif %}
</span>
</a> </a>
{% endfor %} {% endfor %}

View file

@ -35,33 +35,27 @@
<h2>Sources</h2> <h2>Sources</h2>
</div> </div>
<nav class="source-list"> <nav class="source-list">
<a class="source-link {% if not selected_source and not selected_folder %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&amp;{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&amp;{% endif %}{% if title_query %}q={{ title_query|urlencode }}&amp;{% endif %}view={{ selected_view }}&amp;preview=none"> <a class="source-link all-sources-link {% if not selected_source and not selected_folder %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&amp;{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&amp;{% endif %}{% if title_query %}q={{ title_query|urlencode }}&amp;{% endif %}view={{ selected_view }}&amp;preview=none">
<span class="source-name">All sources</span> <span class="source-name">All sources</span>
{% if total_count %}<span class="source-count">{{ 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> </a>
{% for entry in source_counts %} {% for entry in source_counts %}
<a class="source-link {% if entry.source == selected_source and not selected_folder %}selected{% endif %}" href="?source={{ entry.source|urlencode }}{% if selected_label %}&amp;label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&amp;kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&amp;q={{ title_query|urlencode }}{% endif %}&amp;view={{ selected_view }}&amp;preview=none"> <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> <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> </a>
{% endfor %} {% endfor %}
</nav>
{% if keep_count or trash_count %} <nav class="system-folders" aria-label="Folders">
<div class="source-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"> <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-name">&#9733; Keep</span>
<span class="source-count">{{ keep_count }}</span> {% if keep_count %}<span class="source-count">{{ keep_count }}</span>{% endif %}
</a> </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"> <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-name">&#128465; Deleted</span>
<span class="source-count">{{ trash_count }}</span> {% if trash_count %}<span class="source-count">{{ trash_count }}</span>{% endif %}
</a> </a>
{% endif %}
</div>
{% endif %}
</nav> </nav>
</aside> </aside>
@ -73,7 +67,7 @@
{% if selected_folder == 'deleted' %} {% if selected_folder == 'deleted' %}
<button type="button" class="empty-trash-button" data-empty-trash>Empty Trash</button> <button type="button" class="empty-trash-button" data-empty-trash>Empty Trash</button>
{% else %} {% else %}
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span> <span>{{ artifacts|length }} message{{ artifacts|length|pluralize }}</span>
{% endif %} {% endif %}
</div> </div>
@ -82,7 +76,7 @@
{% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %} {% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %}
{% else %} {% else %}
<div class="empty compact"> <div class="empty compact">
<h3>No artifacts</h3> <h3>No messages</h3>
<p>Nothing in the last {{ 7 }} days for these filters.</p> <p>Nothing in the last {{ 7 }} days for these filters.</p>
</div> </div>
{% endif %} {% endif %}
@ -94,7 +88,7 @@
<section class="preview-pane"> <section class="preview-pane">
<header class="pane-bar preview-actions" data-actions> <header class="pane-bar preview-actions" data-actions>
<div class="preview-title" data-preview-title {% if selected_artifact %}title="{{ selected_artifact.title }}"{% endif %}> <div class="preview-title" data-preview-title {% if selected_artifact %}title="{{ selected_artifact.title }}"{% endif %}>
{% if selected_artifact %}{{ selected_artifact.title }}{% else %}No artifact loaded{% endif %} {% if selected_artifact %}{{ selected_artifact.title }}{% else %}No message loaded{% endif %}
</div> </div>
<nav class="view-toggle" aria-label="Preview mode"> <nav class="view-toggle" aria-label="Preview mode">
<a class="{% if selected_view == 'rendered' %}selected{% endif %}{% if not selected_artifact %} disabled{% endif %}" href="{% if selected_artifact %}{{ rendered_url }}{% else %}#{% endif %}" data-view="rendered" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Rendered</a> <a class="{% if selected_view == 'rendered' %}selected{% endif %}{% if not selected_artifact %} disabled{% endif %}" href="{% if selected_artifact %}{{ rendered_url }}{% else %}#{% endif %}" data-view="rendered" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Rendered</a>
@ -124,8 +118,8 @@
<img class="preview-image" src="{% url 'artifact' selected_artifact.id %}" alt="{{ selected_artifact.title }}"> <img class="preview-image" src="{% url 'artifact' selected_artifact.id %}" alt="{{ selected_artifact.title }}">
{% elif selected_artifact.kind == "binary" %} {% elif selected_artifact.kind == "binary" %}
<div class="empty preview-empty"> <div class="empty preview-empty">
<h2>Binary Artifact</h2> <h2>Binary Message</h2>
<p>This artifact type cannot be previewed inline yet.</p> <p>This message type cannot be previewed inline yet.</p>
<p><a href="{% url 'artifact' selected_artifact.id %}">Open or download it</a>.</p> <p><a href="{% url 'artifact' selected_artifact.id %}">Open or download it</a>.</p>
</div> </div>
{% else %} {% else %}
@ -135,10 +129,10 @@
{% else %} {% else %}
<div class="empty preview-empty"> <div class="empty preview-empty">
{% if artifacts %} {% if artifacts %}
<h2>No artifact loaded</h2> <h2>No message loaded</h2>
<p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p> <p>Press <kbd>Right Arrow</kbd> or select a message from the middle column to preview it.</p>
{% else %} {% 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> <p>This day is ready for new agent output.</p>
{% endif %} {% endif %}
</div> </div>
@ -147,6 +141,25 @@
</section> </section>
</main> </main>
<div class="row-menu" hidden data-row-menu>
<button type="button" data-menu-action="keep">Keep</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="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> <script>
(() => { (() => {
const feed = document.querySelector(".feed"); const feed = document.querySelector(".feed");
@ -232,7 +245,7 @@
try { try {
await navigator.clipboard.writeText(absoluteUrl); await navigator.clipboard.writeText(absoluteUrl);
} catch { } catch {
window.prompt("Copy artifact link", absoluteUrl); window.prompt("Copy message link", absoluteUrl);
} }
const original = button.textContent; const original = button.textContent;
button.textContent = "Copied"; button.textContent = "Copied";
@ -341,7 +354,7 @@
if (!actions) return; if (!actions) return;
const previewTitle = actions.querySelector("[data-preview-title]"); const previewTitle = actions.querySelector("[data-preview-title]");
if (previewTitle) { if (previewTitle) {
previewTitle.textContent = "No artifact loaded"; previewTitle.textContent = "No message loaded";
previewTitle.removeAttribute("title"); previewTitle.removeAttribute("title");
} }
actions.querySelectorAll("[data-view]").forEach((link) => { actions.querySelectorAll("[data-view]").forEach((link) => {
@ -411,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) => {
@ -436,15 +453,17 @@
`<span class="source-name">${escapeHtml(name)}</span>` + `<span class="source-name">${escapeHtml(name)}</span>` +
`<span class="source-count">${count}</span>`; `<span class="source-count">${count}</span>`;
// Insert alphabetically among the per-source links (after "All sources"). // Newest activity goes to the top of the per-source list (just under
// the pinned "All sources"), matching the recency ordering.
const list = document.querySelector(".source-list"); const list = document.querySelector(".source-list");
const others = sourceLinks().filter( const firstSource = list.querySelector(".source-link:not(.all-sources-link)");
(l) => l.querySelector(".source-name")?.textContent.trim() !== "All sources", list.insertBefore(link, firstSource);
); };
const after = others.find(
(l) => (l.querySelector(".source-name")?.textContent.trim() || "") > name, const moveSourceToTop = (link) => {
); const list = document.querySelector(".source-list");
list.insertBefore(link, after || null); const firstSource = list.querySelector(".source-link:not(.all-sources-link)");
if (firstSource !== link) list.insertBefore(link, firstSource);
}; };
// From the newly-arrived rows: bump each source's unread count (adding a // From the newly-arrived rows: bump each source's unread count (adding a
@ -459,8 +478,12 @@
Object.entries(counts).forEach(([source, n]) => { Object.entries(counts).forEach(([source, n]) => {
total += n; total += n;
const existing = findSourceLink(source); const existing = findSourceLink(source);
if (existing) bumpSourceBadge(existing, n); if (existing) {
else addSourceLink(source, n); bumpSourceBadge(existing, n);
moveSourceToTop(existing); // most-recent activity -> top (recency order)
} else {
addSourceLink(source, n);
}
}); });
const allLink = findSourceLink("All sources"); const allLink = findSourceLink("All sources");
if (allLink && total) bumpSourceBadge(allLink, total); if (allLink && total) bumpSourceBadge(allLink, total);
@ -471,7 +494,7 @@
const header = document.querySelector(".artifact-sidebar .pane-heading span"); const header = document.querySelector(".artifact-sidebar .pane-heading span");
if (!header) return; if (!header) return;
const total = Math.max(0, (parseInt(header.textContent, 10) || 0) + delta); const total = Math.max(0, (parseInt(header.textContent, 10) || 0) + delta);
header.textContent = `${total} artifact${total === 1 ? "" : "s"}`; header.textContent = `${total} message${total === 1 ? "" : "s"}`;
}; };
const bumpFolderCount = (folder, delta) => { const bumpFolderCount = (folder, delta) => {
@ -528,6 +551,203 @@
}); });
} }
// --- Per-row actions menu (⋯ trigger + right-click) ---
const rowMenu = document.querySelector("[data-row-menu]");
let menuRowId = null;
const currentMenuRow = () =>
menuRowId ? artifactList.querySelector(`.artifact-row[data-artifact-id="${menuRowId}"]`) : null;
const closeRowMenu = () => {
if (rowMenu) rowMenu.hidden = true;
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));
const top = Math.max(8, Math.min(y, window.innerHeight - rect.height - 8));
rowMenu.style.left = `${left}px`;
rowMenu.style.top = `${top}px`;
};
const copyText = async (text) => {
try { await navigator.clipboard.writeText(text); } catch (e) { /* clipboard unavailable */ }
};
const keepFromMenu = async (row, id) => {
try {
const response = await fetch(`/a/${id}/keep`, {method: "POST", headers: {"X-CSRFToken": csrfToken}});
if (!response.ok) return;
const data = await response.json();
row.dataset.kept = data.is_kept ? "1" : "0";
row.classList.toggle("kept", data.is_kept);
const badge = row.querySelector("[data-kept-badge]");
if (badge) badge.hidden = !data.is_kept;
bumpFolderCount("keep", data.is_kept ? 1 : -1);
if (feed.dataset.folder === "keep" && !data.is_kept) removeRowFromList(row);
const keepButton = document.querySelector("[data-keep-button]");
if (keepButton && keepButton.dataset.artifactId === id) {
keepButton.classList.toggle("kept", data.is_kept);
keepButton.textContent = data.is_kept ? "✓ Kept" : "Keep";
}
} 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();
const id = menuRowId;
closeRowMenu();
if (!row || !id) return;
const action = btn.dataset.menuAction;
if (action === "delete") softDeleteRow(id);
else if (action === "keep") keepFromMenu(row, id);
else if (action === "open") window.open(row.dataset.renderedUrl, "_blank", "noopener");
else if (action === "copy") copyText(window.location.origin + row.dataset.renderedUrl);
});
}
document.addEventListener("click", (event) => {
if (rowMenu && !rowMenu.hidden && !rowMenu.contains(event.target)) closeRowMenu();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeRowMenu();
});
// Right-click a feed row opens the same menu at the cursor.
artifactList.addEventListener("contextmenu", (event) => {
const row = event.target.closest(".artifact-row");
if (!row || feed.dataset.folder) return; // normal feed only
event.preventDefault();
openRowMenu(row, event.clientX, event.clientY);
});
// Click a source's unread badge to mark all of that source's messages read.
const markSourceRead = async (badge) => {
const link = badge.closest(".source-link");
if (!link) return;
const isAll = link.classList.contains("all-sources-link");
const source = link.dataset.source || "";
const body = new URLSearchParams();
if (!isAll && source) body.set("source", source);
try {
const response = await fetch("{% url 'feed-mark-read' %}", {
method: "POST",
headers: {"X-CSRFToken": csrfToken, "Content-Type": "application/x-www-form-urlencoded"},
body: body.toString(),
});
if (!response.ok) return;
} catch (e) { return; }
if (isAll) {
document.querySelectorAll(".source-list .source-count").forEach((b) => b.remove());
artifactList.querySelectorAll(".artifact-row.unread").forEach((r) => r.classList.remove("unread"));
} else {
const n = parseInt(badge.textContent, 10) || 0;
badge.remove();
const allBadge = document.querySelector(".all-sources-link .source-count");
if (allBadge) {
const t = (parseInt(allBadge.textContent, 10) || 0) - n;
if (t > 0) allBadge.textContent = String(t);
else allBadge.remove();
}
artifactList
.querySelectorAll(`.artifact-row.unread[data-source="${CSS.escape(source)}"]`)
.forEach((r) => r.classList.remove("unread"));
}
};
const sourceListEl = document.querySelector(".source-list");
if (sourceListEl) {
const badgeFromEvent = (event) => event.target.closest("[data-mark-read]");
sourceListEl.addEventListener("click", (event) => {
const badge = badgeFromEvent(event);
if (!badge) return;
event.preventDefault();
event.stopPropagation();
markSourceRead(badge);
});
sourceListEl.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
const badge = badgeFromEvent(event);
if (!badge) return;
event.preventDefault();
markSourceRead(badge);
});
}
const renderPreview = (row, pushState = true) => { const renderPreview = (row, pushState = true) => {
navigationMode = "artifacts"; navigationMode = "artifacts";
markRead(row); markRead(row);
@ -540,7 +760,7 @@
: kind === "image" : kind === "image"
? `<img class="preview-image" src="${source}" alt="${title}">` ? `<img class="preview-image" src="${source}" alt="${title}">`
: kind === "binary" : kind === "binary"
? `<div class="empty preview-empty"><h2>Binary 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>`; : `<iframe class="preview-frame" src="${source}" title="${title}"></iframe>`;
previewBody.innerHTML = `<div class="preview-frame-wrap">${content}</div>`; previewBody.innerHTML = `<div class="preview-frame-wrap">${content}</div>`;
@ -555,7 +775,7 @@
} }
allRows().forEach((candidate) => candidate.classList.remove("selected")); allRows().forEach((candidate) => candidate.classList.remove("selected"));
disableActions(); disableActions();
previewBody.innerHTML = `<div class="empty preview-empty"><h2>No 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()); window.history.pushState({}, "", noPreviewUrl());
const currentSource = document.querySelector(".source-link.selected"); const currentSource = document.querySelector(".source-link.selected");
if (currentSource) currentSource.focus({preventScroll: true}); if (currentSource) currentSource.focus({preventScroll: true});
@ -563,13 +783,19 @@
// Delegated so rows appended by infinite scroll work without rebinding. // Delegated so rows appended by infinite scroll work without rebinding.
artifactList.addEventListener("click", (event) => { artifactList.addEventListener("click", (event) => {
// Row action icons (delete/undelete) intercept before selection. // Row action icons (delete/undelete/menu) intercept before selection.
const action = event.target.closest("[data-delete], [data-undelete]"); const action = event.target.closest("[data-delete], [data-undelete], [data-menu-trigger]");
if (action) { if (action) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (action.hasAttribute("data-delete")) softDeleteRow(action.getAttribute("data-delete")); if (action.hasAttribute("data-menu-trigger")) {
else undeleteRow(action.getAttribute("data-undelete")); const rect = action.getBoundingClientRect();
openRowMenu(action.closest(".artifact-row"), rect.right, rect.bottom);
} else if (action.hasAttribute("data-delete")) {
softDeleteRow(action.getAttribute("data-delete"));
} else {
undeleteRow(action.getAttribute("data-undelete"));
}
return; return;
} }
const row = event.target.closest(".artifact-row"); const row = event.target.closest(".artifact-row");
@ -787,7 +1013,7 @@
const header = document.querySelector(".artifact-sidebar .pane-heading span"); const header = document.querySelector(".artifact-sidebar .pane-heading span");
if (header) { if (header) {
const total = (parseInt(header.textContent, 10) || 0) + data.count; const total = (parseInt(header.textContent, 10) || 0) + data.count;
header.textContent = `${total} artifact${total === 1 ? "" : "s"}`; header.textContent = `${total} message${total === 1 ? "" : "s"}`;
} }
} catch { } catch {
// ignore; retry next tick // ignore; retry next tick

View file

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

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

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

View file

@ -17,6 +17,7 @@ from artifacts.models import ApiKey, Artifact
User = get_user_model() User = get_user_model()
@override_settings(AGENTFEED_ASYNC_PURGE=False) # DELETE triggers purge; run it inline in tests
class ArtifactAPITestCase(APITestCase): class ArtifactAPITestCase(APITestCase):
"""Exercises the ingest/list/detail API against a real (temp) filesystem """Exercises the ingest/list/detail API against a real (temp) filesystem
and a real (test) database, the way an agent posting over HTTP would.""" and a real (test) database, the way an agent posting over HTTP would."""

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

@ -1,5 +1,6 @@
import shutil import shutil
import tempfile import tempfile
from datetime import timedelta
from pathlib import Path from pathlib import Path
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@ -8,11 +9,13 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from artifacts.models import Artifact from artifacts.models import Artifact
from artifacts.storage import user_usage_bytes from artifacts.storage import purge_expired_trash, user_usage_bytes
User = get_user_model() User = get_user_model()
# Run the purge inline (no background thread) so its effects are deterministic.
@override_settings(AGENTFEED_ASYNC_PURGE=False)
class TrashKeepViewTestCase(TestCase): class TrashKeepViewTestCase(TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@ -105,3 +108,51 @@ class TrashKeepViewTestCase(TestCase):
page = self.client.get("/?folder=keep").content.decode() page = self.client.get("/?folder=keep").content.decode()
self.assertIn("KeptOne", page) self.assertIn("KeptOne", page)
self.assertNotIn("NormalOne", page) self.assertNotIn("NormalOne", page)
def test_purge_expired_trash_removes_old_only(self):
old = self.make()
recent = self.make()
Artifact.objects.filter(pk=old.id).update(deleted_at=timezone.now() - timedelta(days=4))
Artifact.objects.filter(pk=recent.id).update(deleted_at=timezone.now())
purged = purge_expired_trash()
self.assertEqual(purged, 1)
self.assertFalse(Artifact.objects.filter(pk=old.id).exists())
self.assertTrue(Artifact.objects.filter(pk=recent.id).exists())
def test_delete_triggers_expired_purge(self):
old = self.make()
Artifact.objects.filter(pk=old.id).update(deleted_at=timezone.now() - timedelta(days=4))
fresh = self.make()
# Deleting fresh runs the purge (inline here) -> the expired one goes,
# the freshly-trashed one stays.
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)