Compare commits

...

13 commits
v0.3.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
4254164bef v0.5.0: Trash (soft-delete) + Keep folder
Delete now moves an artifact to a recoverable Trash instead of hard-removing.

- Model: Artifact.deleted_at (nullable, indexed) = in Trash.
- Soft-delete everywhere: DELETE /api/artifacts/{id} sets deleted_at
  (idempotent 204); session POST /a/{id}/delete for the feed's hover Delete
  icon; POST /a/{id}/undelete restores; POST /feed/empty-trash purges.
- Trashed artifacts are off-quota (deleting frees quota) and eviction-protected
  (never evicted early); hidden from feed, sources, counts, and all API reads
  (list/detail/raw).
- Sidebar: "Deleted" and "Keep" system folders at the bottom (counts, hidden
  when empty); ?folder=deleted / ?folder=keep views reuse the row rendering.
  Deleted view has hover Undelete + an Empty Trash button. Keep = view over
  is_kept. Live-poll disabled in folder views.
- Docs (api.md delete section, CLAUDE.md), 10 new/updated tests. 150 passing;
  API soft-delete lifecycle verified live.

Purge is manual (Empty Trash) this release; auto purge-on-delete (background,
~3-day retention) is the planned fast-follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-07 12:48:43 +01:00
6c1823e65e v0.4.1: live updates also refresh the source sidebar
Extends v0.4.0 without a second request: the sidebar is updated client-side
from the rows the existing /feed/updates poll already returns.

- Each new row's data-source drives the sidebar: per-source + "All sources"
  unread counts bump; a first-time source is inserted into the list
  (alphabetically). Reuses the existing count-badge pattern.
- sourceLinks is now live-queried (was cached at load) so sources added live
  stay keyboard-navigable.
- No endpoint/model change. 142 tests pass; feed renders.

Caveat: while filtered to a single source, other new sources appear only on
reload (the poll is scoped to the selected view).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-05 14:37:45 +01:00
cae414c307 v0.4.0: live feed updates (auto-insert new posts)
New artifacts appear at the top of the feed without a manual refresh.

- GET /feed/updates (session-authed): new artifacts since a timestamp marker,
  owner-scoped and filter-aware (source/label/kind/q), returned as row HTML.
- Feed JS polls every 5s, paused while the tab is hidden (immediate poll on
  refocus). Prepends new rows into today's group without touching the selected
  row or the preview iframe — what you're reading stays put and selected.
- Fresh rows show "now", aging into their real time after ~3 min; brief
  highlight on arrival.
- Extracted _artifact_rows.html partial, shared by the day-group render and the
  updates endpoint, so live rows match exactly.
- Polling chosen over SSE/WebSockets: zero infra on sync-WSGI + SQLite (see
  planning/next_steps for the SSE upgrade path). 142 tests pass; feed page +
  endpoint verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 23:02:07 +01:00
f6f004c689 v0.3.2: add GET /api/artifacts/{id}/raw for content read-back
Metadata (GET /api/artifacts/{id}) stays metadata-only; the full content now
has its own endpoint so an agent can verify exactly what it posted.

- New ArtifactRawView at /api/artifacts/{id}/raw: bearer-or-session auth,
  owner-scoped. Serves exact source text (markdown/html_full/json/csv), file
  bytes (image/pdf/binary), or 404 for render-only kinds (html_fragment/deck).
  Reuses raw_artifact_path/safe_artifact_path.
- serialize_artifact exposes api_raw_url; /api/capabilities advertises the
  route. (The old /a/{id}?raw= viewer stays session-only for browsers.)
- Docs + tests (exact content, owner-scoping, 403 unauth, 404 render-only).
  Suite: 136 passing; verified live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 15:20:37 +01:00
2b901f7844 v0.3.1: bearer-token reads; public skill + capabilities
Fixes an inconsistency an agent hit: it could POST/PATCH/DELETE with its token
but GET (readback) required a session and 302-redirected to the browser login.

- GET /api/artifacts and GET /api/artifacts/{id} now accept a bearer token OR
  a session, owner-scoped (a token reads only its owner's artifacts). Agents
  can read back what they posted with the same token.
- Unauthenticated API requests return a JSON 403, never a browser-login
  redirect (resolve_request_user helper; dropped @login_required on the JSON
  read views).
- GET /api/skill and GET /api/capabilities are now public (no user data), so
  an agent can fetch the contract + posting instructions before it has a key.
- Docs (api.md, CLAUDE.md) updated; tests for bearer readback, owner-scoping,
  and the JSON-403-not-redirect behavior. Suite: 132 passing; verified live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 14:50:48 +01:00
30 changed files with 1988 additions and 172 deletions

View file

@ -9,6 +9,150 @@ former name, "Daybook".)
_Nothing yet._
## [0.7.1] — 2026-09-08
### Added
- **Custom labels.** Curate a personal palette of coloured labels in
**Settings → Labels**, then apply them to any message from its ⋯ menu (or
right-click) — the menu stays open so you can apply several at once, with a
checkmark showing which are applied. Labels are unified with tags: applying
one adds it to the message's tags and renders as a chip. Backed by
`POST /a/{id}/label` (session-auth, owner-scoped, palette-validated); chip
colours sync to the global `TagColor`.
- **Change your password in-app** from **Settings → Account**: enter the current
password, then a new one. Uses Django's `PasswordChangeForm` and keeps the
session logged in after the change.
### Changed
- Sidebar: the **Keep** and **Deleted** folders are now always shown (even when
empty); the count badge only appears when the folder is non-empty.
## [0.7.0] — 2026-09-07
### Added
- **Mark all as read.** Click a source's unread badge in the sidebar to mark
all of that source's messages read (clears the badge); click the **All
sources** total to mark everything read. Owner-scoped, excludes trashed; any
visible feed rows update too. Backed by `POST /feed/mark-read`.
## [0.6.3] — 2026-09-07
### Changed
- **User-facing wording: "artifact(s)" → "messages"** throughout the UI (feed
count, empty states, preview, About, Storage). The model, API
(`/api/artifacts`), and posting skill keep "artifact" as the internal term —
no contract change for agents.
- Sidebar: the Keep/Deleted folders now have spacing between them and share the
"All sources" bold+blue-tint styling (they're special folders).
### Fixed
- About page now points to Settings → API Keys (was the stale "Settings →
Account / personal API token").
## [0.6.2] — 2026-09-07
### Changed
- 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
### Added
- **Trash (soft-delete).** Deleting an artifact now moves it to a recoverable
**Trash** instead of removing it. Feed rows get a hover **Delete** icon; a
**"Deleted"** folder at the bottom of the sidebar lists trashed items with a
hover **Undelete**, plus an **Empty Trash** button to purge permanently.
Trashed items are **off-quota** (deleting frees quota immediately) and
eviction-protected. `DELETE /api/artifacts/{id}` is now a soft-delete too
(idempotent); trashed items are hidden from all API reads.
- **Keep folder.** A sidebar folder listing kept artifacts (a view over the
existing `is_kept` flag), mirroring Trash. Both hide when empty.
- Session endpoints `POST /a/{id}/delete`, `/a/{id}/undelete`,
`/feed/empty-trash`; feed `?folder=deleted` / `?folder=keep` views.
### Changed
- `DELETE /api/artifacts/{id}` no longer hard-deletes — see above.
### Note
- Permanent purge is manual (**Empty Trash**) in this release; automatic
purge-on-delete (background, ~3-day retention) is the planned fast-follow.
### Changed
- Live feed updates now also keep the **source sidebar** current: as new posts
arrive, per-source and total unread counts bump, and a source posting for the
first time is added to the list (alphabetically) — all derived client-side
from the polled rows, no extra request. Sources added live stay
keyboard-navigable. Caveat: while filtered to a single source, *other* new
sources still only appear on reload (the poll is scoped to the selected view).
## [0.4.0] — 2026-09-04
### Added
- **Live feed updates.** New artifacts now appear at the top of the feed
automatically, without a manual refresh — the browser polls a lightweight
`GET /feed/updates` every 5s (paused while the tab is hidden, one immediate
poll on refocus). Owner-scoped and **respects the active source/label/kind/q
filters**, so a new post that doesn't match your current view won't show. The
currently selected/previewed artifact stays put and visibly selected. Fresh
rows display "now" and age into their real time after a few minutes.
(Chosen over SSE/WebSockets deliberately: zero new infrastructure on the
sync-WSGI + SQLite stack; see `planning/next_steps/` for the SSE upgrade path.)
### Changed
- Extracted the feed row markup into a reusable `_artifact_rows.html` partial
(shared by the day-group render and the live-updates endpoint).
## [0.3.2] — 2026-09-04
### Added
- `GET /api/artifacts/{id}/raw` — the artifact's full content, separate from the
metadata detail endpoint. Bearer-token (or session) auth, owner-scoped, so an
agent can read back and verify exactly what it posted: exact source text for
`markdown`/`html_full`/`json`/`csv`, file bytes for `image`/`pdf`/`binary`,
`404` for render-only `html_fragment`/`deck`. The metadata response now
includes `api_raw_url`, and `/api/capabilities` advertises the route.
## [0.3.1] — 2026-09-04
### Changed
- Artifact **read** endpoints (`GET /api/artifacts`, `GET /api/artifacts/{id}`)
now accept a bearer token as well as a browser session, owner-scoped — an
agent can read back what it posted with the same token. Unauthenticated API
requests return a JSON `403` instead of a 302 redirect to the browser login.
- `GET /api/skill` and `GET /api/capabilities` are now **public** (no auth) —
they expose no user data, so an agent can fetch the contract and posting
instructions before it has a key.
## [0.3.0] — 2026-09-04
### Changed
@ -117,7 +261,18 @@ First tagged release.
- Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a
runbook (`deploy/cloud_agent_deploy.md`) and health check.
[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.3.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.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.3.2]: https://git.cygnus.systems/peter/daybook/compare/v0.3.1...v0.3.2
[0.3.1]: https://git.cygnus.systems/peter/daybook/compare/v0.3.0...v0.3.1
[0.3.0]: https://git.cygnus.systems/peter/daybook/compare/v0.2.1...v0.3.0
[0.2.1]: https://git.cygnus.systems/peter/daybook/compare/v0.2.0...v0.2.1
[0.2.0]: https://git.cygnus.systems/peter/daybook/compare/v0.1.1...v0.2.0

View file

@ -11,10 +11,11 @@ The core product principle is: agents publish content, not files. The server own
- Each user has a personal ingest API token (DRF `Token` model), viewable/regenerable from Settings → Account.
- Require `source` in every artifact payload — a free-text label identifying what posted it (agent-profile, script, or person name). Ownership of the resulting artifact comes from whichever user's token authenticated the POST, not from the `source` value itself.
- `source` and `tags` together form one unified **label** system: every artifact shows all its labels next to the title, and a single `label` filter/query param matches against either.
- Require login (Django session auth) for the whole app, including the feed and API reads. Ingest (POST/PATCH) stays per-user bearer-token authenticated, since agents don't have browser sessions.
- Require login (Django session auth) for the browser feed/UI. The JSON API accepts a per-user bearer token for both ingest (POST/PATCH/DELETE) and artifact reads (GET list/detail), owner-scoped — an agent reads back with the same token it posts with; API requests never redirect to a browser login (JSON 403 instead). `GET /api/capabilities` and `GET /api/skill` are public (no user data), so agents can bootstrap before they have a key.
- Exception to the above: an artifact can be explicitly opted into public sharing (per-artifact, owner-toggled from the feed UI) via a high-entropy `share_token`, served unauthenticated at `GET /s/{share_token}`. Not exposed via the ingest API yet — UI-only. Turning sharing off immediately invalidates the old link. Images embedded in a shared artifact are also served unauthenticated while it's shared, so inline content doesn't break for a link recipient.
- User accounts can be created from Settings → Users (superuser-only in-app page) or via Django admin. Regular users are not staff and cannot access `/admin/`. Creating a user via Settings → Users also issues their API token immediately, shown once.
- `TagColor` (label color overrides) stays global/shared across all users.
- Deleting an artifact is a **soft-delete to Trash** (`deleted_at`), not a hard removal — recoverable via the "Deleted" sidebar folder (undelete), off-quota and eviction-protected while trashed, and purged permanently via "Empty Trash". `DELETE /api/artifacts/{id}` is soft too (idempotent); trashed artifacts are hidden from all API reads. A **Keep** sidebar folder is a view over `is_kept`. Both the Keep and Deleted folders are always shown in the sidebar (even when empty); the count badge only appears when the folder is non-empty. Trash auto-purges after a ~3-day retention window via a cron-less background thread triggered by deletes (`AGENTFEED_ASYNC_PURGE`, on by default); "Empty Trash" purges immediately.
- Use random 8-character artifact IDs.
- Start as a plain Django project; add Docker after core ingest and viewer work.
- Preserve original markdown as `.md` alongside rendered `.html`.

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.15 on 2026-09-07 11:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('artifacts', '0015_migrate_tokens_to_apikeys'),
]
operations = [
migrations.AddField(
model_name='artifact',
name='deleted_at',
field=models.DateTimeField(blank=True, db_index=True, null=True),
),
]

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

@ -60,6 +60,9 @@ class Artifact(models.Model):
metadata = models.JSONField(default=dict, blank=True)
read_at = models.DateTimeField(null=True, blank=True)
is_kept = models.BooleanField(default=False)
# Soft-delete: non-null = in Trash (hidden from feed/sources, off-quota,
# recoverable via undelete, purged by Empty Trash). NULL = live.
deleted_at = models.DateTimeField(null=True, blank=True, db_index=True)
size_bytes = models.BigIntegerField(default=0)
# NULL = not shared. Many rows share NULL (SQL treats each NULL as
# distinct, so `unique=True` still holds); a real value must be unique
@ -74,6 +77,10 @@ class Artifact(models.Model):
def is_shared(self):
return bool(self.share_token)
@property
def is_deleted(self):
return self.deleted_at is not None
class Meta:
ordering = ["-created_at"]
@ -89,6 +96,23 @@ class TagColor(models.Model):
return f"{self.name} ({self.color})"
class Label(models.Model):
"""A user's reusable label in their personal palette. Applying one from the
feed UI just adds its `name` to an artifact's `tags`; the chip colour lives
in the global TagColor table (kept in sync when the palette entry is saved)."""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="labels")
name = models.CharField(max_length=40)
color = models.CharField(max_length=20) # #RRGGBB
class Meta:
unique_together = ("user", "name")
ordering = ["name"]
def __str__(self):
return f"{self.name} ({self.color})"
class UserProfile(models.Model):
DEFAULT_QUOTA_BYTES = 1024 * 1024 * 1024 # 1 GB

View file

@ -7,9 +7,11 @@ SUM rather than a stat of every file.
"""
import shutil
import threading
from datetime import timedelta
from django.conf import settings
from django.db import connection
from django.db.models import F, Sum
from django.utils import timezone
@ -70,8 +72,13 @@ def artifact_total_bytes(artifact):
def user_usage_bytes(user):
# Trashed artifacts are off-quota (deleting relieves quota immediately);
# their files linger on disk until Empty Trash / purge, but don't count.
artifact_bytes = (
Artifact.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0
Artifact.objects.filter(owner=user, deleted_at__isnull=True)
.aggregate(total=Sum("size_bytes"))
.get("total")
or 0
)
asset_bytes = (
Asset.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0
@ -113,8 +120,10 @@ def make_room_for(user, incoming_bytes):
if used + incoming_bytes <= quota:
return []
# Trashed artifacts are eviction-protected (recovery window is guaranteed)
# and already off-quota, so they're never eviction candidates.
unkept = list(
Artifact.objects.filter(owner=user, is_kept=False).order_by("created_at")
Artifact.objects.filter(owner=user, is_kept=False, deleted_at__isnull=True).order_by("created_at")
)
# Check whether eviction can possibly succeed before deleting anything -
# otherwise a doomed request would still destroy every unkept artifact on
@ -137,6 +146,55 @@ def make_room_for(user, incoming_bytes):
return evicted
def purge_trashed(user):
"""Permanently remove all of a user's trashed artifacts (files + rows).
Backs the "Empty Trash" action."""
trashed = list(Artifact.objects.filter(owner=user, deleted_at__isnull=False))
for artifact in trashed:
delete_artifact_files(artifact)
artifact.delete()
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)

View file

@ -6,9 +6,15 @@ from . import views
urlpatterns = [
path("", views.feed, name="feed"),
path("feed/older", views.feed_older, name="feed-older"),
path("feed/updates", views.feed_updates, name="feed-updates"),
path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"),
path("a/<str:artifact_id>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"),
path("a/<str:artifact_id>/share", views.toggle_artifact_share, name="artifact-toggle-share"),
path("a/<str:artifact_id>/label", views.toggle_artifact_label, name="artifact-toggle-label"),
path("a/<str:artifact_id>/delete", views.delete_artifact, name="artifact-delete"),
path("a/<str:artifact_id>/undelete", views.undelete_artifact, name="artifact-undelete"),
path("feed/empty-trash", views.empty_trash, name="feed-empty-trash"),
path("feed/mark-read", views.mark_all_read, name="feed-mark-read"),
path("a/<str:artifact_id>", views.artifact_view, name="artifact"),
path("s/<str:share_token>", views.shared_artifact_view, name="artifact-shared"),
path("assets/<str:asset_id>", views.asset_view, name="asset"),
@ -19,9 +25,11 @@ urlpatterns = [
path("api/keys/<int:key_id>", views.ApiKeyDetailView.as_view(), name="api-key-detail"),
path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"),
path("api/artifacts/<str:artifact_id>", views.ArtifactDetailView.as_view(), name="artifact-detail-api"),
path("api/artifacts/<str:artifact_id>/raw", views.ArtifactRawView.as_view(), name="artifact-raw-api"),
path("settings/", views.settings_home, name="settings-home"),
path("settings/account/", views.settings_account, name="settings-account"),
path("settings/api-keys/", views.settings_api_keys, name="settings-api-keys"),
path("settings/labels/", views.settings_labels, name="settings-labels"),
path("settings/users/", views.settings_users, name="settings-users"),
path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"),
path("settings/storage/", views.settings_storage, name="settings-storage"),

View file

@ -15,16 +15,18 @@ import nh3
import openpyxl
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import views as auth_views
from django.contrib.auth import update_session_auth_hash, views as auth_views
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.models import User
from django.db import OperationalError, transaction
from django.db.models import Count, Q, Sum
from django.db.models import Count, Max, Q, Sum
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.template.defaultfilters import slugify
from django.template.defaultfilters import date as date_filter, slugify
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.dateparse import parse_date
from django.utils.dateparse import parse_date, parse_datetime
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin
@ -35,7 +37,7 @@ from rest_framework.views import APIView
from .demo_data import create_demo_artifacts
from .forms import AgentFeedLoginForm
from .models import ApiKey, Artifact, Asset, TagColor
from .models import ApiKey, Artifact, Asset, Label, TagColor
from .storage import (
QuotaExceeded,
SIDECAR_SUFFIXES,
@ -43,6 +45,8 @@ from .storage import (
delete_artifact_files,
disk_free_bytes,
make_room_for,
purge_trashed,
spawn_trash_purge,
sweep_orphan_assets,
total_allocated_bytes,
user_quota_bytes,
@ -82,12 +86,14 @@ DEFAULT_THEMES = {
}
@method_decorator(login_required, name="get")
class ArtifactListCreateView(APIView):
parser_classes = [JSONParser, FormParser, MultiPartParser]
def get(self, request):
artifacts = Artifact.objects.filter(owner=request.user)
user = resolve_request_user(request)
if user is None:
return Response({"detail": "Authentication required (session or bearer token)."}, status=403)
artifacts = Artifact.objects.filter(owner=user, deleted_at__isnull=True)
if source := request.query_params.get("source"):
artifacts = artifacts.filter(source=source)
if label := request.query_params.get("label"):
@ -223,10 +229,12 @@ def asset_view(request, asset_id):
return FileResponse(path.open("rb"), content_type=asset.content_type or "application/octet-stream")
@method_decorator(login_required, name="get")
class ArtifactDetailView(APIView):
def get(self, request, artifact_id):
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
user = resolve_request_user(request)
if user is None:
return Response({"detail": "Authentication required (session or bearer token)."}, status=403)
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True)
return Response(serialize_artifact(artifact, request))
def patch(self, request, artifact_id):
@ -234,7 +242,7 @@ class ArtifactDetailView(APIView):
if user is None:
return HttpResponseForbidden("Invalid or missing API token")
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True)
data = request.data
not_editable = IMMUTABLE_PATCH_FIELDS & set(data.keys())
if not_editable:
@ -293,24 +301,59 @@ class ArtifactDetailView(APIView):
if user is None:
return HttpResponseForbidden("Invalid or missing API token")
# Soft-delete: move to Trash (recoverable, off-quota) rather than
# permanently removing. Purged later via Empty Trash. Idempotent.
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
delete_artifact_files(artifact)
artifact.delete()
if artifact.deleted_at is None:
artifact.deleted_at = timezone.now()
artifact.save(update_fields=["deleted_at"])
spawn_trash_purge() # sweep expired trash off the request thread
return Response(status=204)
class ArtifactRawView(APIView):
"""The artifact's full content, for read-back / verification. Same auth as
the detail endpoint (bearer token or session), owner-scoped.
GET /api/artifacts/{id}/raw
- markdown / html_full / json / csv -> exact posted source (text/plain)
- image / pdf / binary -> the stored file bytes
- html_fragment / deck -> 404 (render-only; no raw kept)
"""
def get(self, request, artifact_id):
user = resolve_request_user(request)
if user is None:
return Response({"detail": "Authentication required (session or bearer token)."}, status=403)
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True)
raw_path = raw_artifact_path(artifact)
if raw_path and raw_path.exists():
return FileResponse(raw_path.open("rb"), content_type="text/plain; charset=utf-8")
if artifact.kind in {Artifact.IMAGE, Artifact.PDF, Artifact.BINARY}:
path = safe_artifact_path(artifact.path)
if path.exists():
content_type, _ = mimetypes.guess_type(path.name)
return FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream")
return Response(
{"detail": "No raw content available for this kind (render-only)."}, status=404
)
class CapabilitiesView(APIView):
"""Static capability info. Reachable by a logged-in session or a valid API token,
so agents can check connectivity before posting. Exposes no user data."""
def get(self, request):
if not request.user.is_authenticated and resolve_token_user(request) is None:
return HttpResponseForbidden("Login or a valid API token is required")
# Public: capability/contract info only, no user data.
from daybook_project import __version__
methods = {
"/api/artifacts": ["GET", "POST"],
"/api/artifacts/{id}": ["GET", "PATCH", "DELETE"],
"/api/artifacts/{id}/raw": ["GET"],
"/api/assets": ["POST"],
"/api/capabilities": ["GET"],
"/api/skill": ["GET"],
@ -362,9 +405,9 @@ class SkillView(APIView):
"""
def get(self, request):
if not request.user.is_authenticated and resolve_token_user(request) is None:
return HttpResponseForbidden("Login or a valid API token is required")
# Public: the skill is bootstrapping instructions (how to authenticate
# and post) and contains no secrets — an agent shouldn't need a key to
# learn how to use one.
origin = request.build_absolute_uri("/").rstrip("/")
try:
skill = render_posting_skill(origin)
@ -480,21 +523,40 @@ def feed(request):
selected_artifact_id = request.GET.get("artifact", "")
preview_none = request.GET.get("preview") == "none"
selected_view = "raw" if request.GET.get("view") == "raw" else "rendered"
selected_folder = request.GET.get("folder", "")
if selected_folder not in ("deleted", "keep"):
selected_folder = ""
all_artifacts = Artifact.objects.filter(owner=request.user)
# Live (non-trashed) artifacts drive the normal feed and the source sidebar.
live = Artifact.objects.filter(owner=request.user, deleted_at__isnull=True)
if selected_folder == "deleted":
# System folder: all trashed items, not source-scoped.
selected_source = ""
base = Artifact.objects.filter(owner=request.user, deleted_at__isnull=False)
elif selected_folder == "keep":
selected_source = ""
base = live.filter(is_kept=True)
else:
base = live
artifacts = apply_feed_filters(
all_artifacts, selected_source, selected_label, selected_kind, title_query
base, selected_source, selected_label, selected_kind, title_query
)
window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1)
if not artifacts.filter(day__gte=window_start).exists():
# Nothing recent for these filters: fall back to the most recent days that
# do match, so selecting a source is never met with a blank list.
recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS])
if recent_days:
window_start = recent_days[-1]
window_artifacts = list(artifacts.filter(day__gte=window_start).order_by("-created_at"))
older_count = artifacts.filter(day__lt=window_start).count()
if selected_folder:
# System folders show everything (no 7-day window / infinite scroll).
window_start = today
window_artifacts = list(artifacts.order_by("-created_at"))
older_count = 0
else:
window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1)
if not artifacts.filter(day__gte=window_start).exists():
# Nothing recent for these filters: fall back to the most recent days
# that do match, so selecting a source is never met with a blank list.
recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS])
if recent_days:
window_start = recent_days[-1]
window_artifacts = list(artifacts.filter(day__gte=window_start).order_by("-created_at"))
older_count = artifacts.filter(day__lt=window_start).count()
overrides = tag_color_overrides()
day_groups = group_artifacts_by_day(window_artifacts, overrides)
@ -504,6 +566,8 @@ def feed(request):
selected_artifact = next((a for a in window_artifacts if a.id == selected_artifact_id), None)
toggle_query = {"view": "rendered"}
if selected_folder:
toggle_query["folder"] = selected_folder
if selected_source:
toggle_query["source"] = selected_source
if selected_label:
@ -518,18 +582,22 @@ def feed(request):
toggle_query["view"] = "raw"
raw_url = f"?{urlencode(toggle_query)}"
# Source counts come from the other filters only, so the sidebar still shows
# every source available under the current label/type/name filters.
# Source counts + labels always come from live artifacts, so the sidebar is
# the same whichever folder you're viewing.
source_scoped = apply_feed_filters(
all_artifacts, "", 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_scoped.values("source")
.annotate(count=Count("id", filter=Q(read_at__isnull=True)))
.order_by("source")
.annotate(count=Count("id", filter=Q(read_at__isnull=True)), latest=Max("created_at"))
.order_by("-latest")
)
keep_count = live.filter(is_kept=True).count()
trash_count = Artifact.objects.filter(owner=request.user, deleted_at__isnull=False).count()
all_labels = sorted({tag for tags in all_artifacts.values_list("tags", flat=True) for tag in tags})
all_labels = sorted({tag for tags in live.values_list("tags", flat=True) for tag in tags})
return render(
request,
"artifacts/feed.html",
@ -538,8 +606,14 @@ def feed(request):
"day_groups": day_groups,
"older_count": older_count,
"window_start": window_start,
# Marker for live polling: newest artifact shown, else now.
"feed_since": (window_artifacts[0].created_at if window_artifacts else timezone.now()).isoformat(),
"source_counts": source_counts,
"total_count": source_scoped.filter(read_at__isnull=True).count(),
"keep_count": keep_count,
"trash_count": trash_count,
"selected_folder": selected_folder,
"in_trash": selected_folder == "deleted",
"all_labels": labels_with_colors_list(all_labels, overrides),
"raw_url": raw_url,
"rendered_url": rendered_url,
@ -552,6 +626,7 @@ def feed(request):
"today": today,
"title_query": title_query,
"kind_choices": Artifact.KIND_CHOICES,
"label_palette": request.user.labels.all(),
},
)
@ -566,6 +641,31 @@ def mark_artifact_read(request, artifact_id):
return JsonResponse({"id": artifact.id, "read_at": artifact.read_at.isoformat()})
@login_required
@require_POST
def toggle_artifact_label(request, artifact_id):
"""Toggle a palette label on/off for one of the user's own messages. The
label name is written into/removed from the artifact's `tags` list (labels
are unified with tags). Only names in the user's palette are accepted."""
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user, deleted_at__isnull=True)
name = (request.POST.get("name") or "").strip()
label = Label.objects.filter(user=request.user, name=name).first()
if label is None:
return JsonResponse({"error": "No such label in your palette."}, status=400)
tags = list(artifact.tags)
if name in tags:
tags.remove(name)
applied = False
else:
tags.append(name)
applied = True
artifact.tags = tags
artifact.save(update_fields=["tags"])
# Keep the chip colour in sync with the palette (TagColor is global).
TagColor.objects.update_or_create(name=name, defaults={"color": label.color})
return JsonResponse({"applied": applied, "name": name, "color": label.color, "tags": tags})
@login_required
@require_POST
def toggle_artifact_keep(request, artifact_id):
@ -592,12 +692,58 @@ def toggle_artifact_share(request, artifact_id):
return JsonResponse({"id": artifact.id, "is_shared": artifact.is_shared, "share_url": share_url})
@login_required
@require_POST
def delete_artifact(request, artifact_id):
"""Soft-delete: move to Trash (recoverable, off-quota). Idempotent."""
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
if artifact.deleted_at is None:
artifact.deleted_at = timezone.now()
artifact.save(update_fields=["deleted_at"])
spawn_trash_purge() # sweep expired trash off the request thread
return JsonResponse({"id": artifact.id, "deleted": True})
@login_required
@require_POST
def undelete_artifact(request, artifact_id):
"""Restore a trashed artifact back to the live feed."""
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
if artifact.deleted_at is not None:
artifact.deleted_at = None
artifact.save(update_fields=["deleted_at"])
return JsonResponse({"id": artifact.id, "deleted": False})
@login_required
@require_POST
def empty_trash(request):
"""Permanently purge all of the user's trashed artifacts."""
count = purge_trashed(request.user)
return JsonResponse({"purged": count})
@login_required
@require_POST
def mark_all_read(request):
"""Mark all of the user's unread (non-trashed) messages as read, optionally
scoped to one source. Backs the sidebar 'Mark all as read' action."""
unread = Artifact.objects.filter(
owner=request.user, deleted_at__isnull=True, read_at__isnull=True
)
source = request.POST.get("source", "")
if source:
unread = unread.filter(source=source)
marked = unread.update(read_at=timezone.now())
return JsonResponse({"marked": marked, "source": source})
@login_required
def feed_older(request):
"""Return the next page of older day-groups as an HTML fragment for infinite scroll."""
before = parse_date(request.GET.get("before", "")) or timezone.localdate()
artifacts = apply_feed_filters(
Artifact.objects.filter(owner=request.user),
Artifact.objects.filter(owner=request.user, deleted_at__isnull=True),
request.GET.get("source", ""),
request.GET.get("label", ""),
request.GET.get("kind", ""),
@ -631,6 +777,58 @@ def feed_older(request):
)
@login_required
def feed_updates(request):
"""New artifacts since a timestamp, as row HTML for the feed to prepend live.
Owner-scoped and filter-aware — respects the source/label/kind/q the browser
is currently viewing, so a new post that doesn't match won't appear."""
after = parse_datetime(request.GET.get("after", "") or "")
if after is None:
return JsonResponse({"count": 0})
selected_source = request.GET.get("source", "")
selected_label = request.GET.get("label", "")
selected_kind = request.GET.get("kind", "")
title_query = request.GET.get("q", "")
artifacts = apply_feed_filters(
Artifact.objects.filter(owner=request.user, deleted_at__isnull=True),
selected_source,
selected_label,
selected_kind,
title_query,
).filter(created_at__gt=after).order_by("-created_at")
new = list(artifacts[:100])
if not new:
return JsonResponse({"count": 0})
overrides = tag_color_overrides()
for artifact in new:
artifact.label_chips = tags_with_colors(artifact.tags, overrides)
html = render_to_string(
"artifacts/_artifact_rows.html",
{
"artifacts": new,
"selected_artifact": None,
"selected_source": selected_source,
"selected_label": selected_label,
"selected_kind": selected_kind,
"title_query": title_query,
"selected_view": "raw" if request.GET.get("view") == "raw" else "rendered",
},
request=request,
)
return JsonResponse(
{
"count": len(new),
"newest": new[0].created_at.isoformat(),
"today_label": date_filter(timezone.localdate(), "D, M j Y"),
"html": html,
}
)
@login_required
@xframe_options_sameorigin
def artifact_view(request, artifact_id):
@ -1073,6 +1271,15 @@ def resolve_token_user(request):
return api_key.user
def resolve_request_user(request):
"""The acting user from either a browser session or a bearer API key.
Used by the JSON read endpoints so agents can read back with the same token
they post with (owner-scoped). None if neither authenticates."""
if request.user.is_authenticated:
return request.user
return resolve_token_user(request)
def normalize_tags(tags):
if isinstance(tags, str):
return [tag.strip() for tag in tags.split(",") if tag.strip()]
@ -1262,6 +1469,7 @@ def serialize_artifact(artifact, request):
"url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])),
"api_url": request.build_absolute_uri(reverse("artifact-detail-api", args=[artifact.id])),
"raw_url": request.build_absolute_uri(f"{reverse('artifact', args=[artifact.id])}?raw=1"),
"api_raw_url": request.build_absolute_uri(reverse("artifact-raw-api", args=[artifact.id])),
"raw_available": raw_artifact_path(artifact) is not None,
"source": artifact.source,
"title": artifact.title,
@ -1326,10 +1534,25 @@ def settings_home(request):
@login_required
def settings_account(request):
if request.method == "POST":
password_form = PasswordChangeForm(request.user, request.POST)
if password_form.is_valid():
user = password_form.save()
# Keep the current session valid — changing the password rotates the
# session auth hash, which would otherwise log the user out.
update_session_auth_hash(request, user)
messages.success(request, "Password changed.")
return redirect("settings-account")
else:
password_form = PasswordChangeForm(request.user)
return render(
request,
"artifacts/settings_account.html",
{"active_section": "account", "key_count": request.user.api_keys.count()},
{
"active_section": "account",
"key_count": request.user.api_keys.count(),
"password_form": password_form,
},
)
@ -1359,6 +1582,49 @@ def settings_api_keys(request):
)
LABEL_HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
@login_required
def settings_labels(request):
if request.method == "POST":
action = request.POST.get("action")
if action == "create":
name = (request.POST.get("name") or "").strip()
color = (request.POST.get("color") or "").strip()
if not name:
messages.error(request, "Give the label a name.")
elif not LABEL_HEX_RE.match(color):
messages.error(request, "Pick a colour (#RRGGBB).")
elif Label.objects.filter(user=request.user, name=name).exists():
messages.error(request, f"You already have a label called '{name}'.")
else:
Label.objects.create(user=request.user, name=name, color=color)
TagColor.objects.update_or_create(name=name, defaults={"color": color})
messages.success(request, f"Created label '{name}'.")
elif action == "update":
color = (request.POST.get("color") or "").strip()
label = Label.objects.filter(pk=request.POST.get("label_id") or "", user=request.user).first()
if label is None:
messages.error(request, "No such label.")
elif not LABEL_HEX_RE.match(color):
messages.error(request, "Pick a colour (#RRGGBB).")
else:
label.color = color
label.save(update_fields=["color"])
TagColor.objects.update_or_create(name=label.name, defaults={"color": color})
messages.success(request, f"Updated '{label.name}'.")
elif action == "delete":
deleted, _ = Label.objects.filter(pk=request.POST.get("label_id") or "", user=request.user).delete()
messages.success(request, "Label removed from your palette.") if deleted else messages.error(request, "No such label.")
return redirect("settings-labels")
return render(
request,
"artifacts/settings_labels.html",
{"active_section": "labels", "labels": request.user.labels.all()},
)
@login_required
def settings_users(request):
if not request.user.is_superuser:

View file

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

View file

@ -19,6 +19,10 @@ AGENTFEED_KEY_PROVISIONING_API = os.environ.get(
"AGENTFEED_KEY_PROVISIONING_API", ""
).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.):
# Django otherwise thinks every request is plain HTTP, which breaks CSRF checks
# on login/POST. The proxy must forward X-Forwarded-Proto: https.

View file

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

View file

@ -16,11 +16,15 @@ Authorization: Bearer <your-personal-key>
Every artifact belongs to the user whose key authenticated the POST — feeds are private per user.
Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) require a
logged-in browser session, not a bearer token — visit the app in a browser and log in first.
Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) accept
**either** a bearer token **or** a logged-in browser session, and are owner-scoped — a
token reads only its own user's artifacts (the same rule POST/PATCH/DELETE enforce). So an
agent can read back what it posted with the same token. Unauthenticated API requests get a
JSON `403`, never a browser-login redirect.
`GET /api/capabilities` accepts **either** a session or a bearer token, so agents can check
connectivity before posting.
`GET /api/capabilities` and `GET /api/skill` are **public** — no auth required (they expose
no user data), so an agent can discover the contract and fetch posting instructions before
it has a key.
## Artifact Kinds
@ -104,7 +108,7 @@ Endpoint:
```http
POST /api/artifacts
Content-Type: application/json
Authorization: Bearer <your-personal-token>
Authorization: Bearer <your-personal-key>
```
Required fields:
@ -184,7 +188,7 @@ $body = @{
Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts" `
-Method Post `
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
-ContentType "application/json" `
-Body $body
```
@ -237,7 +241,7 @@ Endpoint:
```http
POST /api/artifacts
Content-Type: multipart/form-data
Authorization: Bearer <your-personal-token>
Authorization: Bearer <your-personal-key>
```
Required fields:
@ -284,7 +288,7 @@ PowerShell/curl example:
```powershell
curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
-H "Authorization: Bearer <your-personal-token>" `
-H "Authorization: Bearer <your-personal-key>" `
-F "source=orion" `
-F "source_type=agent" `
-F "source_name=Orion Agent" `
@ -301,7 +305,7 @@ artifact), upload it first and reference the returned URL in your content.
```http
POST /api/assets
Content-Type: multipart/form-data
Authorization: Bearer <your-personal-token>
Authorization: Bearer <your-personal-key>
```
| Field | Notes |
@ -343,7 +347,7 @@ Endpoint:
GET /api/artifacts
```
Requires a logged-in browser session. Returns only the current user's artifacts.
Accepts a bearer token or a browser session. Returns only the authenticated user's artifacts.
Supported filters (combine freely; all are ANDed):
@ -388,7 +392,7 @@ Endpoint:
```http
PATCH /api/artifacts/{id}
Content-Type: application/json
Authorization: Bearer <your-personal-token>
Authorization: Bearer <your-personal-key>
```
Editable fields:
@ -434,7 +438,7 @@ $body = @{
Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" `
-Method Patch `
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
-ContentType "application/json" `
-Body $body
```
@ -445,42 +449,48 @@ Endpoint:
```http
DELETE /api/artifacts/{id}
Authorization: Bearer <your-personal-token>
Authorization: Bearer <your-personal-key>
```
Removes the artifact's database row and its files (primary file, markdown
sidecar, and any attached assets). Returns `204` with no body on success.
**Soft-delete:** moves the artifact to Trash rather than removing it — it's
recoverable (from the app's "Deleted" folder), becomes off-quota, and is hidden
from all API reads (`GET /api/artifacts`, the detail, and `…/raw` all treat it
as gone). Files stay on disk until the trash is purged (Empty Trash in the app).
Returns `204` with no body.
You can only delete artifacts your own token owns; others return `404`, as
does a second delete of the same id.
**Idempotent:** deleting an already-trashed id returns `204` again (not `404`).
You can only delete artifacts your own token owns; another user's returns `404`.
There is no API endpoint to undelete or purge yet — that's app-UI-only.
```bash
curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \
-H "Authorization: Bearer <your-personal-token>"
-H "Authorization: Bearer <your-personal-key>"
```
## Rendered And Raw Content
Rendered browser view:
**For agents (bearer token):** fetch the full content from the API — separate
from the metadata `GET /api/artifacts/{id}`:
```http
GET /a/{id}
GET /api/artifacts/{id}/raw
```
Raw/source view:
Same auth as the detail endpoint (bearer token or session), owner-scoped, so an
agent can read back and verify exactly what it posted. The metadata response
includes `api_raw_url` pointing here.
```http
GET /a/{id}?raw=1
```
- `markdown` / `html_full` / `json` / `csv` → the exact posted source, as
`text/plain`.
- `image` / `pdf` / `binary` → the stored file bytes (with its content type).
- `html_fragment` / `deck` → `404` (render-only; no raw source is kept).
Raw currently exists for:
Byte-faithful to what's stored; note the deploy (Linux) preserves `\n` line
endings.
| Kind | Raw content |
|---|---|
| `markdown` | Preserved `.md` source. |
| `html_full` | Stored HTML file served as text. |
| `json` | Preserved original JSON text. |
| `csv` | Preserved original CSV text. |
**For browsers (session):** the human viewer at `GET /a/{id}` (rendered) and
`GET /a/{id}?raw=1` (source) is session-authed; use the API `…/raw` endpoint
above for token access.
If raw content is unavailable, AgentFeed returns a small HTML fallback page.
@ -541,11 +551,10 @@ This is generated from the server's actual configuration, so it's the
authoritative source if this document and a running instance ever disagree.
Agents should call this endpoint if they need to adapt to the current AgentFeed instance, or
simply to check the instance is reachable and their token is valid. Unlike the other read
endpoints, it accepts a bearer token as well as a browser session:
simply to check the instance is reachable. It's public — no auth required:
```bash
curl -H "Authorization: Bearer <your-personal-token>" http://127.0.0.1:8001/api/capabilities
curl http://127.0.0.1:8001/api/capabilities
```
## Posting Skill
@ -563,14 +572,13 @@ instance. The server rewrites the `AGENTFEED_URL` line to **this instance's own
origin**, so the text a caller receives already points at the right server;
a recipient only has to fill in their `AGENTFEED_TOKEN`.
Same auth as capabilities (a browser session **or** a bearer token), so a
logged-in user can copy it to hand to their agent, or an agent can fetch it
directly. The default response is raw Markdown (`text/markdown`); add
`?format=json` for `{"version", "url", "skill"}`.
**Public** — no auth required (like capabilities), so an agent can fetch its
posting instructions before it has a key. The default response is raw Markdown
(`text/markdown`); add `?format=json` for `{"version", "url", "skill"}`.
```bash
# Fetch the ready-to-use instructions for this instance
curl -H "Authorization: Bearer <your-personal-token>" http://127.0.0.1:8001/api/skill
# Fetch the ready-to-use instructions for this instance (no auth needed)
curl http://127.0.0.1:8001/api/skill
```
Discover it programmatically via `skill_url` in the capabilities response.

View file

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

View file

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

View file

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

View file

@ -360,6 +360,7 @@ h1 {
/* Source sidebar: scrollbar hidden until hover (short list, rarely scrolls). */
.source-list {
flex: 1 1 auto; /* fill available height so system folders pin to the bottom */
scrollbar-color: transparent transparent;
scrollbar-width: none;
}
@ -513,6 +514,18 @@ h1 {
font-size: 0.7rem;
}
/* The unread badge doubles as a "mark all as read" button. */
.source-count[data-mark-read] {
cursor: pointer;
border-radius: 8px;
padding: 0 0.35rem;
}
.source-count[data-mark-read]:hover {
background: rgba(74, 110, 168, 0.28);
color: var(--text);
}
.pane-heading span,
.artifact-time {
color: var(--muted);
@ -1045,6 +1058,173 @@ kbd {
color: var(--muted);
}
/* Newly live-inserted feed rows briefly highlight, then settle. */
@keyframes agentfeed-row-arrive {
from { background: var(--surface-alt); }
to { background: transparent; }
}
.artifact-row.just-arrived {
animation: agentfeed-row-arrive 1.5s ease-out;
}
/* Hover/focus row actions (delete in the feed, undelete in Trash) */
.artifact-row {
position: relative;
}
.row-actions {
position: absolute;
right: 0.4rem;
top: 50%;
transform: translateY(-50%);
display: flex;
gap: 0.15rem;
opacity: 0;
}
.artifact-row:hover .row-actions,
.artifact-row:focus-within .row-actions {
opacity: 1;
}
.row-action {
cursor: pointer;
font-size: 0.95rem;
line-height: 1;
padding: 0.15rem 0.3rem;
border-radius: 4px;
background: var(--surface);
color: var(--muted);
}
.row-action:hover {
background: var(--surface-alt);
color: var(--text);
}
.row-delete:hover {
color: var(--danger-text-plain);
}
/* Per-row actions menu (opened by the ⋯ trigger or right-click) */
.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);
margin: 0.25rem 0;
}
.row-menu-labels {
display: flex;
flex-direction: column;
max-height: 13rem;
overflow-y: auto;
}
.row-menu-label {
align-items: center;
display: flex;
gap: 0.45rem;
}
.row-menu-dot {
border-radius: 50%;
flex: 0 0 auto;
height: 0.7rem;
width: 0.7rem;
}
.row-menu-label-name {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Checkmark only visible once the label is applied to the row. */
.row-menu-check {
flex: 0 0 auto;
opacity: 0;
}
.row-menu-label.applied .row-menu-check {
opacity: 1;
}
/* System folders (Keep / Deleted): pinned at the bottom of the sidebar,
below the scrolling source list. */
.system-folders {
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 {
background: var(--danger-bg);
border: 1px solid var(--danger-text);
border-radius: 6px;
color: var(--danger-text);
cursor: pointer;
font-size: 0.78rem;
padding: 0.2rem 0.55rem;
}
.api-key-create {
align-items: end;
display: flex;
@ -1060,6 +1240,32 @@ kbd {
gap: 0.3rem;
}
.password-change {
display: flex;
flex-direction: column;
gap: 0.85rem;
max-width: 22rem;
}
.password-change .form-field {
display: flex;
flex-direction: column;
font-weight: 600;
gap: 0.3rem;
}
.password-change button {
align-self: start;
}
.form-errors {
color: var(--danger, #d1453b);
font-weight: 400;
list-style: none;
margin: 0.15rem 0 0;
padding: 0;
}
.api-key-table {
border-collapse: collapse;
width: 100%;

View file

@ -0,0 +1,42 @@
{% for artifact in artifacts %}
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %} {% if artifact.is_unread %}unread{% endif %} {% if artifact.is_kept %}kept{% endif %} {% if artifact.is_shared %}shared{% endif %}"
href="?{% if selected_source %}source={{ selected_source|urlencode }}&amp;{% endif %}{% 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 %}artifact={{ artifact.id }}&amp;view={{ selected_view }}"
data-artifact-id="{{ artifact.id }}"
data-title="{{ artifact.title }}"
data-kind="{{ artifact.kind }}"
data-source="{{ artifact.source }}"
data-source-type="{{ artifact.source_type }}"
data-source-name="{{ artifact.source_name }}"
data-created="{{ artifact.created_at|date:'M j Y, H:i' }}"
data-size="{{ artifact.size_bytes|filesizeformat }}"
data-kept="{{ artifact.is_kept|yesno:'1,0' }}"
data-shared="{{ artifact.is_shared|yesno:'1,0' }}"
data-share-url="{% if artifact.is_shared %}{{ request.scheme }}://{{ request.get_host }}{% url 'artifact-shared' artifact.share_token %}{% endif %}"
data-tags="{{ artifact.tags|join:', ' }}"
data-rendered-url="{% url 'artifact' artifact.id %}"
data-raw-url="{% url 'artifact' artifact.id %}?raw=1">
<time class="artifact-time" datetime="{{ artifact.created_at|date:'c' }}">{{ artifact.created_at|date:"H:i" }}</time>
<span class="artifact-row-main">
<span class="artifact-title">{{ artifact.title }}</span>
<span class="artifact-kind-text">{% if artifact.kind == "image" %}image{% elif artifact.kind == "pdf" %}pdf{% elif artifact.kind == "markdown" %}markdown{% elif artifact.kind == "deck" %}deck{% elif artifact.kind == "binary" %}binary{% else %}html{% endif %}</span>
<span class="kept-badge" data-kept-badge title="Kept — never auto-deleted"{% if not artifact.is_kept %} hidden{% endif %}>K</span>
<span class="shared-badge" data-shared-badge title="Shared — anyone with the link can view"{% if not artifact.is_shared %} hidden{% endif %}>🔗</span>
{% if artifact.label_chips %}
<span class="tag-chips">
{% for label in artifact.label_chips %}
<span class="tag-chip" style="background:{{ label.color }}">{{ label.name }}</span>
{% endfor %}
</span>
{% endif %}
</span>
<span class="artifact-source">{{ artifact.source }}</span>
<span class="row-actions">
{% 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>
{% 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-menu-trigger" role="button" tabindex="0" data-menu-trigger title="More actions" aria-label="More actions for {{ artifact.title }}">&#8943;</span>
{% endif %}
</span>
</a>
{% endfor %}

View file

@ -1,43 +1,10 @@
{% for group in day_groups %}
<div class="day-group">
<div class="day-group"{% if group.day == today %} data-today="1"{% endif %}>
<div class="day-separator">
<span>{{ group.day|date:"D, M j Y" }}{% if group.day == today %} <em class="today-badge">Today</em>{% endif %}</span>
<span class="day-separator-count">{{ group.artifacts|length }}</span>
</div>
{% for artifact in group.artifacts %}
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %} {% if artifact.is_unread %}unread{% endif %} {% if artifact.is_kept %}kept{% endif %} {% if artifact.is_shared %}shared{% endif %}"
href="?{% if selected_source %}source={{ selected_source|urlencode }}&amp;{% endif %}{% 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 %}artifact={{ artifact.id }}&amp;view={{ selected_view }}"
data-artifact-id="{{ artifact.id }}"
data-title="{{ artifact.title }}"
data-kind="{{ artifact.kind }}"
data-source="{{ artifact.source }}"
data-source-type="{{ artifact.source_type }}"
data-source-name="{{ artifact.source_name }}"
data-created="{{ artifact.created_at|date:'M j Y, H:i' }}"
data-size="{{ artifact.size_bytes|filesizeformat }}"
data-kept="{{ artifact.is_kept|yesno:'1,0' }}"
data-shared="{{ artifact.is_shared|yesno:'1,0' }}"
data-share-url="{% if artifact.is_shared %}{{ request.scheme }}://{{ request.get_host }}{% url 'artifact-shared' artifact.share_token %}{% endif %}"
data-tags="{{ artifact.tags|join:', ' }}"
data-rendered-url="{% url 'artifact' artifact.id %}"
data-raw-url="{% url 'artifact' artifact.id %}?raw=1">
<time class="artifact-time" datetime="{{ artifact.created_at|date:'c' }}">{{ artifact.created_at|date:"H:i" }}</time>
<span class="artifact-row-main">
<span class="artifact-title">{{ artifact.title }}</span>
<span class="artifact-kind-text">{% if artifact.kind == "image" %}image{% elif artifact.kind == "pdf" %}pdf{% elif artifact.kind == "markdown" %}markdown{% elif artifact.kind == "deck" %}deck{% elif artifact.kind == "binary" %}binary{% else %}html{% endif %}</span>
<span class="kept-badge" data-kept-badge title="Kept — never auto-deleted"{% if not artifact.is_kept %} hidden{% endif %}>K</span>
<span class="shared-badge" data-shared-badge title="Shared — anyone with the link can view"{% if not artifact.is_shared %} hidden{% endif %}>🔗</span>
{% if artifact.label_chips %}
<span class="tag-chips">
{% for label in artifact.label_chips %}
<span class="tag-chip" style="background:{{ label.color }}">{{ label.name }}</span>
{% endfor %}
</span>
{% endif %}
</span>
<span class="artifact-source">{{ artifact.source }}</span>
</a>
{% endfor %}
{% include "artifacts/_artifact_rows.html" with artifacts=group.artifacts %}
</div>
{% endfor %}
{% if has_more %}

View file

@ -27,6 +27,7 @@
data-selected-kind="{{ selected_kind }}"
data-title-query="{{ title_query }}"
data-selected-view="{{ selected_view }}"
data-folder="{{ selected_folder }}"
data-window-start="{{ window_start|date:'Y-m-d' }}"
data-older-count="{{ older_count }}">
<aside class="source-sidebar" aria-label="Sources">
@ -34,33 +35,48 @@
<h2>Sources</h2>
</div>
<nav class="source-list">
<a class="source-link {% if not selected_source %}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>
{% if total_count %}<span class="source-count">{{ total_count }}</span>{% endif %}
{% if total_count %}<span class="source-count" data-mark-read role="button" tabindex="0" title="Mark all as read">{{ total_count }}</span>{% endif %}
</a>
{% for entry in source_counts %}
<a class="source-link {% if entry.source == selected_source %}selected{% endif %}" href="?source={{ entry.source|urlencode }}{% if selected_label %}&amp;label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&amp;kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&amp;q={{ title_query|urlencode }}{% endif %}&amp;view={{ selected_view }}&amp;preview=none">
<a class="source-link {% if entry.source == selected_source and not selected_folder %}selected{% endif %}" href="?source={{ entry.source|urlencode }}{% if selected_label %}&amp;label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&amp;kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&amp;q={{ title_query|urlencode }}{% endif %}&amp;view={{ selected_view }}&amp;preview=none" data-source="{{ entry.source }}">
<span class="source-name">{{ entry.source }}</span>
{% if entry.count %}<span class="source-count">{{ entry.count }}</span>{% endif %}
{% if entry.count %}<span class="source-count" data-mark-read role="button" tabindex="0" title="Mark all as read">{{ entry.count }}</span>{% endif %}
</a>
{% endfor %}
</nav>
<nav class="system-folders" aria-label="Folders">
<a class="source-link folder-link {% if selected_folder == 'keep' %}selected{% endif %}" href="?folder=keep&amp;view={{ selected_view }}&amp;preview=none" data-folder-link="keep">
<span class="source-name">&#9733; Keep</span>
{% if keep_count %}<span class="source-count">{{ keep_count }}</span>{% endif %}
</a>
<a class="source-link folder-link {% if selected_folder == 'deleted' %}selected{% endif %}" href="?folder=deleted&amp;view={{ selected_view }}&amp;preview=none" data-folder-link="deleted">
<span class="source-name">&#128465; Deleted</span>
{% if trash_count %}<span class="source-count">{{ trash_count }}</span>{% endif %}
</a>
</nav>
</aside>
<div class="splitter" data-splitter="days" role="separator" aria-label="Resize sources sidebar" aria-orientation="vertical"></div>
<aside class="artifact-sidebar" aria-label="Artifact feed">
<div class="pane-bar pane-heading">
<h2>{% if selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
<h2>{% if selected_folder == 'deleted' %}Deleted{% elif selected_folder == 'keep' %}Keep{% elif selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
{% if selected_folder == 'deleted' %}
<button type="button" class="empty-trash-button" data-empty-trash>Empty Trash</button>
{% else %}
<span>{{ artifacts|length }} message{{ artifacts|length|pluralize }}</span>
{% endif %}
</div>
<div class="artifact-list{% if selected_source %} no-source-col{% endif %}" data-artifact-list>
<div class="artifact-list{% if selected_source %} no-source-col{% endif %}" data-artifact-list data-since="{{ feed_since }}">
{% if day_groups %}
{% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %}
{% else %}
<div class="empty compact">
<h3>No artifacts</h3>
<h3>No messages</h3>
<p>Nothing in the last {{ 7 }} days for these filters.</p>
</div>
{% endif %}
@ -72,7 +88,7 @@
<section class="preview-pane">
<header class="pane-bar preview-actions" data-actions>
<div class="preview-title" data-preview-title {% if selected_artifact %}title="{{ selected_artifact.title }}"{% endif %}>
{% if selected_artifact %}{{ selected_artifact.title }}{% else %}No artifact loaded{% endif %}
{% if selected_artifact %}{{ selected_artifact.title }}{% else %}No message loaded{% endif %}
</div>
<nav class="view-toggle" aria-label="Preview mode">
<a class="{% if selected_view == 'rendered' %}selected{% endif %}{% if not selected_artifact %} disabled{% endif %}" href="{% if selected_artifact %}{{ rendered_url }}{% else %}#{% endif %}" data-view="rendered" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Rendered</a>
@ -102,8 +118,8 @@
<img class="preview-image" src="{% url 'artifact' selected_artifact.id %}" alt="{{ selected_artifact.title }}">
{% elif selected_artifact.kind == "binary" %}
<div class="empty preview-empty">
<h2>Binary Artifact</h2>
<p>This artifact type cannot be previewed inline yet.</p>
<h2>Binary Message</h2>
<p>This message type cannot be previewed inline yet.</p>
<p><a href="{% url 'artifact' selected_artifact.id %}">Open or download it</a>.</p>
</div>
{% else %}
@ -113,10 +129,10 @@
{% else %}
<div class="empty preview-empty">
{% if artifacts %}
<h2>No artifact loaded</h2>
<p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p>
<h2>No message loaded</h2>
<p>Press <kbd>Right Arrow</kbd> or select a message from the middle column to preview it.</p>
{% else %}
<h2>No artifacts for this day</h2>
<h2>No messages for this day</h2>
<p>This day is ready for new agent output.</p>
{% endif %}
</div>
@ -125,6 +141,25 @@
</section>
</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>
(() => {
const feed = document.querySelector(".feed");
@ -195,7 +230,8 @@
const artifactList = document.querySelector("[data-artifact-list]");
// Rows grow as older pages load, so always read them live rather than caching an array.
const allRows = () => Array.from(document.querySelectorAll(".artifact-row"));
const sourceLinks = Array.from(document.querySelectorAll(".source-link"));
// Live-queried so sources added by live updates are keyboard-navigable too.
const sourceLinks = () => Array.from(document.querySelectorAll(".source-link"));
const csrfToken = document.querySelector("[name=csrfmiddlewaretoken]")?.value || "";
const escapeHtml = (value) => value.replace(/[&<>"]/g, (character) => ({
@ -209,7 +245,7 @@
try {
await navigator.clipboard.writeText(absoluteUrl);
} catch {
window.prompt("Copy artifact link", absoluteUrl);
window.prompt("Copy message link", absoluteUrl);
}
const original = button.textContent;
button.textContent = "Copied";
@ -318,7 +354,7 @@
if (!actions) return;
const previewTitle = actions.querySelector("[data-preview-title]");
if (previewTitle) {
previewTitle.textContent = "No artifact loaded";
previewTitle.textContent = "No message loaded";
previewTitle.removeAttribute("title");
}
actions.querySelectorAll("[data-view]").forEach((link) => {
@ -383,6 +419,335 @@
});
};
const findSourceLink = (name) =>
sourceLinks().find((link) => link.querySelector(".source-name")?.textContent.trim() === name);
const bumpSourceBadge = (link, delta) => {
let badge = link.querySelector(".source-count");
const next = (badge ? Number(badge.textContent) || 0 : 0) + delta;
if (next <= 0) {
if (badge) badge.remove(); // empty folder/source shows no count badge
return;
}
if (!badge) {
badge = document.createElement("span");
badge.className = "source-count";
link.appendChild(badge);
}
badge.textContent = String(next);
};
const addSourceLink = (name, count) => {
const params = new URLSearchParams();
params.set("source", name);
if (feed.dataset.selectedLabel) params.set("label", feed.dataset.selectedLabel);
if (feed.dataset.selectedKind) params.set("kind", feed.dataset.selectedKind);
if (feed.dataset.titleQuery) params.set("q", feed.dataset.titleQuery);
params.set("view", feed.dataset.selectedView || "rendered");
params.set("preview", "none");
const link = document.createElement("a");
link.className = "source-link";
link.href = `?${params.toString()}`;
link.innerHTML =
`<span class="source-name">${escapeHtml(name)}</span>` +
`<span class="source-count">${count}</span>`;
// 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 firstSource = list.querySelector(".source-link:not(.all-sources-link)");
list.insertBefore(link, firstSource);
};
const moveSourceToTop = (link) => {
const list = document.querySelector(".source-list");
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
// sidebar entry for a source seen for the first time) plus the total.
const registerNewSources = (rows) => {
const counts = {};
rows.forEach((row) => {
const source = row.dataset.source;
if (source) counts[source] = (counts[source] || 0) + 1;
});
let total = 0;
Object.entries(counts).forEach(([source, n]) => {
total += n;
const existing = findSourceLink(source);
if (existing) {
bumpSourceBadge(existing, n);
moveSourceToTop(existing); // most-recent activity -> top (recency order)
} else {
addSourceLink(source, n);
}
});
const allLink = findSourceLink("All sources");
if (allLink && total) bumpSourceBadge(allLink, total);
};
// --- Delete / undelete / empty trash ---
const updateArtifactCount = (delta) => {
const header = document.querySelector(".artifact-sidebar .pane-heading span");
if (!header) return;
const total = Math.max(0, (parseInt(header.textContent, 10) || 0) + delta);
header.textContent = `${total} message${total === 1 ? "" : "s"}`;
};
const bumpFolderCount = (folder, delta) => {
const link = document.querySelector(`[data-folder-link="${folder}"]`);
if (link) bumpSourceBadge(link, delta);
};
const removeRowFromList = (row) => {
const group = row.closest(".day-group");
row.remove();
if (group) {
const remaining = group.querySelectorAll(".artifact-row").length;
const count = group.querySelector(".day-separator-count");
if (count) count.textContent = String(remaining);
if (remaining === 0) group.remove();
}
};
const softDeleteRow = async (id) => {
const row = artifactList.querySelector(`.artifact-row[data-artifact-id="${id}"]`);
try {
const response = await fetch(`/a/${id}/delete`, {method: "POST", headers: {"X-CSRFToken": csrfToken}});
if (!response.ok) return;
} catch { return; }
if (!row) return;
if (row.classList.contains("selected")) clearPreview();
if (row.classList.contains("unread")) decrementSourceCount(row.dataset.source);
removeRowFromList(row);
updateArtifactCount(-1);
bumpFolderCount("deleted", 1); // best-effort; the folder appears on next nav if it's the first delete
};
const undeleteRow = async (id) => {
const row = artifactList.querySelector(`.artifact-row[data-artifact-id="${id}"]`);
try {
const response = await fetch(`/a/${id}/undelete`, {method: "POST", headers: {"X-CSRFToken": csrfToken}});
if (!response.ok) return;
} catch { return; }
if (!row) return;
removeRowFromList(row);
bumpFolderCount("deleted", -1);
// The restored item reappears in the feed on the next navigation (full render).
};
const emptyTrashButton = document.querySelector("[data-empty-trash]");
if (emptyTrashButton) {
emptyTrashButton.addEventListener("click", async () => {
if (!window.confirm("Permanently delete everything in Trash? This can't be undone.")) return;
try {
const response = await fetch("{% url 'feed-empty-trash' %}", {method: "POST", headers: {"X-CSRFToken": csrfToken}});
if (!response.ok) return;
} catch { return; }
window.location.href = window.location.pathname; // trash empty; folder gone
});
}
// --- 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) => {
navigationMode = "artifacts";
markRead(row);
@ -395,7 +760,7 @@
: kind === "image"
? `<img class="preview-image" src="${source}" alt="${title}">`
: kind === "binary"
? `<div class="empty preview-empty"><h2>Binary Artifact</h2><p>This artifact type cannot be previewed inline yet.</p><p><a href="${source}">Open or download it</a>.</p></div>`
? `<div class="empty preview-empty"><h2>Binary Message</h2><p>This message type cannot be previewed inline yet.</p><p><a href="${source}">Open or download it</a>.</p></div>`
: `<iframe class="preview-frame" src="${source}" title="${title}"></iframe>`;
previewBody.innerHTML = `<div class="preview-frame-wrap">${content}</div>`;
@ -410,7 +775,7 @@
}
allRows().forEach((candidate) => candidate.classList.remove("selected"));
disableActions();
previewBody.innerHTML = `<div class="empty preview-empty"><h2>No artifact loaded</h2><p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p></div>`;
previewBody.innerHTML = `<div class="empty preview-empty"><h2>No message loaded</h2><p>Press <kbd>Right Arrow</kbd> or select a message from the middle column to preview it.</p></div>`;
window.history.pushState({}, "", noPreviewUrl());
const currentSource = document.querySelector(".source-link.selected");
if (currentSource) currentSource.focus({preventScroll: true});
@ -418,6 +783,21 @@
// Delegated so rows appended by infinite scroll work without rebinding.
artifactList.addEventListener("click", (event) => {
// Row action icons (delete/undelete/menu) intercept before selection.
const action = event.target.closest("[data-delete], [data-undelete], [data-menu-trigger]");
if (action) {
event.preventDefault();
event.stopPropagation();
if (action.hasAttribute("data-menu-trigger")) {
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;
}
const row = event.target.closest(".artifact-row");
if (!row) return;
event.preventDefault();
@ -438,12 +818,13 @@
};
const moveSource = (direction) => {
if (!sourceLinks.length) return;
const links = sourceLinks();
if (!links.length) return;
navigationMode = "sources";
const current = document.querySelector(".source-link.selected");
const currentIndex = current ? sourceLinks.indexOf(current) : 0;
const nextIndex = Math.max(0, Math.min(sourceLinks.length - 1, currentIndex + direction));
const nextSource = sourceLinks[nextIndex];
const currentIndex = current ? links.indexOf(current) : 0;
const nextIndex = Math.max(0, Math.min(links.length - 1, currentIndex + direction));
const nextSource = links[nextIndex];
if (nextSource && nextSource !== current) window.location.href = nextSource.href;
};
@ -552,6 +933,98 @@
observeSentinel();
// --- Live updates: poll for new artifacts and prepend them at the top. ---
// Cheap: a small "anything since X?" fetch every few seconds, paused while
// the tab is hidden. New rows are inserted without touching the selected
// row or the preview, so what you're reading stays put. Respects the
// active source/label/kind/q filters (passed through below).
const LIVE_POLL_MS = 5000;
const NOW_TTL_MS = 3 * 60 * 1000; // show "now", then age into the real time
let liveSince = artifactList.dataset.since || null;
let livePolling = false;
const buildUpdatesUrl = () => {
const url = new URL("{% url 'feed-updates' %}", window.location.origin);
const p = url.searchParams;
if (liveSince) p.set("after", liveSince);
p.set("view", feed.dataset.selectedView || "rendered");
if (feed.dataset.selectedSource) p.set("source", feed.dataset.selectedSource);
if (feed.dataset.selectedLabel) p.set("label", feed.dataset.selectedLabel);
if (feed.dataset.selectedKind) p.set("kind", feed.dataset.selectedKind);
if (feed.dataset.titleQuery) p.set("q", feed.dataset.titleQuery);
return url;
};
const ensureTodayGroup = (todayLabel) => {
let group = artifactList.querySelector(".day-group[data-today]");
if (group) return group;
const empty = artifactList.querySelector(".empty");
if (empty) empty.remove();
group = document.createElement("div");
group.className = "day-group";
group.setAttribute("data-today", "1");
group.innerHTML =
`<div class="day-separator"><span>${escapeHtml(todayLabel)} <em class="today-badge">Today</em></span>` +
`<span class="day-separator-count">0</span></div>`;
artifactList.insertBefore(group, artifactList.firstChild);
return group;
};
const markArrived = (rows) => {
rows.forEach((row) => {
const timeEl = row.querySelector(".artifact-time");
if (timeEl) {
timeEl.dataset.realtime = timeEl.textContent;
timeEl.textContent = "now";
setTimeout(() => {
if (timeEl.dataset.realtime) timeEl.textContent = timeEl.dataset.realtime;
}, NOW_TTL_MS);
}
row.classList.add("just-arrived");
setTimeout(() => row.classList.remove("just-arrived"), 1500);
});
};
const pollUpdates = async () => {
// Don't poll while viewing a system folder (Deleted/Keep) — new live
// posts don't belong in those views.
if (livePolling || document.hidden || !liveSince || feed.dataset.folder) return;
livePolling = true;
try {
const response = await fetch(buildUpdatesUrl(), {headers: {"X-Requested-With": "fetch"}});
if (!response.ok) return;
const data = await response.json();
if (!data.count) return;
const group = ensureTodayGroup(data.today_label);
const separator = group.querySelector(".day-separator");
// Newest-first batch goes immediately below the separator, above prior rows.
separator.insertAdjacentHTML("afterend", data.html);
const inserted = [];
let node = separator.nextElementSibling;
while (node && inserted.length < data.count) {
if (node.classList.contains("artifact-row")) inserted.push(node);
node = node.nextElementSibling;
}
markArrived(inserted);
registerNewSources(inserted); // update sidebar: counts + any new source
liveSince = data.newest;
const dayCount = group.querySelector(".day-separator-count");
if (dayCount) dayCount.textContent = String((parseInt(dayCount.textContent, 10) || 0) + data.count);
const header = document.querySelector(".artifact-sidebar .pane-heading span");
if (header) {
const total = (parseInt(header.textContent, 10) || 0) + data.count;
header.textContent = `${total} message${total === 1 ? "" : "s"}`;
}
} catch {
// ignore; retry next tick
} finally {
livePolling = false;
}
};
setInterval(pollUpdates, LIVE_POLL_MS);
document.addEventListener("visibilitychange", () => { if (!document.hidden) pollUpdates(); });
// Populate the details panel for a server-preselected artifact so the first
// "Details" click shows content immediately.
const preselected = selectedRow();

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,54 @@
{% extends "artifacts/settings_base.html" %}
{% block settings_content %}
<h2>Labels</h2>
<p>
Build a palette of labels you can apply to any message from its &#8943; menu
in the feed. A label is just a coloured tag — applying one adds it to the
message and it shows as a chip next to the title. Deleting a label here only
removes it from this palette; messages that already carry it keep the chip.
</p>
<h3>Add a label</h3>
<form method="post" class="label-create">
{% csrf_token %}
<input type="hidden" name="action" value="create">
<label>Name <input type="text" name="name" placeholder="e.g. follow-up" required autocomplete="off" maxlength="40"></label>
<label>Colour <input type="color" name="color" value="#4a6ea8"></label>
<button type="submit">Add label</button>
</form>
<h3>Your labels</h3>
{% if labels %}
<table class="label-table">
<thead>
<tr><th>Label</th><th>Colour</th><th></th></tr>
</thead>
<tbody>
{% for label in labels %}
<tr>
<td><span class="tag-chip" style="background:{{ label.color }}">{{ label.name }}</span></td>
<td>
<form method="post" class="label-recolor">
{% csrf_token %}
<input type="hidden" name="action" value="update">
<input type="hidden" name="label_id" value="{{ label.pk }}">
<input type="color" name="color" value="{{ label.color }}" onchange="this.form.submit()" title="Change colour">
</form>
</td>
<td>
<form method="post" onsubmit="return confirm('Remove &quot;{{ label.name|escapejs }}&quot; from your palette? Messages already labelled keep the chip.');">
{% csrf_token %}
<input type="hidden" name="action" value="delete">
<input type="hidden" name="label_id" value="{{ label.pk }}">
<button type="submit" class="label-delete">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No labels yet. Add one above, then pick it from a message's &#8943; menu.</p>
{% endif %}
{% endblock %}

View file

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

View file

@ -17,6 +17,7 @@ from artifacts.models import ApiKey, Artifact
User = get_user_model()
@override_settings(AGENTFEED_ASYNC_PURGE=False) # DELETE triggers purge; run it inline in tests
class ArtifactAPITestCase(APITestCase):
"""Exercises the ingest/list/detail API against a real (temp) filesystem
and a real (test) database, the way an agent posting over HTTP would."""
@ -58,9 +59,10 @@ class ArtifactAPITestCase(APITestCase):
# -- capabilities -------------------------------------------------
def test_capabilities_requires_auth(self):
def test_capabilities_is_public(self):
# Capability/contract info only, no user data — public, no auth needed.
response = self.client.get(reverse("capabilities-api"))
self.assertEqual(response.status_code, 403)
self.assertEqual(response.status_code, 200)
def test_capabilities_with_token(self):
response = self.client.get(reverse("capabilities-api"), **self.auth_headers(self.token))
@ -85,9 +87,10 @@ class ArtifactAPITestCase(APITestCase):
# -- skill ----------------------------------------------------------
def test_skill_requires_auth(self):
def test_skill_is_public(self):
# Bootstrapping instructions, no secrets — public, no auth needed.
response = self.client.get(reverse("skill-api"))
self.assertEqual(response.status_code, 403)
self.assertEqual(response.status_code, 200)
def test_skill_returns_markdown_with_origin_filled_in(self):
response = self.client.get(reverse("skill-api"), **self.auth_headers(self.token))
@ -156,9 +159,10 @@ class ArtifactAPITestCase(APITestCase):
# -- list (GET) -------------------------------------------------------
def test_list_requires_session_login(self):
def test_list_requires_auth(self):
# No session and no token -> JSON 403, never a browser-login redirect.
response = self.client.get(reverse("artifact-api"))
self.assertEqual(response.status_code, 302) # redirected to login
self.assertEqual(response.status_code, 403)
def test_list_scoped_to_owner(self):
self.create_artifact()
@ -169,6 +173,15 @@ class ArtifactAPITestCase(APITestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual([a["title"] for a in response.data], ["Hello World"])
def test_list_readable_with_bearer_token(self):
# Agents read back with the same token they post with; owner-scoped.
self.create_artifact()
self.create_artifact(token=self.other_token, title="Not mine")
response = self.client.get(reverse("artifact-api"), **self.auth_headers(self.token))
self.assertEqual(response.status_code, 200)
self.assertEqual([a["title"] for a in response.data], ["Hello World"])
def test_list_filter_by_label(self):
self.create_artifact(tags=["urgent"])
self.create_artifact(title="Second", tags=["later"])
@ -220,6 +233,67 @@ class ArtifactAPITestCase(APITestCase):
response = self.client.get(reverse("artifact-detail-api", args=[artifact_id]))
self.assertEqual(response.status_code, 200)
def test_detail_readable_with_bearer_token(self):
# The api_url returned by POST is fetchable with the posting token.
artifact_id = self.create_artifact().data["id"]
response = self.client.get(
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["id"], artifact_id)
# ...but only the owner's token; another user's token gets 404.
response = self.client.get(
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.other_token)
)
self.assertEqual(response.status_code, 404)
def test_detail_requires_auth(self):
artifact_id = self.create_artifact().data["id"]
response = self.client.get(reverse("artifact-detail-api", args=[artifact_id]))
self.assertEqual(response.status_code, 403)
# -- raw content (GET {id}/raw) ---------------------------------------
def _raw_body(self, response):
return b"".join(response.streaming_content).decode()
def test_raw_returns_exact_content_with_bearer(self):
create = self.create_artifact(content="# Exact\n\nverify me")
artifact_id = create.data["id"]
# metadata detail advertises the raw endpoint
self.assertTrue(create.data["api_raw_url"].endswith(f"/api/artifacts/{artifact_id}/raw"))
response = self.client.get(
reverse("artifact-raw-api", args=[artifact_id]), **self.auth_headers(self.token)
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "text/plain; charset=utf-8")
# Content round-trips faithfully; normalize newlines since the stored
# sidecar is written in the host's text mode (CRLF on Windows dev, LF on
# the Linux deploy).
self.assertEqual(self._raw_body(response).replace("\r\n", "\n"), "# Exact\n\nverify me")
def test_raw_requires_auth(self):
artifact_id = self.create_artifact().data["id"]
response = self.client.get(reverse("artifact-raw-api", args=[artifact_id]))
self.assertEqual(response.status_code, 403)
def test_raw_owner_scoped(self):
artifact_id = self.create_artifact().data["id"]
response = self.client.get(
reverse("artifact-raw-api", args=[artifact_id]), **self.auth_headers(self.other_token)
)
self.assertEqual(response.status_code, 404)
def test_raw_not_available_for_render_only_kind(self):
artifact_id = self.create_artifact(kind="html_fragment", content="<p>hi</p>").data["id"]
response = self.client.get(
reverse("artifact-raw-api", args=[artifact_id]), **self.auth_headers(self.token)
)
self.assertEqual(response.status_code, 404)
def test_patch_updates_title_and_tags(self):
artifact_id = self.create_artifact().data["id"]
response = self.client.patch(
@ -261,7 +335,8 @@ class ArtifactAPITestCase(APITestCase):
# -- delete (DELETE) --------------------------------------------------
def test_delete_removes_artifact_and_files(self):
def test_delete_soft_deletes_to_trash(self):
# DELETE moves the artifact to Trash (recoverable), not a hard removal.
artifact_id = self.create_artifact().data["id"]
artifact = Artifact.objects.get(pk=artifact_id)
stored_path = self.artifacts_root / artifact.path
@ -271,9 +346,12 @@ class ArtifactAPITestCase(APITestCase):
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
)
self.assertEqual(response.status_code, 204)
self.assertFalse(Artifact.objects.filter(pk=artifact_id).exists())
self.assertFalse(stored_path.exists())
self.assertFalse(stored_path.with_suffix(".md").exists())
# Row still exists, marked deleted; files kept on disk until purge.
artifact.refresh_from_db()
self.assertIsNotNone(artifact.deleted_at)
self.assertTrue(stored_path.exists())
# But it's gone from the API's list/detail (hidden like the feed).
self.assertEqual(self.client.get(reverse("artifact-api"), **self.auth_headers(self.token)).data, [])
def test_delete_requires_token(self):
artifact_id = self.create_artifact().data["id"]
@ -289,7 +367,8 @@ class ArtifactAPITestCase(APITestCase):
self.assertEqual(response.status_code, 404)
self.assertTrue(Artifact.objects.filter(pk=artifact_id).exists())
def test_delete_twice_second_call_404s(self):
def test_delete_is_idempotent(self):
# Soft-delete is idempotent: deleting an already-trashed artifact is a no-op 204.
artifact_id = self.create_artifact().data["id"]
first = self.client.delete(
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
@ -299,7 +378,7 @@ class ArtifactAPITestCase(APITestCase):
second = self.client.delete(
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
)
self.assertEqual(second.status_code, 404)
self.assertEqual(second.status_code, 204)
def test_deleted_artifact_is_gone_from_list_and_detail(self):
artifact_id = self.create_artifact().data["id"]

View file

@ -0,0 +1,75 @@
from datetime import timedelta
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
User = get_user_model()
class FeedUpdatesViewTestCase(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._n = 0
def make(self, owner=None, title="Note", source="agent"):
self._n += 1
return Artifact.objects.create(
id=f"upd{self._n:05d}",
owner=owner or self.user,
source=source,
title=title,
kind=Artifact.MARKDOWN,
day=timezone.localdate(),
path=f"{source}/x.html",
original_kind=Artifact.MARKDOWN,
size_bytes=1,
)
def marker(self, seconds_ago=60):
return (timezone.now() - timedelta(seconds=seconds_ago)).isoformat()
def get(self, **params):
return self.client.get(reverse("feed-updates"), params)
def test_requires_login(self):
self.client.logout()
response = self.get(after=self.marker())
self.assertEqual(response.status_code, 302)
def test_returns_new_since_marker(self):
self.make(title="Fresh Post")
response = self.get(after=self.marker())
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["count"], 1)
self.assertIn("Fresh Post", data["html"])
self.assertIn("newest", data)
self.assertIn("today_label", data)
def test_nothing_newer_than_now(self):
self.make()
response = self.get(after=self.marker(seconds_ago=-60)) # 60s in the future
self.assertEqual(response.json()["count"], 0)
def test_missing_marker_returns_zero(self):
self.make()
self.assertEqual(self.get().json()["count"], 0)
def test_owner_scoped(self):
self.make(owner=self.other, title="Not mine")
data = self.get(after=self.marker()).json()
self.assertEqual(data["count"], 0)
def test_respects_source_filter(self):
self.make(title="Hermes post", source="hermes")
self.make(title="Janus post", source="janus")
data = self.get(after=self.marker(), source="hermes").json()
self.assertEqual(data["count"], 1)
self.assertIn("Hermes post", data["html"])
self.assertNotIn("Janus post", data["html"])

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)

158
tests/views/test_trash.py Normal file
View file

@ -0,0 +1,158 @@
import shutil
import tempfile
from datetime import timedelta
from pathlib import Path
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from artifacts.models import Artifact
from artifacts.storage import purge_expired_trash, user_usage_bytes
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):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="agentfeed-test-trash-"))
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
cls.settings_override.enable()
@classmethod
def tearDownClass(cls):
cls.settings_override.disable()
shutil.rmtree(cls.artifacts_root, ignore_errors=True)
super().tearDownClass()
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._n = 0
def make(self, owner=None, title="Note", source="agent", is_kept=False, size_bytes=1):
self._n += 1
return Artifact.objects.create(
id=f"trs{self._n:05d}",
owner=owner or self.user,
source=source,
title=title,
kind=Artifact.MARKDOWN,
day=timezone.localdate(),
path=f"{source}/trs{self._n:05d}.html",
original_kind=Artifact.MARKDOWN,
size_bytes=size_bytes,
is_kept=is_kept,
)
def test_delete_soft_deletes(self):
a = self.make()
r = self.client.post(reverse("artifact-delete", args=[a.id]))
self.assertEqual(r.status_code, 200)
a.refresh_from_db()
self.assertIsNotNone(a.deleted_at)
def test_delete_requires_login(self):
self.client.logout()
a = self.make()
r = self.client.post(reverse("artifact-delete", args=[a.id]))
self.assertEqual(r.status_code, 302)
def test_delete_scoped_to_owner(self):
a = self.make(owner=self.other)
r = self.client.post(reverse("artifact-delete", args=[a.id]))
self.assertEqual(r.status_code, 404)
a.refresh_from_db()
self.assertIsNone(a.deleted_at)
def test_deleted_hidden_from_feed_shown_in_trash(self):
a = self.make(title="ThrowawayThing")
self.client.post(reverse("artifact-delete", args=[a.id]))
self.assertNotIn("ThrowawayThing", self.client.get("/").content.decode())
self.assertIn("ThrowawayThing", self.client.get("/?folder=deleted").content.decode())
def test_undelete_restores(self):
a = self.make(title="ComeBack")
self.client.post(reverse("artifact-delete", args=[a.id]))
r = self.client.post(reverse("artifact-undelete", args=[a.id]))
self.assertEqual(r.status_code, 200)
a.refresh_from_db()
self.assertIsNone(a.deleted_at)
self.assertIn("ComeBack", self.client.get("/").content.decode())
def test_empty_trash_purges(self):
a = self.make()
b = self.make()
self.client.post(reverse("artifact-delete", args=[a.id]))
self.client.post(reverse("artifact-delete", args=[b.id]))
r = self.client.post(reverse("feed-empty-trash"))
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()["purged"], 2)
self.assertFalse(Artifact.objects.filter(pk__in=[a.id, b.id]).exists())
def test_deleted_is_off_quota(self):
a = self.make(size_bytes=1000)
self.assertEqual(user_usage_bytes(self.user), 1000)
self.client.post(reverse("artifact-delete", args=[a.id]))
self.assertEqual(user_usage_bytes(self.user), 0)
def test_keep_folder_shows_only_kept(self):
self.make(title="KeptOne", is_kept=True)
self.make(title="NormalOne")
page = self.client.get("/?folder=keep").content.decode()
self.assertIn("KeptOne", 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)