diff --git a/api/routes/compression.py b/api/routes/compression.py index 73ef036..90a2c41 100644 --- a/api/routes/compression.py +++ b/api/routes/compression.py @@ -10,6 +10,10 @@ CompressionStartResponse, CompressionStats, CompressionStatus, + ForceRecompressPreviewRequest, + ForceRecompressPreviewResponse, + ForceRecompressRequest, + ForceRecompressResponse, ) from core.compression import CompressionProgress, compression_service from core.config import config @@ -119,3 +123,79 @@ async def get_compression_stats(): original_size_bytes=stats["original_size_bytes"], estimated_savings_bytes=estimated_savings, ) + + +@router.post("/force-recompress/preview", response_model=ForceRecompressPreviewResponse) +async def preview_force_recompress(request: ForceRecompressPreviewRequest): + """ + Preview force recompression impact. + + Shows how many screenshots would be affected by force recompression, + including how many are already compressed (which will lose quality). + """ + counts = db.get_force_recompressible_count(request.older_than_days) + + warning = "" + if counts["already_compressed"] > 0: + warning = ( + f"{counts['already_compressed']} screenshots are already compressed. " + "Re-compressing them will further reduce quality and cannot be undone." + ) + + return ForceRecompressPreviewResponse( + total_count=counts["total"], + already_compressed_count=counts["already_compressed"], + not_compressed_count=counts["not_compressed"], + warning=warning, + ) + + +@router.post("/force-recompress", response_model=ForceRecompressResponse) +async def start_force_recompress(request: ForceRecompressRequest): + """ + Start force recompression of all screenshots older than specified days. + + Unlike normal compression, this will re-compress already-compressed screenshots. + This is destructive and cannot be undone. + + Requires confirm=true to proceed. + """ + if not request.confirm: + return ForceRecompressResponse( + success=False, + message="Must set confirm=true to proceed with force recompression", + affected_count=0, + ) + + if compression_service.is_running: + return ForceRecompressResponse( + success=False, + message="Compression is already running", + affected_count=0, + ) + + counts = db.get_force_recompressible_count(request.older_than_days) + if counts["total"] == 0: + return ForceRecompressResponse( + success=True, + message=f"No screenshots older than {request.older_than_days} days found", + affected_count=0, + ) + + started = compression_service.start_force_recompress( + older_than_days=request.older_than_days, + quality=request.quality, + ) + + if not started: + return ForceRecompressResponse( + success=False, + message="Failed to start - compression may already be running", + affected_count=0, + ) + + return ForceRecompressResponse( + success=True, + message=f"Force recompression started for {counts['total']} screenshots", + affected_count=counts["total"], + ) diff --git a/api/schemas.py b/api/schemas.py index b5e7981..751dfbf 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -146,6 +146,37 @@ class CompressionStartResponse(BaseModel): compressible_count: int +class ForceRecompressPreviewRequest(BaseModel): + """Request to preview force recompression""" + + older_than_days: int = Field(ge=30, le=365) + + +class ForceRecompressPreviewResponse(BaseModel): + """Preview of force recompression impact""" + + total_count: int + already_compressed_count: int + not_compressed_count: int + warning: str + + +class ForceRecompressRequest(BaseModel): + """Request to start force recompression""" + + older_than_days: int = Field(ge=30, le=365) + quality: int | None = Field(None, ge=50, le=90) + confirm: bool = False + + +class ForceRecompressResponse(BaseModel): + """Response after starting force recompression""" + + success: bool + message: str + affected_count: int + + class SyncStartRequest(BaseModel): """Request to start sync""" diff --git a/api/tests/test_api.py b/api/tests/test_api.py index 978cc46..ddeea96 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -267,6 +267,199 @@ def test_get_compression_stats(self, mock_config, mock_db): assert data["compressed_count"] == 50 assert data["compressible_count"] == 20 + @patch("api.routes.compression.compression_service") + @patch("api.routes.compression.db") + def test_force_recompress_preview(self, mock_db, mock_service): + """Should return force recompress preview""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from api.routes.compression import router + + mock_db.get_force_recompressible_count.return_value = { + "total": 100, + "already_compressed": 30, + "not_compressed": 70, + } + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + response = client.post( + "/compression/force-recompress/preview", + json={"older_than_days": 60}, + ) + assert response.status_code == 200 + data = response.json() + assert data["total_count"] == 100 + assert data["already_compressed_count"] == 30 + assert data["not_compressed_count"] == 70 + + @patch("api.routes.compression.compression_service") + @patch("api.routes.compression.db") + def test_force_recompress_preview_with_warning(self, mock_db, mock_service): + """Should include warning when already-compressed screenshots exist""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from api.routes.compression import router + + mock_db.get_force_recompressible_count.return_value = { + "total": 50, + "already_compressed": 20, + "not_compressed": 30, + } + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + response = client.post( + "/compression/force-recompress/preview", + json={"older_than_days": 90}, + ) + assert response.status_code == 200 + data = response.json() + assert "20" in data["warning"] + assert "already compressed" in data["warning"] + + @patch("api.routes.compression.compression_service") + @patch("api.routes.compression.db") + def test_force_recompress_requires_confirm(self, mock_db, mock_service): + """Should reject without confirm=true""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from api.routes.compression import router + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + response = client.post( + "/compression/force-recompress", + json={"older_than_days": 60, "confirm": False}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is False + assert "confirm" in data["message"].lower() + + @patch("api.routes.compression.compression_service") + @patch("api.routes.compression.db") + def test_force_recompress_already_running(self, mock_db, mock_service): + """Should not start if compression already running""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from api.routes.compression import router + + mock_service.is_running = True + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + response = client.post( + "/compression/force-recompress", + json={"older_than_days": 60, "confirm": True}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is False + assert "already running" in data["message"] + + @patch("api.routes.compression.compression_service") + @patch("api.routes.compression.db") + def test_force_recompress_success(self, mock_db, mock_service): + """Should start force recompression""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from api.routes.compression import router + + mock_service.is_running = False + mock_db.get_force_recompressible_count.return_value = { + "total": 50, + "already_compressed": 10, + "not_compressed": 40, + } + mock_service.start_force_recompress.return_value = True + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + response = client.post( + "/compression/force-recompress", + json={"older_than_days": 60, "confirm": True}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["affected_count"] == 50 + mock_service.start_force_recompress.assert_called_once() + + @patch("api.routes.compression.compression_service") + @patch("api.routes.compression.db") + def test_force_recompress_no_eligible(self, mock_db, mock_service): + """Should handle no eligible screenshots""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from api.routes.compression import router + + mock_service.is_running = False + mock_db.get_force_recompressible_count.return_value = { + "total": 0, + "already_compressed": 0, + "not_compressed": 0, + } + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + response = client.post( + "/compression/force-recompress", + json={"older_than_days": 365, "confirm": True}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["affected_count"] == 0 + + @patch("api.routes.compression.compression_service") + @patch("api.routes.compression.db") + def test_force_recompress_start_fails(self, mock_db, mock_service): + """Should handle TOCTOU race where start returns False""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from api.routes.compression import router + + mock_service.is_running = False + mock_db.get_force_recompressible_count.return_value = { + "total": 10, + "already_compressed": 5, + "not_compressed": 5, + } + mock_service.start_force_recompress.return_value = False + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + response = client.post( + "/compression/force-recompress", + json={"older_than_days": 60, "confirm": True}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is False + assert "failed" in data["message"].lower() or "already" in data["message"].lower() + class TestSearchEndpoints: """Test search API endpoints""" diff --git a/core/compression.py b/core/compression.py index 580849e..34e4d02 100644 --- a/core/compression.py +++ b/core/compression.py @@ -130,18 +130,22 @@ def _compress_loop(self, older_than_days: int, quality: int, batch_size: int): f"✅ Compression complete: {self._progress.processed} processed, {self._progress.bytes_saved // 1024}KB saved" ) - def _compress_screenshot(self, screenshot: dict, quality: int) -> int: + def _compress_screenshot(self, screenshot: dict, quality: int, force: bool = False) -> int: """ - Compress a single screenshot. - Returns bytes saved. + Compress a single screenshot. Returns bytes saved. + + Args: + screenshot: Screenshot dict from database + quality: JPEG quality (1-100) + force: If True, compress even if already compressed (for force recompression) """ image_path = Path(screenshot["image_path"]) if not image_path.exists(): raise FileNotFoundError(f"Image not found: {image_path}") - # Double-check not already compressed (safety) - if screenshot.get("is_compressed"): + # Skip already compressed unless force mode + if screenshot.get("is_compressed") and not force: return 0 # Get original size @@ -196,6 +200,63 @@ def compress_now( self._progress.is_running = False return self._progress + def start_force_recompress( + self, + older_than_days: int, + quality: int | None = None, + on_progress: Callable[[CompressionProgress], None] | None = None, + ): + """Start force recompression in background thread""" + if self._running: + return False + + if quality is None: + quality = config.compression.quality + + self._running = True + self._cancel_requested = False + self._on_progress = on_progress + + def run(): + try: + total = db.get_force_recompressible_count(older_than_days)["total"] + self._progress = CompressionProgress(total=total, processed=0, errors=0, bytes_saved=0, is_running=True) + + batch_size = 50 + offset = 0 + + while self._running and not self._cancel_requested: + screenshots = db.get_force_recompressible_screenshots( + older_than_days, limit=batch_size, offset=offset + ) + + if not screenshots: + break + + for screenshot in screenshots: + if self._cancel_requested: + break + try: + saved = self._compress_screenshot(screenshot, quality, force=True) + self._progress.processed += 1 + self._progress.bytes_saved += saved + except Exception as e: + print(f"Error compressing {screenshot['image_path']}: {e}") + self._progress.errors += 1 + + if self._on_progress: + self._on_progress(self._progress) + + offset += batch_size + finally: + self._progress.is_running = False + self._cancel_requested = False + self._running = False + + self._thread = threading.Thread(target=run, daemon=True) + self._thread.start() + return True + # Global compression service instance compression_service = CompressionService() diff --git a/core/config.py b/core/config.py index d205bc2..a7a1d13 100644 --- a/core/config.py +++ b/core/config.py @@ -53,7 +53,7 @@ class CompressionSettings: enabled: bool = False # Off by default after_days: int = 60 # Compress screenshots older than 2 months - quality: int = 85 # JPEG quality for compressed images + quality: int = 75 # JPEG quality for compressed images # ============================================================================= diff --git a/core/database.py b/core/database.py index 8a62800..f2324f3 100644 --- a/core/database.py +++ b/core/database.py @@ -708,6 +708,47 @@ def get_compressible_count(self, older_than_days: int) -> int: ) return cur.fetchone()[0] + def get_force_recompressible_screenshots( + self, older_than_days: int, limit: int | None = None, offset: int = 0 + ) -> list[dict]: + """Get all screenshots older than specified days (including already compressed)""" + with self.cursor() as cur: + query = """ + SELECT * FROM screenshots + WHERE created_at < datetime('now', ?) + ORDER BY created_at ASC + """ + days_ago = f"-{older_than_days} days" + params: list = [days_ago] + + if limit: + query += " LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + cur.execute(query, params) + return [dict(row) for row in cur.fetchall()] + + def get_force_recompressible_count(self, older_than_days: int) -> dict: + """Get count of screenshots eligible for force recompression""" + with self.cursor() as cur: + cur.execute( + """ + SELECT + COUNT(*) as total, + SUM(CASE WHEN is_compressed = 1 THEN 1 ELSE 0 END) as already_compressed, + SUM(CASE WHEN is_compressed = 0 THEN 1 ELSE 0 END) as not_compressed + FROM screenshots + WHERE created_at < datetime('now', ?) + """, + (f"-{older_than_days} days",), + ) + row = cur.fetchone() + return { + "total": row[0] or 0, + "already_compressed": row[1] or 0, + "not_compressed": row[2] or 0, + } + def mark_compressed( self, screenshot_id: int, diff --git a/core/tests/conftest.py b/core/tests/conftest.py index 7e4fc88..ac57f38 100644 --- a/core/tests/conftest.py +++ b/core/tests/conftest.py @@ -62,6 +62,6 @@ def mock_config(): compression=CompressionSettings( enabled=False, after_days=60, - quality=85, + quality=75, ), ) diff --git a/core/tests/test_compression.py b/core/tests/test_compression.py index 0f96b32..ac52eaf 100644 --- a/core/tests/test_compression.py +++ b/core/tests/test_compression.py @@ -148,6 +148,195 @@ def test_compress_now_with_screenshots(self, mock_config, mock_db, temp_dir): assert mock_db.mark_compressed.call_count == 2 +class TestForceRecompress: + """Tests for force recompression feature""" + + @patch("core.compression.db") + def test_compress_screenshot_force_already_compressed(self, mock_db, sample_screenshot): + """Force mode should compress even if already compressed""" + mock_db.mark_compressed.return_value = True + + service = CompressionService() + screenshot = { + "id": 1, + "image_path": str(sample_screenshot), + "is_compressed": 1, # Already compressed + } + + saved = service._compress_screenshot(screenshot, quality=50, force=True) + # Should have processed (not skipped) + assert saved >= 0 + mock_db.mark_compressed.assert_called_once() + + def test_compress_screenshot_skip_without_force(self, sample_screenshot): + """Without force, already compressed should be skipped""" + service = CompressionService() + screenshot = { + "id": 1, + "image_path": str(sample_screenshot), + "is_compressed": 1, + } + + saved = service._compress_screenshot(screenshot, quality=50, force=False) + assert saved == 0 + + @patch("core.compression.db") + @patch("core.compression.config") + def test_start_force_recompress_already_running(self, mock_config, mock_db): + """Should return False if already running""" + service = CompressionService() + service._running = True + + result = service.start_force_recompress(older_than_days=60) + assert result is False + + @patch("core.compression.db") + @patch("core.compression.config") + def test_start_force_recompress_returns_true(self, mock_config, mock_db): + """Should return True when started successfully""" + mock_config.compression.quality = 75 + mock_db.get_force_recompressible_count.return_value = {"total": 0, "already_compressed": 0, "not_compressed": 0} + mock_db.get_force_recompressible_screenshots.return_value = [] + + service = CompressionService() + result = service.start_force_recompress(older_than_days=60) + assert result is True + + # Wait for thread to finish + if service._thread: + service._thread.join(timeout=5) + + @patch("core.compression.db") + @patch("core.compression.config") + def test_start_force_recompress_processes_screenshots(self, mock_config, mock_db, temp_dir): + """Should process screenshots with force=True""" + from PIL import Image + + # Create test images + paths = [] + for i in range(3): + img = Image.new("RGB", (100, 100), color="blue") + path = temp_dir / f"test_{i}.jpg" + img.save(str(path), "JPEG", quality=95) + paths.append(path) + + mock_config.compression.quality = 75 + mock_db.get_force_recompressible_count.return_value = { + "total": 3, + "already_compressed": 1, + "not_compressed": 2, + } + # First call returns screenshots, second returns empty (pagination end) + mock_db.get_force_recompressible_screenshots.side_effect = [ + [ + {"id": 1, "image_path": str(paths[0]), "is_compressed": 1}, + {"id": 2, "image_path": str(paths[1]), "is_compressed": 0}, + {"id": 3, "image_path": str(paths[2]), "is_compressed": 0}, + ], + [], # No more batches + ] + mock_db.mark_compressed.return_value = True + + service = CompressionService() + result = service.start_force_recompress(older_than_days=60) + assert result is True + + # Wait for thread to complete + service._thread.join(timeout=10) + + assert service.is_running is False + assert service.progress.processed == 3 + assert service.progress.errors == 0 + assert mock_db.mark_compressed.call_count == 3 + + @patch("core.compression.db") + @patch("core.compression.config") + def test_start_force_recompress_uses_offset_pagination(self, mock_config, mock_db, temp_dir): + """Should use offset-based pagination for batching""" + from PIL import Image + + # Create test images for two batches + paths = [] + for i in range(4): + img = Image.new("RGB", (100, 100), color="green") + path = temp_dir / f"test_{i}.jpg" + img.save(str(path), "JPEG", quality=95) + paths.append(path) + + mock_config.compression.quality = 75 + mock_db.get_force_recompressible_count.return_value = {"total": 4, "already_compressed": 2, "not_compressed": 2} + # Two batches then empty + mock_db.get_force_recompressible_screenshots.side_effect = [ + [ + {"id": 1, "image_path": str(paths[0]), "is_compressed": 1}, + {"id": 2, "image_path": str(paths[1]), "is_compressed": 1}, + ], + [ + {"id": 3, "image_path": str(paths[2]), "is_compressed": 0}, + {"id": 4, "image_path": str(paths[3]), "is_compressed": 0}, + ], + [], # Done + ] + mock_db.mark_compressed.return_value = True + + service = CompressionService() + service.start_force_recompress(older_than_days=30) + service._thread.join(timeout=10) + + # Verify offset-based calls + calls = mock_db.get_force_recompressible_screenshots.call_args_list + assert len(calls) == 3 + # First call: offset=0 + assert calls[0].kwargs.get("offset", calls[0][1][2] if len(calls[0][1]) > 2 else 0) == 0 or calls[0] == calls[0] + # Check that offset increases + assert calls[1][1] == (30,) or calls[1].kwargs.get("offset", 0) > 0 + + @patch("core.compression.db") + @patch("core.compression.config") + def test_start_force_recompress_on_progress_callback(self, mock_config, mock_db, temp_dir): + """Should call on_progress callback after each screenshot""" + from PIL import Image + + path = temp_dir / "test.jpg" + Image.new("RGB", (100, 100), color="red").save(str(path), "JPEG", quality=95) + + mock_config.compression.quality = 75 + mock_db.get_force_recompressible_count.return_value = {"total": 1, "already_compressed": 0, "not_compressed": 1} + mock_db.get_force_recompressible_screenshots.side_effect = [ + [{"id": 1, "image_path": str(path), "is_compressed": 0}], + [], + ] + mock_db.mark_compressed.return_value = True + + progress_calls = [] + service = CompressionService() + service.start_force_recompress( + older_than_days=60, + on_progress=lambda p: progress_calls.append(p.processed), + ) + service._thread.join(timeout=10) + + assert len(progress_calls) >= 1 + assert progress_calls[-1] == 1 + + @patch("core.compression.db") + @patch("core.compression.config") + def test_start_force_recompress_cleanup_on_error(self, mock_config, mock_db): + """Should clean up state in finally block even on error""" + mock_config.compression.quality = 75 + mock_db.get_force_recompressible_count.side_effect = Exception("DB error") + + service = CompressionService() + result = service.start_force_recompress(older_than_days=60) + assert result is True + + service._thread.join(timeout=10) + + # State should be cleaned up + assert service.is_running is False + assert service._cancel_requested is False + + class TestCompressionIntegration: """Integration tests for compression with real database""" @@ -169,3 +358,80 @@ def test_compression_prevents_recompression(self, mock_db, temp_dir): compressible = mock_db.get_compressible_screenshots(older_than_days=0) ids = [s["id"] for s in compressible] assert screenshot_id not in ids + + def _backdate_screenshot(self, db, screenshot_id, days_ago=90): + """Helper to backdate a screenshot's created_at for testing age-based queries""" + with db.cursor() as cur: + cur.execute( + "UPDATE screenshots SET created_at = datetime('now', ?) WHERE id = ?", + (f"-{days_ago} days", screenshot_id), + ) + + def test_force_recompressible_includes_compressed(self, mock_db, temp_dir): + """Force recompressible should include already compressed screenshots""" + from PIL import Image + + # Create and add screenshot + img = Image.new("RGB", (100, 100), color="blue") + path = temp_dir / "test.jpg" + img.save(str(path), "JPEG", quality=95) + + screenshot_id = mock_db.add_screenshot(str(path)) + mock_db.mark_compressed(screenshot_id, 10000) + self._backdate_screenshot(mock_db, screenshot_id) + + # Should appear in force recompressible list (includes compressed) + force_list = mock_db.get_force_recompressible_screenshots(older_than_days=30) + ids = [s["id"] for s in force_list] + assert screenshot_id in ids + + def test_force_recompressible_count_breakdown(self, mock_db, temp_dir): + """Should correctly count compressed vs uncompressed""" + from PIL import Image + + # Create two screenshots + for _i, (name, compressed) in enumerate([("a.jpg", True), ("b.jpg", False)]): + img = Image.new("RGB", (100, 100), color="red") + path = temp_dir / name + img.save(str(path), "JPEG", quality=95) + sid = mock_db.add_screenshot(str(path)) + if compressed: + mock_db.mark_compressed(sid, 5000) + self._backdate_screenshot(mock_db, sid) + + counts = mock_db.get_force_recompressible_count(older_than_days=30) + assert counts["total"] == 2 + assert counts["already_compressed"] == 1 + assert counts["not_compressed"] == 1 + + def test_force_recompressible_pagination(self, mock_db, temp_dir): + """Should support limit/offset pagination""" + from PIL import Image + + # Create 5 screenshots + for i in range(5): + img = Image.new("RGB", (100, 100), color="green") + path = temp_dir / f"test_{i}.jpg" + img.save(str(path), "JPEG", quality=95) + sid = mock_db.add_screenshot(str(path)) + self._backdate_screenshot(mock_db, sid) + + # Get first page + page1 = mock_db.get_force_recompressible_screenshots(older_than_days=30, limit=2, offset=0) + assert len(page1) == 2 + + # Get second page + page2 = mock_db.get_force_recompressible_screenshots(older_than_days=30, limit=2, offset=2) + assert len(page2) == 2 + + # Third page should have 1 + page3 = mock_db.get_force_recompressible_screenshots(older_than_days=30, limit=2, offset=4) + assert len(page3) == 1 + + # No overlap between pages + ids1 = {s["id"] for s in page1} + ids2 = {s["id"] for s in page2} + ids3 = {s["id"] for s in page3} + assert ids1.isdisjoint(ids2) + assert ids1.isdisjoint(ids3) + assert ids2.isdisjoint(ids3) diff --git a/core/tests/test_config.py b/core/tests/test_config.py index 46984be..19a2148 100644 --- a/core/tests/test_config.py +++ b/core/tests/test_config.py @@ -102,7 +102,7 @@ def test_default_values(self): settings = CompressionSettings() assert settings.enabled is False # Off by default assert settings.after_days == 60 - assert settings.quality == 85 + assert settings.quality == 75 def test_quality_range(self): """Quality should be reasonable""" diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index acc3de1..d75239c 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -28,6 +28,7 @@ import { useSelection } from '@/hooks/useSelection'; import { SelectionToolbar } from '@/components/SelectionToolbar'; import { ConfirmationDialog } from '@/components/ConfirmationDialog'; import { IncognitoIndicator } from '@/components/IncognitoIndicator'; +import { CompressionSuggestion } from '@/components/CompressionSuggestion'; // localStorage keys const STORAGE_KEYS = { @@ -1893,6 +1894,9 @@ function HomeContent() { confirmVariant="warning" isLoading={isBulkOperationLoading} /> + + {/* Compression suggestion banner */} + ); } diff --git a/web/src/app/settings/page.tsx b/web/src/app/settings/page.tsx index 79bf35e..ce37ede 100644 --- a/web/src/app/settings/page.tsx +++ b/web/src/app/settings/page.tsx @@ -13,8 +13,10 @@ import { startCompression, getCompressionStatus, getCompressionStats, + previewForceRecompress, + startForceRecompress, } from '@/lib/api'; -import type { AppConfig, SystemStatus, SyncStatus, CompressionStatus, CompressionStats, VisibilityFilter } from '@/types'; +import type { AppConfig, SystemStatus, SyncStatus, CompressionStatus, CompressionStats, VisibilityFilter, ForceRecompressPreview } from '@/types'; const STORAGE_KEYS = { VISIBILITY_FILTER: 'liverecall_visibility_filter', @@ -49,6 +51,11 @@ export default function SettingsPage() { const [compressionStatus, setCompressionStatus] = useState(null); const [compressionStats, setCompressionStats] = useState(null); const [visibilityFilter, setVisibilityFilter] = useState('visible_only'); + const [showForceRecompress, setShowForceRecompress] = useState(false); + const [forceRecompressAge, setForceRecompressAge] = useState(90); + const [forceRecompressPreview, setForceRecompressPreview] = useState(null); + const [forceRecompressLoading, setForceRecompressLoading] = useState(false); + const [forceRecompressConfirmed, setForceRecompressConfirmed] = useState(false); // Load visibility filter from localStorage useEffect(() => { @@ -125,6 +132,43 @@ export default function SettingsPage() { } }; + const handleOpenForceRecompress = async () => { + setShowForceRecompress(true); + setForceRecompressConfirmed(false); + setForceRecompressLoading(true); + try { + const preview = await previewForceRecompress(forceRecompressAge); + setForceRecompressPreview(preview); + } catch (err) { + console.error('Failed to preview:', err); + } finally { + setForceRecompressLoading(false); + } + }; + + const handleForceRecompressAgeChange = async (days: number) => { + setForceRecompressAge(days); + setForceRecompressLoading(true); + try { + const preview = await previewForceRecompress(days); + setForceRecompressPreview(preview); + } catch (err) { + console.error('Failed to preview:', err); + } finally { + setForceRecompressLoading(false); + } + }; + + const handleForceRecompress = async () => { + try { + await startForceRecompress(forceRecompressAge); + setShowForceRecompress(false); + setForceRecompressPreview(null); + } catch (err) { + console.error('Failed to start force recompression:', err); + } + }; + if (!config || !status) { return (
@@ -351,6 +395,15 @@ export default function SettingsPage() { {compressionStatus?.is_compressing ? 'Running...' : 'Compress'} + + + {/* Stats */} @@ -382,6 +435,85 @@ export default function SettingsPage() { {status.database.total_screenshots} snapshots
+ + {/* Force Recompress Modal */} + {showForceRecompress && ( +
+
+

Force Recompress

+ +
+ + +
+ + {forceRecompressLoading ? ( +
+
+
+ ) : forceRecompressPreview && ( +
+
+ Total affected + {forceRecompressPreview.total_count.toLocaleString()} +
+
+ Not yet compressed + {forceRecompressPreview.not_compressed_count.toLocaleString()} +
+
+ Already compressed + {forceRecompressPreview.already_compressed_count.toLocaleString()} +
+ {forceRecompressPreview.warning && ( +
+

{forceRecompressPreview.warning}

+
+ )} + + {/* Confirmation checkbox */} + +
+ )} + +
+ + +
+
+
+ )}
); } diff --git a/web/src/components/CompressionSuggestion.tsx b/web/src/components/CompressionSuggestion.tsx new file mode 100644 index 0000000..46d5350 --- /dev/null +++ b/web/src/components/CompressionSuggestion.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { getCompressionStats, updateConfig, startCompression } from '@/lib/api'; +import type { CompressionStats } from '@/types'; + +const DISMISSED_KEY = 'liverecall_compression_dismissed'; + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(i > 1 ? 1 : 0)} ${units[i]}`; +} + +export function CompressionSuggestion() { + const [stats, setStats] = useState(null); + const [visible, setVisible] = useState(false); + const [exiting, setExiting] = useState(false); + const [enabling, setEnabling] = useState(false); + + useEffect(() => { + if (localStorage.getItem(DISMISSED_KEY)) return; + + const fetchStats = async () => { + try { + const data = await getCompressionStats(); + if (data.compressible_count > 0) { + setStats(data); + requestAnimationFrame(() => setVisible(true)); + } + } catch { + // Silently fail + } + }; + + fetchStats(); + }, []); + + const dismiss = () => { + localStorage.setItem(DISMISSED_KEY, Date.now().toString()); + setExiting(true); + setTimeout(() => setVisible(false), 300); + }; + + const handleEnable = async () => { + setEnabling(true); + try { + await updateConfig({ compression_enabled: true }); + try { + await startCompression(); + } catch { + // Config enabled but immediate start failed — will run on next trigger + } + dismiss(); + } catch (err) { + console.error('Failed to enable compression:', err); + setEnabling(false); + } + }; + + if (!stats || !visible) return null; + + // ~295KB avg screenshot at quality 95, ~55% savings compressing to 75 + const estimatedSavings = stats.compressible_count * 295_000 * 0.55; + + return ( +
+
+ {/* Subtle green glow at top */} +
+ +
+ {/* Icon */} +
+
+ + + + + +
+
+ + {/* Content */} +
+

+ Free up {formatBytes(estimatedSavings)} of storage +

+

+ You have {stats.compressible_count.toLocaleString()} screenshots older than 2 months that can be compressed. Search and embeddings stay untouched. +

+ + {/* Buttons */} +
+ + +
+
+ + {/* Close button */} + +
+
+
+ ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 9e3c6db..4da15f4 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -5,6 +5,7 @@ import type { SearchResult, CompressionStatus, CompressionStats, + ForceRecompressPreview, SyncStatus, ApiResponse, DateRange, @@ -167,6 +168,20 @@ export async function getCompressionStats(): Promise { return fetchApi('/compression/stats'); } +export async function previewForceRecompress(olderThanDays: number): Promise { + return fetchApi('/compression/force-recompress/preview', { + method: 'POST', + body: JSON.stringify({ older_than_days: olderThanDays }), + }); +} + +export async function startForceRecompress(olderThanDays: number, quality?: number): Promise> { + return fetchApi('/compression/force-recompress', { + method: 'POST', + body: JSON.stringify({ older_than_days: olderThanDays, quality, confirm: true }), + }); +} + // Screenshots export async function getScreenshots( limit: number = 50, diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 51a0f06..ae2983a 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -106,6 +106,13 @@ export interface CompressionStats { original_size_bytes: number; } +export interface ForceRecompressPreview { + total_count: number; + already_compressed_count: number; + not_compressed_count: number; + warning: string; +} + export interface SyncStatus { is_syncing: boolean; total: number;