From 3ff34ec9c2df63ec4f5b3e0cec0d8e9f51214538 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 14 Aug 2025 21:36:27 -0400 Subject: [PATCH] switched to tagging and added views to support it, in progress for other tagging functions. --- .gitignore | 1 + app/__init__.py | 12 +- app/main.py | 206 +++++-- app/models.py | 42 +- app/static/js/modal-pagination.js | 97 ++++ app/static/style.css | 506 ++++++++++-------- app/templates/_gallery_grid.html | 42 +- app/templates/layout.html | 7 +- app/templates/tags_list.html | 46 ++ app/utils/image_importer.py | 156 ++++-- ...dd_on_delete_cascade_to_image_tags_set_.py | 138 +++++ ...rop_imagerecord_artist_use_tags_instead.py | 39 ++ 12 files changed, 943 insertions(+), 349 deletions(-) create mode 100644 app/templates/tags_list.html create mode 100644 migrations/versions/486bf4238616_add_on_delete_cascade_to_image_tags_set_.py create mode 100644 migrations/versions/a46e641430b8_drop_imagerecord_artist_use_tags_instead.py diff --git a/.gitignore b/.gitignore index 4f6306f..6eb5949 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Ignore imported and generated image folders images/ import/ +imagerepo/ # Optional: Ignore SQLite DB, Python cache, virtual env, etc. *.db diff --git a/app/__init__.py b/app/__init__.py index b7c253c..0430d32 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -5,12 +5,22 @@ from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate from flask_login import LoginManager from werkzeug.middleware.proxy_fix import ProxyFix - +from sqlalchemy import event +from sqlalchemy.engine import Engine +import sqlite3 db = SQLAlchemy() migrate = Migrate() login_manager = LoginManager() +@event.listens_for(Engine, "connect") +def set_sqlite_pragma(dbapi_connection, connection_record): + # Only apply to SQLite connections + if isinstance(dbapi_connection, sqlite3.Connection): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys = ON") + cursor.close() + def create_app(config_class='config.Config'): app = Flask(__name__) app.config.from_object(config_class) diff --git a/app/main.py b/app/main.py index 28b953a..4437231 100644 --- a/app/main.py +++ b/app/main.py @@ -1,22 +1,23 @@ # app/main.py -from flask import Blueprint, render_template, flash, redirect, url_for, abort, send_from_directory, request +from flask import Blueprint, render_template, flash, redirect, url_for, abort, send_from_directory, request, jsonify from flask_login import login_required, current_user -from sqlalchemy import desc, func +from sqlalchemy import desc, func, text +from sqlalchemy.orm import joinedload from app.utils.image_importer import import_images_task -from app.models import ImageRecord +from app.models import ImageRecord, Tag, ArchiveRecord, image_tags from app import db import os +import shutil main = Blueprint('main', __name__) + @main.route('/') def index(): - from random import choice - from app.models import ImageRecord - + from random import choice # (kept from your original) image = ImageRecord.query.order_by(func.random()).first() background_url = None @@ -27,24 +28,40 @@ def index(): return render_template('index.html', background_url=background_url) + @main.route('/gallery') @login_required def gallery(): + """ + Gallery with pagination and optional tag filter (?tag=artist:NAME or any tag name). + Keeps your pagination shape so modal-pagination.js continues to work. + """ page = request.args.get('page', 1, type=int) per_page = request.args.get('per_page', 35, type=int) + tag_name = request.args.get('tag') + # Base query + eager-load tags (cheap and future-proofs tag chips) + q = ImageRecord.query.options(joinedload(ImageRecord.tags)) - images = ImageRecord.query.order_by( - desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at)) - ).paginate(page=page, per_page=per_page) + # Optional tag filter + if tag_name: + q = q.join(ImageRecord.tags).filter(Tag.name == tag_name) + + images = q.order_by(desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at))) \ + .paginate(page=page, per_page=per_page) + + # Preserve next/prev URLs (carry tag= so modal pagination fetches stay filtered) + next_url = url_for('main.gallery', page=images.next_num, per_page=per_page, tag=tag_name) if images.has_next else '' + prev_url = url_for('main.gallery', page=images.prev_num, per_page=per_page, tag=tag_name) if images.has_prev else '' return render_template( 'gallery.html', images=images, + active_tag=tag_name, has_next=images.has_next, - next_page_url=url_for('main.gallery', page=images.next_num, per_page=per_page) if images.has_next else '', + next_page_url=next_url, has_prev=images.has_prev, - prev_page_url=url_for('main.gallery', page=images.prev_num, per_page=per_page) if images.has_prev else '' + prev_page_url=prev_url ) @@ -52,51 +69,58 @@ def gallery(): def serve_image(filename): return send_from_directory('/images', filename) -@main.route('/artist/') + +@main.route('/tags') @login_required -def artist_gallery(artist_name): - page = request.args.get('page', 1, type=int) - per_page = request.args.get('per_page', 35, type=int) +def tag_list(): + """ + Generic tag explorer: + /tags -> all tags + /tags?kind=user -> only user tags + /tags?kind=artist -> only artist tags + /tags?kind=archive -> only archive tags + Shows a few preview images per tag. + """ + images_per_tag = 3 + kind = request.args.get('kind') # 'artist' | 'archive' | 'user' | None - images = ImageRecord.query.filter_by(artist=artist_name).order_by( - desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at)) - ).paginate(page=page, per_page=per_page) + q = Tag.query + if kind: + q = q.filter_by(kind=kind) - return render_template( - 'artist_gallery.html', - images=images, - artist=artist_name, - has_next=images.has_next, - next_page_url=url_for('main.artist_gallery', artist_name=artist_name, page=images.next_num, per_page=per_page) if images.has_next else '', - has_prev=images.has_prev, - prev_page_url=url_for('main.artist_gallery', artist_name=artist_name, page=images.prev_num, per_page=per_page) if images.has_prev else '' - ) + tags = q.order_by(Tag.name.asc()).all() + tag_data = [] + for t in tags: + imgs = ( + ImageRecord.query + .join(ImageRecord.tags) + .filter(Tag.id == t.id) + .order_by(desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at))) + .limit(images_per_tag) + .all() + ) + # Display label: for artist/archive use the part after ':', else full name + label = t.name.split(":", 1)[1] if (t.kind in ("artist", "archive") and ":" in t.name) else t.name + tag_data.append({"name": label, "tag": t.name, "images": imgs, "kind": t.kind}) + + return render_template('tags_list.html', + tag_data=tag_data, + images_per_tag=images_per_tag, + active_kind=kind) @main.route('/artists') @login_required def artist_list(): - from app.models import ImageRecord - from sqlalchemy import func + # Backward-compatible route that now shows the Tag Explorer filtered to artist tags + return redirect(url_for('main.tag_list', kind='artist')) - # Define how many images to show per artist card - images_per_artist = 3 # Change this value to tweak +@main.route('/artist/') +@login_required +def artist_gallery(artist_name): + # Backward-compatible: go to gallery filtered by artist tag + return redirect(url_for('main.gallery', tag=f"artist:{artist_name}")) - # Get all unique artist names - artists = db.session.query(ImageRecord.artist).distinct().all() - artist_data = [] - - for (artist,) in artists: - images = ( - ImageRecord.query - .filter_by(artist=artist) - .order_by(ImageRecord.taken_at.desc().nullslast(), ImageRecord.imported_at.desc()) - .limit(images_per_artist) - .all() - ) - artist_data.append({'name': artist, 'images': images}) - - return render_template('artist_list.html', artist_data=artist_data, images_per_artist=images_per_artist) @main.route('/settings') @login_required @@ -105,6 +129,7 @@ def settings(): abort(403) return render_template('settings.html') + @main.route('/trigger-thumbnail-generation', methods=['POST']) @login_required def trigger_thumbnail_generation(): @@ -121,6 +146,7 @@ def trigger_thumbnail_generation(): return redirect(url_for('main.settings')) + @main.route('/import-images') @login_required def trigger_image_import(): @@ -129,13 +155,93 @@ def trigger_image_import(): flash("Image import triggered.", "success") return redirect(url_for('main.settings')) + @main.route('/reset-db', methods=['POST']) @login_required def reset_db(): + """ + Safe reset that works with Postgres (TRUNCATE ... CASCADE). + Falls back to ordered deletes if TRUNCATE isn't supported (e.g., SQLite). + """ if not current_user.is_admin: abort(403) - ImageRecord.query.delete() - db.session.commit() - flash("Database has been reset. All image records removed.", "info") - return redirect(url_for('main.settings')) \ No newline at end of file + try: + # Postgres fast path + db.session.execute(text(""" + TRUNCATE TABLE image_tags, archive_record, image_record, tag + RESTART IDENTITY CASCADE + """)) + db.session.commit() + except Exception: + # Fallback: manual delete order + db.session.execute(image_tags.delete()) + ArchiveRecord.query.delete() + ImageRecord.query.delete() + Tag.query.delete() + db.session.commit() + + # Optional: clear thumbs on disk to regenerate fresh + shutil.rmtree("/images/thumbs", ignore_errors=True) + os.makedirs("/images/thumbs", exist_ok=True) + + flash("Database has been reset. All records removed.", "info") + return redirect(url_for('main.settings')) + + +# ---------------------------- +# Tag add/remove endpoints +# ---------------------------- + +@main.get("/image//tags") +@login_required +def list_tags(image_id): + from app.models import ImageRecord + img = ImageRecord.query.get_or_404(image_id) + return jsonify(ok=True, tags=[{"name": t.name, "kind": t.kind} for t in img.tags]) + + +@main.post("/image//tags/add") +@login_required +def add_tag(image_id): + """ + Minimal JSON endpoint to add a tag to an image. + Accepts either 'artist:NAME' / 'archive:...' or a bare freeform tag ('mytag'). + """ + name = (request.form.get("name") or "").strip() + if not name: + abort(400) + + # Normalize: keep explicit kind prefixes, otherwise treat as user tag + if ":" in name: + kind = name.split(":", 1)[0] + tag_name = name + else: + kind = "user" + tag_name = name + + img = ImageRecord.query.get_or_404(image_id) + tag = Tag.query.filter_by(name=tag_name).first() + if not tag: + tag = Tag(name=tag_name, kind=kind) + db.session.add(tag) + db.session.flush() + if tag not in img.tags: + img.tags.append(tag) + db.session.commit() + return jsonify(ok=True, tag={"name": tag.name, "kind": tag.kind}) + + +@main.post("/image//tags/remove") +@login_required +def remove_tag(image_id): + """ + Minimal JSON endpoint to remove a tag from an image. + """ + name = (request.form.get("name") or "").strip() + img = ImageRecord.query.get_or_404(image_id) + tag = Tag.query.filter_by(name=name).first() + if tag and tag in img.tags: + img.tags.remove(tag) + db.session.commit() + return jsonify(ok=True) diff --git a/app/models.py b/app/models.py index f0704b7..83b134f 100644 --- a/app/models.py +++ b/app/models.py @@ -5,10 +5,18 @@ from datetime import datetime # tag to object relationship table image_tags = db.Table( - 'image_tags', - db.Column('image_id', db.Integer, db.ForeignKey('image_record.id'), primary_key=True), - db.Column('tag_id', db.Integer, db.ForeignKey('tag.id'), primary_key=True), - db.UniqueConstraint('image_id', 'tag_id', name='uq_image_tag') # prevent duplicate tags per image + "image_tags", + db.Column( + "image_id", db.Integer, + db.ForeignKey("image_record.id", ondelete="CASCADE"), + primary_key=True, + ), + db.Column( + "tag_id", db.Integer, + db.ForeignKey("tag.id", ondelete="CASCADE"), + primary_key=True, + ), + db.UniqueConstraint("image_id", "tag_id", name="uq_image_tag"), ) class User(UserMixin, db.Model): @@ -19,13 +27,14 @@ class User(UserMixin, db.Model): is_admin = db.Column(db.Boolean, default=False) class ImageRecord(db.Model): + __tablename__ = "image_record" id = db.Column(db.Integer, primary_key=True) filename = db.Column(db.String(255), nullable=False) filepath = db.Column(db.String(512), nullable=False) thumb_path = db.Column(db.String(512)) hash = db.Column(db.String(64), unique=True, nullable=False) # SHA256 perceptual_hash = db.Column(db.String(16), nullable=True) # hex of 64-bit hash - artist = db.Column(db.String(255), nullable=True) + # artist = db.Column(db.String(255), nullable=True) file_size = db.Column(db.Integer, nullable=True) width = db.Column(db.Integer, nullable=True) height = db.Column(db.Integer, nullable=True) @@ -34,22 +43,31 @@ class ImageRecord(db.Model): taken_at = db.Column(db.DateTime, nullable=True) imported_at = db.Column(db.DateTime, default=db.func.now()) is_thumbnail = db.Column(db.Boolean, default=False) - tags = db.relationship('Tag', secondary=image_tags, back_populates='images') + tags = db.relationship( + "Tag", + secondary=image_tags, + back_populates="images", + passive_deletes=True, # <-- let DB handle deletes + ) class Tag(db.Model): + # __tablename__ = "tag" (implicit) id = db.Column(db.Integer, primary_key=True) - # Tag names are unique so we can upsert quickly (e.g. "zip:Artist/ArchiveName.zip") name = db.Column(db.String(255), unique=True, nullable=False) - # Optional β€œkind” if you want to filter by tag type later ("zip", "import-source", etc.) kind = db.Column(db.String(64), nullable=True) - - images = db.relationship('ImageRecord', secondary=image_tags, back_populates='tags') + images = db.relationship( + "ImageRecord", + secondary=image_tags, + back_populates="tags", + passive_deletes=True, + ) class ArchiveRecord(db.Model): """ Tracks archives we've processed so we can skip re-importing the same large file. Also holds a Tag that marks images that came from this archive. """ + __tablename__ = "archive_record" id = db.Column(db.Integer, primary_key=True) filename = db.Column(db.String(512), nullable=False) # full path or logical name file_size = db.Column(db.BigInteger, nullable=False) @@ -60,8 +78,8 @@ class ArchiveRecord(db.Model): artist = db.Column(db.String(255), nullable=True) # Link to the tag we created for this archive so we can reuse it if the same archive returns - tag_id = db.Column(db.Integer, db.ForeignKey('tag.id'), nullable=False) - tag = db.relationship('Tag', backref=db.backref('archives', lazy='dynamic')) + tag_id = db.Column(db.Integer, db.ForeignKey("tag.id", ondelete="SET NULL"), nullable=True) + tag = db.relationship("Tag") @login_manager.user_loader def load_user(user_id): diff --git a/app/static/js/modal-pagination.js b/app/static/js/modal-pagination.js index e5dc2fa..f6a48ac 100644 --- a/app/static/js/modal-pagination.js +++ b/app/static/js/modal-pagination.js @@ -7,6 +7,12 @@ document.addEventListener('DOMContentLoaded', () => { const modalClose = document.getElementById('modalClose'); const modalImageWrapper = document.querySelector('.modal-image-wrapper'); + // NEW: tag editor elements + const tagEditor = document.getElementById('modalTagEditor'); + const tagList = document.getElementById('modalTagList'); + const tagForm = document.getElementById('modalTagForm'); + const tagInput = tagForm ? tagForm.querySelector('input[name="name"]') : null; + let images = Array.from(document.querySelectorAll('.img-clickable')); let currentIndex = -1; let isZoomed = false; @@ -21,9 +27,93 @@ document.addEventListener('DOMContentLoaded', () => { const hasPrevPageInput = document.getElementById('hasPrevPage'); const prevPageUrlInput = document.getElementById('prevPageUrl'); + // --------------------------- + // Tag editor helpers + // --------------------------- + function setEditorImageId(id) { + if (tagEditor) tagEditor.dataset.imageId = id || ''; + } + function getEditorImageId() { + return tagEditor ? tagEditor.dataset.imageId : ''; + } + function renderTags(tags) { + if (!tagList) return; + tagList.innerHTML = ''; + (tags || []).forEach(t => { + const chip = document.createElement('span'); + chip.className = 'tag-chip'; + const label = (t.kind === 'artist') + ? `🎨 ${t.name.split(':', 1)[0] === 'artist' ? t.name.split(':', 2)[1] : t.name}` + : (t.kind === 'archive') + ? `πŸ—œοΈ ${t.name.split(':', 1)[0] === 'archive' ? t.name.split(':', 2)[1] : t.name}` + : `#${t.name}`; + chip.innerHTML = `${label} `; + tagList.appendChild(chip); + }); + } + async function loadTags(imageId) { + if (!imageId) { renderTags([]); return; } + try { + const r = await fetch(`/image/${imageId}/tags`); + const j = await r.json(); + if (j.ok) renderTags(j.tags); + } catch { + // ignore + } + } + async function addTag(imageId, name) { + const fd = new FormData(); + fd.append('name', name); + const r = await fetch(`/image/${imageId}/tags/add`, { method: 'POST', body: fd }); + const j = await r.json(); + if (j.ok) { + await loadTags(imageId); + return true; + } + return false; + } + async function removeTag(imageId, name) { + const fd = new FormData(); + fd.append('name', name); + const r = await fetch(`/image/${imageId}/tags/remove`, { method: 'POST', body: fd }); + const j = await r.json(); + if (j.ok) { + await loadTags(imageId); + return true; + } + return false; + } + + if (tagForm) { + tagForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const id = getEditorImageId(); + const name = (tagInput.value || '').trim(); + if (!id || !name) return; + await addTag(id, name); + tagInput.value = ''; + }); + } + if (tagList) { + tagList.addEventListener('click', async (e) => { + const btn = e.target.closest('button.x'); + if (!btn) return; + const id = getEditorImageId(); + const name = btn.dataset.name; + if (!id || !name) return; + await removeTag(id, name); + }); + } + + // --------------------------- + // Existing viewer logic + // --------------------------- function updateImage(index) { const img = images[index]; const fileType = img.dataset.type || "image"; + const imageId = img.dataset.id || ""; + + // clear wrapper modalImageWrapper.innerHTML = ""; if (fileType === "video") { @@ -45,6 +135,10 @@ document.addEventListener('DOMContentLoaded', () => { currentIndex = index; resetZoom(); + + // NEW: load tags for this image into the modal editor + setEditorImageId(imageId); + loadTags(imageId); } function openModal(index) { @@ -58,6 +152,9 @@ document.addEventListener('DOMContentLoaded', () => { modalImageWrapper.innerHTML = ''; currentIndex = -1; resetZoom(); + // clear tag UI + setEditorImageId(''); + renderTags([]); history.replaceState({}, '', window.location.pathname + window.location.search); } diff --git a/app/static/style.css b/app/static/style.css index 4b179f6..9d244bb 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -1,49 +1,136 @@ -/* /app/static/style.css */ -/* ========== General Layout ========== */ +/* /app/static/style.css +============================================================================== + ImageRepo – Global Styles + - Theme tokens + - Base & layout + - Header / Navbar + - Flash messages + - Pagination + - Gallery + - Modal viewer + - Tag overlays & editor + - Tag/Artist cards (tags_list.html) + - Settings + - Index / Landing + - Forms +==============================================================================*/ + +/*------------------------------------------------------------------------------ + Theme tokens +------------------------------------------------------------------------------*/ +:root { + /* surfaces */ + --bg: #5d6668; + --panel: #1c1c1c; + --panel-alt: rgba(0, 0, 0, .4); + + /* nav */ + --nav-bg: rgba(30, 30, 30, 0.92); + --nav-border: rgba(255, 255, 255, 0.10); + + /* text */ + --text: #ffffff; + --text-dim: #e2e2e2; + + /* accents */ + --link: #d4aeff; + --link-hover: #d4d4d4; + + /* shadows */ + --shadow-1: 0 2px 6px rgba(0, 0, 0, 0.1); + --shadow-2: 0 3px 4px rgba(0, 0, 0, 0.7); + --shadow-3: 0 4px 10px rgba(0, 0, 0, 0.4); + + /* buttons */ + --btn-primary: #0066cc; + --btn-primary-hover: #004d99; + --btn-danger: #cc0033; + --btn-danger-hover: #990024; + + /* flash */ + --flash-info: #3182ce; + --flash-success: #38a169; + --flash-warning: #dd6b20; + --flash-danger: #e53e3e; + + /* filter pills (tags_list) */ + --filter-bg: #f8fafc; /* slate-50 */ + --filter-fg: #0f172a; /* slate-900 */ + --filter-border: #e2e8f0; /* slate-200 */ + --filter-active-bg: #4f46e5; /* indigo-600 */ + --filter-active-fg: #ffffff; + --filter-active-border: #4338ca; /* indigo-700 */ +} + +/* Optional dark-scheme tuning for filter pills */ +@media (prefers-color-scheme: dark) { + :root { + --filter-bg: rgba(255, 255, 255, 0.06); + --filter-fg: #e2e8f0; /* slate-200 */ + --filter-border: rgba(255, 255, 255, 0.12); + --filter-active-bg: #6366f1; /* indigo-500 */ + --filter-active-fg: #0b1020; /* deeper contrast if desired */ + --filter-active-border: #818cf8; + } +} + +/*------------------------------------------------------------------------------ + Base & layout +------------------------------------------------------------------------------*/ +* { box-sizing: border-box; } + body { font-family: Arial, sans-serif; margin: 0; padding: 0 1rem; - background-color: #5d6668; + color: var(--text); + background-color: var(--bg); } +.content { padding: 8px; } + .mainview { - max-width: 1600px; - margin: 0 auto; - background-color: rgba(0,0,0,.4); -} -/* Navbar container */ -.navbar { - display: flex; - background-color: #1e1e1e; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - justify-content: flex-start; - flex-wrap: wrap; - justify-content: center; + max-width: 1600px; + margin: 0 auto; + background-color: var(--panel-alt); +} + +/*------------------------------------------------------------------------------ + Header / Navbar (sticky) +------------------------------------------------------------------------------*/ +header { + position: sticky; + top: 0; + z-index: 1000; +} + +.navbar { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: .25rem .5rem; + background-color: var(--nav-bg); + border-bottom: 1px solid var(--nav-border); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); } -/* Button-style nav links */ .nav-button { display: inline-block; padding: 0.5rem 1rem; - color: white; + color: var(--text); text-decoration: none; border-radius: 5px; font-weight: bold; font-size: 1.5rem; transition: background-color 0.2s ease, transform 0.1s ease; } +.nav-button:hover { background-color: #444; transform: translateY(-1px); } +.nav-button:active { background-color: #1a1a1a; transform: translateY(1px); } -.nav-button:hover { - background-color: #444; - transform: translateY(-1px); -} - -.nav-button:active { - background-color: #1a1a1a; - transform: translateY(1px); -} - +/*------------------------------------------------------------------------------ + Flash messages +------------------------------------------------------------------------------*/ .flash-container { max-width: 800px; margin: 1rem auto; @@ -56,37 +143,46 @@ body { border-radius: 6px; font-weight: 500; color: #fff; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); + box-shadow: var(--shadow-1); } - -.flash-info { - background-color: #3182ce; /* blue */ -} - -.flash-message, .flash-success { - background-color: #38a169; /* green */ -} - -.flash-warning { - background-color: #dd6b20; /* orange */ -} - +.flash-info { background-color: var(--flash-info); } +.flash-message, +.flash-success { background-color: var(--flash-success); } +.flash-warning { background-color: var(--flash-warning); } .flash-danger, -.flash-error { - background-color: #e53e3e; /* red */ +.flash-error { background-color: var(--flash-danger); } + +/*------------------------------------------------------------------------------ + Pagination +------------------------------------------------------------------------------*/ +.pagination-container { + text-align: center; + margin: 2rem 0; } - - -.content { - padding: 8px; +.pagination-link { + display: inline-block; + margin: 0 0.3rem; + padding: 0.5rem 1rem; + border: 1px solid #aaa; + border-radius: 4px; + color: #ffffff; + text-decoration: none; } +.pagination-link.active, +.pagination-link:hover { + background-color: #007BFF; + color: white; + border-color: #007BFF; +} +.pagination-ellipsis { padding: 0 0.5rem; color: #999; } -/* ========== Gallery Layout ========== */ - +/*------------------------------------------------------------------------------ + Gallery +------------------------------------------------------------------------------*/ .image-date { display: block; font-size: 0.8rem; - color: #e2e2e2; + color: var(--text-dim); margin-top: 0.25rem; } @@ -102,16 +198,13 @@ body { flex-direction: column; align-items: center; height: 450px; - background-color: #1c1c1c; - box-shadow: 0 3px 4px rgba(0,0,0,0.7); + background-color: var(--panel); + box-shadow: var(--shadow-2); padding: 8px; border-radius: 8px; transition: transform 0.2s ease; } - -.gallery-item:hover { - transform: scale(1.03); -} +.gallery-item:hover { transform: scale(1.03); } .gallery-thumb { position: relative; @@ -125,58 +218,29 @@ body { } .gallery-item a { - color: #d4aeff; + color: var(--link); font-weight: bold; text-decoration: none; font-size: 0.95rem; } +.gallery-item a:hover { text-decoration: underline; color: var(--link-hover); } -.gallery-item a:hover { - text-decoration: underline; - color: #d4d4d4; -} - +/* video play glyph sits under tags, above image */ .play-overlay { position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - font-size: 3rem; + inset: 0; + display: grid; + place-items: center; color: white; - text-shadow: 0 0 8px rgba(0, 0, 0, 0.8); - opacity: 1; + font-size: 2rem; + text-shadow: 0 1px 4px rgba(0,0,0,.6); + z-index: 1; pointer-events: none; - z-index: 2; } -.pagination-container { - text-align: center; - margin: 2rem 0; -} - -.pagination-link { - display: inline-block; - margin: 0 0.3rem; - padding: 0.5rem 1rem; - border: 1px solid #aaa; - border-radius: 4px; - color: #ffffff; - text-decoration: none; -} - -.pagination-link.active, -.pagination-link:hover { - background-color: #007BFF; - color: white; - border-color: #007BFF; -} - -.pagination-ellipsis { - padding: 0 0.5rem; - color: #999; -} - -/* MODAL STYLES */ +/*------------------------------------------------------------------------------ + Modal viewer +------------------------------------------------------------------------------*/ .modal { display: none; position: fixed; @@ -186,10 +250,7 @@ body { justify-content: center; align-items: center; } - -.modal.active { - display: flex; -} +.modal.active { display: flex; } .modal-content { position: relative; @@ -201,7 +262,6 @@ body { align-items: center; } - /* Modal body */ .modal-body { display: flex; @@ -211,27 +271,17 @@ body { width: 100%; } -/* Image wrapper for tall image logic */ +/* Image wrapper & zoom */ .modal-image-wrapper { max-width: 100%; max-height: 80vh; overflow: hidden; cursor: default; } - -.modal-image-wrapper.zoomed { - overflow: auto; - cursor: grab; -} - +.modal-image-wrapper.zoomed { overflow: auto; cursor: grab; } .modal-image-wrapper.zoomed img { - width: auto; - height: auto; - max-width: none; - max-height: none; + width: auto; height: auto; max-width: none; max-height: none; } - - .modal-image-wrapper img { max-width: 100%; max-height: 80vh; @@ -239,19 +289,13 @@ body { border-radius: 6px; box-shadow: 0 0 10px rgba(0,0,0,0.6); } - -/* Adjust for very tall images */ -.modal-image-wrapper.too-tall { - overflow-y: auto; -} - +/* Tall images */ +.modal-image-wrapper.too-tall { overflow-y: auto; } .modal-image-wrapper.too-tall img { - width: 90vw; - height: auto; - max-height: none; + width: 90vw; height: auto; max-height: none; } -/* Modal buttons */ +/* Modal navigation buttons */ .modal-button { position: absolute; top: 10%; @@ -270,20 +314,52 @@ body { transition: opacity 0.2s; border-radius: 4px; } +.modal-content:hover .modal-button { opacity: 1; } +.modal-prev { left: 0; } +.modal-next { right: 0; } -.modal-content:hover .modal-button { - opacity: 1; +/*------------------------------------------------------------------------------ + Tag overlays & editor +------------------------------------------------------------------------------*/ +/* Pills used inside overlays and editors */ +.tags { + margin-top: .4rem; + display: flex; + flex-wrap: wrap; + gap: .25rem; } -.modal-prev { - left: 0; +.tag-chip { + font: 12px/1.6 system-ui, sans-serif; + padding: 2px 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.92); + color: #334155; + text-decoration: none; + border: 1px solid rgba(199, 210, 254, 0.9); + white-space: nowrap; +} +.tag-chip:hover { background: rgba(255, 255, 255, 1); } + +.tag-editor { margin-top: .5rem; } +.tag-form { margin-top: .4rem; display: flex; gap: .5rem; } +.tag-form input { flex: 1; } + +/* Tag overlay bar at the top of the image */ +.tag-overlay { + position: absolute; + top: 0; left: 0; right: 0; + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 6px; + z-index: 2; + background: linear-gradient(to bottom, rgba(0,0,0,0.45), rgba(0,0,0,0)); } -.modal-next { - right: 0; -} - -/* Artist Styling */ +/*------------------------------------------------------------------------------ + Tag/Artist cards (tags_list.html) +------------------------------------------------------------------------------*/ .artist-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); @@ -291,17 +367,28 @@ body { padding: 1rem; } +.artist-card-link { + text-decoration: none; + color: inherit; + display: block; + transition: transform 0.2s ease; +} +.artist-card-link:hover .artist-card { + transform: scale(1.02); + box-shadow: var(--shadow-3); +} + .artist-card { - background-color: #1c1c1c; + background-color: var(--panel); padding: 1rem; border-radius: 8px; box-shadow: 0 2px 6px rgba(0,0,0,0.2); + transition: transform 0.2s ease; } - .artist-card h3 { margin-bottom: 0.5rem; text-align: center; - color: white; + color: var(--text); } .artist-preview { @@ -311,7 +398,6 @@ body { overflow: hidden; border-radius: 6px; } - .artist-thumb { flex: 1; height: 300px; @@ -320,37 +406,54 @@ body { background-repeat: no-repeat; transition: transform 0.3s ease; } +.artist-thumb:hover { transform: scale(1.05); } -.artist-thumb:hover { - transform: scale(1.05); +/* Filter pills (top of tags_list) */ +.filter-bar { + display: flex; + flex-wrap: wrap; + gap: .5rem; + margin: .75rem 0 1rem; } - -.artist-card-link { +.filter-btn { + display: inline-flex; + align-items: center; + gap: .4rem; + padding: .4rem .8rem; + border-radius: 999px; + border: 1px solid var(--filter-border); + background: var(--filter-bg); + color: var(--filter-fg); text-decoration: none; - color: inherit; - display: block; - transition: transform 0.2s ease; -} - -.artist-card-link:hover .artist-card { - transform: scale(1.02); - box-shadow: 0 4px 10px rgba(0,0,0,0.4); + font: 14px/1.2 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + transition: transform .15s ease, box-shadow .15s ease, background .15s ease; +} +.filter-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0,0,0,.08); +} +.filter-btn.is-active { + background: var(--filter-active-bg); + color: var(--filter-active-fg); + border-color: var(--filter-active-border); +} +.filter-btn:focus-visible { + outline: 2px solid #6366f1; + outline-offset: 2px; } +/*------------------------------------------------------------------------------ + Settings +------------------------------------------------------------------------------*/ .settings-container { max-width: 600px; margin: 2rem auto; padding: 1.5rem; background: #f8f8f8; border-radius: 12px; - box-shadow: 0 2px 10px rgba(0,0,0,0.1); + box-shadow: var(--shadow-1); } - -.settings-section h2 { - margin-bottom: 1rem; - text-align: center; -} - +.settings-section h2 { margin-bottom: 1rem; text-align: center; } .settings-actions { display: flex; flex-direction: column; @@ -366,24 +469,10 @@ body { cursor: pointer; transition: background 0.3s ease; } - -.primary-btn { - background-color: #0066cc; - color: white; -} - -.primary-btn:hover { - background-color: #004d99; -} - -.danger-btn { - background-color: #cc0033; - color: white; -} - -.danger-btn:hover { - background-color: #990024; -} +.primary-btn { background-color: var(--btn-primary); color: #fff; } +.primary-btn:hover { background-color: var(--btn-primary-hover); } +.danger-btn { background-color: var(--btn-danger); color: #fff; } +.danger-btn:hover { background-color: var(--btn-danger-hover); } .settings-info { margin-top: 2rem; @@ -392,21 +481,20 @@ body { text-align: center; } +/*------------------------------------------------------------------------------ + Index / Landing +------------------------------------------------------------------------------*/ .index-hero { position: relative; height: 100vh; background-size: cover; background-position: center; - filter: blur(0px); /* Required to initialize */ + filter: blur(0px); /* initialize */ overflow: hidden; } - .index-overlay { position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; + inset: 0; background-color: rgba(20, 20, 20, 0.5); backdrop-filter: blur(16px); display: flex; @@ -414,60 +502,35 @@ body { justify-content: center; z-index: 1; } - .index-message { text-align: center; color: #fff; padding: 2rem; max-width: 600px; } +.index-message h1 { font-size: 2.5rem; margin-bottom: 1rem; } +.index-message p { font-size: 1.2rem; margin-bottom: 2rem; } +.index-message .btn { font-size: 1.1rem; } -.index-message h1 { - font-size: 2.5rem; - margin-bottom: 1rem; -} - -.index-message p { - font-size: 1.2rem; - margin-bottom: 2rem; -} - -.index-message .btn { - font-size: 1.1rem; -} - -/* Form Styling */ +/*------------------------------------------------------------------------------ + Forms +------------------------------------------------------------------------------*/ .form-container { max-width: 400px; margin: 2rem auto; padding: 2rem; background-color: #ffffffdd; border-radius: 10px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + box-shadow: var(--shadow-1); } - -.form-card { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.form-group { - display: flex; - flex-direction: column; -} - -.form-label { - margin-bottom: 0.5rem; - font-weight: 600; -} - +.form-card { display: flex; flex-direction: column; gap: 1rem; } +.form-group { display: flex; flex-direction: column; } +.form-label { margin-bottom: 0.5rem; font-weight: 600; } .form-input { padding: 0.6rem; border: 1px solid #ccc; border-radius: 5px; } - .form-button { padding: 0.75rem; background-color: #5a67d8; @@ -476,12 +539,5 @@ body { border-radius: 5px; cursor: pointer; } - -.form-button:hover { - background-color: #434190; -} - -.form-footer-text { - margin-top: 1rem; - text-align: center; -} +.form-button:hover { background-color: #434190; } +.form-footer-text { margin-top: 1rem; text-align: center; } diff --git a/app/templates/_gallery_grid.html b/app/templates/_gallery_grid.html index 56c29c6..0efea6a 100644 --- a/app/templates/_gallery_grid.html +++ b/app/templates/_gallery_grid.html @@ -3,21 +3,44 @@ {% for image in images.items %} {% endfor %} + + diff --git a/app/templates/layout.html b/app/templates/layout.html index 8e94661..ce269c5 100644 --- a/app/templates/layout.html +++ b/app/templates/layout.html @@ -8,7 +8,7 @@
-
+
-
- {% with messages = get_flashed_messages(with_categories=true) %} diff --git a/app/templates/tags_list.html b/app/templates/tags_list.html new file mode 100644 index 0000000..ff8a9bd --- /dev/null +++ b/app/templates/tags_list.html @@ -0,0 +1,46 @@ +{% extends "layout.html" %} +{% block title %}{{ 'Artists' if active_kind == 'artist' else 'Tags' }}{% endblock %} + +{% block content %} +

{{ 'Artists' if active_kind == 'artist' else 'Tags' }}

+ + + + + + +{% endblock %} diff --git a/app/utils/image_importer.py b/app/utils/image_importer.py index df49892..9da830d 100644 --- a/app/utils/image_importer.py +++ b/app/utils/image_importer.py @@ -241,37 +241,33 @@ def import_images_task(source_dir: str, dest_dir: str) -> str: return summary -def process_archive(archive_path: str, dest_dir: str, artist: str, existing_phashes) -> int: +def process_archive(archive_path, dest_dir, artist, existing_phashes): """ - Extract archive into a temp dir, import/tag media, and record ArchiveRecord for de-dup. + Extracts archive to temp dir, imports/tag existing images, and records ArchiveRecord. + Creates an archive:* tag ONLY if at least one image was imported or tagged. Returns number of items imported or tagged. """ archive_size = os.path.getsize(archive_path) archive_hash = calculate_hash(archive_path) - # De-dup the archive itself + # Skip if archive already processed existing_archive = ArchiveRecord.query.filter_by(hash=archive_hash).first() if existing_archive: print(f"[SKIP] Archive already imported: {archive_path}") return 0 - # Create/reuse tag for this archive tag_name = f"archive:{artist}/{os.path.basename(archive_path)}" - tag = Tag.query.filter_by(name=tag_name).first() - if not tag: - tag = Tag(name=tag_name, kind="archive") - db.session.add(tag) - db.session.flush() # ensure tag.id + tmpdir = tempfile.mkdtemp(prefix="extract_") - # Create an extraction dir under the configured temp base - tmpdir = tempfile.mkdtemp(prefix="extract_", dir=str(get_tmp_base())) + touched_records = [] # ImageRecord objects we imported or matched imported_or_tagged = 0 + created_tag = None try: - # Resilient extraction (unar β†’ fallback 7z for RAR; 7z β†’ fallback unar for others) - extract_archive_resilient(archive_path, tmpdir) + # Extract using pyunpack + Archive(archive_path).extractall(tmpdir) - # Walk extracted tree and process media files + # Import / collect all media for root, _, files in os.walk(tmpdir): for file in files: if not file.lower().endswith(ALLOWED_MEDIA_EXTS): @@ -281,24 +277,41 @@ def process_archive(archive_path: str, dest_dir: str, artist: str, existing_phas added, _phash, rec = import_single_file( src_path=src_path, dest_dir=dest_dir, - artist=artist, + artist=artist, # still used for auto artist: tag existing_phashes=existing_phashes, - commit=False # batch after loop + commit=False ) - if rec is not None and tag not in rec.tags: - rec.tags.append(tag) - imported_or_tagged += 1 + if rec is not None: + touched_records.append(rec) + if added: + imported_or_tagged += 1 - # Record this archive as processed + # Only now: create/attach the archive tag if we actually touched images + if touched_records: + created_tag = Tag.query.filter_by(name=tag_name).first() + if not created_tag: + created_tag = Tag(name=tag_name, kind='archive') + db.session.add(created_tag) + db.session.flush() + + newly_tagged = 0 + for rec in touched_records: + if created_tag not in rec.tags: + rec.tags.append(created_tag) + newly_tagged += 1 + imported_or_tagged += newly_tagged + + # Record this archive as processed. arch = ArchiveRecord( filename=archive_path, file_size=archive_size, hash=archive_hash, artist=artist, - tag=tag + tag=created_tag # None if we had no media ) db.session.add(arch) db.session.commit() + print(f"[INFO] Archive processed: {archive_path} items={imported_or_tagged}") return imported_or_tagged @@ -307,69 +320,85 @@ def process_archive(archive_path: str, dest_dir: str, artist: str, existing_phas # ============================================================================= -# Core: Single-file import path +# Core: Single-file import path (content-addressed storage) # ============================================================================= +def build_hashed_dest_path(target_dir: str, original_name: str, content_hash: str) -> str: + """ + Always produce a content-addressed destination: + /__ + If that path already exists, append a numeric suffix. + """ + base, ext = os.path.splitext(original_name) + candidate = os.path.join(target_dir, f"{base}__{content_hash[:10]}{ext}") + if not os.path.exists(candidate): + return candidate + + # Rare: collision (e.g., concurrent import) β†’ add counter + i = 2 + while True: + candidate = os.path.join(target_dir, f"{base}__{content_hash[:10]}_{i}{ext}") + if not os.path.exists(candidate): + return candidate + i += 1 + + def import_single_file(src_path: str, dest_dir: str, artist: str, existing_phashes, commit: bool = True): """ Import a single media file. If an equivalent file already exists, return that record so callers can tag it (no duplicate import). Returns (added: bool, phash_or_None, record_or_None). """ - filename = os.path.basename(src_path) + original_name = os.path.basename(src_path) print(f"[INFO] Processing: {src_path}") file_size = os.path.getsize(src_path) - # Check by name + size - existing = ImageRecord.query.filter_by(filename=filename, file_size=file_size).first() - if existing: - print(f"[SKIP] Duplicate by name+size: {filename}") - return (False, None, existing) - - # Check by content hash + # Compute content hash first (authoritative de-dup + used in filepath) file_hash = calculate_hash(src_path) existing = ImageRecord.query.filter_by(hash=file_hash).first() if existing: - print(f"[SKIP] Duplicate by hash: {filename}") + print(f"[SKIP] Duplicate by hash: {original_name}") return (False, None, existing) + # Optional info: same name+size (but different content) β†’ we still import with hashed name + existing_ns = ImageRecord.query.filter_by(filename=original_name, file_size=file_size).first() + if existing_ns: + print(f"[INFO] Same name+size exists but different hash; storing with hashed name: {original_name}") + # Metadata & pHash similarity metadata = extract_metadata(src_path) if not metadata["width"] or not metadata["height"]: - print(f"[SKIP] Missing dimension data for {filename}") + print(f"[SKIP] Missing dimension data for {original_name}") return (False, None, None) phash = calculate_perceptual_hash(src_path) if phash and is_similar_image(phash, metadata["width"], metadata["height"], existing_phashes): - print(f"[SKIP] {filename} is visually similar to an existing larger image.") + print(f"[SKIP] {original_name} is visually similar to an existing larger image.") return (False, phash, None) - # Copy into /images// + # Copy into /images//__ target_dir = os.path.join(dest_dir, artist) os.makedirs(target_dir, exist_ok=True) - dest_path = os.path.join(target_dir, filename) + dest_path = build_hashed_dest_path(target_dir, original_name, file_hash) shutil.copy2(src_path, dest_path) - # Thumbnail + # Thumbnails (mirrored for both images and videos) try: if dest_path.lower().endswith((".mp4", ".mov")): - thumb_filename = f"{uuid.uuid4().hex}.jpg" - thumb_path = os.path.join(dest_dir, "thumbs", thumb_filename) - thumb_path = generate_video_thumbnail(dest_path, thumb_path) + thumb_path = generate_video_thumbnail_mirrored(dest_path) else: thumb_path = generate_thumbnail(dest_path) except Exception as e: - print(f"[WARN] Failed to generate thumbnail for {filename}: {e}") + print(f"[WARN] Failed to generate thumbnail for {original_name}: {e}") thumb_path = None - # Create DB record + # Create DB record (keep display name as original; filepath is content-addressed) record = ImageRecord( - filename=filename, + filename=original_name, filepath=dest_path, thumb_path=thumb_path, hash=file_hash, perceptual_hash=str(phash) if phash else None, - artist=artist, file_size=metadata["file_size"], width=metadata["width"], height=metadata["height"], @@ -378,6 +407,19 @@ def import_single_file(src_path: str, dest_dir: str, artist: str, existing_phash taken_at=metadata["taken_at"], imported_at=datetime.utcnow() ) + + # Auto-tag with artist: (treat artist as a tag only) + if artist: + artist_tag_name = f"artist:{artist}" + artist_tag = Tag.query.filter_by(name=artist_tag_name).first() + if not artist_tag: + artist_tag = Tag(name=artist_tag_name, kind="artist") + db.session.add(artist_tag) + db.session.flush() + if artist_tag not in record.tags: + record.tags.append(artist_tag) + + db.session.add(record) if commit: db.session.commit() @@ -390,13 +432,15 @@ def import_single_file(src_path: str, dest_dir: str, artist: str, existing_phash def generate_thumbnail(image_path: str, size: tuple[int, int] = (400, 400), overwrite: bool = False) -> str: """ - Save thumbnails mirrored under /images/thumbs//. + Save thumbnails mirrored under /images/thumbs//. + (unique_filename includes the hash suffix, so no collisions) """ images_root = Path("/images").resolve() image_path = Path(image_path).resolve() rel_path = image_path.relative_to(images_root) # raises if not under /images thumb_path = images_root / "thumbs" / rel_path + thumb_path = thumb_path.with_suffix(".jpg") if thumb_path.suffix.lower() not in (".jpg", ".jpeg") else thumb_path thumb_path.parent.mkdir(parents=True, exist_ok=True) if not overwrite and thumb_path.exists(): @@ -409,15 +453,23 @@ def generate_thumbnail(image_path: str, size: tuple[int, int] = (400, 400), over return str(thumb_path) -def generate_video_thumbnail(video_path: str, output_path: str, time_position: str = "00:00:01") -> str | None: - os.makedirs(os.path.dirname(output_path), exist_ok=True) - command = [ - "ffmpeg", "-i", video_path, "-ss", time_position, - "-vframes", "1", "-vf", f"scale={THUMB_SIZE[0]}:-1", output_path +def generate_video_thumbnail_mirrored(video_path: str, time_position: str = "00:00:01") -> str | None: + """ + Mirror the stored (hashed) video path under /images/thumbs/... and save as .jpg. + """ + images_root = Path("/images").resolve() + video_path = Path(video_path).resolve() + rel_path = video_path.relative_to(images_root) # raises if not under /images + thumb_path = (images_root / "thumbs" / rel_path).with_suffix(".jpg") + thumb_path.parent.mkdir(parents=True, exist_ok=True) + + cmd = [ + "ffmpeg", "-i", str(video_path), "-ss", time_position, + "-vframes", "1", "-vf", f"scale={THUMB_SIZE[0]}:-1", str(thumb_path) ] try: - subprocess.run(command, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - return output_path + subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return str(thumb_path) except subprocess.CalledProcessError as e: print(f"[WARN] Failed to extract video thumbnail for {video_path}: {e}") return None @@ -544,4 +596,4 @@ def is_first_volume(path: str) -> bool: if name.endswith((".zip", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz")): return True - return True + return True \ No newline at end of file diff --git a/migrations/versions/486bf4238616_add_on_delete_cascade_to_image_tags_set_.py b/migrations/versions/486bf4238616_add_on_delete_cascade_to_image_tags_set_.py new file mode 100644 index 0000000..9053ddb --- /dev/null +++ b/migrations/versions/486bf4238616_add_on_delete_cascade_to_image_tags_set_.py @@ -0,0 +1,138 @@ +"""Add ON DELETE CASCADE to image_tags; SET NULL for ArchiveRecord.tag_id + +Revision ID: 486bf4238616 +Revises: fe4dfdd6616c +Create Date: 2025-08-14 22:41:40.746201 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '486bf4238616' +down_revision = 'fe4dfdd6616c' +branch_labels = None +depends_on = None + + +from alembic import op +import sqlalchemy as sa + +from alembic import op +import sqlalchemy as sa + +# revision identifiers +revision = "486bf4238616" +down_revision = "fe4dfdd6616c" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + dialect = bind.dialect.name + + if dialect == "sqlite": + # --- image_tags: recreate with CASCADE FKs --- + op.create_table( + "image_tags_new", + sa.Column("image_id", sa.Integer(), nullable=False), + sa.Column("tag_id", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("image_id", "tag_id"), + sa.ForeignKeyConstraint(["image_id"], ["image_record.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["tag_id"], ["tag.id"], ondelete="CASCADE"), + sa.UniqueConstraint("image_id", "tag_id", name="uq_image_tag"), + ) + op.execute("INSERT INTO image_tags_new (image_id, tag_id) SELECT image_id, tag_id FROM image_tags") + op.drop_table("image_tags") + op.rename_table("image_tags_new", "image_tags") + + # --- archive_record.tag_id: leave FK as-is on SQLite (dev) --- + # We do not alter ondelete here due to SQLite ALTER limitations. + # Your reset route deletes in safe order, so this is fine for testing. + + else: + # --- Postgres path: drop/recreate FKs normally --- + # image_tags -> ON DELETE CASCADE + op.drop_constraint("image_tags_image_id_fkey", "image_tags", type_="foreignkey") + op.drop_constraint("image_tags_tag_id_fkey", "image_tags", type_="foreignkey") + op.create_foreign_key( + "image_tags_image_id_fkey", + "image_tags", + "image_record", + ["image_id"], + ["id"], + ondelete="CASCADE", + ) + op.create_foreign_key( + "image_tags_tag_id_fkey", + "image_tags", + "tag", + ["tag_id"], + ["id"], + ondelete="CASCADE", + ) + + # archive_record.tag_id -> NULLable + ON DELETE SET NULL + op.alter_column("archive_record", "tag_id", existing_type=sa.Integer(), nullable=True) + op.drop_constraint("archive_record_tag_id_fkey", "archive_record", type_="foreignkey") + op.create_foreign_key( + "archive_record_tag_id_fkey", + "archive_record", + "tag", + ["tag_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade(): + bind = op.get_bind() + dialect = bind.dialect.name + + if dialect == "sqlite": + # revert image_tags to non-cascade FKs (recreate again) + op.create_table( + "image_tags_old", + sa.Column("image_id", sa.Integer(), nullable=False), + sa.Column("tag_id", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("image_id", "tag_id"), + sa.ForeignKeyConstraint(["image_id"], ["image_record.id"]), + sa.ForeignKeyConstraint(["tag_id"], ["tag.id"]), + sa.UniqueConstraint("image_id", "tag_id", name="uq_image_tag"), + ) + op.execute("INSERT INTO image_tags_old (image_id, tag_id) SELECT image_id, tag_id FROM image_tags") + op.drop_table("image_tags") + op.rename_table("image_tags_old", "image_tags") + + # archive_record left unchanged in upgrade, nothing to revert here. + + else: + # Postgres: revert to no-cascade FKs and non-nullable tag_id + op.drop_constraint("archive_record_tag_id_fkey", "archive_record", type_="foreignkey") + op.create_foreign_key( + "archive_record_tag_id_fkey", + "archive_record", + "tag", + ["tag_id"], + ["id"], + ) + op.alter_column("archive_record", "tag_id", existing_type=sa.Integer(), nullable=False) + + op.drop_constraint("image_tags_tag_id_fkey", "image_tags", type_="foreignkey") + op.drop_constraint("image_tags_image_id_fkey", "image_tags", type_="foreignkey") + op.create_foreign_key( + "image_tags_image_id_fkey", + "image_tags", + "image_record", + ["image_id"], + ["id"], + ) + op.create_foreign_key( + "image_tags_tag_id_fkey", + "image_tags", + "tag", + ["tag_id"], + ["id"], + ) diff --git a/migrations/versions/a46e641430b8_drop_imagerecord_artist_use_tags_instead.py b/migrations/versions/a46e641430b8_drop_imagerecord_artist_use_tags_instead.py new file mode 100644 index 0000000..bdc6964 --- /dev/null +++ b/migrations/versions/a46e641430b8_drop_imagerecord_artist_use_tags_instead.py @@ -0,0 +1,39 @@ +"""Drop ImageRecord.artist (use tags instead) + +Revision ID: a46e641430b8 +Revises: 486bf4238616 +Create Date: 2025-08-15 00:52:13.293364 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a46e641430b8' +down_revision = '486bf4238616' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + dialect = bind.dialect.name + + if dialect == "sqlite": + # Recreate table without the 'artist' column (SQLite can't drop columns natively) + with op.batch_alter_table("image_record", recreate="always") as batch_op: + batch_op.drop_column("artist") + else: + # Postgres / others can drop directly + op.drop_column("image_record", "artist") + +def downgrade(): + bind = op.get_bind() + dialect = bind.dialect.name + + if dialect == "sqlite": + with op.batch_alter_table("image_record", recreate="always") as batch_op: + batch_op.add_column(sa.Column("artist", sa.String(length=255), nullable=True)) + else: + op.add_column("image_record", sa.Column("artist", sa.String(length=255), nullable=True))