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:
parent
6c1823e65e
commit
4254164bef
14 changed files with 438 additions and 43 deletions
25
CHANGELOG.md
25
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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
18
artifacts/migrations/0016_artifact_deleted_at.py
Normal file
18
artifacts/migrations/0016_artifact_deleted_at.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ urlpatterns = [
|
|||
path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"),
|
||||
path("a/<str:artifact_id>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"),
|
||||
path("a/<str:artifact_id>/share", views.toggle_artifact_share, name="artifact-toggle-share"),
|
||||
path("a/<str:artifact_id>/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("s/<str:share_token>", views.shared_artifact_view, name="artifact-shared"),
|
||||
path("assets/<str:asset_id>", views.asset_view, name="asset"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
12
docs/api.md
12
docs/api.md
|
|
@ -452,11 +452,15 @@ DELETE /api/artifacts/{id}
|
|||
Authorization: Bearer <your-personal-token>
|
||||
```
|
||||
|
||||
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" \
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -30,5 +30,10 @@
|
|||
{% endif %}
|
||||
</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 }}">↩</span>
|
||||
{% else %}
|
||||
<span class="row-action row-delete" role="button" tabindex="0" data-delete="{{ artifact.id }}" title="Delete" aria-label="Delete {{ artifact.title }}">🗑</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
data-selected-kind="{{ selected_kind }}"
|
||||
data-title-query="{{ title_query }}"
|
||||
data-selected-view="{{ selected_view }}"
|
||||
data-folder="{{ selected_folder }}"
|
||||
data-window-start="{{ window_start|date:'Y-m-d' }}"
|
||||
data-older-count="{{ older_count }}">
|
||||
<aside class="source-sidebar" aria-label="Sources">
|
||||
|
|
@ -34,16 +35,33 @@
|
|||
<h2>Sources</h2>
|
||||
</div>
|
||||
<nav class="source-list">
|
||||
<a class="source-link {% if not selected_source %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}view={{ selected_view }}&preview=none">
|
||||
<a class="source-link {% if not selected_source and not selected_folder %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}view={{ selected_view }}&preview=none">
|
||||
<span class="source-name">All sources</span>
|
||||
{% if total_count %}<span class="source-count">{{ total_count }}</span>{% endif %}
|
||||
</a>
|
||||
{% for entry in source_counts %}
|
||||
<a class="source-link {% if entry.source == selected_source %}selected{% endif %}" href="?source={{ entry.source|urlencode }}{% if selected_label %}&label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&view={{ selected_view }}&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 %}&label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&view={{ selected_view }}&preview=none">
|
||||
<span class="source-name">{{ entry.source }}</span>
|
||||
{% if entry.count %}<span class="source-count">{{ entry.count }}</span>{% endif %}
|
||||
</a>
|
||||
{% 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&view={{ selected_view }}&preview=none" data-folder-link="keep">
|
||||
<span class="source-name">★ 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&view={{ selected_view }}&preview=none" data-folder-link="deleted">
|
||||
<span class="source-name">🗑 Deleted</span>
|
||||
<span class="source-count">{{ trash_count }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
|
|
@ -51,8 +69,12 @@
|
|||
|
||||
<aside class="artifact-sidebar" aria-label="Artifact feed">
|
||||
<div class="pane-bar pane-heading">
|
||||
<h2>{% if selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
|
||||
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
|
||||
<h2>{% if selected_folder == 'deleted' %}Deleted{% elif selected_folder == 'keep' %}Keep{% elif selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
|
||||
{% if selected_folder == 'deleted' %}
|
||||
<button type="button" class="empty-trash-button" data-empty-trash>Empty Trash</button>
|
||||
{% else %}
|
||||
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<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);
|
||||
};
|
||||
|
||||
// --- 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) => {
|
||||
navigationMode = "artifacts";
|
||||
markRead(row);
|
||||
|
|
@ -479,6 +563,15 @@
|
|||
|
||||
// Delegated so rows appended by infinite scroll work without rebinding.
|
||||
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");
|
||||
if (!row) return;
|
||||
event.preventDefault();
|
||||
|
|
@ -667,7 +760,9 @@
|
|||
};
|
||||
|
||||
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;
|
||||
try {
|
||||
const response = await fetch(buildUpdatesUrl(), {headers: {"X-Requested-With": "fetch"}});
|
||||
|
|
|
|||
|
|
@ -334,7 +334,8 @@ class ArtifactAPITestCase(APITestCase):
|
|||
|
||||
# -- delete (DELETE) --------------------------------------------------
|
||||
|
||||
def test_delete_removes_artifact_and_files(self):
|
||||
def test_delete_soft_deletes_to_trash(self):
|
||||
# DELETE moves the artifact to Trash (recoverable), not a hard removal.
|
||||
artifact_id = self.create_artifact().data["id"]
|
||||
artifact = Artifact.objects.get(pk=artifact_id)
|
||||
stored_path = self.artifacts_root / artifact.path
|
||||
|
|
@ -344,9 +345,12 @@ class ArtifactAPITestCase(APITestCase):
|
|||
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||
)
|
||||
self.assertEqual(response.status_code, 204)
|
||||
self.assertFalse(Artifact.objects.filter(pk=artifact_id).exists())
|
||||
self.assertFalse(stored_path.exists())
|
||||
self.assertFalse(stored_path.with_suffix(".md").exists())
|
||||
# Row still exists, marked deleted; files kept on disk until purge.
|
||||
artifact.refresh_from_db()
|
||||
self.assertIsNotNone(artifact.deleted_at)
|
||||
self.assertTrue(stored_path.exists())
|
||||
# But it's gone from the API's list/detail (hidden like the feed).
|
||||
self.assertEqual(self.client.get(reverse("artifact-api"), **self.auth_headers(self.token)).data, [])
|
||||
|
||||
def test_delete_requires_token(self):
|
||||
artifact_id = self.create_artifact().data["id"]
|
||||
|
|
@ -362,7 +366,8 @@ class ArtifactAPITestCase(APITestCase):
|
|||
self.assertEqual(response.status_code, 404)
|
||||
self.assertTrue(Artifact.objects.filter(pk=artifact_id).exists())
|
||||
|
||||
def test_delete_twice_second_call_404s(self):
|
||||
def test_delete_is_idempotent(self):
|
||||
# Soft-delete is idempotent: deleting an already-trashed artifact is a no-op 204.
|
||||
artifact_id = self.create_artifact().data["id"]
|
||||
first = self.client.delete(
|
||||
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||
|
|
@ -372,7 +377,7 @@ class ArtifactAPITestCase(APITestCase):
|
|||
second = self.client.delete(
|
||||
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||
)
|
||||
self.assertEqual(second.status_code, 404)
|
||||
self.assertEqual(second.status_code, 204)
|
||||
|
||||
def test_deleted_artifact_is_gone_from_list_and_detail(self):
|
||||
artifact_id = self.create_artifact().data["id"]
|
||||
|
|
|
|||
107
tests/views/test_trash.py
Normal file
107
tests/views/test_trash.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue