From f6f004c6898de8bd7717636207b7411c1af52921 Mon Sep 17 00:00:00 2001 From: peter Date: Fri, 4 Sep 2026 15:20:37 +0100 Subject: [PATCH 01/12] 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 --- CHANGELOG.md | 13 +++++++++++- artifacts/urls.py | 1 + artifacts/views.py | 33 ++++++++++++++++++++++++++++++ daybook_project/__init__.py | 2 +- docs/api.md | 28 ++++++++++++++------------ tests/api/test_artifacts.py | 40 +++++++++++++++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60b5f60..26f8719 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ former name, "Daybook".) _Nothing yet._ +## [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 @@ -128,7 +138,8 @@ First tagged release. - Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a runbook (`deploy/cloud_agent_deploy.md`) and health check. -[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.3.1...HEAD +[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.3.2...HEAD +[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 diff --git a/artifacts/urls.py b/artifacts/urls.py index 1cbd657..008ceb5 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -19,6 +19,7 @@ urlpatterns = [ path("api/keys/", views.ApiKeyDetailView.as_view(), name="api-key-detail"), path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"), path("api/artifacts/", views.ArtifactDetailView.as_view(), name="artifact-detail-api"), + path("api/artifacts//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"), diff --git a/artifacts/views.py b/artifacts/views.py index 99f326b..d82eed9 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -303,6 +303,37 @@ class ArtifactDetailView(APIView): 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) + + 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.""" @@ -314,6 +345,7 @@ class CapabilitiesView(APIView): 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"], @@ -1274,6 +1306,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, diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index 72aff5b..47770b1 100644 --- a/daybook_project/__init__.py +++ b/daybook_project/__init__.py @@ -1,4 +1,4 @@ # Single source of truth for the app version. Bump this, commit, then tag the # commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in # GET /api/capabilities ("version"). -__version__ = "0.3.1" +__version__ = "0.3.2" diff --git a/docs/api.md b/docs/api.md index e804074..187270c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -465,26 +465,28 @@ curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \ ## 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. diff --git a/tests/api/test_artifacts.py b/tests/api/test_artifacts.py index 34193b7..0c8797c 100644 --- a/tests/api/test_artifacts.py +++ b/tests/api/test_artifacts.py @@ -253,6 +253,46 @@ class ArtifactAPITestCase(APITestCase): 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="

hi

").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( From cae414c307527c7221a294e51942e74d5f5d1af2 Mon Sep 17 00:00:00 2001 From: peter Date: Fri, 4 Sep 2026 23:02:07 +0100 Subject: [PATCH 02/12] v0.4.0: live feed updates (auto-insert new posts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 20 +++++- artifacts/urls.py | 1 + artifacts/views.py | 59 +++++++++++++++- daybook_project/__init__.py | 2 +- static/agentfeed.css | 10 +++ templates/artifacts/_artifact_rows.html | 34 +++++++++ templates/artifacts/_day_groups.html | 37 +--------- templates/artifacts/feed.html | 91 ++++++++++++++++++++++++- tests/views/test_feed_updates.py | 75 ++++++++++++++++++++ 9 files changed, 289 insertions(+), 40 deletions(-) create mode 100644 templates/artifacts/_artifact_rows.html create mode 100644 tests/views/test_feed_updates.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 26f8719..cf4ed70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ former name, "Daybook".) _Nothing yet._ +## [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 @@ -138,7 +155,8 @@ First tagged release. - Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a runbook (`deploy/cloud_agent_deploy.md`) and health check. -[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.3.2...HEAD +[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.4.0...HEAD +[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 diff --git a/artifacts/urls.py b/artifacts/urls.py index 008ceb5..cc1f03c 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -6,6 +6,7 @@ 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//read", views.mark_artifact_read, name="artifact-mark-read"), path("a//keep", views.toggle_artifact_keep, name="artifact-toggle-keep"), path("a//share", views.toggle_artifact_share, name="artifact-toggle-share"), diff --git a/artifacts/views.py b/artifacts/views.py index d82eed9..1a6afc3 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -22,9 +22,10 @@ from django.db import OperationalError, transaction from django.db.models import Count, 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 @@ -573,6 +574,8 @@ 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(), "all_labels": labels_with_colors_list(all_labels, overrides), @@ -666,6 +669,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), + 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): diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index 47770b1..bc935aa 100644 --- a/daybook_project/__init__.py +++ b/daybook_project/__init__.py @@ -1,4 +1,4 @@ # Single source of truth for the app version. Bump this, commit, then tag the # commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in # GET /api/capabilities ("version"). -__version__ = "0.3.2" +__version__ = "0.4.0" diff --git a/static/agentfeed.css b/static/agentfeed.css index 805b76b..865c1a0 100644 --- a/static/agentfeed.css +++ b/static/agentfeed.css @@ -1045,6 +1045,16 @@ 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; +} + .api-key-create { align-items: end; display: flex; diff --git a/templates/artifacts/_artifact_rows.html b/templates/artifacts/_artifact_rows.html new file mode 100644 index 0000000..1125e01 --- /dev/null +++ b/templates/artifacts/_artifact_rows.html @@ -0,0 +1,34 @@ +{% for artifact in artifacts %} + + + + {{ artifact.title }} + {% 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 %} + K + 🔗 + {% if artifact.label_chips %} + + {% for label in artifact.label_chips %} + {{ label.name }} + {% endfor %} + + {% endif %} + + {{ artifact.source }} + +{% endfor %} diff --git a/templates/artifacts/_day_groups.html b/templates/artifacts/_day_groups.html index 111fb86..63971f4 100644 --- a/templates/artifacts/_day_groups.html +++ b/templates/artifacts/_day_groups.html @@ -1,43 +1,10 @@ {% for group in day_groups %} -
+ {% endfor %} {% if has_more %} diff --git a/templates/artifacts/feed.html b/templates/artifacts/feed.html index 55bfdb7..a240ebc 100644 --- a/templates/artifacts/feed.html +++ b/templates/artifacts/feed.html @@ -55,7 +55,7 @@ {{ artifacts|length }} artifact{{ artifacts|length|pluralize }}
-
+
{% if day_groups %} {% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %} {% else %} @@ -552,6 +552,95 @@ 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 = + `
${escapeHtml(todayLabel)} Today` + + `0
`; + 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 () => { + if (livePolling || document.hidden || !liveSince) 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); + 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} artifact${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(); diff --git a/tests/views/test_feed_updates.py b/tests/views/test_feed_updates.py new file mode 100644 index 0000000..e1610af --- /dev/null +++ b/tests/views/test_feed_updates.py @@ -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"]) From 6c1823e65eedc4fa5b4858f67e232cfbb7f87e91 Mon Sep 17 00:00:00 2001 From: peter Date: Sat, 5 Sep 2026 14:37:45 +0100 Subject: [PATCH 03/12] 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 --- CHANGELOG.md | 13 ++++++- daybook_project/__init__.py | 2 +- templates/artifacts/feed.html | 73 ++++++++++++++++++++++++++++++++--- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf4ed70..5e1ed0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ former name, "Daybook".) _Nothing yet._ +## [0.4.1] — 2026-09-05 + +### 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 @@ -155,7 +165,8 @@ First tagged release. - Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a runbook (`deploy/cloud_agent_deploy.md`) and health check. -[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.4.0...HEAD +[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.4.1...HEAD +[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 diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index bc935aa..53f5709 100644 --- a/daybook_project/__init__.py +++ b/daybook_project/__init__.py @@ -1,4 +1,4 @@ # Single source of truth for the app version. Bump this, commit, then tag the # commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in # GET /api/capabilities ("version"). -__version__ = "0.4.0" +__version__ = "0.4.1" diff --git a/templates/artifacts/feed.html b/templates/artifacts/feed.html index a240ebc..78764fe 100644 --- a/templates/artifacts/feed.html +++ b/templates/artifacts/feed.html @@ -195,7 +195,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) => ({ @@ -383,6 +384,66 @@ }); }; + const findSourceLink = (name) => + sourceLinks().find((link) => link.querySelector(".source-name")?.textContent.trim() === name); + + const bumpSourceBadge = (link, delta) => { + let badge = link.querySelector(".source-count"); + if (!badge) { + badge = document.createElement("span"); + badge.className = "source-count"; + badge.textContent = "0"; + link.appendChild(badge); + } + badge.textContent = String((Number(badge.textContent) || 0) + delta); + }; + + 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 = + `${escapeHtml(name)}` + + `${count}`; + + // Insert alphabetically among the per-source links (after "All sources"). + const list = document.querySelector(".source-list"); + const others = sourceLinks().filter( + (l) => l.querySelector(".source-name")?.textContent.trim() !== "All sources", + ); + const after = others.find( + (l) => (l.querySelector(".source-name")?.textContent.trim() || "") > name, + ); + list.insertBefore(link, after || null); + }; + + // 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); + else addSourceLink(source, n); + }); + const allLink = findSourceLink("All sources"); + if (allLink && total) bumpSourceBadge(allLink, total); + }; + const renderPreview = (row, pushState = true) => { navigationMode = "artifacts"; markRead(row); @@ -438,12 +499,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; }; @@ -623,6 +685,7 @@ 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); From 4254164befc2eb4279a1e676fd4c6a9ba0604d2f Mon Sep 17 00:00:00 2001 From: peter Date: Mon, 7 Sep 2026 12:48:43 +0100 Subject: [PATCH 04/12] 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 --- CHANGELOG.md | 25 +++- CLAUDE.md | 1 + .../migrations/0016_artifact_deleted_at.py | 18 +++ artifacts/models.py | 7 ++ artifacts/storage.py | 21 +++- artifacts/urls.py | 3 + artifacts/views.py | 107 ++++++++++++++---- daybook_project/__init__.py | 2 +- docs/api.md | 12 +- static/agentfeed.css | 51 +++++++++ templates/artifacts/_artifact_rows.html | 5 + templates/artifacts/feed.html | 105 ++++++++++++++++- tests/api/test_artifacts.py | 17 ++- tests/views/test_trash.py | 107 ++++++++++++++++++ 14 files changed, 438 insertions(+), 43 deletions(-) create mode 100644 artifacts/migrations/0016_artifact_deleted_at.py create mode 100644 tests/views/test_trash.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e1ed0f..e23c61d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,27 @@ former name, "Daybook".) _Nothing yet._ -## [0.4.1] — 2026-09-05 +## [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 @@ -165,7 +185,8 @@ First tagged release. - Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a runbook (`deploy/cloud_agent_deploy.md`) and health check. -[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.4.1...HEAD +[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.5.0...HEAD +[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 diff --git a/CLAUDE.md b/CLAUDE.md index 5db5bbd..75af818 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ The core product principle is: agents publish content, not files. The server own - Exception to the above: an artifact can be explicitly opted into public sharing (per-artifact, owner-toggled from the feed UI) via a high-entropy `share_token`, served unauthenticated at `GET /s/{share_token}`. Not exposed via the ingest API yet — UI-only. Turning sharing off immediately invalidates the old link. Images embedded in a shared artifact are also served unauthenticated while it's shared, so inline content doesn't break for a link recipient. - User accounts can be created from Settings → Users (superuser-only in-app page) or via Django admin. Regular users are not staff and cannot access `/admin/`. Creating a user via Settings → Users also issues their API token immediately, shown once. - `TagColor` (label color overrides) stays global/shared across all users. +- Deleting an artifact is a **soft-delete to Trash** (`deleted_at`), not a hard removal — recoverable via the "Deleted" sidebar folder (undelete), off-quota and eviction-protected while trashed, and purged permanently via "Empty Trash". `DELETE /api/artifacts/{id}` is soft too (idempotent); trashed artifacts are hidden from all API reads. A **Keep** sidebar folder is a view over `is_kept`. Both folders hide when empty. (Auto purge-on-delete is a planned fast-follow; currently purge is manual.) - Use random 8-character artifact IDs. - Start as a plain Django project; add Docker after core ingest and viewer work. - Preserve original markdown as `.md` alongside rendered `.html`. diff --git a/artifacts/migrations/0016_artifact_deleted_at.py b/artifacts/migrations/0016_artifact_deleted_at.py new file mode 100644 index 0000000..0454f2f --- /dev/null +++ b/artifacts/migrations/0016_artifact_deleted_at.py @@ -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), + ), + ] diff --git a/artifacts/models.py b/artifacts/models.py index bbd14a8..dd87793 100644 --- a/artifacts/models.py +++ b/artifacts/models.py @@ -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"] diff --git a/artifacts/storage.py b/artifacts/storage.py index 1ac53cd..2a7f7bd 100644 --- a/artifacts/storage.py +++ b/artifacts/storage.py @@ -70,8 +70,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 +118,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 +144,16 @@ 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) + + ASSET_ORPHAN_GRACE = timedelta(hours=24) diff --git a/artifacts/urls.py b/artifacts/urls.py index cc1f03c..158b3d1 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -10,6 +10,9 @@ urlpatterns = [ path("a//read", views.mark_artifact_read, name="artifact-mark-read"), path("a//keep", views.toggle_artifact_keep, name="artifact-toggle-keep"), path("a//share", views.toggle_artifact_share, name="artifact-toggle-share"), + path("a//delete", views.delete_artifact, name="artifact-delete"), + path("a//undelete", views.undelete_artifact, name="artifact-undelete"), + path("feed/empty-trash", views.empty_trash, name="feed-empty-trash"), path("a/", views.artifact_view, name="artifact"), path("s/", views.shared_artifact_view, name="artifact-shared"), path("assets/", views.asset_view, name="asset"), diff --git a/artifacts/views.py b/artifacts/views.py index 1a6afc3..84ddfa1 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -44,6 +44,7 @@ from .storage import ( delete_artifact_files, disk_free_bytes, make_room_for, + purge_trashed, sweep_orphan_assets, total_allocated_bytes, user_quota_bytes, @@ -90,7 +91,7 @@ class ArtifactListCreateView(APIView): 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) + 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"): @@ -231,7 +232,7 @@ class ArtifactDetailView(APIView): 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) + 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): @@ -239,7 +240,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: @@ -298,9 +299,12 @@ 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"]) return Response(status=204) @@ -318,7 +322,7 @@ class ArtifactRawView(APIView): 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) + 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(): @@ -516,21 +520,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) @@ -540,6 +563,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: @@ -554,18 +579,20 @@ 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 ) source_counts = ( source_scoped.values("source") .annotate(count=Count("id", filter=Q(read_at__isnull=True))) .order_by("source") ) + 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", @@ -578,6 +605,10 @@ def feed(request): "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, @@ -630,12 +661,42 @@ 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"]) + 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 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", ""), @@ -683,7 +744,7 @@ def feed_updates(request): selected_kind = request.GET.get("kind", "") title_query = request.GET.get("q", "") artifacts = apply_feed_filters( - Artifact.objects.filter(owner=request.user), + Artifact.objects.filter(owner=request.user, deleted_at__isnull=True), selected_source, selected_label, selected_kind, diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index 53f5709..4cfb5af 100644 --- a/daybook_project/__init__.py +++ b/daybook_project/__init__.py @@ -1,4 +1,4 @@ # Single source of truth for the app version. Bump this, commit, then tag the # commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in # GET /api/capabilities ("version"). -__version__ = "0.4.1" +__version__ = "0.5.0" diff --git a/docs/api.md b/docs/api.md index 187270c..5135f76 100644 --- a/docs/api.md +++ b/docs/api.md @@ -452,11 +452,15 @@ DELETE /api/artifacts/{id} Authorization: Bearer ``` -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" \ diff --git a/static/agentfeed.css b/static/agentfeed.css index 865c1a0..667a968 100644 --- a/static/agentfeed.css +++ b/static/agentfeed.css @@ -1055,6 +1055,57 @@ kbd { animation: agentfeed-row-arrive 1.5s ease-out; } +/* Hover/focus row actions (delete in the feed, undelete in Trash) */ +.artifact-row { + position: relative; +} + +.row-action { + position: absolute; + right: 0.5rem; + top: 50%; + transform: translateY(-50%); + opacity: 0; + cursor: pointer; + font-size: 0.95rem; + line-height: 1; + padding: 0.15rem 0.3rem; + border-radius: 4px; + background: var(--surface); + color: var(--muted); +} + +.artifact-row:hover .row-action, +.artifact-row:focus-within .row-action { + opacity: 1; +} + +.row-action:hover { + background: var(--surface-alt); + color: var(--text); +} + +.row-delete:hover { + color: var(--danger-text-plain); +} + +/* System folders (Keep / Deleted) at the bottom of the sidebar */ +.source-folders { + border-top: 1px solid var(--grid-line); + margin-top: 0.4rem; + padding-top: 0.4rem; +} + +.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; diff --git a/templates/artifacts/_artifact_rows.html b/templates/artifacts/_artifact_rows.html index 1125e01..45c2f2f 100644 --- a/templates/artifacts/_artifact_rows.html +++ b/templates/artifacts/_artifact_rows.html @@ -30,5 +30,10 @@ {% endif %} {{ artifact.source }} + {% if in_trash %} + ↩ + {% else %} + 🗑 + {% endif %} {% endfor %} diff --git a/templates/artifacts/feed.html b/templates/artifacts/feed.html index 78764fe..90a26bb 100644 --- a/templates/artifacts/feed.html +++ b/templates/artifacts/feed.html @@ -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 }}">
@@ -51,8 +69,12 @@