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

@ -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 }}">&#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>
{% endfor %}

View file

@ -27,6 +27,7 @@
data-selected-kind="{{ selected_kind }}"
data-title-query="{{ title_query }}"
data-selected-view="{{ selected_view }}"
data-folder="{{ selected_folder }}"
data-window-start="{{ window_start|date:'Y-m-d' }}"
data-older-count="{{ older_count }}">
<aside class="source-sidebar" aria-label="Sources">
@ -34,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 }}&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>
{% 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 %}&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>
{% 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&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>
</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"}});