diff --git a/apps/entities/routes.py b/apps/entities/routes.py index 7abdf94ae..68ffbb1df 100644 --- a/apps/entities/routes.py +++ b/apps/entities/routes.py @@ -7,6 +7,10 @@ from __future__ import annotations import logging import os +import re +import time +import uuid +from datetime import datetime from pathlib import Path from typing import Any @@ -14,11 +18,11 @@ from flask import Blueprint, jsonify, request logger = logging.getLogger(__name__) +import think.deferred_deletes as deferred_deletes from apps.utils import log_app_action from convey import state from think.entities import ( block_journal_entity, - delete_journal_entity, entity_last_active_ts, entity_memory_path, entity_slug, @@ -26,7 +30,6 @@ from think.entities import ( load_detected_entities_recent, load_entities, load_facet_relationship, - load_journal_entity, load_observations, rename_entity_memory, save_entities, @@ -35,6 +38,7 @@ from think.entities import ( unblock_journal_entity, validate_aka_uniqueness, ) +from think.entities.journal import delete_journal_entity, load_journal_entity from think.facets import get_facets from think.utils import now_ms @@ -43,6 +47,7 @@ entities_bp = Blueprint( __name__, url_prefix="/app/entities", ) +ENTITY_DELETE_TTL = 10.0 def _get_entity_metadata(facet_name: str, entity_name: str) -> dict: @@ -905,27 +910,81 @@ def update_journal_entity(entity_id: str) -> Any: def delete_journal_entity_route(entity_id: str) -> Any: """Permanently delete a journal entity and all facet relationships.""" try: - result = delete_journal_entity(entity_id) + journal_entity = load_journal_entity(entity_id) + if journal_entity is None: + return jsonify({"error": f"Entity '{entity_id}' not found"}), 400 + + if journal_entity.get("is_principal"): + return jsonify({"error": "Cannot delete the principal (self) entity"}), 400 + + ttl = ENTITY_DELETE_TTL + pending_id = uuid.uuid4().hex + + def _commit() -> None: + try: + result = delete_journal_entity(entity_id) + facets = result.get("facets_deleted", []) + except Exception: + facets = [] + logger.exception( + "deferred journal_entity_delete failed for %s", entity_id + ) + log_app_action( + app="entities", + facet=None, + action="journal_entity_delete", + params={ + "entity_id": entity_id, + "facets_deleted": facets, + "pending_id": pending_id, + "phase": "committed", + }, + ) + deferred_deletes.schedule_with_id(pending_id, _commit, ttl_seconds=ttl) log_app_action( app="entities", - facet=None, # Journal-level action + facet=None, action="journal_entity_delete", params={ "entity_id": entity_id, - "facets_deleted": result.get("facets_deleted", []), + "pending_id": pending_id, + "phase": "pending", }, ) + return jsonify( + { + "success": True, + "pending": pending_id, + "commit_at_ms": int((time.time() + ttl) * 1000), + "ttl_seconds": ttl, + } + ) - return jsonify(result) - - except ValueError as e: - return jsonify({"error": str(e)}), 400 except Exception as e: logger.exception("Failed to delete journal entity") return jsonify({"error": f"Failed to delete journal entity: {str(e)}"}), 500 +@entities_bp.route("/api/cancel-delete/", methods=["POST"]) +def cancel_delete_journal_entity(pending_id: str) -> Any: + """Cancel a pending deferred journal-entity deletion.""" + if not re.fullmatch(r"[0-9a-f]{32}", pending_id): + return jsonify({"error": "already committed or unknown"}), 410 + + if not deferred_deletes.cancel(pending_id): + return jsonify({"error": "already committed or unknown"}), 410 + + log_app_action( + app="entities", + facet=None, + action="journal_entity_delete", + params={"pending_id": pending_id, "phase": "cancelled"}, + day=datetime.now().strftime("%Y%m%d"), + ) + return jsonify({"cancelled": pending_id}) + + @entities_bp.route("/api/journal/entity//block", methods=["POST"]) def block_journal_entity_route(entity_id: str) -> Any: """Block a journal entity and detach all facet relationships.""" diff --git a/apps/entities/tests/test_delete_journal_entity.py b/apps/entities/tests/test_delete_journal_entity.py new file mode 100644 index 000000000..abc5af4ba --- /dev/null +++ b/apps/entities/tests/test_delete_journal_entity.py @@ -0,0 +1,139 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import json +import re +import time +from datetime import datetime + +from think.entities.journal import save_journal_entity + + +def _action_log_rows(journal_root, day): + log_path = journal_root / "config" / "actions" / f"{day}.jsonl" + if not log_path.exists(): + return [] + return [ + json.loads(line) + for line in log_path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + +def _create_journal_entity(entity_id, *, is_principal=False): + save_journal_entity( + { + "id": entity_id, + "name": entity_id.title(), + "type": "Person", + "is_principal": is_principal, + } + ) + + +def test_delete_journal_entity_route_rejects_principal( + client, journal_copy, monkeypatch +): + entity_id = "principal-delete-test" + today = datetime.now().strftime("%Y%m%d") + _create_journal_entity(entity_id, is_principal=True) + + response = client.delete(f"/app/entities/api/journal/entity/{entity_id}") + + assert response.status_code == 400 + assert response.get_json() == {"error": "Cannot delete the principal (self) entity"} + assert (journal_copy / "entities" / entity_id).exists() + rows = _action_log_rows(journal_copy, today) + assert not any( + row["action"] == "journal_entity_delete" + and row["params"].get("entity_id") == entity_id + for row in rows + ) + + +def test_delete_journal_entity_route_rejects_missing_entity(client): + response = client.delete("/app/entities/api/journal/entity/missing-entity") + + assert response.status_code == 400 + assert response.get_json() == {"error": "Entity 'missing-entity' not found"} + + +def test_delete_journal_entity_route_returns_pending_response_shape( + client, journal_copy, monkeypatch +): + entity_id = "pending-delete-test" + today = datetime.now().strftime("%Y%m%d") + _create_journal_entity(entity_id) + monkeypatch.setattr("apps.entities.routes.ENTITY_DELETE_TTL", 0.05) + before_ms = int(time.time() * 1000) + + response = client.delete(f"/app/entities/api/journal/entity/{entity_id}") + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + assert re.fullmatch(r"[0-9a-f]{32}", data["pending"]) + assert data["ttl_seconds"] == 0.05 + assert data["commit_at_ms"] >= before_ms + assert (journal_copy / "entities" / entity_id).exists() + rows = _action_log_rows(journal_copy, today) + assert any( + row["action"] == "journal_entity_delete" + and row["params"].get("entity_id") == entity_id + and row["params"].get("phase") == "pending" + for row in rows + ) + time.sleep(0.2) + + +def test_cancel_delete_journal_entity_within_window_keeps_entity( + client, journal_copy, monkeypatch +): + entity_id = "cancel-delete-test" + _create_journal_entity(entity_id) + monkeypatch.setattr("apps.entities.routes.ENTITY_DELETE_TTL", 0.2) + + delete_response = client.delete(f"/app/entities/api/journal/entity/{entity_id}") + pending_id = delete_response.get_json()["pending"] + + cancel_response = client.post(f"/app/entities/api/cancel-delete/{pending_id}") + + assert cancel_response.status_code == 200 + assert cancel_response.get_json() == {"cancelled": pending_id} + time.sleep(0.3) + assert (journal_copy / "entities" / entity_id).exists() + + +def test_cancel_delete_journal_entity_too_late_after_commit( + client, journal_copy, monkeypatch +): + entity_id = "late-delete-test" + today = datetime.now().strftime("%Y%m%d") + _create_journal_entity(entity_id) + monkeypatch.setattr("apps.entities.routes.ENTITY_DELETE_TTL", 0.05) + + delete_response = client.delete(f"/app/entities/api/journal/entity/{entity_id}") + pending_id = delete_response.get_json()["pending"] + + time.sleep(0.2) + cancel_response = client.post(f"/app/entities/api/cancel-delete/{pending_id}") + + assert cancel_response.status_code == 410 + assert cancel_response.get_json() == {"error": "already committed or unknown"} + assert not (journal_copy / "entities" / entity_id).exists() + rows = _action_log_rows(journal_copy, today) + assert any( + row["action"] == "journal_entity_delete" + and row["params"].get("entity_id") == entity_id + and row["params"].get("phase") == "committed" + for row in rows + ) + + +def test_cancel_delete_journal_entity_unknown_pending_id_returns_410(client): + response = client.post(f"/app/entities/api/cancel-delete/{'b' * 32}") + + assert response.status_code == 410 + assert response.get_json() == {"error": "already committed or unknown"} diff --git a/apps/entities/workspace.html b/apps/entities/workspace.html index a8eadc23e..d48c9546b 100644 --- a/apps/entities/workspace.html +++ b/apps/entities/workspace.html @@ -2097,6 +2097,9 @@ function showDeleteJournalEntityModal() { function closeDeleteJournalEntityModal() { hideInlineError('delete-modal-error'); + const btn = document.getElementById('confirmDeleteJournalEntityBtn'); + btn.disabled = false; + btn.textContent = 'delete permanently'; document.getElementById('deleteJournalEntityModal').style.display = 'none'; } @@ -2117,9 +2120,25 @@ function confirmDeleteJournalEntity() { throw new Error(data.error); } hideInlineError('delete-modal-error'); + journalEntitiesData = (journalEntitiesData || []).filter(entity => entity.id !== entityId); closeDeleteJournalEntityModal(); navigateToList(); - loadEntities(); + renderJournalEntities(document.getElementById('journal-entity-card-search')?.value.trim() || ''); + + let pendingNotificationId = null; + pendingNotificationId = window.AppServices.notifications.show({ + app: 'entities', + icon: '🗑️', + title: 'Deleting entity…', + message: 'Cancels in 10s', + dismissible: true, + autoDismiss: 10000, + buttons: [{ + label: 'Cancel', + dismiss: false, + onClick: () => cancelJournalEntityDelete(data.pending, pendingNotificationId) + }] + }); }) .catch(error => { console.error('Error deleting entity:', error); @@ -2129,6 +2148,50 @@ function confirmDeleteJournalEntity() { }); } +async function cancelJournalEntityDelete(pendingId, notificationId) { + try { + const response = await fetch(`api/cancel-delete/${pendingId}`, { + method: 'POST' + }); + const data = await response.json().catch(() => ({})); + + if (notificationId !== null) { + window.AppServices.notifications.dismiss(notificationId); + } + + if (response.ok) { + await loadEntities(); + window.AppServices.notifications.show({ + app: 'entities', + icon: '↩️', + title: 'Delete cancelled', + autoDismiss: 3000 + }); + return; + } + + if (response.status === 410) { + window.AppServices.notifications.show({ + app: 'entities', + icon: '⏱️', + title: 'Too late — already deleted', + autoDismiss: 3000 + }); + return; + } + + throw new Error(data.error || 'Failed to cancel delete'); + } catch (error) { + window.AppServices.notifications.show({ + app: 'entities', + icon: '⚠️', + title: 'Couldn\'t cancel delete', + message: error.message || 'Please try again.', + autoDismiss: 3000 + }); + } +} + function renderDetailView(entity, observations) { const isDetached = entity.detached === true; const needsAttachment = entity.needs_attachment === true; @@ -2577,12 +2640,11 @@ function loadEntities() { if (!currentFacet) { // All-facet mode: load journal entities - loadJournalEntities(); - return; + return loadJournalEntities(); } // Facet-specific mode - fetch(`api/${encodeURIComponent(currentFacet)}`) + return fetch(`api/${encodeURIComponent(currentFacet)}`) .then(response => response.json()) .then(data => { if (data.error) { @@ -2614,7 +2676,7 @@ function loadEntities() { } function loadJournalEntities() { - fetch('api/journal') + return fetch('api/journal') .then(response => response.json()) .then(data => { if (data.error) { diff --git a/apps/transcripts/routes.py b/apps/transcripts/routes.py index f13389656..c9a91eff4 100644 --- a/apps/transcripts/routes.py +++ b/apps/transcripts/routes.py @@ -11,7 +11,9 @@ import logging import os import re import shutil -from datetime import date +import time +import uuid +from datetime import date, datetime from glob import glob from pathlib import Path from typing import Any @@ -25,6 +27,7 @@ from flask import ( url_for, ) +import think.deferred_deletes as deferred_deletes from apps.utils import log_app_action from convey import emit, state from convey.utils import DATE_RE, error_response, format_date, success_response @@ -42,6 +45,7 @@ logger = logging.getLogger(__name__) # Regex for YYYYMM month format validation MONTH_RE = re.compile(r"\d{6}") +SEGMENT_DELETE_TTL = 10.0 transcripts_bp = Blueprint( "app:transcripts", @@ -524,31 +528,75 @@ def delete_segment(day: str, stream: str, segment_key: str) -> Any: return error_response("Invalid segment path", 403) try: - # Remove the entire segment directory - shutil.rmtree(segment_dir) + ttl = SEGMENT_DELETE_TTL + pending_id = uuid.uuid4().hex + search_index_warning = not is_supervisor_up() + + def _commit() -> None: + shutil.rmtree(segment_dir) + emit( + "supervisor", + "request", + cmd=["sol", "indexer", "--rescan-full"], + ) + log_app_action( + app="transcripts", + facet=None, + action="segment_delete", + params={ + "day": day, + "segment_key": segment_key, + "stream": stream, + "pending_id": pending_id, + "phase": "committed", + }, + day=day, + ) - # Log the deletion for audit trail + deferred_deletes.schedule_with_id(pending_id, _commit, ttl_seconds=ttl) log_app_action( app="transcripts", - facet=None, # Transcripts are not facet-scoped + facet=None, action="segment_delete", - params={"day": day, "segment_key": segment_key}, + params={ + "day": day, + "segment_key": segment_key, + "stream": stream, + "pending_id": pending_id, + "phase": "pending", + }, day=day, ) - payload = {"deleted": segment_key} - if not is_supervisor_up(): + payload = { + "deleted": segment_key, + "pending": pending_id, + "commit_at_ms": int((time.time() + ttl) * 1000), + "ttl_seconds": ttl, + } + if search_index_warning: payload["search_index_warning"] = True - # Trigger indexer rescan to remove deleted segment from search index - # Supervisor queues by command name, serializing concurrent indexer requests - emit( - "supervisor", - "request", - cmd=["sol", "indexer", "--rescan-full"], - ) - return success_response(payload) - except OSError as e: + except Exception as e: return error_response(f"Failed to delete segment: {e}", 500) + + +@transcripts_bp.route("/api/cancel-delete/", methods=["POST"]) +def cancel_delete_segment(pending_id: str) -> Any: + """Cancel a pending deferred segment deletion.""" + if not re.fullmatch(r"[0-9a-f]{32}", pending_id): + return error_response("already committed or unknown", 410) + + if not deferred_deletes.cancel(pending_id): + return error_response("already committed or unknown", 410) + + log_app_action( + app="transcripts", + facet=None, + action="segment_delete", + params={"pending_id": pending_id, "phase": "cancelled"}, + day=datetime.now().strftime("%Y%m%d"), + ) + return jsonify({"cancelled": pending_id}) diff --git a/apps/transcripts/tests/test_segment_routes.py b/apps/transcripts/tests/test_segment_routes.py index b2477ba19..a244c4a92 100644 --- a/apps/transcripts/tests/test_segment_routes.py +++ b/apps/transcripts/tests/test_segment_routes.py @@ -1,6 +1,11 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc +import json +import re +import time +from datetime import datetime + import pytest FIXTURE_DAY = "20260304" @@ -8,6 +13,17 @@ FIXTURE_STREAM = "default" FIXTURE_SEGMENT = "090000_300" +def _action_log_rows(journal_root, day): + log_path = journal_root / "config" / "actions" / f"{day}.jsonl" + if not log_path.exists(): + return [] + return [ + json.loads(line) + for line in log_path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + @pytest.mark.parametrize("stream", ["-bad", "Upper", "..bad"]) def test_segment_content_rejects_invalid_stream(client, stream): response = client.get( @@ -68,6 +84,7 @@ def test_delete_segment_happy_path_removes_segment_directory( client, journal_copy, monkeypatch ): monkeypatch.setattr("apps.transcripts.routes.is_supervisor_up", lambda: True) + monkeypatch.setattr("apps.transcripts.routes.SEGMENT_DELETE_TTL", 0.05) segment_dir = ( journal_copy / "chronicle" / FIXTURE_DAY / FIXTURE_STREAM / FIXTURE_SEGMENT ) @@ -77,31 +94,162 @@ def test_delete_segment_happy_path_removes_segment_directory( ) assert response.status_code == 200 - assert response.get_json() == {"success": True, "deleted": FIXTURE_SEGMENT} + assert response.get_json()["deleted"] == FIXTURE_SEGMENT + time.sleep(0.2) assert not segment_dir.exists() -def test_delete_segment_includes_search_index_warning_when_supervisor_is_down(client): +def test_delete_segment_includes_search_index_warning_when_supervisor_is_down( + client, monkeypatch +): + monkeypatch.setattr("apps.transcripts.routes.SEGMENT_DELETE_TTL", 0.05) response = client.delete( f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" ) assert response.status_code == 200 - assert response.get_json() == { - "success": True, - "deleted": FIXTURE_SEGMENT, - "search_index_warning": True, - } + data = response.get_json() + assert data["success"] is True + assert data["deleted"] == FIXTURE_SEGMENT + assert data["search_index_warning"] is True + time.sleep(0.2) def test_delete_segment_omits_search_index_warning_when_supervisor_is_up( client, monkeypatch ): monkeypatch.setattr("apps.transcripts.routes.is_supervisor_up", lambda: True) + monkeypatch.setattr("apps.transcripts.routes.SEGMENT_DELETE_TTL", 0.05) response = client.delete( f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" ) assert response.status_code == 200 - assert response.get_json() == {"success": True, "deleted": FIXTURE_SEGMENT} + assert response.get_json()["deleted"] == FIXTURE_SEGMENT + time.sleep(0.2) + + +def test_delete_segment_returns_pending_response_shape(client, monkeypatch): + monkeypatch.setattr("apps.transcripts.routes.is_supervisor_up", lambda: True) + monkeypatch.setattr("apps.transcripts.routes.SEGMENT_DELETE_TTL", 0.05) + before_ms = int(time.time() * 1000) + + response = client.delete( + f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + assert data["deleted"] == FIXTURE_SEGMENT + assert re.fullmatch(r"[0-9a-f]{32}", data["pending"]) + assert data["ttl_seconds"] == 0.05 + assert data["commit_at_ms"] >= before_ms + time.sleep(0.2) + + +def test_cancel_delete_segment_within_window_keeps_directory( + client, journal_copy, monkeypatch +): + monkeypatch.setattr("apps.transcripts.routes.SEGMENT_DELETE_TTL", 0.2) + segment_dir = ( + journal_copy / "chronicle" / FIXTURE_DAY / FIXTURE_STREAM / FIXTURE_SEGMENT + ) + + delete_response = client.delete( + f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" + ) + pending_id = delete_response.get_json()["pending"] + + cancel_response = client.post(f"/app/transcripts/api/cancel-delete/{pending_id}") + + assert cancel_response.status_code == 200 + assert cancel_response.get_json() == {"cancelled": pending_id} + time.sleep(0.3) + assert segment_dir.exists() + + +def test_cancel_delete_segment_too_late_after_commit(client, journal_copy, monkeypatch): + monkeypatch.setattr("apps.transcripts.routes.SEGMENT_DELETE_TTL", 0.05) + segment_dir = ( + journal_copy / "chronicle" / FIXTURE_DAY / FIXTURE_STREAM / FIXTURE_SEGMENT + ) + + delete_response = client.delete( + f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" + ) + pending_id = delete_response.get_json()["pending"] + + time.sleep(0.2) + cancel_response = client.post(f"/app/transcripts/api/cancel-delete/{pending_id}") + + assert cancel_response.status_code == 410 + assert cancel_response.get_json() == {"error": "already committed or unknown"} + assert not segment_dir.exists() + + +def test_cancel_delete_segment_unknown_pending_id_returns_410(client): + response = client.post(f"/app/transcripts/api/cancel-delete/{'a' * 32}") + + assert response.status_code == 410 + assert response.get_json() == {"error": "already committed or unknown"} + + +def test_cancel_delete_segment_malformed_pending_id_returns_410(client): + response = client.post("/app/transcripts/api/cancel-delete/not-hex") + + assert response.status_code == 410 + assert response.get_json() == {"error": "already committed or unknown"} + + +def test_delete_segment_writes_pending_and_committed_audit_rows( + client, journal_copy, monkeypatch +): + monkeypatch.setattr("apps.transcripts.routes.is_supervisor_up", lambda: True) + monkeypatch.setattr("apps.transcripts.routes.SEGMENT_DELETE_TTL", 0.05) + + delete_response = client.delete( + f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" + ) + pending_id = delete_response.get_json()["pending"] + + day_rows = _action_log_rows(journal_copy, FIXTURE_DAY) + assert any( + row["action"] == "segment_delete" + and row["params"].get("pending_id") == pending_id + and row["params"].get("phase") == "pending" + for row in day_rows + ) + + time.sleep(0.2) + day_rows = _action_log_rows(journal_copy, FIXTURE_DAY) + assert any( + row["action"] == "segment_delete" + and row["params"].get("pending_id") == pending_id + and row["params"].get("phase") == "committed" + for row in day_rows + ) + + +def test_cancel_delete_segment_writes_cancelled_audit_row( + client, journal_copy, monkeypatch +): + monkeypatch.setattr("apps.transcripts.routes.SEGMENT_DELETE_TTL", 0.2) + cancel_response = client.delete( + f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" + ) + cancel_pending_id = cancel_response.get_json()["pending"] + cancel_result = client.post( + f"/app/transcripts/api/cancel-delete/{cancel_pending_id}" + ) + + assert cancel_result.status_code == 200 + cancel_day = datetime.now().strftime("%Y%m%d") + cancel_rows = _action_log_rows(journal_copy, cancel_day) + assert any( + row["action"] == "segment_delete" + and row["params"].get("pending_id") == cancel_pending_id + and row["params"].get("phase") == "cancelled" + for row in cancel_rows + ) diff --git a/apps/transcripts/workspace.html b/apps/transcripts/workspace.html index 61bfa3500..1866a1dc1 100644 --- a/apps/transcripts/workspace.html +++ b/apps/transcripts/workspace.html @@ -3324,7 +3324,7 @@ body.has-date-nav .workspace:has(.tr-wrap) { deleteSegmentModalBody.innerHTML = `

${escapeHtml(seg.stream)} · ${escapeHtml(day)} · ${escapeHtml(seg.start)} - ${escapeHtml(seg.end)}

-

You'll have ~30 seconds to undo this after confirming.

+

You'll have about 10 seconds to undo this after confirming.

This removes:

  • audio
  • @@ -3363,6 +3363,29 @@ body.has-date-nav .workspace:has(.tr-wrap) { await performDeleteSegment(seg); } + function sortSegmentsByKey() { + allSegments.sort((a, b) => a.key.localeCompare(b.key)); + } + + function refreshSegmentIndicators() { + return fetch(`/app/transcripts/api/ranges/${day}`) + .then(r => r.ok ? r.json() : Promise.reject('Failed to fetch ranges')) + .then(data => { + segmentsLane.innerHTML = ''; + (data.audio || []).forEach(rg => { + const [s, e] = rg.map(parseTime); + addSegmentIndicator('audio', s, e, 0); + }); + (data.screen || []).forEach(rg => { + const [s, e] = rg.map(parseTime); + addSegmentIndicator('screen', s, e, 1); + }); + }) + .catch(() => { + // Range indicators may be stale, but the main segment state is already updated. + }); + } + async function performDeleteSegment(seg) { try { const response = await fetch(`/app/transcripts/api/segment/${day}/${seg.stream}/${seg.key}`, { @@ -3385,24 +3408,22 @@ body.has-date-nav .workspace:has(.tr-wrap) { // Re-render zoom timeline buildZoomSegments(); - // Refresh range indicators on left timeline - fetch(`/app/transcripts/api/ranges/${day}`) - .then(r => r.ok ? r.json() : Promise.reject('Failed to fetch ranges')) - .then(data => { - // Clear and rebuild segment indicators - segmentsLane.innerHTML = ''; - (data.audio || []).forEach(rg => { - const [s, e] = rg.map(parseTime); - addSegmentIndicator('audio', s, e, 0); - }); - (data.screen || []).forEach(rg => { - const [s, e] = rg.map(parseTime); - addSegmentIndicator('screen', s, e, 1); - }); - }) - .catch(() => { - // Range indicators may be stale, but segment is deleted - }); + refreshSegmentIndicators(); + + let pendingNotificationId = null; + pendingNotificationId = window.AppServices.notifications.show({ + app: 'transcripts', + icon: '🗑️', + title: 'Deleting segment…', + message: 'Cancels in 10s', + dismissible: true, + autoDismiss: 10000, + buttons: [{ + label: 'Cancel', + dismiss: false, + onClick: () => cancelSegmentDelete(data.pending, seg, pendingNotificationId) + }] + }); if (data.search_index_warning === true) { window.AppServices.notifications.show({ @@ -3430,6 +3451,55 @@ body.has-date-nav .workspace:has(.tr-wrap) { } } + async function cancelSegmentDelete(pendingId, seg, notificationId) { + try { + const response = await fetch(`/app/transcripts/api/cancel-delete/${pendingId}`, { + method: 'POST' + }); + const data = await response.json().catch(() => ({})); + + if (notificationId !== null) { + window.AppServices.notifications.dismiss(notificationId); + } + + if (response.ok) { + if (!allSegments.some(existing => existing.key === seg.key)) { + allSegments.push(seg); + sortSegmentsByKey(); + } + buildZoomSegments(); + refreshSegmentIndicators(); + window.AppServices.notifications.show({ + app: 'transcripts', + icon: '↩️', + title: 'Delete cancelled', + autoDismiss: 3000 + }); + return; + } + + if (response.status === 410) { + window.AppServices.notifications.show({ + app: 'transcripts', + icon: '⏱️', + title: 'Too late — already deleted', + autoDismiss: 3000 + }); + return; + } + + throw new Error(data.error || 'Failed to cancel delete'); + } catch (err) { + window.AppServices.notifications.show({ + app: 'transcripts', + icon: '⚠️', + title: 'Couldn\'t cancel delete', + message: err.message || 'Please try again.', + autoDismiss: 3000 + }); + } + } + window.closeDeleteSegmentModal = closeDeleteSegmentModal; window.confirmDeleteSegment = confirmDeleteSegment; diff --git a/convey/static/app.css b/convey/static/app.css index e0fa99539..b3dec1d48 100644 --- a/convey/static/app.css +++ b/convey/static/app.css @@ -1947,6 +1947,9 @@ a.notification-card:active { margin-top: 8px; padding-top: 8px; border-top: 1px solid #e5e7eb; + display: flex; + align-items: center; + gap: 8px; } .notification-time { @@ -1954,6 +1957,31 @@ a.notification-card:active { color: #9ca3af; } +.notification-actions { + display: flex; + gap: 8px; + margin-left: auto; +} + +.notification-action { + padding: 4px 10px; + border: 1px solid #d1d5db; + border-radius: 999px; + background: #f9fafb; + color: #374151; + cursor: pointer; + font-size: 12px; + font-weight: 600; +} + +.notification-action:hover { + background: #f3f4f6; +} + +.notification-action:active { + background: #e5e7eb; +} + /* Responsive */ @media (max-width: 768px) { .notification-center { diff --git a/convey/static/app.js b/convey/static/app.js index b632cf828..37ed8414c 100644 --- a/convey/static/app.js +++ b/convey/static/app.js @@ -1540,25 +1540,35 @@ window.AppServices = { _container: null, _dismissTimers: {}, - /** - * Show a persistent notification card - * @param {object} options - {app, icon, title, message, action, facet, dismissible, badge, autoDismiss} - * @returns {number} Notification ID - */ - show(options) { - const notif = { - id: this._nextId++, - app: options.app || 'system', - icon: options.icon || '📬', - title: options.title || 'Notification', + /** + * Show a persistent notification card + * @param {object} options - {app, icon, title, message, action, facet, dismissible, badge, autoDismiss, buttons} + * @returns {number} Notification ID + */ + show(options) { + const buttons = Array.isArray(options.buttons) + ? options.buttons + .filter(button => button && button.label) + .map(button => ({ + label: String(button.label), + onClick: typeof button.onClick === 'function' ? button.onClick : null, + dismiss: button.dismiss !== false + })) + : []; + const notif = { + id: this._nextId++, + app: options.app || 'system', + icon: options.icon || '📬', + title: options.title || 'Notification', message: options.message || '', action: options.action || null, - facet: options.facet || null, - dismissible: options.dismissible !== false, - badge: options.badge || null, - timestamp: Date.now(), - autoDismiss: options.autoDismiss || null - }; + facet: options.facet || null, + dismissible: options.dismissible !== false, + badge: options.badge || null, + timestamp: Date.now(), + autoDismiss: options.autoDismiss || null, + buttons + }; this._stack.push(notif); this._addToHistory(notif); @@ -1772,14 +1782,14 @@ window.AppServices = { * Attach click handler to notification card * @private */ - _attachClickHandler(card, n) { - if (!n.action) return; + _attachClickHandler(card, n) { + if (!n.action) return; - card.onclick = (e) => { - // Ignore clicks on close button - if (e.target.closest('.notification-close')) { - return; - } + card.onclick = (e) => { + // Ignore clicks on controls inside the card + if (e.target.closest('.notification-close, .notification-action')) { + return; + } // Prevent default for anchor tags if (card.tagName === 'A') { @@ -1793,12 +1803,54 @@ window.AppServices = { // Navigate to the path window.location.href = n.action; - }; - }, - - /** - * Create a new notification card element - * @private + }; + }, + + _syncButtons(card, n) { + const footer = card.querySelector('.notification-footer'); + if (!footer) return; + + let actionsEl = footer.querySelector('.notification-actions'); + if (!n.buttons || n.buttons.length === 0) { + if (actionsEl) actionsEl.remove(); + return; + } + + if (!actionsEl) { + actionsEl = document.createElement('div'); + actionsEl.className = 'notification-actions'; + footer.appendChild(actionsEl); + } + + actionsEl.replaceChildren(); + n.buttons.forEach((button, idx) => { + const buttonEl = document.createElement('button'); + buttonEl.type = 'button'; + buttonEl.className = 'notification-action'; + buttonEl.dataset.btn = String(idx); + buttonEl.textContent = button.label; + actionsEl.appendChild(buttonEl); + }); + + actionsEl.querySelectorAll('.notification-action').forEach((buttonEl) => { + buttonEl.onclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + const button = n.buttons[Number(buttonEl.dataset.btn)]; + if (!button) return; + if (button.onClick) { + button.onClick(n); + } + if (button.dismiss !== false) { + this.dismiss(n.id); + } + }; + }); + }, + + /** + * Create a new notification card element + * @private */ _createCard(n) { // Use anchor tag for semantic HTML when action exists @@ -1829,15 +1881,16 @@ window.AppServices = {
    ${window.AppServices._escapeHtml(n.title)}
    ${n.message ? `
    ${window.AppServices._escapeHtml(n.message)}
    ` : ''} ${n.badge ? `${n.badge}` : ''} - - - ${n.autoDismiss ? `
    ` : ''} - `; + + + ${n.autoDismiss ? `
    ` : ''} + `; + this._syncButtons(card, n); - // Attach click handler - this._attachClickHandler(card, n); + // Attach click handler + this._attachClickHandler(card, n); if (n.autoDismiss) { const self = this; @@ -1903,13 +1956,14 @@ window.AppServices = { } // Update time - const timeEl = card.querySelector('.notification-time'); - if (timeEl) { - timeEl.textContent = this._getRelativeTime(n.timestamp); - } - - // Update action and facet - if (n.action) { + const timeEl = card.querySelector('.notification-time'); + if (timeEl) { + timeEl.textContent = this._getRelativeTime(n.timestamp); + } + this._syncButtons(card, n); + + // Update action and facet + if (n.action) { if (card.tagName === 'A') { card.href = n.action; } diff --git a/tests/test_deferred_deletes.py b/tests/test_deferred_deletes.py new file mode 100644 index 000000000..a404287e0 --- /dev/null +++ b/tests/test_deferred_deletes.py @@ -0,0 +1,102 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import threading +import time + +import pytest + +import think.deferred_deletes as deferred_deletes + + +@pytest.fixture(autouse=True) +def cleanup_deferred_deletes(): + yield + for pending_id in list(deferred_deletes._TIMERS): + deferred_deletes.cancel(pending_id) + + +def test_schedule_runs_commit_once_after_ttl(): + committed = [] + done = threading.Event() + + def commit(): + committed.append("ran") + done.set() + + deferred_deletes.schedule(commit, ttl_seconds=0.05) + + assert done.wait(0.3) + assert committed == ["ran"] + + +def test_cancel_prevents_commit(): + committed = [] + + deferred_id = deferred_deletes.schedule( + lambda: committed.append("ran"), + ttl_seconds=1.0, + ) + + assert deferred_deletes.cancel(deferred_id) is True + time.sleep(1.15) + assert committed == [] + + +def test_double_cancel_returns_false_after_first(): + deferred_id = deferred_deletes.schedule(lambda: None, ttl_seconds=1.0) + + assert deferred_deletes.cancel(deferred_id) is True + assert deferred_deletes.cancel(deferred_id) is False + + +def test_cancel_unknown_id_returns_false(): + assert deferred_deletes.cancel("0" * 32) is False + + +def test_cancel_commit_race_runs_at_most_once(): + iterations = 50 + + for _ in range(iterations): + commit_count = 0 + commit_lock = threading.Lock() + start = threading.Event() + cancel_results = [] + + def commit(): + nonlocal commit_count + with commit_lock: + commit_count += 1 + + deferred_id = deferred_deletes.schedule(commit, ttl_seconds=0.05) + + def attempt_cancel(): + start.wait() + cancel_results.append(deferred_deletes.cancel(deferred_id)) + + threads = [threading.Thread(target=attempt_cancel) for _ in range(8)] + for thread in threads: + thread.start() + + start.set() + time.sleep(0.15) + for thread in threads: + thread.join() + + true_cancels = sum(cancel_results) + assert commit_count in (0, 1) + assert not (true_cancels and commit_count) + assert (true_cancels == 1 and commit_count == 0) or ( + true_cancels == 0 and commit_count == 1 + ) + + +def test_scheduled_timers_are_daemon_threads(): + deferred_id = deferred_deletes.schedule(lambda: None, ttl_seconds=1.0) + + with deferred_deletes._LOCK: + timer = deferred_deletes._TIMERS[deferred_id] + + assert timer.daemon is True diff --git a/think/deferred_deletes.py b/think/deferred_deletes.py new file mode 100644 index 000000000..b0172aa6f --- /dev/null +++ b/think/deferred_deletes.py @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Process-local timer registry for deferred destructive actions.""" + +from __future__ import annotations + +import logging +import threading +import uuid +from collections.abc import Callable + +logger = logging.getLogger(__name__) + +_TIMERS: dict[str, threading.Timer] = {} +_LOCK = threading.Lock() + + +def schedule_with_id( + pending_id: str, + commit_fn: Callable[[], None], + ttl_seconds: float = 10.0, +) -> str: + """Schedule ``commit_fn`` to run after ``ttl_seconds`` using ``pending_id``.""" + + def _fire(fire_pending_id: str) -> None: + with _LOCK: + timer = _TIMERS.pop(fire_pending_id, None) + if timer is None: + return + + try: + commit_fn() + except Exception: + logger.exception("Deferred delete commit failed for %s", fire_pending_id) + + timer = threading.Timer(ttl_seconds, _fire, args=(pending_id,)) + timer.daemon = True + with _LOCK: + _TIMERS[pending_id] = timer + timer.start() + return pending_id + + +def schedule(commit_fn: Callable[[], None], ttl_seconds: float = 10.0) -> str: + """Schedule ``commit_fn`` using a generated pending id.""" + + return schedule_with_id(uuid.uuid4().hex, commit_fn, ttl_seconds=ttl_seconds) + + +def cancel(pending_id: str) -> bool: + """Cancel a pending deferred delete if it still exists.""" + + with _LOCK: + timer = _TIMERS.pop(pending_id, None) + if timer is None: + return False + + timer.cancel() + return True