diff --git a/CHANGELOG.md b/CHANGELOG.md index e23c61d..5e1ed0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,27 +9,7 @@ former name, "Daybook".) _Nothing yet._ -## [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. +## [0.4.1] — 2026-09-05 ### Changed - Live feed updates now also keep the **source sidebar** current: as new posts @@ -185,8 +165,7 @@ 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.5.0...HEAD -[0.5.0]: https://git.cygnus.systems/peter/daybook/compare/v0.4.1...v0.5.0 +[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 diff --git a/CLAUDE.md b/CLAUDE.md index 75af818..5db5bbd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,6 @@ 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 deleted file mode 100644 index 0454f2f..0000000 --- a/artifacts/migrations/0016_artifact_deleted_at.py +++ /dev/null @@ -1,18 +0,0 @@ -# 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 dd87793..bbd14a8 100644 --- a/artifacts/models.py +++ b/artifacts/models.py @@ -60,9 +60,6 @@ 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 @@ -77,10 +74,6 @@ 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 2a7f7bd..1ac53cd 100644 --- a/artifacts/storage.py +++ b/artifacts/storage.py @@ -70,13 +70,8 @@ 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, deleted_at__isnull=True) - .aggregate(total=Sum("size_bytes")) - .get("total") - or 0 + Artifact.objects.filter(owner=user).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 @@ -118,10 +113,8 @@ 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, deleted_at__isnull=True).order_by("created_at") + Artifact.objects.filter(owner=user, is_kept=False).order_by("created_at") ) # Check whether eviction can possibly succeed before deleting anything - # otherwise a doomed request would still destroy every unkept artifact on @@ -144,16 +137,6 @@ 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 158b3d1..cc1f03c 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -10,9 +10,6 @@ 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 84ddfa1..1a6afc3 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -44,7 +44,6 @@ from .storage import ( delete_artifact_files, disk_free_bytes, make_room_for, - purge_trashed, sweep_orphan_assets, total_allocated_bytes, user_quota_bytes, @@ -91,7 +90,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, deleted_at__isnull=True) + artifacts = Artifact.objects.filter(owner=user) if source := request.query_params.get("source"): artifacts = artifacts.filter(source=source) if label := request.query_params.get("label"): @@ -232,7 +231,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, deleted_at__isnull=True) + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user) return Response(serialize_artifact(artifact, request)) def patch(self, request, artifact_id): @@ -240,7 +239,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, deleted_at__isnull=True) + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user) data = request.data not_editable = IMMUTABLE_PATCH_FIELDS & set(data.keys()) if not_editable: @@ -299,12 +298,9 @@ 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) - if artifact.deleted_at is None: - artifact.deleted_at = timezone.now() - artifact.save(update_fields=["deleted_at"]) + delete_artifact_files(artifact) + artifact.delete() return Response(status=204) @@ -322,7 +318,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, deleted_at__isnull=True) + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user) raw_path = raw_artifact_path(artifact) if raw_path and raw_path.exists(): @@ -520,40 +516,21 @@ 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 = "" - # 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 + all_artifacts = Artifact.objects.filter(owner=request.user) artifacts = apply_feed_filters( - base, selected_source, selected_label, selected_kind, title_query + all_artifacts, selected_source, selected_label, selected_kind, title_query ) - 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() + 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) @@ -563,8 +540,6 @@ 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: @@ -579,20 +554,18 @@ def feed(request): toggle_query["view"] = "raw" raw_url = f"?{urlencode(toggle_query)}" - # Source counts + labels always come from live artifacts, so the sidebar is - # the same whichever folder you're viewing. + # Source counts come from the other filters only, so the sidebar still shows + # every source available under the current label/type/name filters. source_scoped = apply_feed_filters( - live, "", selected_label, selected_kind, title_query + all_artifacts, "", 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 live.values_list("tags", flat=True) for tag in tags}) + all_labels = sorted({tag for tags in all_artifacts.values_list("tags", flat=True) for tag in tags}) return render( request, "artifacts/feed.html", @@ -605,10 +578,6 @@ 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, @@ -661,42 +630,12 @@ 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, deleted_at__isnull=True), + Artifact.objects.filter(owner=request.user), request.GET.get("source", ""), request.GET.get("label", ""), request.GET.get("kind", ""), @@ -744,7 +683,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, deleted_at__isnull=True), + Artifact.objects.filter(owner=request.user), selected_source, selected_label, selected_kind, diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index 4cfb5af..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.5.0" +__version__ = "0.4.1" diff --git a/docs/api.md b/docs/api.md index 5135f76..187270c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -452,15 +452,11 @@ DELETE /api/artifacts/{id} Authorization: Bearer ``` -**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. +Removes the artifact's database row and its files (primary file, markdown +sidecar, and any attached assets). Returns `204` with no body on success. -**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. +You can only delete artifacts your own token owns; others return `404`, as +does a second delete of the same id. ```bash curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \ diff --git a/static/agentfeed.css b/static/agentfeed.css index 667a968..865c1a0 100644 --- a/static/agentfeed.css +++ b/static/agentfeed.css @@ -1055,57 +1055,6 @@ 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 45c2f2f..1125e01 100644 --- a/templates/artifacts/_artifact_rows.html +++ b/templates/artifacts/_artifact_rows.html @@ -30,10 +30,5 @@ {% endif %} {{ artifact.source }} - {% if in_trash %} - ↩ - {% else %} - 🗑 - {% endif %} {% endfor %} diff --git a/templates/artifacts/feed.html b/templates/artifacts/feed.html index 90a26bb..78764fe 100644 --- a/templates/artifacts/feed.html +++ b/templates/artifacts/feed.html @@ -27,7 +27,6 @@ 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 }}"> @@ -69,12 +51,8 @@