From 482c5a8eadc587072c4eed0401a1f30e872f537a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 18 Jan 2026 21:57:08 -0500 Subject: [PATCH] import filter work and cleanup --- app/main.py | 149 ++++++- app/static/js/modal-pagination.js | 8 +- app/static/js/showcase.js | 6 +- app/static/style.css | 65 ++- app/templates/_gallery_grid.html | 7 +- app/templates/settings.html | 636 ++++++++++++++++++++++++------ 6 files changed, 725 insertions(+), 146 deletions(-) diff --git a/app/main.py b/app/main.py index 8be0383..ce83867 100644 --- a/app/main.py +++ b/app/main.py @@ -244,24 +244,34 @@ def search_tags(): Query params: - q: search query (matches tag name, case-insensitive) - limit: max results (default 10) + - exclude_kind: comma-separated list of tag kinds to exclude (e.g., 'archive') """ query = request.args.get('q', '').strip().lower() limit = request.args.get('limit', 10, type=int) + exclude_kind = request.args.get('exclude_kind', '').strip() + exclude_kinds = [k.strip() for k in exclude_kind.split(',') if k.strip()] if not query: # Return most-used tags when no query - tags = ( + base_query = ( Tag.query .join(image_tags) .group_by(Tag.id) + ) + if exclude_kinds: + base_query = base_query.filter(~Tag.kind.in_(exclude_kinds)) + tags = ( + base_query .order_by(func.count(image_tags.c.image_id).desc()) .limit(limit) .all() ) else: + base_query = Tag.query.filter(Tag.name.ilike(f'%{query}%')) + if exclude_kinds: + base_query = base_query.filter(~Tag.kind.in_(exclude_kinds)) tags = ( - Tag.query - .filter(Tag.name.ilike(f'%{query}%')) + base_query .order_by(Tag.name) .limit(limit) .all() @@ -571,3 +581,136 @@ def get_import_stats(): return jsonify(ok=True, stats=stats) except Exception as e: return jsonify(ok=False, error=str(e)), 500 + + +# ---------------------------- +# Image filter/cleanup endpoints +# ---------------------------- + +@main.get("/api/filter-scan") +def scan_filterable_images(): + """ + Scan existing images against current filter settings. + Returns counts and IDs of images that would be filtered. + Query params: + - filter_type: 'transparency' or 'dimensions' + - threshold: for transparency (0.0-1.0), or min_width/min_height for dimensions + - min_width: minimum width (for dimensions filter) + - min_height: minimum height (for dimensions filter) + - limit: max results to return (default 100) + """ + from app.utils.image_importer import is_mostly_transparent + + filter_type = request.args.get("filter_type", "transparency") + limit = request.args.get("limit", 100, type=int) + + results = [] + + if filter_type == "transparency": + threshold = request.args.get("threshold", 0.9, type=float) + + # Only check images that could have transparency + images = ImageRecord.query.filter( + ImageRecord.filepath.ilike("%.png") | + ImageRecord.filepath.ilike("%.gif") | + ImageRecord.filepath.ilike("%.webp") + ).limit(limit * 5).all() # Check more to find enough matches + + for img in images: + if len(results) >= limit: + break + if img.filepath and os.path.exists(img.filepath): + try: + if is_mostly_transparent(img.filepath, threshold): + results.append({ + "id": img.id, + "filename": img.filename, + "filepath": img.filepath, + "thumb_url": url_for('main.serve_image', + filename=(img.thumb_path or img.filepath).replace('/images/', '')) + }) + except Exception: + pass + + elif filter_type == "dimensions": + min_width = request.args.get("min_width", 0, type=int) + min_height = request.args.get("min_height", 0, type=int) + + if min_width <= 0 and min_height <= 0: + return jsonify(ok=True, count=0, images=[], message="No dimension filter set") + + query = ImageRecord.query + if min_width > 0: + query = query.filter(ImageRecord.width < min_width) + if min_height > 0: + query = query.filter(ImageRecord.height < min_height) + + images = query.limit(limit).all() + + for img in images: + results.append({ + "id": img.id, + "filename": img.filename, + "filepath": img.filepath, + "width": img.width, + "height": img.height, + "thumb_url": url_for('main.serve_image', + filename=(img.thumb_path or img.filepath).replace('/images/', '')) + }) + + # Get total count (without limit) for progress indication + total_count = len(results) + + return jsonify(ok=True, count=total_count, images=results, filter_type=filter_type) + + +@main.post("/api/filter-delete") +def delete_filtered_images(): + """ + Delete images by their IDs after filter review. + Query params: + - image_ids: comma-separated list of image IDs to delete + - confirm: must be 'true' to actually delete + """ + image_ids_str = request.form.get("image_ids", "") + confirm = request.form.get("confirm") == "true" + + if not confirm: + return jsonify(ok=False, error="Confirmation required"), 400 + + if not image_ids_str: + return jsonify(ok=False, error="No image IDs provided"), 400 + + try: + image_ids = [int(x) for x in image_ids_str.split(",") if x.strip()] + except ValueError: + return jsonify(ok=False, error="Invalid image IDs"), 400 + + deleted_count = 0 + errors = [] + + for img_id in image_ids: + img = ImageRecord.query.get(img_id) + if not img: + continue + + try: + # Remove image file from disk + if img.filepath and os.path.exists(img.filepath): + os.remove(img.filepath) + # Remove thumbnail if exists + if img.thumb_path and os.path.exists(img.thumb_path): + os.remove(img.thumb_path) + # Delete DB record + db.session.delete(img) + deleted_count += 1 + except Exception as e: + errors.append(f"Failed to delete {img.filename}: {e}") + + db.session.commit() + + return jsonify( + ok=True, + deleted_count=deleted_count, + errors=errors if errors else None + ) diff --git a/app/static/js/modal-pagination.js b/app/static/js/modal-pagination.js index 2d47a1c..2fba69b 100644 --- a/app/static/js/modal-pagination.js +++ b/app/static/js/modal-pagination.js @@ -159,8 +159,8 @@ document.addEventListener('DOMContentLoaded', () => { async function fetchAutocomplete(query) { try { const url = query - ? `/api/tags/search?q=${encodeURIComponent(query)}&limit=8` - : `/api/tags/search?limit=8`; + ? `/api/tags/search?q=${encodeURIComponent(query)}&limit=8&exclude_kind=archive` + : `/api/tags/search?limit=8&exclude_kind=archive`; const r = await fetch(url); const j = await r.json(); renderAutocomplete(j.tags || []); @@ -296,6 +296,10 @@ document.addEventListener('DOMContentLoaded', () => { modal.classList.add('active'); updateImage(index); history.replaceState({ modalIndex: index }, '', window.location.href); + // Focus the tag input after modal opens + if (tagInput) { + setTimeout(() => tagInput.focus(), 100); + } } function closeModal() { diff --git a/app/static/js/showcase.js b/app/static/js/showcase.js index 6530896..731d74d 100644 --- a/app/static/js/showcase.js +++ b/app/static/js/showcase.js @@ -133,10 +133,12 @@ } item.appendChild(imgEl); - if (img.tags && img.tags.length > 0) { + // Filter out archive tags from display (they're still visible in modal) + const visibleTags = (img.tags || []).filter(t => t.kind !== 'archive'); + if (visibleTags.length > 0) { const overlay = document.createElement('div'); overlay.className = 'tag-overlay'; - img.tags.forEach(t => { + visibleTags.forEach(t => { const chip = document.createElement('a'); chip.className = 'tag-chip'; chip.href = `/gallery?tag=${encodeURIComponent(t.name)}`; diff --git a/app/static/style.css b/app/static/style.css index 038f6f6..b8fcd12 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -890,17 +890,51 @@ header { /*------------------------------------------------------------------------------ Settings ------------------------------------------------------------------------------*/ +.settings-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + max-width: 1600px; + margin: 1.5rem auto; + padding: 0 1.5rem; + align-items: start; +} +.settings-column { + display: flex; + flex-direction: column; + gap: 1.5rem; +} +@media (max-width: 1200px) { + .settings-grid { + grid-template-columns: repeat(2, 1fr); + } + .settings-column:first-child { + grid-column: 1 / -1; + flex-direction: row; + } + .settings-column:first-child > .settings-container { + flex: 1; + } +} +@media (max-width: 768px) { + .settings-grid { + grid-template-columns: 1fr; + padding: 0 1rem; + } + .settings-column:first-child { + flex-direction: column; + } +} .settings-container { - max-width: 500px; - margin: 2rem auto; - padding: 2rem; + padding: 1.5rem; background: var(--panel); border-radius: 8px; box-shadow: var(--shadow-2); border: 1px solid rgba(255, 255, 255, 0.08); } -.settings-section h2 { - margin-bottom: 1.5rem; +.settings-section h2, +.settings-info h2 { + margin-bottom: 1rem; text-align: center; color: var(--text); font-weight: 500; @@ -933,7 +967,6 @@ header { .danger-btn:hover { background-color: var(--btn-danger-hover); } .settings-info { - margin-top: 2rem; font-size: 0.9rem; color: var(--text-muted); text-align: center; @@ -1101,6 +1134,26 @@ header { border-color: var(--btn-primary); background: rgba(255, 255, 255, 0.12); } + +/* Select dropdowns - ensure dark theme for options */ +select.form-input { + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23ffffff' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2.5rem; +} +select.form-input option, +select.form-input optgroup { + background: #1a1a1a; + color: #ffffff; +} +select.form-input optgroup { + font-weight: 600; + color: #a0a0a0; +} + .form-button { padding: 0.75rem; background-color: var(--btn-primary); diff --git a/app/templates/_gallery_grid.html b/app/templates/_gallery_grid.html index b5938f1..67b8b77 100644 --- a/app/templates/_gallery_grid.html +++ b/app/templates/_gallery_grid.html @@ -10,17 +10,16 @@ - {% if image.tags %} + {% set visible_tags = image.tags | selectattr('kind', 'ne', 'archive') | list %} + {% if visible_tags %}
- {% for t in image.tags %} + {% for t in visible_tags %} {% if t.kind == 'artist' %} 🎨 {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }} - {% elif t.kind == 'archive' %} - 🗜️ {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }} {% elif t.kind == 'character' %} 👤 {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }} {% elif t.kind == 'series' %} diff --git a/app/templates/settings.html b/app/templates/settings.html index 0ebb7e1..16f27de 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -4,136 +4,207 @@ {% block content %}

Settings

-
-
-

Admin Actions

-
-
- -
- -
- -
- -
- -
+
+ +
+
+
+

About These Tools

+

Use these tools to manage your image library. You can trigger imports, configure filters, and clean up unwanted images.

+

Note: Resetting the database does not delete the actual image files from disk.

+
-
-
- -
-
-

Import Filters

-

Configure filters to skip certain images during import.

+
+
+

Admin Actions

+
+
+ +
-
-
- - -
+ + +
-
- - -
- -
- -

Useful for filtering out UI elements, overlays, and buttons.

-
- - - -
- - -

Controls how similar images must be to be considered duplicates. Higher values allow more variations through.

-
- - - - -
-

Last Import Statistics

-
-
- - - Processed -
-
- - - Imported -
-
- - - Filtered (size) -
-
- - - Filtered (transparent) +
+ +
-
- -
-
-

Delete Images by Tag

-

Permanently delete all images associated with a specific tag (e.g., remove an artist's entire collection).

+ +
+
+
+

Import Filters

+

Configure filters to skip certain images during import.

-
-
- - + +
+ + +
+ +
+ + +
+ +
+ +

Useful for filtering out UI elements, overlays, and buttons.

+
+ + + +
+ + +

Controls how similar images must be to be considered duplicates.

+
+ + + + +
+

Last Import Statistics

+
+
+ - + Processed +
+
+ - + Imported +
+
+ - + Filtered (size) +
+
+ - + Filtered (transparent) +
+
+
- - - - - -
- -
- - - +
-
-
-
-

Use these tools to manually trigger an import or reset the image database. Resetting the database does not delete the actual image files.

+ +
+
+
+

Delete Images by Tag

+

Permanently delete all images associated with a specific tag.

+ +
+ +
+
+ + +
+ + + + + +
+ +
+ + +
+
+
+ +
+
+

Clean Up Existing Images

+

Scan and remove images that don't meet filter criteria.

+ +
+
+ + +
+ +
+
+ + +

Images with more than this % transparent pixels will be flagged.

+
+
+ + + + +
+ + +
+
-
+
{% endblock %}