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>
This commit is contained in:
peter 2026-09-07 12:48:43 +01:00
parent 6c1823e65e
commit 4254164bef
14 changed files with 438 additions and 43 deletions

View file

@ -9,7 +9,27 @@ former name, "Daybook".)
_Nothing yet._ _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 ### Changed
- Live feed updates now also keep the **source sidebar** current: as new posts - 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 - Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a
runbook (`deploy/cloud_agent_deploy.md`) and health check. runbook (`deploy/cloud_agent_deploy.md`) and health check.
[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.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.1]: https://git.cygnus.systems/peter/daybook/compare/v0.4.0...v0.4.1
[0.4.0]: https://git.cygnus.systems/peter/daybook/compare/v0.3.2...v0.4.0 [0.4.0]: https://git.cygnus.systems/peter/daybook/compare/v0.3.2...v0.4.0
[0.3.2]: https://git.cygnus.systems/peter/daybook/compare/v0.3.1...v0.3.2 [0.3.2]: https://git.cygnus.systems/peter/daybook/compare/v0.3.1...v0.3.2

View file

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

View file

@ -0,0 +1,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

@ -60,6 +60,9 @@ class Artifact(models.Model):
metadata = models.JSONField(default=dict, blank=True) metadata = models.JSONField(default=dict, blank=True)
read_at = models.DateTimeField(null=True, blank=True) read_at = models.DateTimeField(null=True, blank=True)
is_kept = models.BooleanField(default=False) 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) size_bytes = models.BigIntegerField(default=0)
# NULL = not shared. Many rows share NULL (SQL treats each NULL as # NULL = not shared. Many rows share NULL (SQL treats each NULL as
# distinct, so `unique=True` still holds); a real value must be unique # distinct, so `unique=True` still holds); a real value must be unique
@ -74,6 +77,10 @@ class Artifact(models.Model):
def is_shared(self): def is_shared(self):
return bool(self.share_token) return bool(self.share_token)
@property
def is_deleted(self):
return self.deleted_at is not None
class Meta: class Meta:
ordering = ["-created_at"] ordering = ["-created_at"]

View file

@ -70,8 +70,13 @@ def artifact_total_bytes(artifact):
def user_usage_bytes(user): 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_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_bytes = (
Asset.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0 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: if used + incoming_bytes <= quota:
return [] return []
# Trashed artifacts are eviction-protected (recovery window is guaranteed)
# and already off-quota, so they're never eviction candidates.
unkept = list( 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 - # Check whether eviction can possibly succeed before deleting anything -
# otherwise a doomed request would still destroy every unkept artifact on # otherwise a doomed request would still destroy every unkept artifact on
@ -137,6 +144,16 @@ def make_room_for(user, incoming_bytes):
return evicted 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) ASSET_ORPHAN_GRACE = timedelta(hours=24)

View file

@ -10,6 +10,9 @@ urlpatterns = [
path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"), path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"),
path("a/<str:artifact_id>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"), path("a/<str:artifact_id>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"),
path("a/<str:artifact_id>/share", views.toggle_artifact_share, name="artifact-toggle-share"), path("a/<str:artifact_id>/share", views.toggle_artifact_share, name="artifact-toggle-share"),
path("a/<str:artifact_id>/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("a/<str:artifact_id>", views.artifact_view, name="artifact"), path("a/<str:artifact_id>", views.artifact_view, name="artifact"),
path("s/<str:share_token>", views.shared_artifact_view, name="artifact-shared"), path("s/<str:share_token>", views.shared_artifact_view, name="artifact-shared"),
path("assets/<str:asset_id>", views.asset_view, name="asset"), path("assets/<str:asset_id>", views.asset_view, name="asset"),

View file

@ -44,6 +44,7 @@ from .storage import (
delete_artifact_files, delete_artifact_files,
disk_free_bytes, disk_free_bytes,
make_room_for, make_room_for,
purge_trashed,
sweep_orphan_assets, sweep_orphan_assets,
total_allocated_bytes, total_allocated_bytes,
user_quota_bytes, user_quota_bytes,
@ -90,7 +91,7 @@ class ArtifactListCreateView(APIView):
user = resolve_request_user(request) user = resolve_request_user(request)
if user is None: if user is None:
return Response({"detail": "Authentication required (session or bearer token)."}, status=403) 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"): if source := request.query_params.get("source"):
artifacts = artifacts.filter(source=source) artifacts = artifacts.filter(source=source)
if label := request.query_params.get("label"): if label := request.query_params.get("label"):
@ -231,7 +232,7 @@ class ArtifactDetailView(APIView):
user = resolve_request_user(request) user = resolve_request_user(request)
if user is None: if user is None:
return Response({"detail": "Authentication required (session or bearer token)."}, status=403) 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)) return Response(serialize_artifact(artifact, request))
def patch(self, request, artifact_id): def patch(self, request, artifact_id):
@ -239,7 +240,7 @@ class ArtifactDetailView(APIView):
if user is None: if user is None:
return HttpResponseForbidden("Invalid or missing API token") 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 data = request.data
not_editable = IMMUTABLE_PATCH_FIELDS & set(data.keys()) not_editable = IMMUTABLE_PATCH_FIELDS & set(data.keys())
if not_editable: if not_editable:
@ -298,9 +299,12 @@ class ArtifactDetailView(APIView):
if user is None: if user is None:
return HttpResponseForbidden("Invalid or missing API token") 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) artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
delete_artifact_files(artifact) if artifact.deleted_at is None:
artifact.delete() artifact.deleted_at = timezone.now()
artifact.save(update_fields=["deleted_at"])
return Response(status=204) return Response(status=204)
@ -318,7 +322,7 @@ class ArtifactRawView(APIView):
user = resolve_request_user(request) user = resolve_request_user(request)
if user is None: if user is None:
return Response({"detail": "Authentication required (session or bearer token)."}, status=403) 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) raw_path = raw_artifact_path(artifact)
if raw_path and raw_path.exists(): if raw_path and raw_path.exists():
@ -516,16 +520,35 @@ def feed(request):
selected_artifact_id = request.GET.get("artifact", "") selected_artifact_id = request.GET.get("artifact", "")
preview_none = request.GET.get("preview") == "none" preview_none = request.GET.get("preview") == "none"
selected_view = "raw" if request.GET.get("view") == "raw" else "rendered" 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( artifacts = apply_feed_filters(
all_artifacts, selected_source, selected_label, selected_kind, title_query base, 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) window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1)
if not artifacts.filter(day__gte=window_start).exists(): if not artifacts.filter(day__gte=window_start).exists():
# Nothing recent for these filters: fall back to the most recent days that # Nothing recent for these filters: fall back to the most recent days
# do match, so selecting a source is never met with a blank list. # 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]) recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS])
if recent_days: if recent_days:
window_start = recent_days[-1] window_start = recent_days[-1]
@ -540,6 +563,8 @@ def feed(request):
selected_artifact = next((a for a in window_artifacts if a.id == selected_artifact_id), None) selected_artifact = next((a for a in window_artifacts if a.id == selected_artifact_id), None)
toggle_query = {"view": "rendered"} toggle_query = {"view": "rendered"}
if selected_folder:
toggle_query["folder"] = selected_folder
if selected_source: if selected_source:
toggle_query["source"] = selected_source toggle_query["source"] = selected_source
if selected_label: if selected_label:
@ -554,18 +579,20 @@ def feed(request):
toggle_query["view"] = "raw" toggle_query["view"] = "raw"
raw_url = f"?{urlencode(toggle_query)}" raw_url = f"?{urlencode(toggle_query)}"
# Source counts come from the other filters only, so the sidebar still shows # Source counts + labels always come from live artifacts, so the sidebar is
# every source available under the current label/type/name filters. # the same whichever folder you're viewing.
source_scoped = apply_feed_filters( source_scoped = apply_feed_filters(
all_artifacts, "", selected_label, selected_kind, title_query live, "", selected_label, selected_kind, title_query
) )
source_counts = ( source_counts = (
source_scoped.values("source") source_scoped.values("source")
.annotate(count=Count("id", filter=Q(read_at__isnull=True))) .annotate(count=Count("id", filter=Q(read_at__isnull=True)))
.order_by("source") .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( return render(
request, request,
"artifacts/feed.html", "artifacts/feed.html",
@ -578,6 +605,10 @@ def feed(request):
"feed_since": (window_artifacts[0].created_at if window_artifacts else timezone.now()).isoformat(), "feed_since": (window_artifacts[0].created_at if window_artifacts else timezone.now()).isoformat(),
"source_counts": source_counts, "source_counts": source_counts,
"total_count": source_scoped.filter(read_at__isnull=True).count(), "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), "all_labels": labels_with_colors_list(all_labels, overrides),
"raw_url": raw_url, "raw_url": raw_url,
"rendered_url": rendered_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}) 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 @login_required
def feed_older(request): def feed_older(request):
"""Return the next page of older day-groups as an HTML fragment for infinite scroll.""" """Return the next page of older day-groups as an HTML fragment for infinite scroll."""
before = parse_date(request.GET.get("before", "")) or timezone.localdate() before = parse_date(request.GET.get("before", "")) or timezone.localdate()
artifacts = apply_feed_filters( 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("source", ""),
request.GET.get("label", ""), request.GET.get("label", ""),
request.GET.get("kind", ""), request.GET.get("kind", ""),
@ -683,7 +744,7 @@ def feed_updates(request):
selected_kind = request.GET.get("kind", "") selected_kind = request.GET.get("kind", "")
title_query = request.GET.get("q", "") title_query = request.GET.get("q", "")
artifacts = apply_feed_filters( artifacts = apply_feed_filters(
Artifact.objects.filter(owner=request.user), Artifact.objects.filter(owner=request.user, deleted_at__isnull=True),
selected_source, selected_source,
selected_label, selected_label,
selected_kind, selected_kind,

View file

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

View file

@ -452,11 +452,15 @@ DELETE /api/artifacts/{id}
Authorization: Bearer <your-personal-token> Authorization: Bearer <your-personal-token>
``` ```
Removes the artifact's database row and its files (primary file, markdown **Soft-delete:** moves the artifact to Trash rather than removing it — it's
sidecar, and any attached assets). Returns `204` with no body on success. 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 **Idempotent:** deleting an already-trashed id returns `204` again (not `404`).
does a second delete of the same id. 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 ```bash
curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \ curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \

View file

@ -1055,6 +1055,57 @@ kbd {
animation: agentfeed-row-arrive 1.5s ease-out; 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 { .api-key-create {
align-items: end; align-items: end;
display: flex; display: flex;

View file

@ -30,5 +30,10 @@
{% endif %} {% endif %}
</span> </span>
<span class="artifact-source">{{ artifact.source }}</span> <span class="artifact-source">{{ artifact.source }}</span>
{% 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>
{% endif %}
</a> </a>
{% endfor %} {% endfor %}

View file

@ -27,6 +27,7 @@
data-selected-kind="{{ selected_kind }}" data-selected-kind="{{ selected_kind }}"
data-title-query="{{ title_query }}" data-title-query="{{ title_query }}"
data-selected-view="{{ selected_view }}" data-selected-view="{{ selected_view }}"
data-folder="{{ selected_folder }}"
data-window-start="{{ window_start|date:'Y-m-d' }}" data-window-start="{{ window_start|date:'Y-m-d' }}"
data-older-count="{{ older_count }}"> data-older-count="{{ older_count }}">
<aside class="source-sidebar" aria-label="Sources"> <aside class="source-sidebar" aria-label="Sources">
@ -34,16 +35,33 @@
<h2>Sources</h2> <h2>Sources</h2>
</div> </div>
<nav class="source-list"> <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 {% if not selected_source and not selected_folder %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&amp;{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&amp;{% endif %}{% if title_query %}q={{ title_query|urlencode }}&amp;{% endif %}view={{ selected_view }}&amp;preview=none">
<span class="source-name">All sources</span> <span class="source-name">All sources</span>
{% if total_count %}<span class="source-count">{{ total_count }}</span>{% endif %} {% if total_count %}<span class="source-count">{{ total_count }}</span>{% endif %}
</a> </a>
{% for entry in source_counts %} {% for entry in source_counts %}
<a class="source-link {% if entry.source == selected_source %}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">
<span class="source-name">{{ entry.source }}</span> <span class="source-name">{{ entry.source }}</span>
{% if entry.count %}<span class="source-count">{{ entry.count }}</span>{% endif %} {% if entry.count %}<span class="source-count">{{ entry.count }}</span>{% endif %}
</a> </a>
{% endfor %} {% endfor %}
{% if keep_count or trash_count %}
<div class="source-folders">
{% if keep_count %}
<a class="source-link folder-link {% if selected_folder == 'keep' %}selected{% endif %}" href="?folder=keep&amp;view={{ selected_view }}&amp;preview=none" data-folder-link="keep">
<span class="source-name">&#9733; Keep</span>
<span class="source-count">{{ keep_count }}</span>
</a>
{% endif %}
{% if trash_count %}
<a class="source-link folder-link {% if selected_folder == 'deleted' %}selected{% endif %}" href="?folder=deleted&amp;view={{ selected_view }}&amp;preview=none" data-folder-link="deleted">
<span class="source-name">&#128465; Deleted</span>
<span class="source-count">{{ trash_count }}</span>
</a>
{% endif %}
</div>
{% endif %}
</nav> </nav>
</aside> </aside>
@ -51,8 +69,12 @@
<aside class="artifact-sidebar" aria-label="Artifact feed"> <aside class="artifact-sidebar" aria-label="Artifact feed">
<div class="pane-bar pane-heading"> <div class="pane-bar pane-heading">
<h2>{% if selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2> <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 }} artifact{{ artifacts|length|pluralize }}</span> <span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
{% endif %}
</div> </div>
<div class="artifact-list{% if selected_source %} no-source-col{% endif %}" data-artifact-list data-since="{{ feed_since }}"> <div class="artifact-list{% if selected_source %} no-source-col{% endif %}" data-artifact-list data-since="{{ feed_since }}">
@ -444,6 +466,68 @@
if (allLink && total) bumpSourceBadge(allLink, total); 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} artifact${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
});
}
const renderPreview = (row, pushState = true) => { const renderPreview = (row, pushState = true) => {
navigationMode = "artifacts"; navigationMode = "artifacts";
markRead(row); markRead(row);
@ -479,6 +563,15 @@
// Delegated so rows appended by infinite scroll work without rebinding. // Delegated so rows appended by infinite scroll work without rebinding.
artifactList.addEventListener("click", (event) => { artifactList.addEventListener("click", (event) => {
// Row action icons (delete/undelete) intercept before selection.
const action = event.target.closest("[data-delete], [data-undelete]");
if (action) {
event.preventDefault();
event.stopPropagation();
if (action.hasAttribute("data-delete")) softDeleteRow(action.getAttribute("data-delete"));
else undeleteRow(action.getAttribute("data-undelete"));
return;
}
const row = event.target.closest(".artifact-row"); const row = event.target.closest(".artifact-row");
if (!row) return; if (!row) return;
event.preventDefault(); event.preventDefault();
@ -667,7 +760,9 @@
}; };
const pollUpdates = async () => { const pollUpdates = async () => {
if (livePolling || document.hidden || !liveSince) return; // 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; livePolling = true;
try { try {
const response = await fetch(buildUpdatesUrl(), {headers: {"X-Requested-With": "fetch"}}); const response = await fetch(buildUpdatesUrl(), {headers: {"X-Requested-With": "fetch"}});

View file

@ -334,7 +334,8 @@ class ArtifactAPITestCase(APITestCase):
# -- delete (DELETE) -------------------------------------------------- # -- 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_id = self.create_artifact().data["id"]
artifact = Artifact.objects.get(pk=artifact_id) artifact = Artifact.objects.get(pk=artifact_id)
stored_path = self.artifacts_root / artifact.path stored_path = self.artifacts_root / artifact.path
@ -344,9 +345,12 @@ class ArtifactAPITestCase(APITestCase):
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token) reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
) )
self.assertEqual(response.status_code, 204) self.assertEqual(response.status_code, 204)
self.assertFalse(Artifact.objects.filter(pk=artifact_id).exists()) # Row still exists, marked deleted; files kept on disk until purge.
self.assertFalse(stored_path.exists()) artifact.refresh_from_db()
self.assertFalse(stored_path.with_suffix(".md").exists()) 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): def test_delete_requires_token(self):
artifact_id = self.create_artifact().data["id"] artifact_id = self.create_artifact().data["id"]
@ -362,7 +366,8 @@ class ArtifactAPITestCase(APITestCase):
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
self.assertTrue(Artifact.objects.filter(pk=artifact_id).exists()) 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"] artifact_id = self.create_artifact().data["id"]
first = self.client.delete( first = self.client.delete(
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token) reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
@ -372,7 +377,7 @@ class ArtifactAPITestCase(APITestCase):
second = self.client.delete( second = self.client.delete(
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token) 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): def test_deleted_artifact_is_gone_from_list_and_detail(self):
artifact_id = self.create_artifact().data["id"] artifact_id = self.create_artifact().data["id"]

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

@ -0,0 +1,107 @@
import shutil
import tempfile
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 user_usage_bytes
User = get_user_model()
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)