From 4350ade65b66e80ccdeb72238eea582763f54f45 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Sun, 26 Jan 2025 09:59:53 +0100
Subject: [PATCH 001/154] Add a flag to hide instructions in a set card

---
 .env.sample             | 4 ++++
 bricktracker/config.py  | 1 +
 bricktracker/set.py     | 7 +++++--
 templates/set/card.html | 2 +-
 4 files changed, 11 insertions(+), 3 deletions(-)

diff --git a/.env.sample b/.env.sample
index 7cbc5c04..06584dba 100644
--- a/.env.sample
+++ b/.env.sample
@@ -111,6 +111,10 @@
 # Default: false
 # BK_HIDE_MISSING_PARTS=true
 
+# Optional: Hide the 'Instructions' entry in a Set card
+# Default: false
+# BK_HIDE_SET_INSTRUCTIONS=true
+
 # Optional: Hide the 'Wishlist' entry from the menu. Does not disable the route.
 # Default: false
 # BK_HIDE_WISHES=true
diff --git a/bricktracker/config.py b/bricktracker/config.py
index 08db61be..f9d8f2bb 100644
--- a/bricktracker/config.py
+++ b/bricktracker/config.py
@@ -30,6 +30,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
     {'n': 'HIDE_ALL_PARTS', 'c': bool},
     {'n': 'HIDE_ALL_SETS', 'c': bool},
     {'n': 'HIDE_MISSING_PARTS', 'c': bool},
+    {'n': 'HIDE_SET_INSTRUCTIONS', 'c': bool},
     {'n': 'HIDE_WISHES', 'c': bool},
     {'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': '"minifigures"."name" ASC'},
     {'n': 'MINIFIGURES_FOLDER', 'd': 'minifigs', 's': True},
diff --git a/bricktracker/set.py b/bricktracker/set.py
index aa536b8c..f16ea6f6 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -3,7 +3,7 @@ import traceback
 from typing import Any, Self
 from uuid import uuid4
 
-from flask import url_for
+from flask import current_app, url_for
 
 from .exceptions import DatabaseException, NotFoundException
 from .minifigure_list import BrickMinifigureList
@@ -179,7 +179,10 @@ class BrickSet(RebrickableSet):
 
     # Compute the url for the set instructions
     def url_for_instructions(self, /) -> str:
-        if len(self.instructions):
+        if (
+            not current_app.config['HIDE_SET_INSTRUCTIONS'] and
+            len(self.instructions)
+        ):
             return url_for(
                 'set.details',
                 id=self.fields.id,
diff --git a/templates/set/card.html b/templates/set/card.html
index 9fcea3d7..1308c71e 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -35,7 +35,7 @@
       {% endfor %}
     </ul>
   {% endif %}
-  {% if solo %}
+  {% if solo and not config['HIDE_SET_INSTRUCTIONS'] %}
     <div class="accordion accordion-flush border-top" id="set-details">
       {% if not delete %}
         {{ accordion.header('Instructions', 'instructions', 'set-details', expanded=open_instructions, quantity=item.instructions | length, icon='file-line', class='p-0') }}

From 0f53674d8a365bc352b4ad0a47bc1b8614d849d4 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Sun, 26 Jan 2025 10:29:33 +0100
Subject: [PATCH 002/154] Grey out legacy database tables in the admin

---
 bricktracker/sql_counter.py   | 3 +++
 templates/admin/database.html | 4 ++--
 2 files changed, 5 insertions(+), 2 deletions(-)

diff --git a/bricktracker/sql_counter.py b/bricktracker/sql_counter.py
index d104269d..71754941 100644
--- a/bricktracker/sql_counter.py
+++ b/bricktracker/sql_counter.py
@@ -22,6 +22,7 @@ class BrickCounter(object):
     table: str
     icon: str
     count: int
+    legacy: bool
 
     def __init__(
         self,
@@ -44,3 +45,5 @@ class BrickCounter(object):
                 self.name = name
 
             self.icon = icon
+
+        self.legacy = '(legacy)' in self.name
diff --git a/templates/admin/database.html b/templates/admin/database.html
index 36d2b0de..97043653 100644
--- a/templates/admin/database.html
+++ b/templates/admin/database.html
@@ -22,8 +22,8 @@
   <div class="d-flex justify-content-start">
     <ul class="list-group me-2">
       {% for counter in database_counters %}
-        <li class="list-group-item d-flex justify-content-between align-items-start">
-          <span><i class="ri-{{ counter.icon }}"></i> {{ counter.name }}</span> <span class="badge text-bg-primary rounded-pill ms-2">{{ counter.count }}</span>
+        <li class="list-group-item d-flex justify-content-between align-items-start {% if counter.legacy %}list-group-item-dark{% endif %}">
+          <span><i class="ri-{{ counter.icon }}"></i> {{ counter.name }}</span> <span class="badge {% if counter.legacy %}text-bg-light border{% else %}text-bg-primary{% endif %} rounded-pill ms-2">{{ counter.count }}</span>
         </li>
         {% if not (loop.index % 5) %}
           </ul>

From 25aec890a0368196e939b42ffbc742b02d7b2f10 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 27 Jan 2025 10:04:24 +0100
Subject: [PATCH 003/154] Rename download_rebrickable to insert_rebrickable and
 make it return if an insertion occured

---
 bricktracker/rebrickable_set.py | 10 +++++++---
 bricktracker/set.py             |  2 +-
 2 files changed, 8 insertions(+), 4 deletions(-)

diff --git a/bricktracker/rebrickable_set.py b/bricktracker/rebrickable_set.py
index 5a1c41f2..23072d37 100644
--- a/bricktracker/rebrickable_set.py
+++ b/bricktracker/rebrickable_set.py
@@ -52,8 +52,8 @@ class RebrickableSet(BrickRecord):
         if record is not None:
             self.ingest(record)
 
-    # Import the set from Rebrickable
-    def download_rebrickable(self, /) -> None:
+    # Insert the set from Rebrickable
+    def insert_rebrickable(self, /) -> bool:
         # Insert the Rebrickable set to the database
         rows, _ = self.insert(
             commit=False,
@@ -61,10 +61,14 @@ class RebrickableSet(BrickRecord):
             override_query=RebrickableSet.insert_query
         )
 
-        if rows > 0:
+        inserted = rows > 0
+
+        if inserted:
             if not current_app.config['USE_REMOTE_IMAGES']:
                 RebrickableImage(self).download()
 
+        return inserted
+
     # Ingest a set
     def ingest(self, record: Row | dict[str, Any], /):
         super().ingest(record)
diff --git a/bricktracker/set.py b/bricktracker/set.py
index f16ea6f6..cdd56783 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -54,7 +54,7 @@ class BrickSet(RebrickableSet):
             self.insert(commit=False)
 
             # Execute the parent download method
-            self.download_rebrickable()
+            self.insert_rebrickable()
 
             # Load the inventory
             RebrickableParts(self.socket, self).download()

From ee78457e82a986850743b9903bcee0d33c24c9ca Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 27 Jan 2025 10:04:37 +0100
Subject: [PATCH 004/154] Remove unused insert_rebrickable

---
 bricktracker/set.py | 4 ----
 1 file changed, 4 deletions(-)

diff --git a/bricktracker/set.py b/bricktracker/set.py
index cdd56783..3e348e9a 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -97,10 +97,6 @@ class BrickSet(RebrickableSet):
 
             logger.debug(traceback.format_exc())
 
-    # Insert a Rebrickable set
-    def insert_rebrickable(self, /) -> None:
-        self.insert()
-
     # Minifigures
     def minifigures(self, /) -> BrickMinifigureList:
         return BrickMinifigureList().load(self)

From 1afb6f841cf90d01ff70ff8f923f59b7b9a2f37f Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 27 Jan 2025 11:21:15 +0100
Subject: [PATCH 005/154] Rename routes

---
 bricktracker/views/minifigure.py | 10 ++++-----
 bricktracker/views/set.py        | 35 ++++++++++++++------------------
 2 files changed, 20 insertions(+), 25 deletions(-)

diff --git a/bricktracker/views/minifigure.py b/bricktracker/views/minifigure.py
index 29c5822e..60647fab 100644
--- a/bricktracker/views/minifigure.py
+++ b/bricktracker/views/minifigure.py
@@ -19,12 +19,12 @@ def list() -> str:
 
 
 # Minifigure details
-@minifigure_page.route('/<number>/details')
+@minifigure_page.route('/<figure>/details')
 @exception_handler(__file__)
-def details(*, number: str) -> str:
+def details(*, figure: str) -> str:
     return render_template(
         'minifigure.html',
-        item=BrickMinifigure().select_generic(number),
-        using=BrickSetList().using_minifigure(number),
-        missing=BrickSetList().missing_minifigure(number),
+        item=BrickMinifigure().select_generic(figure),
+        using=BrickSetList().using_minifigure(figure),
+        missing=BrickSetList().missing_minifigure(figure),
     )
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index b36f7e13..02353c1c 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -108,33 +108,28 @@ def details(*, id: str) -> str:
 
 
 # Update the missing pieces of a minifig part
-@set_page.route('/<id>/minifigures/<minifigure_id>/parts/<part_id>/missing', methods=['POST'])  # noqa: E501
+@set_page.route('/<id>/minifigures/<figure>/parts/<part>/missing', methods=['POST'])  # noqa: E501
 @login_required
 @exception_handler(__file__, json=True)
-def missing_minifigure_part(
-    *,
-    id: str,
-    minifigure_id: str,
-    part_id: str
-) -> Response:
+def missing_minifigure_part(*, id: str, figure: str, part: str) -> Response:
     brickset = BrickSet().select_specific(id)
-    minifigure = BrickMinifigure().select_specific(brickset, minifigure_id)
-    part = BrickPart().select_specific(
+    brickminifigure = BrickMinifigure().select_specific(brickset, figure)
+    brickpart = BrickPart().select_specific(
         brickset,
-        part_id,
-        minifigure=minifigure,
+        part,
+        minifigure=brickminifigure,
     )
 
     missing = request.json.get('missing', '')  # type: ignore
 
-    part.update_missing(missing)
+    brickpart.update_missing(missing)
 
     # Info
-    logger.info('Set {number} ({id}): updated minifigure ({minifigure}) part ({part}) missing count to {missing}'.format(  # noqa: E501
+    logger.info('Set {number} ({id}): updated minifigure ({figure}) part ({part}) missing count to {missing}'.format(  # noqa: E501
         number=brickset.fields.set,
         id=brickset.fields.id,
-        minifigure=minifigure.fields.fig_num,
-        part=part.fields.id,
+        figure=brickminifigure.fields.fig_num,
+        part=brickpart.fields.id,
         missing=missing,
     ))
 
@@ -142,22 +137,22 @@ def missing_minifigure_part(
 
 
 # Update the missing pieces of a part
-@set_page.route('/<id>/parts/<part_id>/missing', methods=['POST'])
+@set_page.route('/<id>/parts/<part>/missing', methods=['POST'])
 @login_required
 @exception_handler(__file__, json=True)
-def missing_part(*, id: str, part_id: str) -> Response:
+def missing_part(*, id: str, part: str) -> Response:
     brickset = BrickSet().select_specific(id)
-    part = BrickPart().select_specific(brickset, part_id)
+    brickpart = BrickPart().select_specific(brickset, part)
 
     missing = request.json.get('missing', '')  # type: ignore
 
-    part.update_missing(missing)
+    brickpart.update_missing(missing)
 
     # Info
     logger.info('Set {number} ({id}): updated part ({part}) missing count to {missing}'.format(  # noqa: E501
         number=brickset.fields.set,
         id=brickset.fields.id,
-        part=part.fields.id,
+        part=brickpart.fields.id,
         missing=missing,
     ))
 

From bdf635e42785d0eb30838343fe3cce36c8eb1295 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 27 Jan 2025 12:04:20 +0100
Subject: [PATCH 006/154] Remove confusing reference to number for sets

---
 bricktracker/set.py  | 24 ++++++++++++------------
 bricktracker/wish.py |  4 ++--
 2 files changed, 14 insertions(+), 14 deletions(-)

diff --git a/bricktracker/set.py b/bricktracker/set.py
index 3e348e9a..523d336a 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -41,8 +41,8 @@ class BrickSet(RebrickableSet):
         try:
             # Insert into the database
             self.socket.auto_progress(
-                message='Set {number}: inserting into database'.format(
-                    number=self.fields.set
+                message='Set {set}: inserting into database'.format(
+                    set=self.fields.set
                 ),
                 increment_total=True,
             )
@@ -64,8 +64,8 @@ class BrickSet(RebrickableSet):
 
             # Commit the transaction to the database
             self.socket.auto_progress(
-                message='Set {number}: writing to the database'.format(
-                    number=self.fields.set
+                message='Set {set}: writing to the database'.format(
+                    set=self.fields.set
                 ),
                 increment_total=True,
             )
@@ -73,15 +73,15 @@ class BrickSet(RebrickableSet):
             BrickSQL().commit()
 
             # Info
-            logger.info('Set {number}: imported (id: {id})'.format(
-                number=self.fields.set,
+            logger.info('Set {set}: imported (id: {id})'.format(
+                set=self.fields.set,
                 id=self.fields.id,
             ))
 
             # Complete
             self.socket.complete(
-                message='Set {number}: imported (<a href="{url}">Go to the set</a>)'.format(  # noqa: E501
-                    number=self.fields.set,
+                message='Set {set}: imported (<a href="{url}">Go to the set</a>)'.format(  # noqa: E501
+                    set=self.fields.set,
                     url=self.url()
                 ),
                 download=True
@@ -89,8 +89,8 @@ class BrickSet(RebrickableSet):
 
         except Exception as e:
             self.socket.fail(
-                message='Error while importing set {number}: {error}'.format(
-                    number=self.fields.set,
+                message='Error while importing set {set}: {error}'.format(
+                    set=self.fields.set,
                     error=e,
                 )
             )
@@ -155,9 +155,9 @@ class BrickSet(RebrickableSet):
         )
 
         if rows != 1:
-            raise DatabaseException('Could not update the status "{status}" for set {number} ({id})'.format(  # noqa: E501
+            raise DatabaseException('Could not update the status "{status}" for set {set} ({id})'.format(  # noqa: E501
                 status=checkbox.fields.name,
-                number=self.fields.set,
+                set=self.fields.set,
                 id=self.fields.id,
             ))
 
diff --git a/bricktracker/wish.py b/bricktracker/wish.py
index 1e301fa6..def41e28 100644
--- a/bricktracker/wish.py
+++ b/bricktracker/wish.py
@@ -31,8 +31,8 @@ class BrickWish(RebrickableSet):
         # Load from database
         if not self.select():
             raise NotFoundException(
-                'Wish with number {number} was not found in the database'.format(  # noqa: E501
-                    number=self.fields.set,
+                'Wish for set {set} was not found in the database'.format(  # noqa: E501
+                    set=self.fields.set,
                 ),
             )
 

From 900492ae14a093116be593052fb0af4050840cc6 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 27 Jan 2025 14:15:07 +0100
Subject: [PATCH 007/154] Provide decorator for socket actions, for repetitive
 tasks like checking if authenticated or ready for Rebrickable actions

---
 bricktracker/socket.py           | 74 +++----------------------
 bricktracker/socket_decorator.py | 93 ++++++++++++++++++++++++++++++++
 2 files changed, 101 insertions(+), 66 deletions(-)
 create mode 100644 bricktracker/socket_decorator.py

diff --git a/bricktracker/socket.py b/bricktracker/socket.py
index c7215ae7..3fbd6cd5 100644
--- a/bricktracker/socket.py
+++ b/bricktracker/socket.py
@@ -1,14 +1,13 @@
 import logging
 from typing import Any, Final, Tuple
 
-from flask import copy_current_request_context, Flask, request
+from flask import Flask, request
 from flask_socketio import SocketIO
 
-from .configuration_list import BrickConfigurationList
 from .instructions import BrickInstructions
 from .instructions_list import BrickInstructionsList
-from .login import LoginManager
 from .set import BrickSet
+from .socket_decorator import authenticated_socket, rebrickable_socket
 from .sql import close as sql_close
 
 logger = logging.getLogger(__name__)
@@ -87,12 +86,8 @@ class BrickSocket(object):
             self.disconnected()
 
         @self.socket.on(MESSAGES['DOWNLOAD_INSTRUCTIONS'], namespace=self.namespace)  # noqa: E501
+        @authenticated_socket(self)
         def download_instructions(data: dict[str, Any], /) -> None:
-            # Needs to be authenticated
-            if LoginManager.is_not_authenticated():
-                self.fail(message='You need to be authenticated')
-                return
-
             instructions = BrickInstructions(
                 '{name}.pdf'.format(name=data.get('alt', '')),
                 socket=self
@@ -107,71 +102,18 @@ class BrickSocket(object):
             except Exception:
                 pass
 
-            # Start it in a thread if requested
-            if self.threaded:
-                @copy_current_request_context
-                def do_download() -> None:
-                    instructions.download(path)
+            instructions.download(path)
 
-                    BrickInstructionsList(force=True)
-
-                self.socket.start_background_task(do_download)
-            else:
-                instructions.download(path)
-
-                BrickInstructionsList(force=True)
+            BrickInstructionsList(force=True)
 
         @self.socket.on(MESSAGES['IMPORT_SET'], namespace=self.namespace)
+        @rebrickable_socket(self)
         def import_set(data: dict[str, Any], /) -> None:
-            # Needs to be authenticated
-            if LoginManager.is_not_authenticated():
-                self.fail(message='You need to be authenticated')
-                return
-
-            # Needs the Rebrickable API key
-            try:
-                BrickConfigurationList.error_unless_is_set('REBRICKABLE_API_KEY')  # noqa: E501
-            except Exception as e:
-                self.fail(message=str(e))
-                return
-
-            brickset = BrickSet(socket=self)
-
-            # Start it in a thread if requested
-            if self.threaded:
-                @copy_current_request_context
-                def do_download() -> None:
-                    brickset.download(data)
-
-                self.socket.start_background_task(do_download)
-            else:
-                brickset.download(data)
+            BrickSet(socket=self).download(data)
 
         @self.socket.on(MESSAGES['LOAD_SET'], namespace=self.namespace)
         def load_set(data: dict[str, Any], /) -> None:
-            # Needs to be authenticated
-            if LoginManager.is_not_authenticated():
-                self.fail(message='You need to be authenticated')
-                return
-
-            # Needs the Rebrickable API key
-            try:
-                BrickConfigurationList.error_unless_is_set('REBRICKABLE_API_KEY')  # noqa: E501
-            except Exception as e:
-                self.fail(message=str(e))
-                return
-
-            brickset = BrickSet(socket=self)
-
-            # Start it in a thread if requested
-            if self.threaded:
-                @copy_current_request_context
-                def do_load() -> None:
-                    brickset.load(data)
-
-                self.socket.start_background_task(do_load)
-            else:
-                brickset.load(data)
+            BrickSet(socket=self).load(data)
 
     # Update the progress auto-incrementing
     def auto_progress(
diff --git a/bricktracker/socket_decorator.py b/bricktracker/socket_decorator.py
new file mode 100644
index 00000000..331b457c
--- /dev/null
+++ b/bricktracker/socket_decorator.py
@@ -0,0 +1,93 @@
+from functools import wraps
+from threading import Thread
+from typing import Callable, ParamSpec, TYPE_CHECKING, Union
+
+from flask import copy_current_request_context
+
+from .configuration_list import BrickConfigurationList
+from .login import LoginManager
+if TYPE_CHECKING:
+    from .socket import BrickSocket
+
+# What a threaded function can return (None or Thread)
+SocketReturn = Union[None, Thread]
+
+# Threaded signature (*arg, **kwargs -> (None or Thread)
+P = ParamSpec('P')
+SocketCallable = Callable[P, SocketReturn]
+
+
+# Fail if not authenticated
+def authenticated_socket(
+    self: 'BrickSocket',
+    /,
+    *,
+    threaded: bool = True,
+) -> Callable[[SocketCallable], SocketCallable]:
+    def outer(function: SocketCallable, /) -> SocketCallable:
+        @wraps(function)
+        def wrapper(*args, **kwargs) -> SocketReturn:
+            # Needs to be authenticated
+            if LoginManager.is_not_authenticated():
+                self.fail(message='You need to be authenticated')
+                return
+
+            # Apply threading
+            if threaded:
+                return threaded_socket(self)(function)(*args, **kwargs)
+            else:
+                return function(*args, **kwargs)
+
+        return wrapper
+    return outer
+
+
+# Fail if not ready for Rebrickable (authenticated, API key)
+# Automatically makes it threaded
+def rebrickable_socket(
+    self: 'BrickSocket',
+    /,
+    *,
+    threaded: bool = True,
+) -> Callable[[SocketCallable], SocketCallable]:
+    def outer(function: SocketCallable, /) -> SocketCallable:
+        @wraps(function)
+        # Automatically authenticated
+        @authenticated_socket(self, threaded=False)
+        def wrapper(*args, **kwargs) -> SocketReturn:
+            # Needs the Rebrickable API key
+            try:
+                BrickConfigurationList.error_unless_is_set('REBRICKABLE_API_KEY')  # noqa: E501
+            except Exception as e:
+                self.fail(message=str(e))
+                return
+
+            # Apply threading
+            if threaded:
+                return threaded_socket(self)(function)(*args, **kwargs)
+            else:
+                return function(*args, **kwargs)
+
+        return wrapper
+    return outer
+
+
+# Start the function in a thread if the socket is threaded
+def threaded_socket(
+    self: 'BrickSocket',
+    /
+) -> Callable[[SocketCallable], SocketCallable]:
+    def outer(function: SocketCallable, /) -> SocketCallable:
+        @wraps(function)
+        def wrapper(*args, **kwargs) -> SocketReturn:
+            # Start it in a thread if requested
+            if self.threaded:
+                @copy_current_request_context
+                def do_function() -> None:
+                    function(*args, **kwargs)
+
+                return self.socket.start_background_task(do_function)
+            else:
+                return function(*args, **kwargs)
+        return wrapper
+    return outer

From d1325b595cb642c958fd0f81677e9ec166f20dc7 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 27 Jan 2025 14:20:12 +0100
Subject: [PATCH 008/154] Inject the socket only where necessary

---
 bricktracker/rebrickable_set.py | 21 ++++++++-------------
 bricktracker/set.py             | 20 +++++++++++---------
 bricktracker/socket.py          |  4 ++--
 3 files changed, 21 insertions(+), 24 deletions(-)

diff --git a/bricktracker/rebrickable_set.py b/bricktracker/rebrickable_set.py
index 23072d37..1cd4b8d6 100644
--- a/bricktracker/rebrickable_set.py
+++ b/bricktracker/rebrickable_set.py
@@ -21,7 +21,6 @@ logger = logging.getLogger(__name__)
 
 # A set from Rebrickable
 class RebrickableSet(BrickRecord):
-    socket: 'BrickSocket'
     theme: 'BrickTheme'
     instructions: list[BrickInstructions]
 
@@ -36,7 +35,6 @@ class RebrickableSet(BrickRecord):
         self,
         /,
         *,
-        socket: 'BrickSocket | None' = None,
         record: Row | dict[str, Any] | None = None
     ):
         super().__init__()
@@ -44,10 +42,6 @@ class RebrickableSet(BrickRecord):
         # Placeholders
         self.instructions = []
 
-        # Save the socket
-        if socket is not None:
-            self.socket = socket
-
         # Ingest the record if it has one
         if record is not None:
             self.ingest(record)
@@ -92,20 +86,21 @@ class RebrickableSet(BrickRecord):
     # Load the set from Rebrickable
     def load(
         self,
+        socket: 'BrickSocket',
         data: dict[str, Any],
         /,
         *,
         from_download=False,
     ) -> bool:
         # Reset the progress
-        self.socket.progress_count = 0
-        self.socket.progress_total = 2
+        socket.progress_count = 0
+        socket.progress_total = 2
 
         try:
-            self.socket.auto_progress(message='Parsing set number')
+            socket.auto_progress(message='Parsing set number')
             set = parse_set(str(data['set']))
 
-            self.socket.auto_progress(
+            socket.auto_progress(
                 message='Set {set}: loading from Rebrickable'.format(
                     set=set,
                 ),
@@ -122,12 +117,12 @@ class RebrickableSet(BrickRecord):
                 instance=self,
             ).get()
 
-            self.socket.emit('SET_LOADED', self.short(
+            socket.emit('SET_LOADED', self.short(
                 from_download=from_download
             ))
 
             if not from_download:
-                self.socket.complete(
+                socket.complete(
                     message='Set {set}: loaded from Rebrickable'.format(
                         set=self.fields.set
                     )
@@ -136,7 +131,7 @@ class RebrickableSet(BrickRecord):
             return True
 
         except Exception as e:
-            self.socket.fail(
+            socket.fail(
                 message='Could not load the set from Rebrickable: {error}. Data: {data}'.format(  # noqa: E501
                     error=str(e),
                     data=data,
diff --git a/bricktracker/set.py b/bricktracker/set.py
index 523d336a..e521268a 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -1,6 +1,6 @@
 import logging
 import traceback
-from typing import Any, Self
+from typing import Any, Self, TYPE_CHECKING
 from uuid import uuid4
 
 from flask import current_app, url_for
@@ -14,6 +14,8 @@ from .rebrickable_set import RebrickableSet
 from .set_checkbox import BrickSetCheckbox
 from .set_checkbox_list import BrickSetCheckboxList
 from .sql import BrickSQL
+if TYPE_CHECKING:
+    from .socket import BrickSocket
 
 logger = logging.getLogger(__name__)
 
@@ -33,14 +35,14 @@ class BrickSet(RebrickableSet):
         )
 
     # Import a set into the database
-    def download(self, data: dict[str, Any], /) -> None:
+    def download(self, socket: 'BrickSocket', data: dict[str, Any], /) -> None:
         # Load the set
-        if not self.load(data, from_download=True):
+        if not self.load(socket, data, from_download=True):
             return
 
         try:
             # Insert into the database
-            self.socket.auto_progress(
+            socket.auto_progress(
                 message='Set {set}: inserting into database'.format(
                     set=self.fields.set
                 ),
@@ -57,13 +59,13 @@ class BrickSet(RebrickableSet):
             self.insert_rebrickable()
 
             # Load the inventory
-            RebrickableParts(self.socket, self).download()
+            RebrickableParts(socket, self).download()
 
             # Load the minifigures
-            RebrickableMinifigures(self.socket, self).download()
+            RebrickableMinifigureList(socket, self).download()
 
             # Commit the transaction to the database
-            self.socket.auto_progress(
+            socket.auto_progress(
                 message='Set {set}: writing to the database'.format(
                     set=self.fields.set
                 ),
@@ -79,7 +81,7 @@ class BrickSet(RebrickableSet):
             ))
 
             # Complete
-            self.socket.complete(
+            socket.complete(
                 message='Set {set}: imported (<a href="{url}">Go to the set</a>)'.format(  # noqa: E501
                     set=self.fields.set,
                     url=self.url()
@@ -88,7 +90,7 @@ class BrickSet(RebrickableSet):
             )
 
         except Exception as e:
-            self.socket.fail(
+            socket.fail(
                 message='Error while importing set {set}: {error}'.format(
                     set=self.fields.set,
                     error=e,
diff --git a/bricktracker/socket.py b/bricktracker/socket.py
index 3fbd6cd5..7aedaf26 100644
--- a/bricktracker/socket.py
+++ b/bricktracker/socket.py
@@ -109,11 +109,11 @@ class BrickSocket(object):
         @self.socket.on(MESSAGES['IMPORT_SET'], namespace=self.namespace)
         @rebrickable_socket(self)
         def import_set(data: dict[str, Any], /) -> None:
-            BrickSet(socket=self).download(data)
+            BrickSet().download(self, data)
 
         @self.socket.on(MESSAGES['LOAD_SET'], namespace=self.namespace)
         def load_set(data: dict[str, Any], /) -> None:
-            BrickSet(socket=self).load(data)
+            BrickSet().load(self, data)
 
     # Update the progress auto-incrementing
     def auto_progress(

From 1f7a984692ffe17abeabc36c79c22427a56f4ad0 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 27 Jan 2025 17:07:30 +0100
Subject: [PATCH 009/154] Rename load to from_set for clarity

---
 bricktracker/minifigure_list.py | 2 +-
 bricktracker/set.py             | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/bricktracker/minifigure_list.py b/bricktracker/minifigure_list.py
index 04ece731..4b5f1837 100644
--- a/bricktracker/minifigure_list.py
+++ b/bricktracker/minifigure_list.py
@@ -61,7 +61,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
         return self
 
     # Load minifigures from a brickset
-    def load(self, brickset: 'BrickSet', /) -> Self:
+    def from_set(self, brickset: 'BrickSet', /) -> Self:
         # Save the brickset
         self.brickset = brickset
 
diff --git a/bricktracker/set.py b/bricktracker/set.py
index e521268a..17f71b88 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -101,7 +101,7 @@ class BrickSet(RebrickableSet):
 
     # Minifigures
     def minifigures(self, /) -> BrickMinifigureList:
-        return BrickMinifigureList().load(self)
+        return BrickMinifigureList().from_set(self)
 
     # Parts
     def parts(self, /) -> BrickPartList:

From a0fd62b9d25a61025edb9ef94e38707fd1b510cb Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 27 Jan 2025 18:39:35 +0100
Subject: [PATCH 010/154] Deduplicate minifigures

---
 .env.sample                                   |  11 +-
 bricktracker/config.py                        |   4 +-
 bricktracker/minifigure.py                    | 161 ++++++------------
 bricktracker/minifigure_list.py               |  66 +++++--
 bricktracker/part.py                          |   8 +-
 bricktracker/part_list.py                     |   2 +-
 bricktracker/rebrickable_image.py             |  14 +-
 bricktracker/rebrickable_minifigure.py        | 130 ++++++++++++++
 bricktracker/rebrickable_minifigures.py       |  85 ---------
 bricktracker/rebrickable_parts.py             |   2 +-
 bricktracker/set.py                           |   5 +-
 bricktracker/set_list.py                      |  16 +-
 bricktracker/sql/migrations/0007.sql          |  30 ++++
 bricktracker/sql/migrations/0008.sql          |  32 ++++
 .../minifigure/base/{select.sql => base.sql}  |  17 +-
 bricktracker/sql/minifigure/insert.sql        |  20 +--
 bricktracker/sql/minifigure/list/all.sql      |  12 +-
 bricktracker/sql/minifigure/list/from_set.sql |   5 +-
 bricktracker/sql/minifigure/list/last.sql     |  10 +-
 .../sql/minifigure/list/missing_part.sql      |  10 +-
 .../sql/minifigure/list/using_part.sql        |   8 +-
 .../sql/minifigure/select/generic.sql         |  28 +--
 .../sql/minifigure/select/specific.sql        |   7 +-
 bricktracker/sql/part/list/all.sql            |  15 +-
 bricktracker/sql/part/list/missing.sql        |  10 +-
 bricktracker/sql/part/select/generic.sql      |  10 +-
 .../sql/rebrickable/minifigure/insert.sql     |  11 ++
 .../sql/rebrickable/minifigure/list.sql       |   6 +
 .../sql/rebrickable/minifigure/select.sql     |   8 +
 bricktracker/sql/schema/drop.sql              |   3 +
 bricktracker/sql/set/base/full.sql            |  10 +-
 bricktracker/sql/set/delete/set.sql           |   4 +-
 .../sql/set/list/missing_minifigure.sql       |   2 +-
 .../sql/set/list/using_minifigure.sql         |   2 +-
 bricktracker/sql/set/select/full.sql          |   2 +-
 bricktracker/sql_counter.py                   |   4 +-
 bricktracker/views/set.py                     |   2 +-
 templates/minifigure/card.html                |   8 +-
 templates/minifigure/table.html               |   2 +-
 templates/set/card.html                       |   2 +-
 40 files changed, 441 insertions(+), 343 deletions(-)
 create mode 100644 bricktracker/rebrickable_minifigure.py
 delete mode 100644 bricktracker/rebrickable_minifigures.py
 create mode 100644 bricktracker/sql/migrations/0007.sql
 create mode 100644 bricktracker/sql/migrations/0008.sql
 rename bricktracker/sql/minifigure/base/{select.sql => base.sql} (56%)
 create mode 100644 bricktracker/sql/rebrickable/minifigure/insert.sql
 create mode 100644 bricktracker/sql/rebrickable/minifigure/list.sql
 create mode 100644 bricktracker/sql/rebrickable/minifigure/select.sql

diff --git a/.env.sample b/.env.sample
index 06584dba..91caf765 100644
--- a/.env.sample
+++ b/.env.sample
@@ -121,10 +121,11 @@
 
 # Optional: Change the default order of minifigures. By default ordered by insertion order.
 # Useful column names for this option are:
-# - "minifigures"."fig_num": minifigure ID (fig-xxxxx)
-# - "minifigures"."name": minifigure name
-# Default: "minifigures"."name" ASC
-# BK_MINIFIGURES_DEFAULT_ORDER="minifigures"."name" ASC
+# - "rebrickable_minifigures"."figure": minifigure ID (fig-xxxxx)
+# - "rebrickable_minifigures"."number": minifigure ID as an integer (xxxxx)
+# - "rebrickable_minifigures"."name": minifigure name
+# Default: "rebrickable_minifigures"."name" ASC
+# BK_MINIFIGURES_DEFAULT_ORDER="rebrickable_minifigures"."name" ASC
 
 # Optional: Folder where to store the minifigures images, relative to the '/app/static/' folder
 # Default: minifigs
@@ -175,7 +176,7 @@
 # BK_REBRICKABLE_IMAGE_NIL_MINIFIGURE=
 
 # Optional: Pattern of the link to Rebrickable for a minifigure. Will be passed to Python .format()
-# Default: https://rebrickable.com/minifigs/{number}
+# Default: https://rebrickable.com/minifigs/{figure}
 # BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN=
 
 # Optional: Pattern of the link to Rebrickable for a part. Will be passed to Python .format()
diff --git a/bricktracker/config.py b/bricktracker/config.py
index f9d8f2bb..236eb544 100644
--- a/bricktracker/config.py
+++ b/bricktracker/config.py
@@ -32,7 +32,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
     {'n': 'HIDE_MISSING_PARTS', 'c': bool},
     {'n': 'HIDE_SET_INSTRUCTIONS', 'c': bool},
     {'n': 'HIDE_WISHES', 'c': bool},
-    {'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': '"minifigures"."name" ASC'},
+    {'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': '"rebrickable_minifigures"."name" ASC'},  # noqa: E501
     {'n': 'MINIFIGURES_FOLDER', 'd': 'minifigs', 's': True},
     {'n': 'NO_THREADED_SOCKET', 'c': bool},
     {'n': 'PARTS_DEFAULT_ORDER', 'd': '"inventory"."name" ASC, "inventory"."color_name" ASC, "inventory"."is_spare" ASC'},  # noqa: E501
@@ -42,7 +42,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
     {'n': 'REBRICKABLE_API_KEY', 'e': 'REBRICKABLE_API_KEY', 'd': ''},
     {'n': 'REBRICKABLE_IMAGE_NIL', 'd': 'https://rebrickable.com/static/img/nil.png'},  # noqa: E501
     {'n': 'REBRICKABLE_IMAGE_NIL_MINIFIGURE', 'd': 'https://rebrickable.com/static/img/nil_mf.jpg'},  # noqa: E501
-    {'n': 'REBRICKABLE_LINK_MINIFIGURE_PATTERN', 'd': 'https://rebrickable.com/minifigs/{number}'},  # noqa: E501
+    {'n': 'REBRICKABLE_LINK_MINIFIGURE_PATTERN', 'd': 'https://rebrickable.com/minifigs/{figure}'},  # noqa: E501
     {'n': 'REBRICKABLE_LINK_PART_PATTERN', 'd': 'https://rebrickable.com/parts/{number}/_/{color}'},  # noqa: E501
     {'n': 'REBRICKABLE_LINK_INSTRUCTIONS_PATTERN', 'd': 'https://rebrickable.com/instructions/{path}'},  # noqa: E501
     {'n': 'REBRICKABLE_USER_AGENT', 'd': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'},  # noqa: E501
diff --git a/bricktracker/minifigure.py b/bricktracker/minifigure.py
index 0ad55b13..76a482e0 100644
--- a/bricktracker/minifigure.py
+++ b/bricktracker/minifigure.py
@@ -1,48 +1,61 @@
-from sqlite3 import Row
-from typing import Any, Self, TYPE_CHECKING
-
-from flask import current_app, url_for
+import logging
+import traceback
+from typing import Self, TYPE_CHECKING
 
 from .exceptions import ErrorException, NotFoundException
 from .part_list import BrickPartList
-from .rebrickable_image import RebrickableImage
-from .record import BrickRecord
+from .rebrickable_parts import RebrickableParts
+from .rebrickable_minifigure import RebrickableMinifigure
 if TYPE_CHECKING:
     from .set import BrickSet
+    from .socket import BrickSocket
+
+logger = logging.getLogger(__name__)
 
 
 # Lego minifigure
-class BrickMinifigure(BrickRecord):
-    brickset: 'BrickSet | None'
-
+class BrickMinifigure(RebrickableMinifigure):
     # Queries
     insert_query: str = 'minifigure/insert'
     generic_query: str = 'minifigure/select/generic'
     select_query: str = 'minifigure/select/specific'
 
-    def __init__(
-        self,
-        /,
-        *,
-        brickset: 'BrickSet | None' = None,
-        record: Row | dict[str, Any] | None = None,
-    ):
-        super().__init__()
+    def download(self, socket: 'BrickSocket'):
+        if self.brickset is None:
+            raise ErrorException('Importing a minifigure from Rebrickable outside of a set is not supported')  # noqa: E501
 
-        # Save the brickset
-        self.brickset = brickset
+        try:
+            # Insert into the database
+            socket.auto_progress(
+                message='Set {set}: inserting minifigure {figure} into database'.format(  # noqa: E501
+                    set=self.brickset.fields.set,
+                    figure=self.fields.figure
+                )
+            )
 
-        # Ingest the record if it has one
-        if record is not None:
-            self.ingest(record)
+            # Insert into database
+            self.insert(commit=False)
 
-    # Return the number just in digits format
-    def clean_number(self, /) -> str:
-        number: str = self.fields.fig_num
-        number = number.removeprefix('fig-')
-        number = number.lstrip('0')
+            # Insert the rebrickable set into database
+            self.insert_rebrickable()
 
-        return number
+            # Load the inventory
+            RebrickableParts(
+                socket,
+                self.brickset,
+                minifigure=self,
+            ).download()
+
+        except Exception as e:
+            socket.fail(
+                message='Error while importing minifigure {figure} from {set}: {error}'.format(  # noqa: E501
+                    figure=self.fields.figure,
+                    set=self.brickset.fields.set,
+                    error=e,
+                )
+            )
+
+            logger.debug(traceback.format_exc())
 
     # Parts
     def generic_parts(self, /) -> BrickPartList:
@@ -51,108 +64,38 @@ class BrickMinifigure(BrickRecord):
     # Parts
     def parts(self, /) -> BrickPartList:
         if self.brickset is None:
-            raise ErrorException('Part list for minifigure {number} requires a brickset'.format(  # noqa: E501
-                number=self.fields.fig_num,
+            raise ErrorException('Part list for minifigure {figure} requires a brickset'.format(  # noqa: E501
+                figure=self.fields.figure,
             ))
 
         return BrickPartList().load(self.brickset, minifigure=self)
 
     # Select a generic minifigure
-    def select_generic(self, fig_num: str, /) -> Self:
+    def select_generic(self, figure: str, /) -> Self:
         # Save the parameters to the fields
-        self.fields.fig_num = fig_num
+        self.fields.figure = figure
 
         if not self.select(override_query=self.generic_query):
             raise NotFoundException(
-                'Minifigure with number {number} was not found in the database'.format(  # noqa: E501
-                    number=self.fields.fig_num,
+                'Minifigure with figure {figure} was not found in the database'.format(  # noqa: E501
+                    figure=self.fields.figure,
                 ),
             )
 
         return self
 
-    # Select a specific minifigure (with a set and an number)
-    def select_specific(self, brickset: 'BrickSet', fig_num: str, /) -> Self:
+    # Select a specific minifigure (with a set and a figure)
+    def select_specific(self, brickset: 'BrickSet', figure: str, /) -> Self:
         # Save the parameters to the fields
         self.brickset = brickset
-        self.fields.fig_num = fig_num
+        self.fields.figure = figure
 
         if not self.select():
             raise NotFoundException(
-                'Minifigure with number {number} from set {set} was not found in the database'.format(  # noqa: E501
-                    number=self.fields.fig_num,
+                'Minifigure with figure {figure} from set {set} was not found in the database'.format(  # noqa: E501
+                    figure=self.fields.figure,
                     set=self.brickset.fields.set,
                 ),
             )
 
         return self
-
-    # Return a dict with common SQL parameters for a minifigure
-    def sql_parameters(self, /) -> dict[str, Any]:
-        parameters = super().sql_parameters()
-
-        # Supplement from the brickset
-        if self.brickset is not None:
-            if 'u_id' not in parameters:
-                parameters['u_id'] = self.brickset.fields.id
-
-            if 'set_num' not in parameters:
-                parameters['set_num'] = self.brickset.fields.set
-
-        return parameters
-
-    # Self url
-    def url(self, /) -> str:
-        return url_for(
-            'minifigure.details',
-            number=self.fields.fig_num,
-        )
-
-    # Compute the url for minifigure part image
-    def url_for_image(self, /) -> str:
-        if not current_app.config['USE_REMOTE_IMAGES']:
-            if self.fields.set_img_url is None:
-                file = RebrickableImage.nil_minifigure_name()
-            else:
-                file = self.fields.fig_num
-
-            return RebrickableImage.static_url(file, 'MINIFIGURES_FOLDER')
-        else:
-            if self.fields.set_img_url is None:
-                return current_app.config['REBRICKABLE_IMAGE_NIL_MINIFIGURE']
-            else:
-                return self.fields.set_img_url
-
-    # Compute the url for the rebrickable page
-    def url_for_rebrickable(self, /) -> str:
-        if current_app.config['REBRICKABLE_LINKS']:
-            try:
-                return current_app.config['REBRICKABLE_LINK_MINIFIGURE_PATTERN'].format(  # noqa: E501
-                    number=self.fields.fig_num.lower(),
-                )
-            except Exception:
-                pass
-
-        return ''
-
-    # Normalize from Rebrickable
-    @staticmethod
-    def from_rebrickable(
-        data: dict[str, Any],
-        /,
-        *,
-        brickset: 'BrickSet | None' = None,
-        **_,
-    ) -> dict[str, Any]:
-        record = {
-            'fig_num': data['set_num'],
-            'name': data['set_name'],
-            'quantity': data['quantity'],
-            'set_img_url': data['set_img_url'],
-        }
-
-        if brickset is not None:
-            record['set_num'] = brickset.fields.set
-            record['u_id'] = brickset.fields.id
-
-        return record
diff --git a/bricktracker/minifigure_list.py b/bricktracker/minifigure_list.py
index 4b5f1837..81affa6d 100644
--- a/bricktracker/minifigure_list.py
+++ b/bricktracker/minifigure_list.py
@@ -1,11 +1,17 @@
+import logging
+import traceback
 from typing import Any, Self, TYPE_CHECKING
 
 from flask import current_app
 
 from .minifigure import BrickMinifigure
+from .rebrickable import Rebrickable
 from .record_list import BrickRecordList
 if TYPE_CHECKING:
     from .set import BrickSet
+    from .socket import BrickSocket
+
+logger = logging.getLogger(__name__)
 
 
 # Lego minifigures
@@ -47,7 +53,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
         if current_app.config['RANDOM']:
             order = 'RANDOM()'
         else:
-            order = 'minifigures.rowid DESC'
+            order = '"bricktracker_minifigures"."rowid" DESC'
 
         for record in self.select(
             override_query=self.last_query,
@@ -73,16 +79,6 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
 
         return self
 
-    # Return a dict with common SQL parameters for a minifigures list
-    def sql_parameters(self, /) -> dict[str, Any]:
-        parameters: dict[str, Any] = super().sql_parameters()
-
-        if self.brickset is not None:
-            parameters['u_id'] = self.brickset.fields.id
-            parameters['set_num'] = self.brickset.fields.set
-
-        return parameters
-
     # Minifigures missing a part
     def missing_part(
         self,
@@ -132,3 +128,51 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
             self.records.append(minifigure)
 
         return self
+
+    # Return a dict with common SQL parameters for a minifigures list
+    def sql_parameters(self, /) -> dict[str, Any]:
+        parameters: dict[str, Any] = super().sql_parameters()
+
+        if self.brickset is not None:
+            parameters['bricktracker_set_id'] = self.brickset.fields.id
+
+        return parameters
+
+    # Import the minifigures from Rebrickable
+    @staticmethod
+    def download(socket: 'BrickSocket', brickset: 'BrickSet', /) -> None:
+        try:
+            socket.auto_progress(
+                message='Set {set}: loading minifigures from Rebrickable'.format(  # noqa: E501
+                    set=brickset.fields.set,
+                ),
+                increment_total=True,
+            )
+
+            logger.debug('rebrick.lego.get_set_minifigs("{set}")'.format(
+                set=brickset.fields.set,
+            ))
+
+            minifigures = Rebrickable[BrickMinifigure](
+                'get_set_minifigs',
+                brickset.fields.set,
+                BrickMinifigure,
+                socket=socket,
+                brickset=brickset,
+            ).list()
+
+            # Process each minifigure
+            socket.update_total(len(minifigures), add=True)
+
+            for minifigure in minifigures:
+                minifigure.download(socket)
+
+        except Exception as e:
+            socket.fail(
+                message='Error while importing set {set} minifigure list: {error}'.format(  # noqa: E501
+                    set=brickset.fields.set,
+                    error=e,
+                )
+            )
+
+            logger.debug(traceback.format_exc())
diff --git a/bricktracker/part.py b/bricktracker/part.py
index 80a51bd4..b6dc1530 100644
--- a/bricktracker/part.py
+++ b/bricktracker/part.py
@@ -137,7 +137,7 @@ class BrickPart(BrickRecord):
 
         if 'set_num' not in parameters:
             if self.minifigure is not None:
-                parameters['set_num'] = self.minifigure.fields.fig_num
+                parameters['set_num'] = self.minifigure.fields.figure
 
             elif self.brickset is not None:
                 parameters['set_num'] = self.brickset.fields.set
@@ -215,14 +215,14 @@ class BrickPart(BrickRecord):
             return url_for(
                 'set.missing_minifigure_part',
                 id=self.fields.u_id,
-                minifigure_id=self.minifigure.fields.fig_num,
-                part_id=self.fields.id,
+                figure=self.minifigure.fields.figure,
+                part=self.fields.id,
             )
 
         return url_for(
             'set.missing_part',
             id=self.fields.u_id,
-            part_id=self.fields.id
+            part=self.fields.id
         )
 
     # Compute the url for the rebrickable page
diff --git a/bricktracker/part_list.py b/bricktracker/part_list.py
index 93897f8e..7805d57e 100644
--- a/bricktracker/part_list.py
+++ b/bricktracker/part_list.py
@@ -120,7 +120,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
         # Use the minifigure number if present,
         # otherwise use the set number
         if self.minifigure is not None:
-            parameters['set_num'] = self.minifigure.fields.fig_num
+            parameters['set_num'] = self.minifigure.fields.figure
         elif self.brickset is not None:
             parameters['set_num'] = self.brickset.fields.set
 
diff --git a/bricktracker/rebrickable_image.py b/bricktracker/rebrickable_image.py
index 0a0d9f43..f15a9b4c 100644
--- a/bricktracker/rebrickable_image.py
+++ b/bricktracker/rebrickable_image.py
@@ -8,7 +8,7 @@ from shutil import copyfileobj
 
 from .exceptions import DownloadException
 if TYPE_CHECKING:
-    from .minifigure import BrickMinifigure
+    from .rebrickable_minifigure import RebrickableMinifigure
     from .part import BrickPart
     from .rebrickable_set import RebrickableSet
 
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
 # A set, part or minifigure image from Rebrickable
 class RebrickableImage(object):
     set: 'RebrickableSet'
-    minifigure: 'BrickMinifigure | None'
+    minifigure: 'RebrickableMinifigure | None'
     part: 'BrickPart | None'
 
     extension: str | None
@@ -26,7 +26,7 @@ class RebrickableImage(object):
         set: 'RebrickableSet',
         /,
         *,
-        minifigure: 'BrickMinifigure | None' = None,
+        minifigure: 'RebrickableMinifigure | None' = None,
         part: 'BrickPart | None' = None,
     ):
         # Save all objects
@@ -87,10 +87,10 @@ class RebrickableImage(object):
                 return self.part.fields.part_img_url_id
 
         if self.minifigure is not None:
-            if self.minifigure.fields.set_img_url is None:
+            if self.minifigure.fields.image is None:
                 return RebrickableImage.nil_minifigure_name()
             else:
-                return self.minifigure.fields.fig_num
+                return self.minifigure.fields.figure
 
         return self.set.fields.set
 
@@ -111,10 +111,10 @@ class RebrickableImage(object):
                 return self.part.fields.part_img_url
 
         if self.minifigure is not None:
-            if self.minifigure.fields.set_img_url is None:
+            if self.minifigure.fields.image is None:
                 return current_app.config['REBRICKABLE_IMAGE_NIL_MINIFIGURE']
             else:
-                return self.minifigure.fields.set_img_url
+                return self.minifigure.fields.image
 
         return self.set.fields.image
 
diff --git a/bricktracker/rebrickable_minifigure.py b/bricktracker/rebrickable_minifigure.py
new file mode 100644
index 00000000..28d3d754
--- /dev/null
+++ b/bricktracker/rebrickable_minifigure.py
@@ -0,0 +1,130 @@
+import logging
+from sqlite3 import Row
+from typing import Any, TYPE_CHECKING
+
+from flask import current_app, url_for
+
+from .exceptions import ErrorException
+from .rebrickable_image import RebrickableImage
+from .record import BrickRecord
+if TYPE_CHECKING:
+    from .set import BrickSet
+    from .socket import BrickSocket
+
+logger = logging.getLogger(__name__)
+
+
+# A minifigure from Rebrickable
+class RebrickableMinifigure(BrickRecord):
+    socket: 'BrickSocket'
+    brickset: 'BrickSet | None'
+
+    # Queries
+    select_query: str = 'rebrickable/minifigure/select'
+    insert_query: str = 'rebrickable/minifigure/insert'
+
+    def __init__(
+        self,
+        /,
+        *,
+        brickset: 'BrickSet | None' = None,
+        socket: 'BrickSocket | None' = None,
+        record: Row | dict[str, Any] | None = None
+    ):
+        super().__init__()
+
+        # Placeholders
+        self.instructions = []
+
+        # Save the brickset
+        self.brickset = brickset
+
+        # Save the socket
+        if socket is not None:
+            self.socket = socket
+
+        # Ingest the record if it has one
+        if record is not None:
+            self.ingest(record)
+
+    # Insert the minifigure from Rebrickable
+    def insert_rebrickable(self, /) -> bool:
+        if self.brickset is None:
+            raise ErrorException('Importing a minifigure from Rebrickable outside of a set is not supported')  # noqa: E501
+
+        # Insert the Rebrickable minifigure to the database
+        rows, _ = self.insert(
+            commit=False,
+            no_defer=True,
+            override_query=RebrickableMinifigure.insert_query
+        )
+
+        inserted = rows > 0
+
+        if inserted:
+            if not current_app.config['USE_REMOTE_IMAGES']:
+                RebrickableImage(
+                    self.brickset,
+                    minifigure=self,
+                ).download()
+
+        return inserted
+
+    # Return a dict with common SQL parameters for a minifigure
+    def sql_parameters(self, /) -> dict[str, Any]:
+        parameters = super().sql_parameters()
+
+        # Supplement from the brickset
+        if self.brickset is not None:
+            if 'bricktracker_set_id' not in parameters:
+                parameters['bricktracker_set_id'] = self.brickset.fields.id
+
+        return parameters
+
+    # Self url
+    def url(self, /) -> str:
+        return url_for(
+            'minifigure.details',
+            figure=self.fields.figure,
+        )
+
+    # Compute the url for minifigure image
+    def url_for_image(self, /) -> str:
+        if not current_app.config['USE_REMOTE_IMAGES']:
+            if self.fields.image is None:
+                file = RebrickableImage.nil_minifigure_name()
+            else:
+                file = self.fields.figure
+
+            return RebrickableImage.static_url(file, 'MINIFIGURES_FOLDER')
+        else:
+            if self.fields.image is None:
+                return current_app.config['REBRICKABLE_IMAGE_NIL_MINIFIGURE']
+            else:
+                return self.fields.image
+
+    # Compute the url for the rebrickable page
+    def url_for_rebrickable(self, /) -> str:
+        if current_app.config['REBRICKABLE_LINKS']:
+            try:
+                return current_app.config['REBRICKABLE_LINK_MINIFIGURE_PATTERN'].format(  # noqa: E501
+                    number=self.fields.figure,
+                )
+            except Exception:
+                pass
+
+        return ''
+
+    # Normalize from Rebrickable
+    @staticmethod
+    def from_rebrickable(data: dict[str, Any], /, **_) -> dict[str, Any]:
+        # Extracting  number
+        number = int(str(data['set_num'])[5:])
+
+        return {
+            'figure': str(data['set_num']),
+            'number': int(number),
+            'name': str(data['set_name']),
+            'quantity': int(data['quantity']),
+            'image': data['set_img_url'],
+        }
diff --git a/bricktracker/rebrickable_minifigures.py b/bricktracker/rebrickable_minifigures.py
deleted file mode 100644
index eb72e06f..00000000
--- a/bricktracker/rebrickable_minifigures.py
+++ /dev/null
@@ -1,85 +0,0 @@
-import logging
-from typing import TYPE_CHECKING
-
-from flask import current_app
-
-from .minifigure import BrickMinifigure
-from .rebrickable import Rebrickable
-from .rebrickable_image import RebrickableImage
-from .rebrickable_parts import RebrickableParts
-if TYPE_CHECKING:
-    from .set import BrickSet
-    from .socket import BrickSocket
-
-logger = logging.getLogger(__name__)
-
-
-# Minifigures from Rebrickable
-class RebrickableMinifigures(object):
-    socket: 'BrickSocket'
-    brickset: 'BrickSet'
-
-    def __init__(self, socket: 'BrickSocket', brickset: 'BrickSet', /):
-        # Save the socket
-        self.socket = socket
-
-        # Save the objects
-        self.brickset = brickset
-
-    # Import the minifigures from Rebrickable
-    def download(self, /) -> None:
-        self.socket.auto_progress(
-            message='Set {number}: loading minifigures from Rebrickable'.format(  # noqa: E501
-                number=self.brickset.fields.set,
-            ),
-            increment_total=True,
-        )
-
-        logger.debug('rebrick.lego.get_set_minifigs("{set}")'.format(
-            set=self.brickset.fields.set,
-        ))
-
-        minifigures = Rebrickable[BrickMinifigure](
-            'get_set_minifigs',
-            self.brickset.fields.set,
-            BrickMinifigure,
-            socket=self.socket,
-            brickset=self.brickset,
-        ).list()
-
-        # Process each minifigure
-        total = len(minifigures)
-        for index, minifigure in enumerate(minifigures):
-            # Insert into the database
-            self.socket.auto_progress(
-                message='Set {number}: inserting minifigure {current}/{total} into database'.format(  # noqa: E501
-                    number=self.brickset.fields.set,
-                    current=index+1,
-                    total=total,
-                )
-            )
-
-            # Insert into database
-            minifigure.insert(commit=False)
-
-            # Grab the image
-            self.socket.progress(
-                message='Set {number}: downloading minifigure {current}/{total} image'.format(  # noqa: E501
-                    number=self.brickset.fields.set,
-                    current=index+1,
-                    total=total,
-                )
-            )
-
-            if not current_app.config['USE_REMOTE_IMAGES']:
-                RebrickableImage(
-                    self.brickset,
-                    minifigure=minifigure
-                ).download()
-
-            # Load the inventory
-            RebrickableParts(
-                self.socket,
-                self.brickset,
-                minifigure=minifigure,
-            ).download()
diff --git a/bricktracker/rebrickable_parts.py b/bricktracker/rebrickable_parts.py
index 69c42dc6..9fd23412 100644
--- a/bricktracker/rebrickable_parts.py
+++ b/bricktracker/rebrickable_parts.py
@@ -40,7 +40,7 @@ class RebrickableParts(object):
         self.minifigure = minifigure
 
         if self.minifigure is not None:
-            self.number = self.minifigure.fields.fig_num
+            self.number = self.minifigure.fields.figure
             self.kind = 'Minifigure'
             self.method = 'get_minifig_elements'
         else:
diff --git a/bricktracker/set.py b/bricktracker/set.py
index 17f71b88..52a2ed93 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -8,7 +8,6 @@ from flask import current_app, url_for
 from .exceptions import DatabaseException, NotFoundException
 from .minifigure_list import BrickMinifigureList
 from .part_list import BrickPartList
-from .rebrickable_minifigures import RebrickableMinifigures
 from .rebrickable_parts import RebrickableParts
 from .rebrickable_set import RebrickableSet
 from .set_checkbox import BrickSetCheckbox
@@ -55,14 +54,14 @@ class BrickSet(RebrickableSet):
             # Insert into database
             self.insert(commit=False)
 
-            # Execute the parent download method
+            # Insert the rebrickable set into database
             self.insert_rebrickable()
 
             # Load the inventory
             RebrickableParts(socket, self).download()
 
             # Load the minifigures
-            RebrickableMinifigureList(socket, self).download()
+            BrickMinifigureList.download(socket, self)
 
             # Commit the transaction to the database
             socket.auto_progress(
diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py
index 3b229e8e..58ae8ec3 100644
--- a/bricktracker/set_list.py
+++ b/bricktracker/set_list.py
@@ -82,13 +82,9 @@ class BrickSetList(BrickRecordList[BrickSet]):
         return self
 
     # Sets missing a minifigure
-    def missing_minifigure(
-        self,
-        fig_num: str,
-        /
-    ) -> Self:
+    def missing_minifigure(self, figure: str, /) -> Self:
         # Save the parameters to the fields
-        self.fields.fig_num = fig_num
+        self.fields.figure = figure
 
         # Load the sets from the database
         for record in self.select(
@@ -127,13 +123,9 @@ class BrickSetList(BrickRecordList[BrickSet]):
         return self
 
     # Sets using a minifigure
-    def using_minifigure(
-        self,
-        fig_num: str,
-        /
-    ) -> Self:
+    def using_minifigure(self, figure: str, /) -> Self:
         # Save the parameters to the fields
-        self.fields.fig_num = fig_num
+        self.fields.figure = figure
 
         # Load the sets from the database
         for record in self.select(
diff --git a/bricktracker/sql/migrations/0007.sql b/bricktracker/sql/migrations/0007.sql
new file mode 100644
index 00000000..09830c47
--- /dev/null
+++ b/bricktracker/sql/migrations/0007.sql
@@ -0,0 +1,30 @@
+-- description: Creation of the deduplicated table of Rebrickable minifigures
+
+BEGIN TRANSACTION;
+
+-- Create a Rebrickable minifigures table: each unique minifigure imported from Rebrickable
+CREATE TABLE "rebrickable_minifigures" (
+    "figure" TEXT NOT NULL,
+    "number" INTEGER NOT NULL,
+    "name" TEXT NOT NULL,
+    "image" TEXT,
+    PRIMARY KEY("figure")
+);
+
+-- Insert existing sets into the new table
+INSERT INTO "rebrickable_minifigures" (
+    "figure",
+    "number",
+    "name",
+    "image"
+)
+SELECT
+    "minifigures"."fig_num",
+    CAST(SUBSTR("minifigures"."fig_num", 5) AS INTEGER),
+    "minifigures"."name",
+    "minifigures"."set_img_url"
+FROM "minifigures"
+GROUP BY
+    "minifigures"."fig_num";
+
+COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/migrations/0008.sql b/bricktracker/sql/migrations/0008.sql
new file mode 100644
index 00000000..48b905aa
--- /dev/null
+++ b/bricktracker/sql/migrations/0008.sql
@@ -0,0 +1,32 @@
+-- description: Migrate the Bricktracker minifigures
+
+PRAGMA foreign_keys = ON;
+
+BEGIN TRANSACTION;
+
+-- Create a Bricktable minifigures table: an amount of minifigures linked to a Bricktracker set
+CREATE TABLE "bricktracker_minifigures" (
+    "bricktracker_set_id" TEXT NOT NULL,
+    "rebrickable_figure" TEXT NOT NULL,
+    "quantity" INTEGER NOT NULL,
+    PRIMARY KEY("bricktracker_set_id", "rebrickable_figure"),
+    FOREIGN KEY("bricktracker_set_id") REFERENCES "bricktracker_sets"("id"),
+    FOREIGN KEY("rebrickable_figure") REFERENCES "rebrickable_minifigures"("figure")
+);
+
+-- Insert existing sets into the new table
+INSERT INTO "bricktracker_minifigures" (
+    "bricktracker_set_id",
+    "rebrickable_figure",
+    "quantity"
+)
+SELECT
+    "minifigures"."u_id",
+    "minifigures"."fig_num",
+    "minifigures"."quantity"
+FROM "minifigures";
+
+-- Rename the original table (don't delete it yet?)
+ALTER TABLE "minifigures" RENAME TO "minifigures_old";
+
+COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/minifigure/base/select.sql b/bricktracker/sql/minifigure/base/base.sql
similarity index 56%
rename from bricktracker/sql/minifigure/base/select.sql
rename to bricktracker/sql/minifigure/base/base.sql
index 81829987..bfaf10d7 100644
--- a/bricktracker/sql/minifigure/base/select.sql
+++ b/bricktracker/sql/minifigure/base/base.sql
@@ -1,10 +1,10 @@
 SELECT
-    "minifigures"."fig_num",
-    "minifigures"."set_num",
-    "minifigures"."name",
-    "minifigures"."quantity",
-    "minifigures"."set_img_url",
-    "minifigures"."u_id",
+    {% block set %}{% endblock %}
+    "bricktracker_minifigures"."quantity",
+    "rebrickable_minifigures"."figure",
+    "rebrickable_minifigures"."number",
+    "rebrickable_minifigures"."name",
+    "rebrickable_minifigures"."image",
     {% block total_missing %}
     NULL AS "total_missing", -- dummy for order: total_missing
     {% endblock %}
@@ -14,7 +14,10 @@ SELECT
     {% block total_sets %}
     NULL AS "total_sets" -- dummy for order: total_sets
     {% endblock %}
-FROM "minifigures"
+FROM "bricktracker_minifigures"
+
+INNER JOIN "rebrickable_minifigures"
+ON "bricktracker_minifigures"."rebrickable_figure" IS NOT DISTINCT FROM "rebrickable_minifigures"."figure"
 
 {% block join %}{% endblock %}
 
diff --git a/bricktracker/sql/minifigure/insert.sql b/bricktracker/sql/minifigure/insert.sql
index d72a2a3b..cd7c413e 100644
--- a/bricktracker/sql/minifigure/insert.sql
+++ b/bricktracker/sql/minifigure/insert.sql
@@ -1,15 +1,9 @@
-INSERT INTO "minifigures" (
-    "fig_num",
-    "set_num",
-    "name",
-    "quantity",
-    "set_img_url",
-    "u_id"
+INSERT INTO "bricktracker_minifigures" (
+    "bricktracker_set_id",
+    "rebrickable_figure",
+    "quantity"
 ) VALUES (
-    :fig_num,
-    :set_num,
-    :name,
-    :quantity,
-    :set_img_url,
-    :u_id
+    :bricktracker_set_id,
+    :figure,
+    :quantity
 )
diff --git a/bricktracker/sql/minifigure/list/all.sql b/bricktracker/sql/minifigure/list/all.sql
index a00f474f..82e61a24 100644
--- a/bricktracker/sql/minifigure/list/all.sql
+++ b/bricktracker/sql/minifigure/list/all.sql
@@ -1,15 +1,15 @@
-{% extends 'minifigure/base/select.sql' %}
+{% extends 'minifigure/base/base.sql' %}
 
 {% block total_missing %}
 SUM(IFNULL("missing_join"."total", 0)) AS "total_missing",
 {% endblock %}
 
 {% block total_quantity %}
-SUM(IFNULL("minifigures"."quantity", 0)) AS "total_quantity",
+SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_quantity",
 {% endblock %}
 
 {% block total_sets %}
-COUNT("minifigures"."set_num") AS "total_sets"
+COUNT("bricktracker_minifigures"."bricktracker_set_id") AS "total_sets"
 {% endblock %}
 
 {% block join %}
@@ -24,11 +24,11 @@ LEFT JOIN (
         "missing"."set_num",
         "missing"."u_id"
 ) missing_join
-ON "minifigures"."u_id" IS NOT DISTINCT FROM "missing_join"."u_id"
-AND "minifigures"."fig_num" IS NOT DISTINCT FROM "missing_join"."set_num"
+ON "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM "missing_join"."u_id"
+AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "missing_join"."set_num"
 {% endblock %}
 
 {% block group %}
 GROUP BY
-    "minifigures"."fig_num"
+    "rebrickable_minifigures"."figure"
 {% endblock %}
diff --git a/bricktracker/sql/minifigure/list/from_set.sql b/bricktracker/sql/minifigure/list/from_set.sql
index ea2dcbea..65b4e69a 100644
--- a/bricktracker/sql/minifigure/list/from_set.sql
+++ b/bricktracker/sql/minifigure/list/from_set.sql
@@ -1,6 +1,5 @@
-{% extends 'minifigure/base/select.sql' %}
+{% extends 'minifigure/base/base.sql' %}
 
 {% block where %}
-WHERE "minifigures"."u_id" IS NOT DISTINCT FROM :u_id
-AND "minifigures"."set_num" IS NOT DISTINCT FROM :set_num
+WHERE "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM :bricktracker_set_id
 {% endblock %}
diff --git a/bricktracker/sql/minifigure/list/last.sql b/bricktracker/sql/minifigure/list/last.sql
index faf3f402..266b7c07 100644
--- a/bricktracker/sql/minifigure/list/last.sql
+++ b/bricktracker/sql/minifigure/list/last.sql
@@ -1,4 +1,4 @@
-{% extends 'minifigure/base/select.sql' %}
+{% extends 'minifigure/base/base.sql' %}
 
 {% block total_missing %}
 SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
@@ -6,12 +6,12 @@ SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
 
 {% block join %}
 LEFT JOIN "missing"
-ON "minifigures"."fig_num" IS NOT DISTINCT FROM "missing"."set_num"
-AND "minifigures"."u_id" IS NOT DISTINCT FROM "missing"."u_id"
+ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "missing"."set_num"
+AND "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM "missing"."u_id"
 {% endblock %}
 
 {% block group %}
 GROUP BY
-    "minifigures"."fig_num",
-    "minifigures"."u_id"
+    "rebrickable_minifigures"."figure",
+    "bricktracker_minifigures"."bricktracker_set_id"
 {% endblock %}
diff --git a/bricktracker/sql/minifigure/list/missing_part.sql b/bricktracker/sql/minifigure/list/missing_part.sql
index e0bc54d2..660da6df 100644
--- a/bricktracker/sql/minifigure/list/missing_part.sql
+++ b/bricktracker/sql/minifigure/list/missing_part.sql
@@ -1,4 +1,4 @@
-{% extends 'minifigure/base/select.sql' %}
+{% extends 'minifigure/base/base.sql' %}
 
 {% block total_missing %}
 SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
@@ -6,12 +6,12 @@ SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
 
 {% block join %}
 LEFT JOIN "missing"
-ON "minifigures"."fig_num" IS NOT DISTINCT FROM "missing"."set_num"
-AND "minifigures"."u_id" IS NOT DISTINCT FROM "missing"."u_id"
+ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "missing"."set_num"
+AND "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM "missing"."u_id"
 {% endblock %}
 
 {% block where %}
-WHERE "minifigures"."fig_num" IN (
+WHERE "rebrickable_minifigures"."figure" IN (
     SELECT
         "missing"."set_num"
     FROM "missing"
@@ -26,5 +26,5 @@ WHERE "minifigures"."fig_num" IN (
 
 {% block group %}
 GROUP BY
-    "minifigures"."fig_num"
+    "rebrickable_minifigures"."figure"
 {% endblock %}
diff --git a/bricktracker/sql/minifigure/list/using_part.sql b/bricktracker/sql/minifigure/list/using_part.sql
index c40d379b..e701d8d6 100644
--- a/bricktracker/sql/minifigure/list/using_part.sql
+++ b/bricktracker/sql/minifigure/list/using_part.sql
@@ -1,11 +1,11 @@
-{% extends 'minifigure/base/select.sql' %}
+{% extends 'minifigure/base/base.sql' %}
 
 {% block total_quantity %}
-SUM("minifigures"."quantity") AS "total_quantity",
+SUM("bricktracker_minifigures"."quantity") AS "total_quantity",
 {% endblock %}
 
 {% block where %}
-WHERE "minifigures"."fig_num" IN (
+WHERE "rebrickable_minifigures"."figure" IN (
     SELECT
         "inventory"."set_num"
     FROM "inventory"
@@ -20,5 +20,5 @@ WHERE "minifigures"."fig_num" IN (
 
 {% block group %}
 GROUP BY
-    "minifigures"."fig_num"
+    "rebrickable_minifigures"."figure"
 {% endblock %}
diff --git a/bricktracker/sql/minifigure/select/generic.sql b/bricktracker/sql/minifigure/select/generic.sql
index 114810d2..966c0221 100644
--- a/bricktracker/sql/minifigure/select/generic.sql
+++ b/bricktracker/sql/minifigure/select/generic.sql
@@ -1,38 +1,28 @@
-{% extends 'minifigure/base/select.sql' %}
+{% extends 'minifigure/base/base.sql' %}
 
 {% block total_missing %}
-SUM(IFNULL("missing_join"."total", 0)) AS "total_missing",
+SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
 {% endblock %}
 
 {% block total_quantity %}
-SUM(IFNULL("minifigures"."quantity", 0)) AS "total_quantity",
+SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_quantity",
 {% endblock %}
 
 {% block total_sets %}
-COUNT("minifigures"."set_num") AS "total_sets"
+COUNT(DISTINCT "bricktracker_minifigures"."bricktracker_set_id") AS "total_sets"
 {% endblock %}
 
 {% block join %}
--- LEFT JOIN + SELECT to avoid messing the total
-LEFT JOIN (
-    SELECT
-        "missing"."set_num",
-        "missing"."u_id",
-        SUM("missing"."quantity") AS "total"
-    FROM "missing"
-    GROUP BY
-        "missing"."set_num",
-        "missing"."u_id"
-) "missing_join"
-ON "minifigures"."u_id" IS NOT DISTINCT FROM "missing_join"."u_id"
-AND "minifigures"."fig_num" IS NOT DISTINCT FROM "missing_join"."set_num"
+LEFT JOIN "missing"
+ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "missing"."set_num"
+AND "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM "missing"."u_id"
 {% endblock %}
 
 {% block where %}
-WHERE "minifigures"."fig_num" IS NOT DISTINCT FROM :fig_num
+WHERE "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM :figure
 {% endblock %}
 
 {% block group %}
 GROUP BY
-    "minifigures"."fig_num"
+    "rebrickable_minifigures"."figure"
 {% endblock %}
diff --git a/bricktracker/sql/minifigure/select/specific.sql b/bricktracker/sql/minifigure/select/specific.sql
index 34a8b3dd..479c9e51 100644
--- a/bricktracker/sql/minifigure/select/specific.sql
+++ b/bricktracker/sql/minifigure/select/specific.sql
@@ -1,7 +1,6 @@
-{% extends 'minifigure/base/select.sql' %}
+{% extends 'minifigure/base/base.sql' %}
 
 {% block where %}
-WHERE "minifigures"."fig_num" IS NOT DISTINCT FROM :fig_num
-AND "minifigures"."u_id" IS NOT DISTINCT FROM :u_id
-AND "minifigures"."set_num" IS NOT DISTINCT FROM :set_num
+WHERE "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM :figure
+AND "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM :bricktracker_set_id
 {% endblock %}
diff --git a/bricktracker/sql/part/list/all.sql b/bricktracker/sql/part/list/all.sql
index b1ff2ace..9d73fccf 100644
--- a/bricktracker/sql/part/list/all.sql
+++ b/bricktracker/sql/part/list/all.sql
@@ -5,15 +5,15 @@ SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
 {% endblock %}
 
 {% block total_quantity %}
-SUM("inventory"."quantity" * IFNULL("minifigures"."quantity", 1)) AS "total_quantity",
+SUM("inventory"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity",
 {% endblock %}
 
 {% block total_sets %}
-COUNT(DISTINCT "bricktracker_sets"."id") AS "total_sets",
+COUNT(DISTINCT "bricktracker_minifigures"."bricktracker_set_id") AS "total_sets",
 {% endblock %}
 
 {% block total_minifigures %}
-SUM(IFNULL("minifigures"."quantity", 0)) AS "total_minifigures"
+SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_minifigures"
 {% endblock %}
 
 {% block join %}
@@ -25,12 +25,9 @@ AND "inventory"."color_id" IS NOT DISTINCT FROM "missing"."color_id"
 AND "inventory"."element_id" IS NOT DISTINCT FROM "missing"."element_id"
 AND "inventory"."u_id" IS NOT DISTINCT FROM "missing"."u_id"
 
-LEFT JOIN "minifigures"
-ON "inventory"."set_num" IS NOT DISTINCT FROM "minifigures"."fig_num"
-AND "inventory"."u_id" IS NOT DISTINCT FROM "minifigures"."u_id"
-
-LEFT JOIN "bricktracker_sets"
-ON "inventory"."u_id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
+LEFT JOIN "bricktracker_minifigures"
+ON "inventory"."set_num" IS NOT DISTINCT FROM "bricktracker_minifigures"."rebrickable_figure"
+AND "inventory"."u_id" IS NOT DISTINCT FROM "bricktracker_minifigures"."bricktracker_set_id"
 {% endblock %}
 
 {% block group %}
diff --git a/bricktracker/sql/part/list/missing.sql b/bricktracker/sql/part/list/missing.sql
index 555916ff..fc64e250 100644
--- a/bricktracker/sql/part/list/missing.sql
+++ b/bricktracker/sql/part/list/missing.sql
@@ -5,11 +5,11 @@ SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
 {% endblock %}
 
 {% block total_sets %}
-COUNT("inventory"."u_id")  - COUNT("minifigures"."u_id") AS "total_sets",
+COUNT("inventory"."u_id")  - COUNT("bricktracker_minifigures"."bricktracker_set_id") AS "total_sets",
 {% endblock %}
 
 {% block total_minifigures %}
-SUM(IFNULL("minifigures"."quantity", 0)) AS "total_minifigures"
+SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_minifigures"
 {% endblock %}
 
 {% block join %}
@@ -21,9 +21,9 @@ AND "missing"."color_id" IS NOT DISTINCT FROM "inventory"."color_id"
 AND "missing"."element_id" IS NOT DISTINCT FROM "inventory"."element_id"
 AND "missing"."u_id" IS NOT DISTINCT FROM "inventory"."u_id"
 
-LEFT JOIN "minifigures"
-ON "missing"."set_num" IS NOT DISTINCT FROM "minifigures"."fig_num"
-AND "missing"."u_id" IS NOT DISTINCT FROM "minifigures"."u_id"
+LEFT JOIN "bricktracker_minifigures"
+ON "inventory"."set_num" IS NOT DISTINCT FROM "bricktracker_minifigures"."rebrickable_figure"
+AND "inventory"."u_id" IS NOT DISTINCT FROM "bricktracker_minifigures"."bricktracker_set_id"
 {% endblock %}
 
 {% block group %}
diff --git a/bricktracker/sql/part/select/generic.sql b/bricktracker/sql/part/select/generic.sql
index 4a75b4ca..28b32a94 100644
--- a/bricktracker/sql/part/select/generic.sql
+++ b/bricktracker/sql/part/select/generic.sql
@@ -5,11 +5,11 @@ SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
 {% endblock %}
 
 {% block total_quantity %}
-SUM((NOT "inventory"."is_spare") * "inventory"."quantity" * IFNULL("minifigures"."quantity", 1)) AS "total_quantity",
+SUM((NOT "inventory"."is_spare") * "inventory"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity",
 {% endblock %}
 
 {% block total_spare %}
-SUM("inventory"."is_spare" * "inventory"."quantity" * IFNULL("minifigures"."quantity", 1)) AS "total_spare",
+SUM("inventory"."is_spare" * "inventory"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_spare",
 {% endblock %}
 
 {% block join %}
@@ -21,9 +21,9 @@ AND "inventory"."color_id" IS NOT DISTINCT FROM "missing"."color_id"
 AND "inventory"."element_id" IS NOT DISTINCT FROM "missing"."element_id"
 AND "inventory"."u_id" IS NOT DISTINCT FROM "missing"."u_id"
 
-LEFT JOIN "minifigures"
-ON "inventory"."set_num" IS NOT DISTINCT FROM "minifigures"."fig_num"
-AND "inventory"."u_id" IS NOT DISTINCT FROM "minifigures"."u_id"
+LEFT JOIN "bricktracker_minifigures"
+ON "inventory"."set_num" IS NOT DISTINCT FROM "bricktracker_minifigures"."rebrickable_figure"
+AND "inventory"."u_id" IS NOT DISTINCT FROM "bricktracker_minifigures"."bricktracker_set_id"
 {% endblock %}
 
 {% block where %}
diff --git a/bricktracker/sql/rebrickable/minifigure/insert.sql b/bricktracker/sql/rebrickable/minifigure/insert.sql
new file mode 100644
index 00000000..06719257
--- /dev/null
+++ b/bricktracker/sql/rebrickable/minifigure/insert.sql
@@ -0,0 +1,11 @@
+INSERT OR IGNORE INTO "rebrickable_minifigures" (
+    "figure",
+    "number",
+    "name",
+    "image"
+) VALUES (
+    :figure,
+    :number,
+    :name,
+    :image
+)
diff --git a/bricktracker/sql/rebrickable/minifigure/list.sql b/bricktracker/sql/rebrickable/minifigure/list.sql
new file mode 100644
index 00000000..ec379d89
--- /dev/null
+++ b/bricktracker/sql/rebrickable/minifigure/list.sql
@@ -0,0 +1,6 @@
+SELECT
+    "rebrickable_minifigures"."figure",
+    "rebrickable_minifigures"."number",
+    "rebrickable_minifigures"."name",
+    "rebrickable_minifigures"."image"
+FROM "rebrickable_minifigures"
diff --git a/bricktracker/sql/rebrickable/minifigure/select.sql b/bricktracker/sql/rebrickable/minifigure/select.sql
new file mode 100644
index 00000000..f1c68c1c
--- /dev/null
+++ b/bricktracker/sql/rebrickable/minifigure/select.sql
@@ -0,0 +1,8 @@
+SELECT
+    "rebrickable_minifigures"."figure",
+    "rebrickable_minifigures"."number",
+    "rebrickable_minifigures"."name",
+    "rebrickable_minifigures"."image"
+FROM "rebrickable_minifigures"
+
+WHERE "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM :figure
diff --git a/bricktracker/sql/schema/drop.sql b/bricktracker/sql/schema/drop.sql
index b961b282..1d39d990 100644
--- a/bricktracker/sql/schema/drop.sql
+++ b/bricktracker/sql/schema/drop.sql
@@ -1,12 +1,15 @@
 BEGIN transaction;
 
+DROP TABLE IF EXISTS "bricktracker_minifigures";
 DROP TABLE IF EXISTS "bricktracker_sets";
 DROP TABLE IF EXISTS "bricktracker_set_checkboxes";
 DROP TABLE IF EXISTS "bricktracker_set_statuses";
 DROP TABLE IF EXISTS "bricktracker_wishes";
 DROP TABLE IF EXISTS "inventory";
 DROP TABLE IF EXISTS "minifigures";
+DROP TABLE IF EXISTS "minifigures_old";
 DROP TABLE IF EXISTS "missing";
+DROP TABLE IF EXISTS "rebrickable_minifigures";
 DROP TABLE IF EXISTS "rebrickable_sets";
 DROP TABLE IF EXISTS "sets";
 DROP TABLE IF EXISTS "sets_old";
diff --git a/bricktracker/sql/set/base/full.sql b/bricktracker/sql/set/base/full.sql
index c169c7a1..68333c2f 100644
--- a/bricktracker/sql/set/base/full.sql
+++ b/bricktracker/sql/set/base/full.sql
@@ -32,11 +32,11 @@ ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "missing_join"."u_id"
 -- LEFT JOIN + SELECT to avoid messing the total
 LEFT JOIN (
     SELECT
-       "minifigures"."u_id",
-       SUM("minifigures"."quantity") AS "total"
-    FROM "minifigures"
+       "bricktracker_minifigures"."bricktracker_set_id",
+       SUM("bricktracker_minifigures"."quantity") AS "total"
+    FROM "bricktracker_minifigures"
     {% block where_minifigures %}{% endblock %}
-    GROUP BY "u_id"
+    GROUP BY "bricktracker_minifigures"."bricktracker_set_id"
 ) "minifigures_join"
-ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "minifigures_join"."u_id"
+ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "minifigures_join"."bricktracker_set_id"
 {% endblock %}
\ No newline at end of file
diff --git a/bricktracker/sql/set/delete/set.sql b/bricktracker/sql/set/delete/set.sql
index dd2c8567..93a51dfd 100644
--- a/bricktracker/sql/set/delete/set.sql
+++ b/bricktracker/sql/set/delete/set.sql
@@ -9,8 +9,8 @@ WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM '{{ id }}';
 DELETE FROM "bricktracker_set_statuses"
 WHERE "bricktracker_set_statuses"."bricktracker_set_id" IS NOT DISTINCT FROM '{{ id }}';
 
-DELETE FROM "minifigures"
-WHERE "minifigures"."u_id" IS NOT DISTINCT FROM '{{ id }}';
+DELETE FROM "bricktracker_minifigures"
+WHERE "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM '{{ id }}';
 
 DELETE FROM "missing"
 WHERE "missing"."u_id" IS NOT DISTINCT FROM '{{ id }}';
diff --git a/bricktracker/sql/set/list/missing_minifigure.sql b/bricktracker/sql/set/list/missing_minifigure.sql
index 5f270882..2f19bfe0 100644
--- a/bricktracker/sql/set/list/missing_minifigure.sql
+++ b/bricktracker/sql/set/list/missing_minifigure.sql
@@ -6,7 +6,7 @@ WHERE "bricktracker_sets"."id" IN (
         "missing"."u_id"
     FROM "missing"
 
-    WHERE "missing"."set_num" IS NOT DISTINCT FROM :fig_num
+    WHERE "missing"."set_num" IS NOT DISTINCT FROM :figure
 
     GROUP BY "missing"."u_id"
 )
diff --git a/bricktracker/sql/set/list/using_minifigure.sql b/bricktracker/sql/set/list/using_minifigure.sql
index f08a5d7c..711866b2 100644
--- a/bricktracker/sql/set/list/using_minifigure.sql
+++ b/bricktracker/sql/set/list/using_minifigure.sql
@@ -6,7 +6,7 @@ WHERE "bricktracker_sets"."id" IN (
         "inventory"."u_id"
     FROM "inventory"
 
-    WHERE "inventory"."set_num" IS NOT DISTINCT FROM :fig_num
+    WHERE "inventory"."set_num" IS NOT DISTINCT FROM :figure
 
     GROUP BY "inventory"."u_id"
 )
diff --git a/bricktracker/sql/set/select/full.sql b/bricktracker/sql/set/select/full.sql
index 4b191368..a89d2c91 100644
--- a/bricktracker/sql/set/select/full.sql
+++ b/bricktracker/sql/set/select/full.sql
@@ -5,7 +5,7 @@ WHERE "missing"."u_id" IS NOT DISTINCT FROM :id
 {% endblock %}
 
 {% block where_minifigures %}
-WHERE "minifigures"."u_id" IS NOT DISTINCT FROM :id
+WHERE "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM :id
 {% endblock %}
 
 {% block where %}
diff --git a/bricktracker/sql_counter.py b/bricktracker/sql_counter.py
index 71754941..d01546f9 100644
--- a/bricktracker/sql_counter.py
+++ b/bricktracker/sql_counter.py
@@ -2,13 +2,15 @@ from typing import Tuple
 
 # Some table aliases to make it look cleaner (id: (name, icon))
 ALIASES: dict[str, Tuple[str, str]] = {
-    'bricktracker_set_checkboxes': ('Checkboxes', 'checkbox-line'),
+    'bricktracker_minifigures': ('Bricktracker minifigures', 'group-line'),
     'bricktracker_set_statuses': ('Bricktracker sets status', 'checkbox-line'),
     'bricktracker_sets': ('Bricktracker sets', 'hashtag'),
     'bricktracker_wishes': ('Bricktracker wishes', 'gift-line'),
     'inventory': ('Parts', 'shapes-line'),
     'minifigures': ('Minifigures', 'group-line'),
+    'minifigures_old': ('Minifigures (legacy)', 'group-line'),
     'missing': ('Missing', 'error-warning-line'),
+    'rebrickable_minifigures': ('Rebrickable minifigures', 'group-line'),
     'rebrickable_sets': ('Rebrickable sets', 'hashtag'),
     'sets': ('Sets', 'hashtag'),
     'sets_old': ('Sets (legacy)', 'hashtag'),
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index 02353c1c..16831752 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -128,7 +128,7 @@ def missing_minifigure_part(*, id: str, figure: str, part: str) -> Response:
     logger.info('Set {number} ({id}): updated minifigure ({figure}) part ({part}) missing count to {missing}'.format(  # noqa: E501
         number=brickset.fields.set,
         id=brickset.fields.id,
-        figure=brickminifigure.fields.fig_num,
+        figure=brickminifigure.fields.figure,
         part=brickpart.fields.id,
         missing=missing,
     ))
diff --git a/templates/minifigure/card.html b/templates/minifigure/card.html
index 79ed4141..b1949ef7 100644
--- a/templates/minifigure/card.html
+++ b/templates/minifigure/card.html
@@ -3,11 +3,11 @@
 {% import 'macro/card.html' as card %}
 
 <div class="card mb-3 flex-fill {% if solo %}card-solo{% endif %}">
-  {{ card.header(item, item.fields.name, solo=solo, number=item.clean_number(), icon='user-line') }}
-  {{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.fig_num, medium=true) }}
+  {{ card.header(item, item.fields.name, solo=solo, number=item.fields.number, icon='user-line') }}
+  {{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.figure, medium=true) }}
   <div class="card-body border-bottom {% if not solo %}p-1{% endif %}">
     {% if last %}
-      {{ badge.set(item.fields.set_num, solo=solo, last=last, id=item.fields.u_id) }}
+      {{ badge.set(item.fields.set, solo=solo, last=last, id=item.fields.rebrickable_set_id) }}
       {{ badge.quantity(item.fields.quantity, solo=solo, last=last) }}
     {% endif %}
     {{ badge.quantity(item.fields.total_quantity, solo=solo, last=last) }}
@@ -19,7 +19,7 @@
   </div>
   {% if solo %}
     <div class="accordion accordion-flush" id="minifigure-details">
-      {{ accordion.table(item.generic_parts(), 'Parts', item.fields.fig_num, 'minifigure-details', 'part/table.html', icon='shapes-line', alt=item.fields.fig_num, read_only_missing=read_only_missing)}}
+      {{ accordion.table(item.generic_parts(), 'Parts', item.fields.figure, 'minifigure-details', 'part/table.html', icon='shapes-line', alt=item.fields.figure, read_only_missing=read_only_missing)}}
       {{ accordion.cards(using, 'Sets using this minifigure', 'using-inventory', 'minifigure-details', 'set/card.html', icon='hashtag') }}
       {{ accordion.cards(missing, 'Sets missing parts of this minifigure', 'missing-inventory', 'minifigure-details', 'set/card.html', icon='error-warning-line') }}
     </div>
diff --git a/templates/minifigure/table.html b/templates/minifigure/table.html
index 94ccef75..66ece790 100644
--- a/templates/minifigure/table.html
+++ b/templates/minifigure/table.html
@@ -6,7 +6,7 @@
     <tbody>
       {% for item in table_collection %}
       <tr>
-        {{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.fig_num) }}
+        {{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.figure) }}
         <td >
           <a class="text-reset" href="{{ item.url() }}" style="max-width:auto">{{ item.fields.name }}</a>
           {% if all %}
diff --git a/templates/set/card.html b/templates/set/card.html
index 1308c71e..d6729ee9 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -57,7 +57,7 @@
         {{ accordion.footer() }}
         {{ accordion.table(item.parts(), 'Parts', 'parts-inventory', 'set-details', 'part/table.html', icon='shapes-line')}}
         {% for minifigure in item.minifigures() %}
-          {{ accordion.table(minifigure.parts(), minifigure.fields.name, minifigure.fields.fig_num, 'set-details', 'part/table.html', quantity=minifigure.fields.quantity, icon='group-line', image=minifigure.url_for_image(), alt=minifigure.fields.fig_num, details=minifigure.url())}}
+          {{ accordion.table(minifigure.parts(), minifigure.fields.name, minifigure.fields.figure, 'set-details', 'part/table.html', quantity=minifigure.fields.quantity, icon='group-line', image=minifigure.url_for_image(), alt=minifigure.fields.figure, details=minifigure.url())}}
         {% endfor %}
       {% endif %}
       {% if g.login.is_authenticated() %}

From 32044dffe4cc8cd0760ef7c0ad6e8fc247fdcd58 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 27 Jan 2025 18:40:51 +0100
Subject: [PATCH 011/154] Remove confusing reference to number for sets

---
 bricktracker/views/set.py | 16 ++++++++--------
 1 file changed, 8 insertions(+), 8 deletions(-)

diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index 16831752..3e2304c4 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -47,8 +47,8 @@ def update_status(*, id: str, checkbox_id: str) -> Response:
     brickset.update_status(checkbox, value)
 
     # Info
-    logger.info('Set {number} ({id}): status "{status}" changed to "{state}"'.format(  # noqa: E501
-        number=brickset.fields.set,
+    logger.info('Set {set} ({id}): status "{status}" changed to "{state}"'.format(  # noqa: E501
+        set=brickset.fields.set,
         id=brickset.fields.id,
         status=checkbox.fields.name,
         state=value,
@@ -77,8 +77,8 @@ def do_delete(*, id: str) -> Response:
     brickset.delete()
 
     # Info
-    logger.info('Set {number} ({id}): deleted'.format(
-        number=brickset.fields.set,
+    logger.info('Set {set} ({id}): deleted'.format(
+        set=brickset.fields.set,
         id=brickset.fields.id,
     ))
 
@@ -125,8 +125,8 @@ def missing_minifigure_part(*, id: str, figure: str, part: str) -> Response:
     brickpart.update_missing(missing)
 
     # Info
-    logger.info('Set {number} ({id}): updated minifigure ({figure}) part ({part}) missing count to {missing}'.format(  # noqa: E501
-        number=brickset.fields.set,
+    logger.info('Set {set} ({id}): updated minifigure ({figure}) part ({part}) missing count to {missing}'.format(  # noqa: E501
+        set=brickset.fields.set,
         id=brickset.fields.id,
         figure=brickminifigure.fields.figure,
         part=brickpart.fields.id,
@@ -149,8 +149,8 @@ def missing_part(*, id: str, part: str) -> Response:
     brickpart.update_missing(missing)
 
     # Info
-    logger.info('Set {number} ({id}): updated part ({part}) missing count to {missing}'.format(  # noqa: E501
-        number=brickset.fields.set,
+    logger.info('Set {set} ({id}): updated part ({part}) missing count to {missing}'.format(  # noqa: E501
+        set=brickset.fields.set,
         id=brickset.fields.id,
         part=brickpart.fields.id,
         missing=missing,

From 26fd9aa3f9cc0f374f9cc3fd16f70d1bf09e45a5 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 27 Jan 2025 18:41:08 +0100
Subject: [PATCH 012/154] Fix hide instructions block placement

---
 templates/set/card.html | 36 +++++++++++++++++++-----------------
 1 file changed, 19 insertions(+), 17 deletions(-)

diff --git a/templates/set/card.html b/templates/set/card.html
index d6729ee9..e9612eb8 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -35,26 +35,28 @@
       {% endfor %}
     </ul>
   {% endif %}
-  {% if solo and not config['HIDE_SET_INSTRUCTIONS'] %}
+  {% if solo %}
     <div class="accordion accordion-flush border-top" id="set-details">
       {% if not delete %}
-        {{ accordion.header('Instructions', 'instructions', 'set-details', expanded=open_instructions, quantity=item.instructions | length, icon='file-line', class='p-0') }}
-        <div class="list-group list-group-flush">
-          {% if item.instructions | length %}
-            {% for instruction in item.instructions %}
-            <a class="list-group-item list-group-item-action" href="{{ instruction.url() }}" target="_blank"><i class="ri-arrow-right-long-line"></i> <i class="ri-{{ instruction.icon() }}"></i> {{ instruction.filename }}</a>
-            {% endfor %}
-          {% else %}
-            <span class="list-group-item list-group-item-action text-center"><i class="ri-error-warning-line"></i> No instructions file found.</span>
-            {% if g.login.is_authenticated() %}
-              <a class="list-group-item list-group-item-action" href="{{ url_for('instructions.upload') }}"><i class="ri-upload-line"></i> Upload an instructions file</a>
+        {% if not config['HIDE_SET_INSTRUCTIONS'] %}
+          {{ accordion.header('Instructions', 'instructions', 'set-details', expanded=open_instructions, quantity=item.instructions | length, icon='file-line', class='p-0') }}
+          <div class="list-group list-group-flush">
+            {% if item.instructions | length %}
+              {% for instruction in item.instructions %}
+              <a class="list-group-item list-group-item-action" href="{{ instruction.url() }}" target="_blank"><i class="ri-arrow-right-long-line"></i> <i class="ri-{{ instruction.icon() }}"></i> {{ instruction.filename }}</a>
+              {% endfor %}
+            {% else %}
+              <span class="list-group-item list-group-item-action text-center"><i class="ri-error-warning-line"></i> No instructions file found.</span>
+              {% if g.login.is_authenticated() %}
+                <a class="list-group-item list-group-item-action" href="{{ url_for('instructions.upload') }}"><i class="ri-upload-line"></i> Upload an instructions file</a>
+              {% endif %}
             {% endif %}
-          {% endif %}
-          {% if g.login.is_authenticated() %}
-            <a class="list-group-item list-group-item-action" href="{{ url_for('instructions.download', set=item.fields.set) }}"><i class="ri-download-line"></i> Download instructions from Rebrickable</a>
-          {% endif %}
-        </div>
-        {{ accordion.footer() }}
+            {% if g.login.is_authenticated() %}
+              <a class="list-group-item list-group-item-action" href="{{ url_for('instructions.download', set=item.fields.set) }}"><i class="ri-download-line"></i> Download instructions from Rebrickable</a>
+            {% endif %}
+          </div>
+          {{ accordion.footer() }}
+        {% endif %}
         {{ accordion.table(item.parts(), 'Parts', 'parts-inventory', 'set-details', 'part/table.html', icon='shapes-line')}}
         {% for minifigure in item.minifigures() %}
           {{ accordion.table(minifigure.parts(), minifigure.fields.name, minifigure.fields.figure, 'set-details', 'part/table.html', quantity=minifigure.fields.quantity, icon='group-line', image=minifigure.url_for_image(), alt=minifigure.fields.figure, details=minifigure.url())}}

From 6dd42ed52d0341d140ddee29e6fba106aee5cb81 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 27 Jan 2025 18:41:44 +0100
Subject: [PATCH 013/154] Add missing checkboxes counter alias

---
 bricktracker/sql_counter.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/bricktracker/sql_counter.py b/bricktracker/sql_counter.py
index d01546f9..f2d1cc52 100644
--- a/bricktracker/sql_counter.py
+++ b/bricktracker/sql_counter.py
@@ -3,6 +3,7 @@ from typing import Tuple
 # Some table aliases to make it look cleaner (id: (name, icon))
 ALIASES: dict[str, Tuple[str, str]] = {
     'bricktracker_minifigures': ('Bricktracker minifigures', 'group-line'),
+    'bricktracker_set_checkboxes': ('Bricktracker set checkboxes', 'checkbox-line'),  # noqa: E501
     'bricktracker_set_statuses': ('Bricktracker sets status', 'checkbox-line'),
     'bricktracker_sets': ('Bricktracker sets', 'hashtag'),
     'bricktracker_wishes': ('Bricktracker wishes', 'gift-line'),

From 8b82594512011fb43a320d2f401da5d3774ee1e0 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 27 Jan 2025 18:41:53 +0100
Subject: [PATCH 014/154] Documentation about base SQL files

---
 .env.sample | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.env.sample b/.env.sample
index 91caf765..04e84ee9 100644
--- a/.env.sample
+++ b/.env.sample
@@ -2,7 +2,7 @@
 # If set, it will append a direct ORDER BY <whatever you set> to the SQL query
 # while listing objects. You can look at the structure of the SQLite database to
 # see the schema and the column names. Some fields are compound and not visible
-# directly from the schema (joins). You can check the query in the */list.sql files
+# directly from the schema (joins). You can check the query in the */list.sql and */base/*.sql files
 # in the source to see all column names.
 # The usual syntax for those variables is "<table>"."<column>" [ASC|DESC].
 # For composite fields (CASE, SUM, COUNT) the syntax is <field>, there is no <table> name.

From 0a129209a505ad8f4d37fa89cba8a95591c13855 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 27 Jan 2025 18:55:26 +0100
Subject: [PATCH 015/154] Add remixicon in the libraries

---
 docs/development.md | 1 +
 1 file changed, 1 insertion(+)

diff --git a/docs/development.md b/docs/development.md
index 6799be07..8657590c 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -16,6 +16,7 @@ It uses the following Python/pip packages:
 It also uses the following libraries and frameworks:
 
 - Boostrap (https://getbootstrap.com/)
+- Remixicon (https://remixicon.com/)
 - `baguettebox` (https://github.com/feimosi/baguetteBox.js)
 - `tinysort` (https://github.com/Sjeiti/TinySort)
 - `sortable` (https://github.com/tofsjonas/sortable)

From 2e36db4d3d34044529cef04e5a59eeaa28fb2812 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 27 Jan 2025 22:23:54 +0100
Subject: [PATCH 016/154] Allow more advanced migration action through a
 companion python file

---
 bricktracker/migrations/__init__.py |  0
 bricktracker/sql.py                 | 28 +++++++++++++++++++++++++++-
 2 files changed, 27 insertions(+), 1 deletion(-)
 create mode 100644 bricktracker/migrations/__init__.py

diff --git a/bricktracker/migrations/__init__.py b/bricktracker/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bricktracker/sql.py b/bricktracker/sql.py
index 07811d9b..9e47d9a4 100644
--- a/bricktracker/sql.py
+++ b/bricktracker/sql.py
@@ -1,3 +1,4 @@
+from importlib import import_module
 import logging
 import os
 import sqlite3
@@ -301,7 +302,32 @@ class BrickSQL(object):
                     version=pending.version)
                 )
 
-                self.executescript(pending.get_query())
+                # Load context from the migrations if it exists
+                # It looks for a file in migrations/ named after the SQL file
+                # and containing one function named migration_xxxx, also named
+                # after the SQL file, returning a context dict.
+                #
+                # For instance:
+                # - sql/migrations/0007.sql
+                # - migrations/0007.py
+                #    - def migration_0007(BrickSQL) -> dict[str, Any]
+                try:
+                    module = import_module(
+                        '.migrations.{name}'.format(
+                            name=pending.name
+                        ),
+                        package='bricktracker'
+                    )
+
+                    function = getattr(module, 'migration_{name}'.format(
+                        name=pending.name
+                    ))
+
+                    context: dict[str, Any] = function(self)
+                except Exception:
+                    context: dict[str, Any] = {}
+
+                self.executescript(pending.get_query(), **context)
                 self.execute('schema/set_version', version=pending.version)
 
     # Tells whether the database needs upgrade

From 270838a54952520861c1b7289eb100d1b4d29906 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 27 Jan 2025 23:07:10 +0100
Subject: [PATCH 017/154] Simplify fields name in the database

---
 bricktracker/migrations/0007.py               | 27 ++++++++
 bricktracker/minifigure_list.py               |  2 +-
 bricktracker/rebrickable_minifigure.py        |  5 +-
 bricktracker/sql/migrations/0007.sql          | 63 +++++++++++++------
 bricktracker/sql/migrations/0008.sql          | 40 ++++++------
 bricktracker/sql/migrations/0009.sql          | 32 ++++++++++
 bricktracker/sql/minifigure/base/base.sql     |  2 +-
 bricktracker/sql/minifigure/insert.sql        |  6 +-
 bricktracker/sql/minifigure/list/all.sql      |  4 +-
 bricktracker/sql/minifigure/list/from_set.sql |  2 +-
 bricktracker/sql/minifigure/list/last.sql     |  4 +-
 .../sql/minifigure/list/missing_part.sql      |  2 +-
 .../sql/minifigure/select/generic.sql         |  4 +-
 .../sql/minifigure/select/specific.sql        |  2 +-
 bricktracker/sql/part/list/all.sql            |  6 +-
 bricktracker/sql/part/list/missing.sql        |  6 +-
 bricktracker/sql/part/select/generic.sql      |  4 +-
 bricktracker/sql/set/base/base.sql            |  2 +-
 bricktracker/sql/set/base/full.sql            |  8 +--
 bricktracker/sql/set/base/light.sql           |  2 +-
 bricktracker/sql/set/delete/set.sql           |  4 +-
 bricktracker/sql/set/insert.sql               |  2 +-
 bricktracker/sql/set/list/generic.sql         |  2 +-
 bricktracker/sql/set/select/full.sql          |  2 +-
 bricktracker/sql/set/update/status.sql        |  6 +-
 25 files changed, 159 insertions(+), 80 deletions(-)
 create mode 100644 bricktracker/migrations/0007.py
 create mode 100644 bricktracker/sql/migrations/0009.sql

diff --git a/bricktracker/migrations/0007.py b/bricktracker/migrations/0007.py
new file mode 100644
index 00000000..fb5b7235
--- /dev/null
+++ b/bricktracker/migrations/0007.py
@@ -0,0 +1,27 @@
+from typing import Any, TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from ..sql import BrickSQL
+
+
+# Grab the list of checkboxes to create a list of SQL columns
+def migration_0007(self: 'BrickSQL') -> dict[str, Any]:
+    records = self.fetchall('checkbox/list')
+
+    return {
+        'sources': ', '.join([
+            '"bricktracker_set_statuses_old"."status_{id}"'.format(id=record['id'])  # noqa: E501
+            for record
+            in records
+        ]),
+        'targets': ', '.join([
+            '"status_{id}"'.format(id=record['id'])
+            for record
+            in records
+        ]),
+        'structure': ', '.join([
+            '"status_{id}" BOOLEAN NOT NULL DEFAULT 0'.format(id=record['id'])
+            for record
+            in records
+        ])
+    }
diff --git a/bricktracker/minifigure_list.py b/bricktracker/minifigure_list.py
index 81affa6d..24b9933a 100644
--- a/bricktracker/minifigure_list.py
+++ b/bricktracker/minifigure_list.py
@@ -134,7 +134,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
         parameters: dict[str, Any] = super().sql_parameters()
 
         if self.brickset is not None:
-            parameters['bricktracker_set_id'] = self.brickset.fields.id
+            parameters['id'] = self.brickset.fields.id
 
         return parameters
 
diff --git a/bricktracker/rebrickable_minifigure.py b/bricktracker/rebrickable_minifigure.py
index 28d3d754..e2963560 100644
--- a/bricktracker/rebrickable_minifigure.py
+++ b/bricktracker/rebrickable_minifigure.py
@@ -75,9 +75,8 @@ class RebrickableMinifigure(BrickRecord):
         parameters = super().sql_parameters()
 
         # Supplement from the brickset
-        if self.brickset is not None:
-            if 'bricktracker_set_id' not in parameters:
-                parameters['bricktracker_set_id'] = self.brickset.fields.id
+        if self.brickset is not None and 'id' not in parameters:
+            parameters['id'] = self.brickset.fields.id
 
         return parameters
 
diff --git a/bricktracker/sql/migrations/0007.sql b/bricktracker/sql/migrations/0007.sql
index 09830c47..036f12e5 100644
--- a/bricktracker/sql/migrations/0007.sql
+++ b/bricktracker/sql/migrations/0007.sql
@@ -1,30 +1,53 @@
--- description: Creation of the deduplicated table of Rebrickable minifigures
+-- description: Renaming various complicated field names to something simpler
+
+PRAGMA foreign_keys = ON;
 
 BEGIN TRANSACTION;
 
--- Create a Rebrickable minifigures table: each unique minifigure imported from Rebrickable
-CREATE TABLE "rebrickable_minifigures" (
-    "figure" TEXT NOT NULL,
-    "number" INTEGER NOT NULL,
-    "name" TEXT NOT NULL,
-    "image" TEXT,
-    PRIMARY KEY("figure")
+-- Rename sets table
+ALTER TABLE "bricktracker_sets" RENAME TO "bricktracker_sets_old";
+
+-- Re-Create a Bricktable set table with the simplified name
+CREATE TABLE "bricktracker_sets" (
+    "id" TEXT NOT NULL,
+    "set" TEXT NOT NULL,
+    PRIMARY KEY("id"),
+    FOREIGN KEY("set") REFERENCES "rebrickable_sets"("set")
 );
 
 -- Insert existing sets into the new table
-INSERT INTO "rebrickable_minifigures" (
-    "figure",
-    "number",
-    "name",
-    "image"
+INSERT INTO "bricktracker_sets" (
+    "id",
+    "set"
 )
 SELECT
-    "minifigures"."fig_num",
-    CAST(SUBSTR("minifigures"."fig_num", 5) AS INTEGER),
-    "minifigures"."name",
-    "minifigures"."set_img_url"
-FROM "minifigures"
-GROUP BY
-    "minifigures"."fig_num";
+    "bricktracker_sets_old"."id",
+    "bricktracker_sets_old"."rebrickable_set"
+FROM "bricktracker_sets_old";
+
+-- Rename status table
+ALTER TABLE "bricktracker_set_statuses" RENAME TO "bricktracker_set_statuses_old";
+
+-- Re-create a table for the status of each checkbox
+CREATE TABLE "bricktracker_set_statuses" (
+    "id" TEXT NOT NULL,
+    {% if structure %}{{ structure }},{% endif %}
+    PRIMARY KEY("id"),
+    FOREIGN KEY("id") REFERENCES "bricktracker_sets"("id")
+);
+
+-- Insert existing status into the new table
+INSERT INTO "bricktracker_set_statuses" (
+    {% if targets %}{{ targets }},{% endif %}
+    "id"
+)
+SELECT
+    {% if sources %}{{ sources }},{% endif %}
+    "bricktracker_set_statuses_old"."bricktracker_set_id"
+FROM "bricktracker_set_statuses_old";
+
+-- Delete the original tables
+DROP TABLE "bricktracker_set_statuses_old";
+DROP TABLE "bricktracker_sets_old";
 
 COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/migrations/0008.sql b/bricktracker/sql/migrations/0008.sql
index 48b905aa..09830c47 100644
--- a/bricktracker/sql/migrations/0008.sql
+++ b/bricktracker/sql/migrations/0008.sql
@@ -1,32 +1,30 @@
--- description: Migrate the Bricktracker minifigures
-
-PRAGMA foreign_keys = ON;
+-- description: Creation of the deduplicated table of Rebrickable minifigures
 
 BEGIN TRANSACTION;
 
--- Create a Bricktable minifigures table: an amount of minifigures linked to a Bricktracker set
-CREATE TABLE "bricktracker_minifigures" (
-    "bricktracker_set_id" TEXT NOT NULL,
-    "rebrickable_figure" TEXT NOT NULL,
-    "quantity" INTEGER NOT NULL,
-    PRIMARY KEY("bricktracker_set_id", "rebrickable_figure"),
-    FOREIGN KEY("bricktracker_set_id") REFERENCES "bricktracker_sets"("id"),
-    FOREIGN KEY("rebrickable_figure") REFERENCES "rebrickable_minifigures"("figure")
+-- Create a Rebrickable minifigures table: each unique minifigure imported from Rebrickable
+CREATE TABLE "rebrickable_minifigures" (
+    "figure" TEXT NOT NULL,
+    "number" INTEGER NOT NULL,
+    "name" TEXT NOT NULL,
+    "image" TEXT,
+    PRIMARY KEY("figure")
 );
 
 -- Insert existing sets into the new table
-INSERT INTO "bricktracker_minifigures" (
-    "bricktracker_set_id",
-    "rebrickable_figure",
-    "quantity"
+INSERT INTO "rebrickable_minifigures" (
+    "figure",
+    "number",
+    "name",
+    "image"
 )
 SELECT
-    "minifigures"."u_id",
     "minifigures"."fig_num",
-    "minifigures"."quantity"
-FROM "minifigures";
-
--- Rename the original table (don't delete it yet?)
-ALTER TABLE "minifigures" RENAME TO "minifigures_old";
+    CAST(SUBSTR("minifigures"."fig_num", 5) AS INTEGER),
+    "minifigures"."name",
+    "minifigures"."set_img_url"
+FROM "minifigures"
+GROUP BY
+    "minifigures"."fig_num";
 
 COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/migrations/0009.sql b/bricktracker/sql/migrations/0009.sql
new file mode 100644
index 00000000..135f95d7
--- /dev/null
+++ b/bricktracker/sql/migrations/0009.sql
@@ -0,0 +1,32 @@
+-- description: Migrate the Bricktracker minifigures
+
+PRAGMA foreign_keys = ON;
+
+BEGIN TRANSACTION;
+
+-- Create a Bricktable minifigures table: an amount of minifigures linked to a Bricktracker set
+CREATE TABLE "bricktracker_minifigures" (
+    "id" TEXT NOT NULL,
+    "figure" TEXT NOT NULL,
+    "quantity" INTEGER NOT NULL,
+    PRIMARY KEY("id", "figure"),
+    FOREIGN KEY("id") REFERENCES "bricktracker_sets"("id"),
+    FOREIGN KEY("figure") REFERENCES "rebrickable_minifigures"("figure")
+);
+
+-- Insert existing sets into the new table
+INSERT INTO "bricktracker_minifigures" (
+    "id",
+    "figure",
+    "quantity"
+)
+SELECT
+    "minifigures"."u_id",
+    "minifigures"."fig_num",
+    "minifigures"."quantity"
+FROM "minifigures";
+
+-- Rename the original table (don't delete it yet?)
+ALTER TABLE "minifigures" RENAME TO "minifigures_old";
+
+COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/minifigure/base/base.sql b/bricktracker/sql/minifigure/base/base.sql
index bfaf10d7..c580b38e 100644
--- a/bricktracker/sql/minifigure/base/base.sql
+++ b/bricktracker/sql/minifigure/base/base.sql
@@ -17,7 +17,7 @@ SELECT
 FROM "bricktracker_minifigures"
 
 INNER JOIN "rebrickable_minifigures"
-ON "bricktracker_minifigures"."rebrickable_figure" IS NOT DISTINCT FROM "rebrickable_minifigures"."figure"
+ON "bricktracker_minifigures"."figure" IS NOT DISTINCT FROM "rebrickable_minifigures"."figure"
 
 {% block join %}{% endblock %}
 
diff --git a/bricktracker/sql/minifigure/insert.sql b/bricktracker/sql/minifigure/insert.sql
index cd7c413e..0a2679e7 100644
--- a/bricktracker/sql/minifigure/insert.sql
+++ b/bricktracker/sql/minifigure/insert.sql
@@ -1,9 +1,9 @@
 INSERT INTO "bricktracker_minifigures" (
-    "bricktracker_set_id",
-    "rebrickable_figure",
+    "id",
+    "figure",
     "quantity"
 ) VALUES (
-    :bricktracker_set_id,
+    :id,
     :figure,
     :quantity
 )
diff --git a/bricktracker/sql/minifigure/list/all.sql b/bricktracker/sql/minifigure/list/all.sql
index 82e61a24..498062d5 100644
--- a/bricktracker/sql/minifigure/list/all.sql
+++ b/bricktracker/sql/minifigure/list/all.sql
@@ -9,7 +9,7 @@ SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_quantity",
 {% endblock %}
 
 {% block total_sets %}
-COUNT("bricktracker_minifigures"."bricktracker_set_id") AS "total_sets"
+COUNT("bricktracker_minifigures"."id") AS "total_sets"
 {% endblock %}
 
 {% block join %}
@@ -24,7 +24,7 @@ LEFT JOIN (
         "missing"."set_num",
         "missing"."u_id"
 ) missing_join
-ON "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM "missing_join"."u_id"
+ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "missing_join"."u_id"
 AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "missing_join"."set_num"
 {% endblock %}
 
diff --git a/bricktracker/sql/minifigure/list/from_set.sql b/bricktracker/sql/minifigure/list/from_set.sql
index 65b4e69a..e22ee951 100644
--- a/bricktracker/sql/minifigure/list/from_set.sql
+++ b/bricktracker/sql/minifigure/list/from_set.sql
@@ -1,5 +1,5 @@
 {% extends 'minifigure/base/base.sql' %}
 
 {% block where %}
-WHERE "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM :bricktracker_set_id
+WHERE "bricktracker_minifigures"."id" IS NOT DISTINCT FROM :id
 {% endblock %}
diff --git a/bricktracker/sql/minifigure/list/last.sql b/bricktracker/sql/minifigure/list/last.sql
index 266b7c07..cacc2f7f 100644
--- a/bricktracker/sql/minifigure/list/last.sql
+++ b/bricktracker/sql/minifigure/list/last.sql
@@ -7,11 +7,11 @@ SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
 {% block join %}
 LEFT JOIN "missing"
 ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "missing"."set_num"
-AND "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM "missing"."u_id"
+AND "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "missing"."u_id"
 {% endblock %}
 
 {% block group %}
 GROUP BY
     "rebrickable_minifigures"."figure",
-    "bricktracker_minifigures"."bricktracker_set_id"
+    "bricktracker_minifigures"."id"
 {% endblock %}
diff --git a/bricktracker/sql/minifigure/list/missing_part.sql b/bricktracker/sql/minifigure/list/missing_part.sql
index 660da6df..3fe42107 100644
--- a/bricktracker/sql/minifigure/list/missing_part.sql
+++ b/bricktracker/sql/minifigure/list/missing_part.sql
@@ -7,7 +7,7 @@ SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
 {% block join %}
 LEFT JOIN "missing"
 ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "missing"."set_num"
-AND "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM "missing"."u_id"
+AND "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "missing"."u_id"
 {% endblock %}
 
 {% block where %}
diff --git a/bricktracker/sql/minifigure/select/generic.sql b/bricktracker/sql/minifigure/select/generic.sql
index 966c0221..16dc56d6 100644
--- a/bricktracker/sql/minifigure/select/generic.sql
+++ b/bricktracker/sql/minifigure/select/generic.sql
@@ -9,13 +9,13 @@ SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_quantity",
 {% endblock %}
 
 {% block total_sets %}
-COUNT(DISTINCT "bricktracker_minifigures"."bricktracker_set_id") AS "total_sets"
+COUNT(DISTINCT "bricktracker_minifigures"."id") AS "total_sets"
 {% endblock %}
 
 {% block join %}
 LEFT JOIN "missing"
 ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "missing"."set_num"
-AND "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM "missing"."u_id"
+AND "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "missing"."u_id"
 {% endblock %}
 
 {% block where %}
diff --git a/bricktracker/sql/minifigure/select/specific.sql b/bricktracker/sql/minifigure/select/specific.sql
index 479c9e51..00a66af0 100644
--- a/bricktracker/sql/minifigure/select/specific.sql
+++ b/bricktracker/sql/minifigure/select/specific.sql
@@ -2,5 +2,5 @@
 
 {% block where %}
 WHERE "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM :figure
-AND "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM :bricktracker_set_id
+AND "bricktracker_minifigures"."id" IS NOT DISTINCT FROM :id
 {% endblock %}
diff --git a/bricktracker/sql/part/list/all.sql b/bricktracker/sql/part/list/all.sql
index 9d73fccf..c5bbf690 100644
--- a/bricktracker/sql/part/list/all.sql
+++ b/bricktracker/sql/part/list/all.sql
@@ -9,7 +9,7 @@ SUM("inventory"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) A
 {% endblock %}
 
 {% block total_sets %}
-COUNT(DISTINCT "bricktracker_minifigures"."bricktracker_set_id") AS "total_sets",
+COUNT(DISTINCT "bricktracker_minifigures"."id") AS "total_sets",
 {% endblock %}
 
 {% block total_minifigures %}
@@ -26,8 +26,8 @@ AND "inventory"."element_id" IS NOT DISTINCT FROM "missing"."element_id"
 AND "inventory"."u_id" IS NOT DISTINCT FROM "missing"."u_id"
 
 LEFT JOIN "bricktracker_minifigures"
-ON "inventory"."set_num" IS NOT DISTINCT FROM "bricktracker_minifigures"."rebrickable_figure"
-AND "inventory"."u_id" IS NOT DISTINCT FROM "bricktracker_minifigures"."bricktracker_set_id"
+ON "inventory"."set_num" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
+AND "inventory"."u_id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id"
 {% endblock %}
 
 {% block group %}
diff --git a/bricktracker/sql/part/list/missing.sql b/bricktracker/sql/part/list/missing.sql
index fc64e250..8f17ae36 100644
--- a/bricktracker/sql/part/list/missing.sql
+++ b/bricktracker/sql/part/list/missing.sql
@@ -5,7 +5,7 @@ SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
 {% endblock %}
 
 {% block total_sets %}
-COUNT("inventory"."u_id")  - COUNT("bricktracker_minifigures"."bricktracker_set_id") AS "total_sets",
+COUNT("inventory"."u_id")  - COUNT("bricktracker_minifigures"."id") AS "total_sets",
 {% endblock %}
 
 {% block total_minifigures %}
@@ -22,8 +22,8 @@ AND "missing"."element_id" IS NOT DISTINCT FROM "inventory"."element_id"
 AND "missing"."u_id" IS NOT DISTINCT FROM "inventory"."u_id"
 
 LEFT JOIN "bricktracker_minifigures"
-ON "inventory"."set_num" IS NOT DISTINCT FROM "bricktracker_minifigures"."rebrickable_figure"
-AND "inventory"."u_id" IS NOT DISTINCT FROM "bricktracker_minifigures"."bricktracker_set_id"
+ON "inventory"."set_num" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
+AND "inventory"."u_id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id"
 {% endblock %}
 
 {% block group %}
diff --git a/bricktracker/sql/part/select/generic.sql b/bricktracker/sql/part/select/generic.sql
index 28b32a94..eb7e194c 100644
--- a/bricktracker/sql/part/select/generic.sql
+++ b/bricktracker/sql/part/select/generic.sql
@@ -22,8 +22,8 @@ AND "inventory"."element_id" IS NOT DISTINCT FROM "missing"."element_id"
 AND "inventory"."u_id" IS NOT DISTINCT FROM "missing"."u_id"
 
 LEFT JOIN "bricktracker_minifigures"
-ON "inventory"."set_num" IS NOT DISTINCT FROM "bricktracker_minifigures"."rebrickable_figure"
-AND "inventory"."u_id" IS NOT DISTINCT FROM "bricktracker_minifigures"."bricktracker_set_id"
+ON "inventory"."set_num" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
+AND "inventory"."u_id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id"
 {% endblock %}
 
 {% block where %}
diff --git a/bricktracker/sql/set/base/base.sql b/bricktracker/sql/set/base/base.sql
index 2f4d6831..8b1f4c88 100644
--- a/bricktracker/sql/set/base/base.sql
+++ b/bricktracker/sql/set/base/base.sql
@@ -21,7 +21,7 @@ SELECT
 FROM "bricktracker_sets"
 
 INNER JOIN "rebrickable_sets"
-ON "bricktracker_sets"."rebrickable_set" IS NOT DISTINCT FROM "rebrickable_sets"."set"
+ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set"
 
 {% block join %}{% endblock %}
 
diff --git a/bricktracker/sql/set/base/full.sql b/bricktracker/sql/set/base/full.sql
index 68333c2f..092e4871 100644
--- a/bricktracker/sql/set/base/full.sql
+++ b/bricktracker/sql/set/base/full.sql
@@ -15,7 +15,7 @@ IFNULL("minifigures_join"."total", 0) AS "total_minifigures"
 {% block join %}
 {% if statuses %}
 LEFT JOIN "bricktracker_set_statuses"
-ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_statuses"."bricktracker_set_id"
+ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_statuses"."id"
 {% endif %}
 
 -- LEFT JOIN + SELECT to avoid messing the total
@@ -32,11 +32,11 @@ ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "missing_join"."u_id"
 -- LEFT JOIN + SELECT to avoid messing the total
 LEFT JOIN (
     SELECT
-       "bricktracker_minifigures"."bricktracker_set_id",
+       "bricktracker_minifigures"."id",
        SUM("bricktracker_minifigures"."quantity") AS "total"
     FROM "bricktracker_minifigures"
     {% block where_minifigures %}{% endblock %}
-    GROUP BY "bricktracker_minifigures"."bricktracker_set_id"
+    GROUP BY "bricktracker_minifigures"."id"
 ) "minifigures_join"
-ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "minifigures_join"."bricktracker_set_id"
+ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "minifigures_join"."id"
 {% endblock %}
\ No newline at end of file
diff --git a/bricktracker/sql/set/base/light.sql b/bricktracker/sql/set/base/light.sql
index b599a873..12df8e2e 100644
--- a/bricktracker/sql/set/base/light.sql
+++ b/bricktracker/sql/set/base/light.sql
@@ -1,6 +1,6 @@
 SELECT
     "bricktracker_sets"."id",
-    "bricktracker_sets"."rebrickable_set" AS "set"
+    "bricktracker_sets"."set"
 FROM "bricktracker_sets"
 
 {% block join %}{% endblock %}
diff --git a/bricktracker/sql/set/delete/set.sql b/bricktracker/sql/set/delete/set.sql
index 93a51dfd..b477f81c 100644
--- a/bricktracker/sql/set/delete/set.sql
+++ b/bricktracker/sql/set/delete/set.sql
@@ -7,10 +7,10 @@ DELETE FROM "bricktracker_sets"
 WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM '{{ id }}';
 
 DELETE FROM "bricktracker_set_statuses"
-WHERE "bricktracker_set_statuses"."bricktracker_set_id" IS NOT DISTINCT FROM '{{ id }}';
+WHERE "bricktracker_set_statuses"."id" IS NOT DISTINCT FROM '{{ id }}';
 
 DELETE FROM "bricktracker_minifigures"
-WHERE "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM '{{ id }}';
+WHERE "bricktracker_minifigures"."id" IS NOT DISTINCT FROM '{{ id }}';
 
 DELETE FROM "missing"
 WHERE "missing"."u_id" IS NOT DISTINCT FROM '{{ id }}';
diff --git a/bricktracker/sql/set/insert.sql b/bricktracker/sql/set/insert.sql
index 2462ac5c..7dd6dec8 100644
--- a/bricktracker/sql/set/insert.sql
+++ b/bricktracker/sql/set/insert.sql
@@ -1,6 +1,6 @@
 INSERT OR IGNORE INTO "bricktracker_sets" (
     "id",
-    "rebrickable_set"
+    "set"
 ) VALUES (
     :id,
     :set
diff --git a/bricktracker/sql/set/list/generic.sql b/bricktracker/sql/set/list/generic.sql
index 0177c2bd..d5b2da42 100644
--- a/bricktracker/sql/set/list/generic.sql
+++ b/bricktracker/sql/set/list/generic.sql
@@ -2,5 +2,5 @@
 
 {% block group %}
 GROUP BY
-    "bricktracker_sets"."rebrickable_set"
+    "bricktracker_sets"."set"
 {% endblock %}
diff --git a/bricktracker/sql/set/select/full.sql b/bricktracker/sql/set/select/full.sql
index a89d2c91..80d11614 100644
--- a/bricktracker/sql/set/select/full.sql
+++ b/bricktracker/sql/set/select/full.sql
@@ -5,7 +5,7 @@ WHERE "missing"."u_id" IS NOT DISTINCT FROM :id
 {% endblock %}
 
 {% block where_minifigures %}
-WHERE "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM :id
+WHERE "bricktracker_minifigures"."id" IS NOT DISTINCT FROM :id
 {% endblock %}
 
 {% block where %}
diff --git a/bricktracker/sql/set/update/status.sql b/bricktracker/sql/set/update/status.sql
index d72616e8..4fc78e46 100644
--- a/bricktracker/sql/set/update/status.sql
+++ b/bricktracker/sql/set/update/status.sql
@@ -1,10 +1,10 @@
 INSERT INTO "bricktracker_set_statuses" (
-    "bricktracker_set_id",
+    "id",
     "{{name}}"
 ) VALUES (
     :id,
     :status
 )
-ON CONFLICT("bricktracker_set_id")
+ON CONFLICT("id")
 DO UPDATE SET "{{name}}" = :status
-WHERE "bricktracker_set_statuses"."bricktracker_set_id" IS NOT DISTINCT FROM :id
+WHERE "bricktracker_set_statuses"."id" IS NOT DISTINCT FROM :id

From 420ff7af7ad705e8305ecee61a7199dc4aba89ab Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 27 Jan 2025 23:13:42 +0100
Subject: [PATCH 018/154] Properly use the _listener variables as expected, and
 allow Enter key to execute the action

---
 static/scripts/socket/instructions.js | 21 ++++++++------
 static/scripts/socket/set.js          | 42 +++++++++++++++++----------
 2 files changed, 38 insertions(+), 25 deletions(-)

diff --git a/static/scripts/socket/instructions.js b/static/scripts/socket/instructions.js
index 271c0c38..fb192243 100644
--- a/static/scripts/socket/instructions.js
+++ b/static/scripts/socket/instructions.js
@@ -11,15 +11,9 @@ class BrickInstructionsSocket extends BrickSocket {
         this.html_files = document.getElementById(`${id}-files`);
 
         if (this.html_button) {
-            this.download_listener = ((bricksocket) => (e) => {
-                if (!bricksocket.disabled && bricksocket.socket !== undefined && bricksocket.socket.connected) {
-                    bricksocket.toggle(false);
-
-                    bricksocket.download_instructions();
-                }
-            })(this);
-
-            this.html_button.addEventListener("click", this.download_listener);
+            this.download_listener = this.html_button.addEventListener("click", ((bricksocket) => (e) => {
+                bricksocket.execute();
+            })(this));
         }
 
         if (this.html_card_dismiss && this.html_card) {
@@ -43,6 +37,15 @@ class BrickInstructionsSocket extends BrickSocket {
         this.download_instructions(true);
     }
 
+    // Execute the action
+    execute() {
+        if (!this.disabled && this.socket !== undefined && this.socket.connected) {
+            this.toggle(false);
+
+            this.download_instructions();
+        }
+    }
+
     // Get the list of checkboxes describing files
     get_files(checked=false) {
         let files = [];
diff --git a/static/scripts/socket/set.js b/static/scripts/socket/set.js
index 60a2244a..41056b8b 100644
--- a/static/scripts/socket/set.js
+++ b/static/scripts/socket/set.js
@@ -5,6 +5,7 @@ class BrickSetSocket extends BrickSocket {
 
         // Listeners
         this.add_listener = undefined;
+        this.input_listener = undefined;
         this.confirm_listener = undefined;
 
         // Form elements (built based on the initial id)
@@ -23,24 +24,15 @@ class BrickSetSocket extends BrickSocket {
         this.html_card_dismiss = document.getElementById(`${id}-card-dismiss`);
 
         if (this.html_button) {
-            this.add_listener = ((bricksocket) => (e) => {
-                if (!bricksocket.disabled && bricksocket.socket !== undefined && bricksocket.socket.connected) {
-                    bricksocket.toggle(false);
+            this.add_listener = this.html_button.addEventListener("click", ((bricksocket) => (e) => {
+                bricksocket.execute();
+            })(this));
 
-                    // Split and save the list if bulk
-                    if (bricksocket.bulk) {
-                        bricksocket.read_set_list()
-                    }
-
-                    if (bricksocket.bulk || (bricksocket.html_no_confim && bricksocket.html_no_confim.checked)) {
-                        bricksocket.import_set(true);
-                    } else {
-                        bricksocket.load_set();
-                    }
+            this.input_listener = this.html_input.addEventListener("keyup", ((bricksocket) => (e) => {
+                if (e.key === 'Enter') {
+                    bricksocket.execute();
                 }
-            })(this);
-
-            this.html_button.addEventListener("click", this.add_listener);
+            })(this))
         }
 
         if (this.html_card_dismiss && this.html_card) {
@@ -80,6 +72,24 @@ class BrickSetSocket extends BrickSocket {
         }
     }
 
+    // Execute the action
+    execute() {
+        if (!this.disabled && this.socket !== undefined && this.socket.connected) {
+            this.toggle(false);
+
+            // Split and save the list if bulk
+            if (this.bulk) {
+                this.read_set_list();
+            }
+
+            if (this.bulk || (this.html_no_confim && this.html_no_confim.checked)) {
+                this.import_set(true);
+            } else {
+                this.load_set();
+            }
+        }
+    }
+
     // Upon receiving a fail message
     fail(data) {
         super.fail(data);

From 9878f426b129cb908a845664457879a34ce634ef Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 27 Jan 2025 23:24:16 +0100
Subject: [PATCH 019/154] Update versions and changelog

---
 CHANGELOG.md            | 27 +++++++++++++++++++++++++++
 bricktracker/version.py |  4 ++--
 2 files changed, 29 insertions(+), 2 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b87c9028..8bdc3b8d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,32 @@
 # Changelog
 
+## Unreleased
+
+## Code
+
+- General cleanup
+
+- Minifigure
+    - Deduplicate
+
+- Socket
+    - Add decorator for rebrickable, authenticated and threaded socket actions
+
+- SQL
+    - Allow for advanced migration scenarios through companion python files
+
+### UI
+
+- Add
+    - Allow adding or bulk adding by pressing Enter in the input field
+
+- Admin
+    - Grey out legacy tables in the database view
+
+- Sets
+    - Add a flag to hide instructions in a set
+
+
 ## 1.1.1: PDF Instructions Download
 
 ### Instructions
diff --git a/bricktracker/version.py b/bricktracker/version.py
index b055c6bb..4424778d 100644
--- a/bricktracker/version.py
+++ b/bricktracker/version.py
@@ -1,4 +1,4 @@
 from typing import Final
 
-__version__: Final[str] = '1.1.1'
-__database_version__: Final[int] = 6
+__version__: Final[str] = '1.2.0'
+__database_version__: Final[int] = 9

From 711c020c27fb27fa7261dd05dad4289ce059c034 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 28 Jan 2025 10:49:16 +0100
Subject: [PATCH 020/154] Add extra fields to set for the future while we are
 refactoring it

---
 bricktracker/sql/migrations/0007.sql | 19 ++++++++++++++++---
 1 file changed, 16 insertions(+), 3 deletions(-)

diff --git a/bricktracker/sql/migrations/0007.sql b/bricktracker/sql/migrations/0007.sql
index 036f12e5..a75f3f2e 100644
--- a/bricktracker/sql/migrations/0007.sql
+++ b/bricktracker/sql/migrations/0007.sql
@@ -1,4 +1,4 @@
--- description: Renaming various complicated field names to something simpler
+-- description: Renaming various complicated field names to something simpler, and add a bunch of extra fields for later
 
 PRAGMA foreign_keys = ON;
 
@@ -7,12 +7,25 @@ BEGIN TRANSACTION;
 -- Rename sets table
 ALTER TABLE "bricktracker_sets" RENAME TO "bricktracker_sets_old";
 
--- Re-Create a Bricktable set table with the simplified name
+-- Create a Bricktracker set storage table for later
+CREATE TABLE "bricktracker_set_storages" (
+    "id" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    PRIMARY KEY("id")
+);
+
+-- Re-Create a Bricktracker set table with the simplified name
 CREATE TABLE "bricktracker_sets" (
     "id" TEXT NOT NULL,
     "set" TEXT NOT NULL,
+    "description" TEXT,
+    "theme" TEXT, -- Custom theme name
+    "storage" TEXT, -- Storage bin location
+    "purchase_date" INTEGER, -- Purchase data
+    "purchase_price" REAL, -- Purchase price
     PRIMARY KEY("id"),
-    FOREIGN KEY("set") REFERENCES "rebrickable_sets"("set")
+    FOREIGN KEY("set") REFERENCES "rebrickable_sets"("set"),
+    FOREIGN KEY("storage") REFERENCES "bricktracker_set_storages"("id")
 );
 
 -- Insert existing sets into the new table

From d5f66151b94ceea87711b182c11cb8cd71def3c0 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 28 Jan 2025 10:49:47 +0100
Subject: [PATCH 021/154] Documentation touch up

---
 bricktracker/sql/migrations/0004.sql | 2 +-
 bricktracker/sql/migrations/0009.sql | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/bricktracker/sql/migrations/0004.sql b/bricktracker/sql/migrations/0004.sql
index 3828204d..ac7aa372 100644
--- a/bricktracker/sql/migrations/0004.sql
+++ b/bricktracker/sql/migrations/0004.sql
@@ -4,7 +4,7 @@ PRAGMA foreign_keys = ON;
 
 BEGIN TRANSACTION;
 
--- Create a Bricktable set table: with their unique IDs, and a reference to the Rebrickable set
+-- Create a Bricktracker set table: with their unique IDs, and a reference to the Rebrickable set
 CREATE TABLE "bricktracker_sets" (
     "id" TEXT NOT NULL,
     "rebrickable_set" TEXT NOT NULL,
diff --git a/bricktracker/sql/migrations/0009.sql b/bricktracker/sql/migrations/0009.sql
index 135f95d7..679a7612 100644
--- a/bricktracker/sql/migrations/0009.sql
+++ b/bricktracker/sql/migrations/0009.sql
@@ -4,7 +4,7 @@ PRAGMA foreign_keys = ON;
 
 BEGIN TRANSACTION;
 
--- Create a Bricktable minifigures table: an amount of minifigures linked to a Bricktracker set
+-- Create a Bricktracker minifigures table: an amount of minifigures linked to a Bricktracker set
 CREATE TABLE "bricktracker_minifigures" (
     "id" TEXT NOT NULL,
     "figure" TEXT NOT NULL,

From 50e5981c58cc40a48f9919c3f90d08b50134b833 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 28 Jan 2025 11:00:39 +0100
Subject: [PATCH 022/154] Cosmetics

---
 templates/admin/checkbox.html | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/templates/admin/checkbox.html b/templates/admin/checkbox.html
index b71cc2a6..dbe53606 100644
--- a/templates/admin/checkbox.html
+++ b/templates/admin/checkbox.html
@@ -22,7 +22,7 @@
                 data-changer-id="{{ checkbox.fields.id }}" data-changer-prefix="grid" data-changer-url="{{ url_for('admin_checkbox.update_status', id=checkbox.fields.id, name='displayed_on_grid')}}"
                 {% if checkbox.fields.displayed_on_grid %}checked{% endif %} autocomplete="off">
               <label class="form-check-label" for="grid-{{ checkbox.fields.id }}">
-                Displayed on the Set Grid
+                <i class="ri-grid-line"></i> Displayed on the Set Grid
                 <i id="status-grid-{{ checkbox.fields.id }}" class="mb-1"></i>
               </label>
             </div>
@@ -49,7 +49,7 @@
         <div class="form-check">
           <input class="form-check-input" type="checkbox" id="grid" name="grid" checked>
           <label class="form-check-label" for="grid-">
-            Displayed on the Set Grid
+            <i class="ri-grid-line"></i> Displayed on the Set Grid
           </label>
         </div>
       </div>

From 964dd9070417ca8bf1d483f5368d7fc50b849c7c Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 28 Jan 2025 14:09:14 +0100
Subject: [PATCH 023/154] Remove unused socket

---
 bricktracker/rebrickable_minifigure.py | 7 -------
 1 file changed, 7 deletions(-)

diff --git a/bricktracker/rebrickable_minifigure.py b/bricktracker/rebrickable_minifigure.py
index e2963560..af2c9260 100644
--- a/bricktracker/rebrickable_minifigure.py
+++ b/bricktracker/rebrickable_minifigure.py
@@ -9,14 +9,12 @@ from .rebrickable_image import RebrickableImage
 from .record import BrickRecord
 if TYPE_CHECKING:
     from .set import BrickSet
-    from .socket import BrickSocket
 
 logger = logging.getLogger(__name__)
 
 
 # A minifigure from Rebrickable
 class RebrickableMinifigure(BrickRecord):
-    socket: 'BrickSocket'
     brickset: 'BrickSet | None'
 
     # Queries
@@ -28,7 +26,6 @@ class RebrickableMinifigure(BrickRecord):
         /,
         *,
         brickset: 'BrickSet | None' = None,
-        socket: 'BrickSocket | None' = None,
         record: Row | dict[str, Any] | None = None
     ):
         super().__init__()
@@ -39,10 +36,6 @@ class RebrickableMinifigure(BrickRecord):
         # Save the brickset
         self.brickset = brickset
 
-        # Save the socket
-        if socket is not None:
-            self.socket = socket
-
         # Ingest the record if it has one
         if record is not None:
             self.ingest(record)

From 7ff1605c21474239cfa05a7cb48b1785f7be4a97 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 28 Jan 2025 14:19:26 +0100
Subject: [PATCH 024/154] Garbage leftover from copy-paste

---
 bricktracker/rebrickable_minifigure.py | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/bricktracker/rebrickable_minifigure.py b/bricktracker/rebrickable_minifigure.py
index af2c9260..973b9fb9 100644
--- a/bricktracker/rebrickable_minifigure.py
+++ b/bricktracker/rebrickable_minifigure.py
@@ -30,9 +30,6 @@ class RebrickableMinifigure(BrickRecord):
     ):
         super().__init__()
 
-        # Placeholders
-        self.instructions = []
-
         # Save the brickset
         self.brickset = brickset
 

From c4bb3c7607cd8910632438bdbf40f7e0a4d669bf Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 28 Jan 2025 19:18:51 +0100
Subject: [PATCH 025/154] Deduplicated parts and missing parts

---
 .env.sample                                   |  16 +-
 bricktracker/config.py                        |   6 +-
 bricktracker/minifigure.py                    |  17 +-
 bricktracker/minifigure_list.py               |  33 +-
 bricktracker/part.py                          | 319 ++++++------------
 bricktracker/part_list.py                     |  86 ++++-
 bricktracker/rebrickable_image.py             |  14 +-
 bricktracker/rebrickable_part.py              | 203 +++++++++++
 bricktracker/rebrickable_parts.py             | 113 -------
 bricktracker/set.py                           |  17 +-
 bricktracker/set_list.py                      |  26 +-
 bricktracker/sql/migrations/0010.sql          |  42 +++
 bricktracker/sql/migrations/0011.sql          |  60 ++++
 bricktracker/sql/minifigure/base/base.sql     |   1 -
 bricktracker/sql/minifigure/list/all.sql      |  17 +-
 bricktracker/sql/minifigure/list/last.sql     |   8 +-
 .../sql/minifigure/list/missing_part.sql      |  24 +-
 .../sql/minifigure/list/using_part.sql        |  15 +-
 .../sql/minifigure/select/generic.sql         |  15 +-
 .../sql/minifigure/select/specific.sql        |   4 +-
 bricktracker/sql/missing/delete/from_set.sql  |   4 -
 bricktracker/sql/missing/insert.sql           |  20 --
 bricktracker/sql/missing/update/from_set.sql  |   5 -
 bricktracker/sql/part/base/base.sql           |  56 +++
 bricktracker/sql/part/base/select.sql         |  43 ---
 bricktracker/sql/part/insert.sql              |  34 +-
 bricktracker/sql/part/list/all.sql            |  28 +-
 .../sql/part/list/from_minifigure.sql         |  23 +-
 bricktracker/sql/part/list/from_set.sql       |  21 --
 bricktracker/sql/part/list/missing.sql        |  30 +-
 bricktracker/sql/part/list/specific.sql       |  11 +
 bricktracker/sql/part/select/generic.sql      |  30 +-
 bricktracker/sql/part/select/specific.sql     |  28 +-
 bricktracker/sql/part/update/missing.sql      |   7 +
 bricktracker/sql/rebrickable/part/insert.sql  |  25 ++
 bricktracker/sql/rebrickable/part/list.sql    |  13 +
 bricktracker/sql/rebrickable/part/select.sql  |  16 +
 bricktracker/sql/schema/drop.sql              |   5 +
 bricktracker/sql/set/base/full.sql            |  10 +-
 bricktracker/sql/set/delete/set.sql           |   7 +-
 .../sql/set/list/missing_minifigure.sql       |  12 +-
 bricktracker/sql/set/list/missing_part.sql    |  15 +-
 .../sql/set/list/using_minifigure.sql         |  11 +-
 bricktracker/sql/set/list/using_part.sql      |  14 +-
 bricktracker/sql/set/select/full.sql          |   2 +-
 bricktracker/sql_counter.py                   |   7 +-
 bricktracker/version.py                       |   2 +-
 bricktracker/views/part.py                    |  27 +-
 bricktracker/views/set.py                     |  59 ++--
 templates/macro/card.html                     |   4 +-
 templates/minifigure/card.html                |   2 +-
 templates/part/card.html                      |   4 +-
 templates/part/table.html                     |  10 +-
 templates/set/card.html                       |   4 +-
 templates/set/mini.html                       |   2 +-
 55 files changed, 871 insertions(+), 756 deletions(-)
 create mode 100644 bricktracker/rebrickable_part.py
 delete mode 100644 bricktracker/rebrickable_parts.py
 create mode 100644 bricktracker/sql/migrations/0010.sql
 create mode 100644 bricktracker/sql/migrations/0011.sql
 delete mode 100644 bricktracker/sql/missing/delete/from_set.sql
 delete mode 100644 bricktracker/sql/missing/insert.sql
 delete mode 100644 bricktracker/sql/missing/update/from_set.sql
 create mode 100644 bricktracker/sql/part/base/base.sql
 delete mode 100644 bricktracker/sql/part/base/select.sql
 delete mode 100644 bricktracker/sql/part/list/from_set.sql
 create mode 100644 bricktracker/sql/part/list/specific.sql
 create mode 100644 bricktracker/sql/part/update/missing.sql
 create mode 100644 bricktracker/sql/rebrickable/part/insert.sql
 create mode 100644 bricktracker/sql/rebrickable/part/list.sql
 create mode 100644 bricktracker/sql/rebrickable/part/select.sql

diff --git a/.env.sample b/.env.sample
index 04e84ee9..46b21317 100644
--- a/.env.sample
+++ b/.env.sample
@@ -28,7 +28,7 @@
 # BK_AUTHENTICATION_KEY=change-this-to-something-random
 
 # Optional: Pattern of the link to Bricklink for a part. Will be passed to Python .format()
-# Default: https://www.bricklink.com/v2/catalog/catalogitem.page?P={number}
+# Default: https://www.bricklink.com/v2/catalog/catalogitem.page?P={part}
 # BK_BRICKLINK_LINK_PART_PATTERN=
 
 # Optional: Display Bricklink links wherever applicable
@@ -139,13 +139,13 @@
 
 # Optional: Change the default order of parts. By default ordered by insertion order.
 # Useful column names for this option are:
-# - "inventory"."part_num": part number
-# - "inventory"."name": part name
-# - "inventory"."color_name": part color name
-# - "inventory"."is_spare": par is a spare part
+# - "bricktracker_parts"."part": part number
+# - "bricktracker_parts"."spare": part is a spare part
+# - "rebrickable_parts"."name": part name
+# - "rebrickable_parts"."color_name": part color name
 # - "total_missing": number of missing parts
-# Default: "inventory"."name" ASC, "inventory"."color_name" ASC, "inventory"."is_spare" ASC
-# BK_PARTS_DEFAULT_ORDER="total_missing" DESC, "inventory"."name" ASC
+# Default: "rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "bricktracker_parts"."spare" ASC
+# BK_PARTS_DEFAULT_ORDER="total_missing" DESC, "rebrickable_parts"."name"."name" ASC
 
 # Optional: Folder where to store the parts images, relative to the '/app/static/' folder
 # Default: parts
@@ -180,7 +180,7 @@
 # BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN=
 
 # Optional: Pattern of the link to Rebrickable for a part. Will be passed to Python .format()
-# Default: https://rebrickable.com/parts/{number}/_/{color}
+# Default: https://rebrickable.com/parts/{part}/_/{color}
 # BK_REBRICKABLE_LINK_PART_PATTERN=
 
 # Optional: Pattern of the link to Rebrickable for instructions. Will be passed to Python .format()
diff --git a/bricktracker/config.py b/bricktracker/config.py
index 236eb544..83cd99a4 100644
--- a/bricktracker/config.py
+++ b/bricktracker/config.py
@@ -10,7 +10,7 @@ from typing import Any, Final
 CONFIG: Final[list[dict[str, Any]]] = [
     {'n': 'AUTHENTICATION_PASSWORD', 'd': ''},
     {'n': 'AUTHENTICATION_KEY', 'd': ''},
-    {'n': 'BRICKLINK_LINK_PART_PATTERN', 'd': 'https://www.bricklink.com/v2/catalog/catalogitem.page?P={number}'},  # noqa: E501
+    {'n': 'BRICKLINK_LINK_PART_PATTERN', 'd': 'https://www.bricklink.com/v2/catalog/catalogitem.page?P={part}'},  # noqa: E501
     {'n': 'BRICKLINK_LINKS', 'c': bool},
     {'n': 'DATABASE_PATH', 'd': './app.db'},
     {'n': 'DATABASE_TIMESTAMP_FORMAT', 'd': '%Y-%m-%d-%H-%M-%S'},
@@ -35,7 +35,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
     {'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': '"rebrickable_minifigures"."name" ASC'},  # noqa: E501
     {'n': 'MINIFIGURES_FOLDER', 'd': 'minifigs', 's': True},
     {'n': 'NO_THREADED_SOCKET', 'c': bool},
-    {'n': 'PARTS_DEFAULT_ORDER', 'd': '"inventory"."name" ASC, "inventory"."color_name" ASC, "inventory"."is_spare" ASC'},  # noqa: E501
+    {'n': 'PARTS_DEFAULT_ORDER', 'd': '"rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "bricktracker_parts"."spare" ASC'},  # noqa: E501
     {'n': 'PARTS_FOLDER', 'd': 'parts', 's': True},
     {'n': 'PORT', 'd': 3333, 'c': int},
     {'n': 'RANDOM', 'e': 'RANDOM', 'c': bool},
@@ -43,7 +43,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
     {'n': 'REBRICKABLE_IMAGE_NIL', 'd': 'https://rebrickable.com/static/img/nil.png'},  # noqa: E501
     {'n': 'REBRICKABLE_IMAGE_NIL_MINIFIGURE', 'd': 'https://rebrickable.com/static/img/nil_mf.jpg'},  # noqa: E501
     {'n': 'REBRICKABLE_LINK_MINIFIGURE_PATTERN', 'd': 'https://rebrickable.com/minifigs/{figure}'},  # noqa: E501
-    {'n': 'REBRICKABLE_LINK_PART_PATTERN', 'd': 'https://rebrickable.com/parts/{number}/_/{color}'},  # noqa: E501
+    {'n': 'REBRICKABLE_LINK_PART_PATTERN', 'd': 'https://rebrickable.com/parts/{part}/_/{color}'},  # noqa: E501
     {'n': 'REBRICKABLE_LINK_INSTRUCTIONS_PATTERN', 'd': 'https://rebrickable.com/instructions/{path}'},  # noqa: E501
     {'n': 'REBRICKABLE_USER_AGENT', 'd': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'},  # noqa: E501
     {'n': 'REBRICKABLE_LINKS', 'e': 'LINKS', 'c': bool},
diff --git a/bricktracker/minifigure.py b/bricktracker/minifigure.py
index 76a482e0..e0318a05 100644
--- a/bricktracker/minifigure.py
+++ b/bricktracker/minifigure.py
@@ -4,7 +4,6 @@ from typing import Self, TYPE_CHECKING
 
 from .exceptions import ErrorException, NotFoundException
 from .part_list import BrickPartList
-from .rebrickable_parts import RebrickableParts
 from .rebrickable_minifigure import RebrickableMinifigure
 if TYPE_CHECKING:
     from .set import BrickSet
@@ -20,7 +19,8 @@ class BrickMinifigure(RebrickableMinifigure):
     generic_query: str = 'minifigure/select/generic'
     select_query: str = 'minifigure/select/specific'
 
-    def download(self, socket: 'BrickSocket'):
+    # Import a minifigure into the database
+    def download(self, socket: 'BrickSocket') -> bool:
         if self.brickset is None:
             raise ErrorException('Importing a minifigure from Rebrickable outside of a set is not supported')  # noqa: E501
 
@@ -40,11 +40,12 @@ class BrickMinifigure(RebrickableMinifigure):
             self.insert_rebrickable()
 
             # Load the inventory
-            RebrickableParts(
+            if not BrickPartList.download(
                 socket,
                 self.brickset,
-                minifigure=self,
-            ).download()
+                minifigure=self
+            ):
+                return False
 
         except Exception as e:
             socket.fail(
@@ -57,6 +58,10 @@ class BrickMinifigure(RebrickableMinifigure):
 
             logger.debug(traceback.format_exc())
 
+            return False
+
+        return True
+
     # Parts
     def generic_parts(self, /) -> BrickPartList:
         return BrickPartList().from_minifigure(self)
@@ -68,7 +73,7 @@ class BrickMinifigure(RebrickableMinifigure):
                 figure=self.fields.figure,
             ))
 
-        return BrickPartList().load(self.brickset, minifigure=self)
+        return BrickPartList().list_specific(self.brickset, minifigure=self)
 
     # Select a generic minifigure
     def select_generic(self, figure: str, /) -> Self:
diff --git a/bricktracker/minifigure_list.py b/bricktracker/minifigure_list.py
index 24b9933a..a59fee57 100644
--- a/bricktracker/minifigure_list.py
+++ b/bricktracker/minifigure_list.py
@@ -82,16 +82,13 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
     # Minifigures missing a part
     def missing_part(
         self,
-        part_num: str,
-        color_id: int,
+        part: str,
+        color: int,
         /,
-        *,
-        element_id: int | None = None,
     ) -> Self:
         # Save the parameters to the fields
-        self.fields.part_num = part_num
-        self.fields.color_id = color_id
-        self.fields.element_id = element_id
+        self.fields.part = part
+        self.fields.color = color
 
         # Load the minifigures from the database
         for record in self.select(
@@ -107,16 +104,13 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
     # Minifigure using a part
     def using_part(
         self,
-        part_num: str,
-        color_id: int,
+        part: str,
+        color: int,
         /,
-        *,
-        element_id: int | None = None,
     ) -> Self:
         # Save the parameters to the fields
-        self.fields.part_num = part_num
-        self.fields.color_id = color_id
-        self.fields.element_id = element_id
+        self.fields.part = part
+        self.fields.color = color
 
         # Load the minifigures from the database
         for record in self.select(
@@ -140,7 +134,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
 
     # Import the minifigures from Rebrickable
     @staticmethod
-    def download(socket: 'BrickSocket', brickset: 'BrickSet', /) -> None:
+    def download(socket: 'BrickSocket', brickset: 'BrickSet', /) -> bool:
         try:
             socket.auto_progress(
                 message='Set {set}: loading minifigures from Rebrickable'.format(  # noqa: E501
@@ -162,10 +156,11 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
             ).list()
 
             # Process each minifigure
-            socket.update_total(len(minifigures), add=True)
-
             for minifigure in minifigures:
-                minifigure.download(socket)
+                if not minifigure.download(socket):
+                    return False
+
+            return True
 
         except Exception as e:
             socket.fail(
@@ -176,3 +171,5 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
             )
 
             logger.debug(traceback.format_exc())
+
+            return False
diff --git a/bricktracker/part.py b/bricktracker/part.py
index b6dc1530..7e82c454 100644
--- a/bricktracker/part.py
+++ b/bricktracker/part.py
@@ -1,103 +1,104 @@
-import os
-from sqlite3 import Row
+import logging
 from typing import Any, Self, TYPE_CHECKING
-from urllib.parse import urlparse
+import traceback
 
-from flask import current_app, url_for
-
-from .exceptions import DatabaseException, ErrorException, NotFoundException
-from .rebrickable_image import RebrickableImage
-from .record import BrickRecord
+from .exceptions import ErrorException, NotFoundException
+from .rebrickable_part import RebrickablePart
 from .sql import BrickSQL
 if TYPE_CHECKING:
     from .minifigure import BrickMinifigure
     from .set import BrickSet
+    from .socket import BrickSocket
+
+logger = logging.getLogger(__name__)
 
 
 # Lego set or minifig part
-class BrickPart(BrickRecord):
-    brickset: 'BrickSet | None'
-    minifigure: 'BrickMinifigure | None'
+class BrickPart(RebrickablePart):
+    identifier: str
+    kind: str
 
     # Queries
     insert_query: str = 'part/insert'
     generic_query: str = 'part/select/generic'
     select_query: str = 'part/select/specific'
 
-    def __init__(
-        self,
-        /,
-        *,
-        brickset: 'BrickSet | None' = None,
-        minifigure: 'BrickMinifigure | None' = None,
-        record: Row | dict[str, Any] | None = None,
-    ):
-        super().__init__()
+    def __init__(self, /, **kwargs):
+        super().__init__(**kwargs)
 
-        # Save the brickset and minifigure
-        self.brickset = brickset
-        self.minifigure = minifigure
+        if self.minifigure is not None:
+            self.identifier = self.minifigure.fields.figure
+            self.kind = 'Minifigure'
+        elif self.brickset is not None:
+            self.identifier = self.brickset.fields.set
+            self.kind = 'Set'
 
-        # Ingest the record if it has one
-        if record is not None:
-            self.ingest(record)
+    # Import a part into the database
+    def download(self, socket: 'BrickSocket') -> bool:
+        if self.brickset is None:
+            raise ErrorException('Importing a part from Rebrickable outside of a set is not supported')  # noqa: E501
 
-    # Delete missing part
-    def delete_missing(self, /) -> None:
-        BrickSQL().execute_and_commit(
-            'missing/delete/from_set',
-            parameters=self.sql_parameters()
-        )
-
-    # Set missing part
-    def set_missing(self, quantity: int, /) -> None:
-        parameters = self.sql_parameters()
-        parameters['quantity'] = quantity
-
-        # Can't use UPSERT because the database has no keys
-        # Try to update
-        database = BrickSQL()
-        rows, _ = database.execute(
-            'missing/update/from_set',
-            parameters=parameters,
-        )
-
-        # Insert if no row has been affected
-        if not rows:
-            rows, _ = database.execute(
-                'missing/insert',
-                parameters=parameters,
+        try:
+            # Insert into the database
+            socket.auto_progress(
+                message='{kind} {identifier}: inserting part {part} into database'.format(  # noqa: E501
+                    kind=self.kind,
+                    identifier=self.identifier,
+                    part=self.fields.part
+                )
             )
 
-            if rows != 1:
-                raise DatabaseException(
-                    'Could not update the missing quantity for part {id}'.format(  # noqa: E501
-                        id=self.fields.id
-                    )
-                )
+            # Insert into database
+            self.insert(commit=False)
 
-        database.commit()
+            # Insert the rebrickable set into database
+            self.insert_rebrickable()
+
+        except Exception as e:
+            socket.fail(
+                message='Error while importing part {part} from {kind} {identifier}: {error}'.format(  # noqa: E501
+                    part=self.fields.part,
+                    kind=self.kind,
+                    identifier=self.identifier,
+                    error=e,
+                )
+            )
+
+            logger.debug(traceback.format_exc())
+
+            return False
+
+        return True
+
+    # A identifier for HTML component
+    def html_id(self) -> str:
+        components: list[str] = []
+
+        if self.fields.figure is not None:
+            components.append(self.fields.figure)
+
+        components.append(self.fields.part)
+        components.append(str(self.fields.color))
+        components.append(str(self.fields.spare))
+
+        return '-'.join(components)
 
     # Select a generic part
     def select_generic(
         self,
-        part_num: str,
-        color_id: int,
+        part: str,
+        color: int,
         /,
-        *,
-        element_id: int | None = None
     ) -> Self:
         # Save the parameters to the fields
-        self.fields.part_num = part_num
-        self.fields.color_id = color_id
-        self.fields.element_id = element_id
+        self.fields.part = part
+        self.fields.color = color
 
         if not self.select(override_query=self.generic_query):
             raise NotFoundException(
-                'Part with number {number}, color ID {color} and element ID {element} was not found in the database'.format(  # noqa: E501
-                    number=self.fields.part_num,
-                    color=self.fields.color_id,
-                    element=self.fields.element_id,
+                'Part with number {number}, color ID {color} was not found in the database'.format(  # noqa: E501
+                    number=self.fields.part,
+                    color=self.fields.color,
                 ),
             )
 
@@ -107,7 +108,9 @@ class BrickPart(BrickRecord):
     def select_specific(
         self,
         brickset: 'BrickSet',
-        id: str,
+        part: str,
+        color: int,
+        spare: int,
         /,
         *,
         minifigure: 'BrickMinifigure | None' = None,
@@ -115,168 +118,48 @@ class BrickPart(BrickRecord):
         # Save the parameters to the fields
         self.brickset = brickset
         self.minifigure = minifigure
-        self.fields.id = id
+        self.fields.part = part
+        self.fields.color = color
+        self.fields.spare = spare
 
         if not self.select():
+            if self.minifigure is not None:
+                figure = self.minifigure.fields.figure
+            else:
+                figure = None
+
             raise NotFoundException(
-                'Part with ID {id} from set {set} was not found in the database'.format(  # noqa: E501
+                'Part {part} with color {color} (spare: {spare}) from set {set} ({id}) (minifigure: {figure}) was not found in the database'.format(  # noqa: E501
+                    part=self.fields.part,
+                    color=self.fields.color,
+                    spare=self.fields.spare,
                     id=self.fields.id,
                     set=self.brickset.fields.set,
+                    figure=figure,
                 ),
             )
 
         return self
 
-    # Return a dict with common SQL parameters for a part
-    def sql_parameters(self, /) -> dict[str, Any]:
-        parameters = super().sql_parameters()
-
-        # Supplement from the brickset
-        if 'u_id' not in parameters and self.brickset is not None:
-            parameters['u_id'] = self.brickset.fields.id
-
-        if 'set_num' not in parameters:
-            if self.minifigure is not None:
-                parameters['set_num'] = self.minifigure.fields.figure
-
-            elif self.brickset is not None:
-                parameters['set_num'] = self.brickset.fields.set
-
-        return parameters
-
     # Update the missing part
     def update_missing(self, missing: Any, /) -> None:
-        # If empty, delete it
-        if missing == '':
-            self.delete_missing()
+        # We need a positive integer
+        try:
+            missing = int(missing)
 
-        else:
-            # Try to understand it as a number
-            try:
-                missing = int(missing)
-            except Exception:
-                raise ErrorException('"{missing}" is not a valid integer'.format(  # noqa: E501
-                    missing=missing
-                ))
+            if missing < 0:
+                missing = 0
+        except Exception:
+            raise ErrorException('"{missing}" is not a valid integer'.format(
+                missing=missing
+            ))
 
-            # If 0, delete it
-            if missing == 0:
-                self.delete_missing()
+        if missing < 0:
+            raise ErrorException('Cannot set a negative missing value')
 
-            else:
-                # If negative, it's an error
-                if missing < 0:
-                    raise ErrorException('Cannot set a negative missing value')
+        self.fields.missing = missing
 
-                # Otherwise upsert it
-                # Not checking if it is too much, you do you
-                self.set_missing(missing)
-
-    # Self url
-    def url(self, /) -> str:
-        return url_for(
-            'part.details',
-            number=self.fields.part_num,
-            color=self.fields.color_id,
-            element=self.fields.element_id,
+        BrickSQL().execute_and_commit(
+            'part/update/missing',
+            parameters=self.sql_parameters()
         )
-
-    # Compute the url for the bricklink page
-    def url_for_bricklink(self, /) -> str:
-        if current_app.config['BRICKLINK_LINKS']:
-            try:
-                return current_app.config['BRICKLINK_LINK_PART_PATTERN'].format(  # noqa: E501
-                    number=self.fields.part_num,
-                )
-            except Exception:
-                pass
-
-        return ''
-
-    # Compute the url for the part image
-    def url_for_image(self, /) -> str:
-        if not current_app.config['USE_REMOTE_IMAGES']:
-            if self.fields.part_img_url is None:
-                file = RebrickableImage.nil_name()
-            else:
-                file = self.fields.part_img_url_id
-
-            return RebrickableImage.static_url(file, 'PARTS_FOLDER')
-        else:
-            if self.fields.part_img_url is None:
-                return current_app.config['REBRICKABLE_IMAGE_NIL']
-            else:
-                return self.fields.part_img_url
-
-    # Compute the url for missing part
-    def url_for_missing(self, /) -> str:
-        # Different URL for a minifigure part
-        if self.minifigure is not None:
-            return url_for(
-                'set.missing_minifigure_part',
-                id=self.fields.u_id,
-                figure=self.minifigure.fields.figure,
-                part=self.fields.id,
-            )
-
-        return url_for(
-            'set.missing_part',
-            id=self.fields.u_id,
-            part=self.fields.id
-        )
-
-    # Compute the url for the rebrickable page
-    def url_for_rebrickable(self, /) -> str:
-        if current_app.config['REBRICKABLE_LINKS']:
-            try:
-                return current_app.config['REBRICKABLE_LINK_PART_PATTERN'].format(  # noqa: E501
-                    number=self.fields.part_num,
-                    color=self.fields.color_id,
-                )
-            except Exception:
-                pass
-
-        return ''
-
-    # Normalize from Rebrickable
-    @staticmethod
-    def from_rebrickable(
-        data: dict[str, Any],
-        /,
-        *,
-        brickset: 'BrickSet | None' = None,
-        minifigure: 'BrickMinifigure | None' = None,
-        **_,
-    ) -> dict[str, Any]:
-        record = {
-            'set_num': data['set_num'],
-            'id': data['id'],
-            'part_num': data['part']['part_num'],
-            'name': data['part']['name'],
-            'part_img_url': data['part']['part_img_url'],
-            'part_img_url_id': None,
-            'color_id': data['color']['id'],
-            'color_name': data['color']['name'],
-            'quantity': data['quantity'],
-            'is_spare': data['is_spare'],
-            'element_id': data['element_id'],
-        }
-
-        if brickset is not None:
-            record['u_id'] = brickset.fields.id
-
-        if minifigure is not None:
-            record['set_num'] = data['fig_num']
-
-        # Extract the file name
-        if data['part']['part_img_url'] is not None:
-            part_img_url_file = os.path.basename(
-                urlparse(data['part']['part_img_url']).path
-            )
-
-            part_img_url_id, _ = os.path.splitext(part_img_url_file)
-
-            if part_img_url_id is not None or part_img_url_id != '':
-                record['part_img_url_id'] = part_img_url_id
-
-        return record
diff --git a/bricktracker/part_list.py b/bricktracker/part_list.py
index 7805d57e..0074b9bf 100644
--- a/bricktracker/part_list.py
+++ b/bricktracker/part_list.py
@@ -1,12 +1,18 @@
+import logging
 from typing import Any, Self, TYPE_CHECKING
+import traceback
 
 from flask import current_app
 
 from .part import BrickPart
+from .rebrickable import Rebrickable
 from .record_list import BrickRecordList
 if TYPE_CHECKING:
     from .minifigure import BrickMinifigure
     from .set import BrickSet
+    from .socket import BrickSocket
+
+logger = logging.getLogger(__name__)
 
 
 # Lego set or minifig parts
@@ -20,7 +26,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
     last_query: str = 'part/list/last'
     minifigure_query: str = 'part/list/from_minifigure'
     missing_query: str = 'part/list/missing'
-    select_query: str = 'part/list/from_set'
+    select_query: str = 'part/list/specific'
 
     def __init__(self, /):
         super().__init__()
@@ -44,8 +50,8 @@ class BrickPartList(BrickRecordList[BrickPart]):
 
         return self
 
-    # Load parts from a brickset or minifigure
-    def load(
+    # List specific parts from a brickset or minifigure
+    def list_specific(
         self,
         brickset: 'BrickSet',
         /,
@@ -64,7 +70,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
                 record=record,
             )
 
-            if current_app.config['SKIP_SPARE_PARTS'] and part.fields.is_spare:
+            if current_app.config['SKIP_SPARE_PARTS'] and part.fields.spare:
                 continue
 
             self.records.append(part)
@@ -90,7 +96,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
                 record=record,
             )
 
-            if current_app.config['SKIP_SPARE_PARTS'] and part.fields.is_spare:
+            if current_app.config['SKIP_SPARE_PARTS'] and part.fields.spare:
                 continue
 
             self.records.append(part)
@@ -115,13 +121,73 @@ class BrickPartList(BrickRecordList[BrickPart]):
 
         # Set id
         if self.brickset is not None:
-            parameters['u_id'] = self.brickset.fields.id
+            parameters['id'] = self.brickset.fields.id
 
         # Use the minifigure number if present,
-        # otherwise use the set number
         if self.minifigure is not None:
-            parameters['set_num'] = self.minifigure.fields.figure
-        elif self.brickset is not None:
-            parameters['set_num'] = self.brickset.fields.set
+            parameters['figure'] = self.minifigure.fields.figure
+        else:
+            parameters['figure'] = None
 
         return parameters
+
+    # Import the parts from Rebrickable
+    @staticmethod
+    def download(
+        socket: 'BrickSocket',
+        brickset: 'BrickSet',
+        /,
+        *,
+        minifigure: 'BrickMinifigure | None' = None,
+    ) -> bool:
+        if minifigure is not None:
+            identifier = minifigure.fields.figure
+            kind = 'Minifigure'
+            method = 'get_minifig_elements'
+        else:
+            identifier = brickset.fields.set
+            kind = 'Set'
+            method = 'get_set_elements'
+
+        try:
+            socket.auto_progress(
+                message='{kind} {identifier}: loading parts inventory from Rebrickable'.format(  # noqa: E501
+                    kind=kind,
+                    identifier=identifier,
+                ),
+                increment_total=True,
+            )
+
+            logger.debug('rebrick.lego.{method}("{identifier}")'.format(
+                method=method,
+                identifier=identifier,
+            ))
+
+            inventory = Rebrickable[BrickPart](
+                method,
+                identifier,
+                BrickPart,
+                socket=socket,
+                brickset=brickset,
+                minifigure=minifigure,
+            ).list()
+
+            # Process each part
+            for part in inventory:
+                if not part.download(socket):
+                    return False
+
+        except Exception as e:
+            socket.fail(
+                message='Error while importing {kind} {identifier} parts list: {error}'.format(  # noqa: E501
+                    kind=kind,
+                    identifier=identifier,
+                    error=e,
+                )
+            )
+
+            logger.debug(traceback.format_exc())
+
+            return False
+
+        return True
diff --git a/bricktracker/rebrickable_image.py b/bricktracker/rebrickable_image.py
index f15a9b4c..509e7186 100644
--- a/bricktracker/rebrickable_image.py
+++ b/bricktracker/rebrickable_image.py
@@ -9,7 +9,7 @@ from shutil import copyfileobj
 from .exceptions import DownloadException
 if TYPE_CHECKING:
     from .rebrickable_minifigure import RebrickableMinifigure
-    from .part import BrickPart
+    from .rebrickable_part import RebrickablePart
     from .rebrickable_set import RebrickableSet
 
 
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
 class RebrickableImage(object):
     set: 'RebrickableSet'
     minifigure: 'RebrickableMinifigure | None'
-    part: 'BrickPart | None'
+    part: 'RebrickablePart | None'
 
     extension: str | None
 
@@ -27,7 +27,7 @@ class RebrickableImage(object):
         /,
         *,
         minifigure: 'RebrickableMinifigure | None' = None,
-        part: 'BrickPart | None' = None,
+        part: 'RebrickablePart | None' = None,
     ):
         # Save all objects
         self.set = set
@@ -81,10 +81,10 @@ class RebrickableImage(object):
     # Return the id depending on the objects provided
     def id(self, /) -> str:
         if self.part is not None:
-            if self.part.fields.part_img_url_id is None:
+            if self.part.fields.image_id is None:
                 return RebrickableImage.nil_name()
             else:
-                return self.part.fields.part_img_url_id
+                return self.part.fields.image_id
 
         if self.minifigure is not None:
             if self.minifigure.fields.image is None:
@@ -105,10 +105,10 @@ class RebrickableImage(object):
     # Return the url depending on the objects provided
     def url(self, /) -> str:
         if self.part is not None:
-            if self.part.fields.part_img_url is None:
+            if self.part.fields.image is None:
                 return current_app.config['REBRICKABLE_IMAGE_NIL']
             else:
-                return self.part.fields.part_img_url
+                return self.part.fields.image
 
         if self.minifigure is not None:
             if self.minifigure.fields.image is None:
diff --git a/bricktracker/rebrickable_part.py b/bricktracker/rebrickable_part.py
new file mode 100644
index 00000000..93c6b34e
--- /dev/null
+++ b/bricktracker/rebrickable_part.py
@@ -0,0 +1,203 @@
+import os
+import logging
+from sqlite3 import Row
+from typing import Any, TYPE_CHECKING
+from urllib.parse import urlparse
+
+from flask import current_app, url_for
+
+from .exceptions import ErrorException
+from .rebrickable_image import RebrickableImage
+from .record import BrickRecord
+if TYPE_CHECKING:
+    from .minifigure import BrickMinifigure
+    from .set import BrickSet
+    from .socket import BrickSocket
+
+logger = logging.getLogger(__name__)
+
+
+# A part from Rebrickable
+class RebrickablePart(BrickRecord):
+    socket: 'BrickSocket'
+    brickset: 'BrickSet | None'
+    minifigure: 'BrickMinifigure | None'
+
+    # Queries
+    select_query: str = 'rebrickable/part/select'
+    insert_query: str = 'rebrickable/part/insert'
+
+    def __init__(
+        self,
+        /,
+        *,
+        brickset: 'BrickSet | None' = None,
+        minifigure: 'BrickMinifigure | None' = None,
+        record: Row | dict[str, Any] | None = None
+    ):
+        super().__init__()
+
+        # Save the brickset
+        self.brickset = brickset
+
+        # Save the minifigure
+        self.minifigure = minifigure
+
+        # Ingest the record if it has one
+        if record is not None:
+            self.ingest(record)
+
+    # Insert the part from Rebrickable
+    def insert_rebrickable(self, /) -> bool:
+        if self.brickset is None:
+            raise ErrorException('Importing a part from Rebrickable outside of a set is not supported')  # noqa: E501
+
+        # Insert the Rebrickable part to the database
+        rows, _ = self.insert(
+            commit=False,
+            no_defer=True,
+            override_query=RebrickablePart.insert_query
+        )
+
+        inserted = rows > 0
+
+        if inserted:
+            if not current_app.config['USE_REMOTE_IMAGES']:
+                RebrickableImage(
+                    self.brickset,
+                    minifigure=self.minifigure,
+                    part=self,
+                ).download()
+
+        return inserted
+
+    # Return a dict with common SQL parameters for a part
+    def sql_parameters(self, /) -> dict[str, Any]:
+        parameters = super().sql_parameters()
+
+        # Set id
+        if self.brickset is not None:
+            parameters['id'] = self.brickset.fields.id
+
+        # Use the minifigure number if present,
+        if self.minifigure is not None:
+            parameters['figure'] = self.minifigure.fields.figure
+        else:
+            parameters['figure'] = None
+
+        return parameters
+
+    # Self url
+    def url(self, /) -> str:
+        return url_for(
+            'part.details',
+            part=self.fields.part,
+            color=self.fields.color,
+        )
+
+    # Compute the url for the bricklink page
+    def url_for_bricklink(self, /) -> str:
+        if current_app.config['BRICKLINK_LINKS']:
+            try:
+                return current_app.config['BRICKLINK_LINK_PART_PATTERN'].format(  # noqa: E501
+                    part=self.fields.part,
+                )
+            except Exception:
+                pass
+
+        return ''
+
+    # Compute the url for the part image
+    def url_for_image(self, /) -> str:
+        if not current_app.config['USE_REMOTE_IMAGES']:
+            if self.fields.image is None:
+                file = RebrickableImage.nil_name()
+            else:
+                file = self.fields.image_id
+
+            return RebrickableImage.static_url(file, 'PARTS_FOLDER')
+        else:
+            if self.fields.image is None:
+                return current_app.config['REBRICKABLE_IMAGE_NIL']
+            else:
+                return self.fields.image
+
+    # Compute the url for missing part
+    def url_for_missing(self, /) -> str:
+        # Different URL for a minifigure part
+        if self.minifigure is not None:
+            figure = self.minifigure.fields.figure
+        else:
+            figure = None
+
+        return url_for(
+            'set.missing_part',
+            id=self.fields.id,
+            figure=figure,
+            part=self.fields.part,
+            color=self.fields.color,
+            spare=self.fields.spare,
+        )
+
+    # Compute the url for the rebrickable page
+    def url_for_rebrickable(self, /) -> str:
+        if current_app.config['REBRICKABLE_LINKS']:
+            try:
+                return current_app.config['REBRICKABLE_LINK_PART_PATTERN'].format(  # noqa: E501
+                    part=self.fields.part,
+                    color=self.fields.color,
+                )
+            except Exception:
+                pass
+
+        return ''
+
+    # Normalize from Rebrickable
+    @staticmethod
+    def from_rebrickable(
+        data: dict[str, Any],
+        /,
+        *,
+        brickset: 'BrickSet | None' = None,
+        minifigure: 'BrickMinifigure | None' = None,
+        **_,
+    ) -> dict[str, Any]:
+        record = {
+            'id': None,
+            'figure': None,
+            'part': data['part']['part_num'],
+            'color': data['color']['id'],
+            'spare': data['is_spare'],
+            'quantity': data['quantity'],
+            'rebrickable_inventory': data['id'],
+            'element': data['element_id'],
+            'color_id': data['color']['id'],
+            'color_name': data['color']['name'],
+            'color_rgb': data['color']['rgb'],
+            'color_transparent': data['color']['is_trans'],
+            'name': data['part']['name'],
+            'category': data['part']['part_cat_id'],
+            'image': data['part']['part_img_url'],
+            'image_id': None,
+            'url': data['part']['part_url'],
+            'print': data['part']['print_of']
+        }
+
+        if brickset is not None:
+            record['id'] = brickset.fields.id
+
+        if minifigure is not None:
+            record['figure'] = minifigure.fields.figure
+
+        # Extract the file name
+        if record['image'] is not None:
+            image_id, _ = os.path.splitext(
+                os.path.basename(
+                    urlparse(record['image']).path
+                )
+            )
+
+            if image_id is not None or image_id != '':
+                record['image_id'] = image_id
+
+        return record
diff --git a/bricktracker/rebrickable_parts.py b/bricktracker/rebrickable_parts.py
deleted file mode 100644
index 9fd23412..00000000
--- a/bricktracker/rebrickable_parts.py
+++ /dev/null
@@ -1,113 +0,0 @@
-import logging
-from typing import TYPE_CHECKING
-
-from flask import current_app
-
-from .part import BrickPart
-from .rebrickable import Rebrickable
-from .rebrickable_image import RebrickableImage
-if TYPE_CHECKING:
-    from .minifigure import BrickMinifigure
-    from .set import BrickSet
-    from .socket import BrickSocket
-
-logger = logging.getLogger(__name__)
-
-
-# A list of parts from Rebrickable
-class RebrickableParts(object):
-    socket: 'BrickSocket'
-    brickset: 'BrickSet'
-    minifigure: 'BrickMinifigure | None'
-
-    number: str
-    kind: str
-    method: str
-
-    def __init__(
-        self,
-        socket: 'BrickSocket',
-        brickset: 'BrickSet',
-        /,
-        *,
-        minifigure: 'BrickMinifigure | None' = None,
-    ):
-        # Save the socket
-        self.socket = socket
-
-        # Save the objects
-        self.brickset = brickset
-        self.minifigure = minifigure
-
-        if self.minifigure is not None:
-            self.number = self.minifigure.fields.figure
-            self.kind = 'Minifigure'
-            self.method = 'get_minifig_elements'
-        else:
-            self.number = self.brickset.fields.set
-            self.kind = 'Set'
-            self.method = 'get_set_elements'
-
-    # Import the parts from Rebrickable
-    def download(self, /) -> None:
-        self.socket.auto_progress(
-            message='{kind} {number}: loading parts inventory from Rebrickable'.format(  # noqa: E501
-                kind=self.kind,
-                number=self.number,
-            ),
-            increment_total=True,
-        )
-
-        logger.debug('rebrick.lego.{method}("{number}")'.format(
-            method=self.method,
-            number=self.number,
-        ))
-
-        inventory = Rebrickable[BrickPart](
-            self.method,
-            self.number,
-            BrickPart,
-            socket=self.socket,
-            brickset=self.brickset,
-            minifigure=self.minifigure,
-        ).list()
-
-        # Process each part
-        total = len(inventory)
-        for index, part in enumerate(inventory):
-            # Skip spare parts
-            if (
-                current_app.config['SKIP_SPARE_PARTS'] and
-                part.fields.is_spare
-            ):
-                continue
-
-            # Insert into the database
-            self.socket.auto_progress(
-                message='{kind} {number}: inserting part {current}/{total} into database'.format(  # noqa: E501
-                    kind=self.kind,
-                    number=self.number,
-                    current=index+1,
-                    total=total,
-                )
-            )
-
-            # Insert into database
-            part.insert(commit=False)
-
-            # Grab the image
-            self.socket.progress(
-                message='{kind} {number}: downloading part {current}/{total} image'.format(  # noqa: E501
-                    kind=self.kind,
-                    number=self.number,
-                    current=index+1,
-                    total=total,
-                )
-            )
-
-            if not current_app.config['USE_REMOTE_IMAGES']:
-                RebrickableImage(
-                    self.brickset,
-                    minifigure=self.minifigure,
-                    part=part,
-                ).download()
diff --git a/bricktracker/set.py b/bricktracker/set.py
index 52a2ed93..63d41281 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -8,7 +8,6 @@ from flask import current_app, url_for
 from .exceptions import DatabaseException, NotFoundException
 from .minifigure_list import BrickMinifigureList
 from .part_list import BrickPartList
-from .rebrickable_parts import RebrickableParts
 from .rebrickable_set import RebrickableSet
 from .set_checkbox import BrickSetCheckbox
 from .set_checkbox_list import BrickSetCheckboxList
@@ -34,10 +33,10 @@ class BrickSet(RebrickableSet):
         )
 
     # Import a set into the database
-    def download(self, socket: 'BrickSocket', data: dict[str, Any], /) -> None:
+    def download(self, socket: 'BrickSocket', data: dict[str, Any], /) -> bool:
         # Load the set
         if not self.load(socket, data, from_download=True):
-            return
+            return False
 
         try:
             # Insert into the database
@@ -58,10 +57,12 @@ class BrickSet(RebrickableSet):
             self.insert_rebrickable()
 
             # Load the inventory
-            RebrickableParts(socket, self).download()
+            if not BrickPartList.download(socket, self):
+                return False
 
             # Load the minifigures
-            BrickMinifigureList.download(socket, self)
+            if not BrickMinifigureList.download(socket, self):
+                return False
 
             # Commit the transaction to the database
             socket.auto_progress(
@@ -98,13 +99,17 @@ class BrickSet(RebrickableSet):
 
             logger.debug(traceback.format_exc())
 
+            return False
+
+        return True
+
     # Minifigures
     def minifigures(self, /) -> BrickMinifigureList:
         return BrickMinifigureList().from_set(self)
 
     # Parts
     def parts(self, /) -> BrickPartList:
-        return BrickPartList().load(self)
+        return BrickPartList().list_specific(self)
 
     # Select a light set (with an id)
     def select_light(self, id: str, /) -> Self:
diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py
index 58ae8ec3..349af66b 100644
--- a/bricktracker/set_list.py
+++ b/bricktracker/set_list.py
@@ -100,16 +100,13 @@ class BrickSetList(BrickRecordList[BrickSet]):
     # Sets missing a part
     def missing_part(
         self,
-        part_num: str,
-        color_id: int,
-        /,
-        *,
-        element_id: int | None = None,
+        part: str,
+        color: int,
+        /
     ) -> Self:
         # Save the parameters to the fields
-        self.fields.part_num = part_num
-        self.fields.color_id = color_id
-        self.fields.element_id = element_id
+        self.fields.part = part
+        self.fields.color = color
 
         # Load the sets from the database
         for record in self.select(
@@ -141,16 +138,13 @@ class BrickSetList(BrickRecordList[BrickSet]):
     # Sets using a part
     def using_part(
         self,
-        part_num: str,
-        color_id: int,
-        /,
-        *,
-        element_id: int | None = None,
+        part: str,
+        color: int,
+        /
     ) -> Self:
         # Save the parameters to the fields
-        self.fields.part_num = part_num
-        self.fields.color_id = color_id
-        self.fields.element_id = element_id
+        self.fields.part = part
+        self.fields.color = color
 
         # Load the sets from the database
         for record in self.select(
diff --git a/bricktracker/sql/migrations/0010.sql b/bricktracker/sql/migrations/0010.sql
new file mode 100644
index 00000000..8b4b6e63
--- /dev/null
+++ b/bricktracker/sql/migrations/0010.sql
@@ -0,0 +1,42 @@
+-- description: Creation of the deduplicated table of Rebrickable parts, and add a bunch of extra fields for later
+
+BEGIN TRANSACTION;
+
+-- Create a Rebrickable parts table: each unique part imported from Rebrickable
+CREATE TABLE "rebrickable_parts" (
+    "part" TEXT NOT NULL,
+    "color_id" INTEGER NOT NULL,
+    "color_name" TEXT NOT NULL,
+    "color_rgb" TEXT, -- can be NULL because it was not saved before
+    "color_transparent" BOOLEAN, -- can be NULL because it was not saved before
+    "name" TEXT NOT NULL,
+    "category" INTEGER, -- can be NULL because it was not saved before
+    "image" TEXT,
+    "image_id" TEXT,
+    "url" TEXT, -- can be NULL because it was not saved before
+    "print" INTEGER, -- can be NULL, was not saved before
+    PRIMARY KEY("part", "color_id")
+);
+
+-- Insert existing parts into the new table
+INSERT INTO "rebrickable_parts" (
+    "part",
+    "color_id",
+    "color_name",
+    "name",
+    "image",
+    "image_id"
+)
+SELECT
+    "inventory"."part_num",
+    "inventory"."color_id",
+    "inventory"."color_name",
+    "inventory"."name",
+    "inventory"."part_img_url",
+    "inventory"."part_img_url_id"
+FROM "inventory"
+GROUP BY
+    "inventory"."part_num",
+    "inventory"."color_id";
+
+COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/migrations/0011.sql b/bricktracker/sql/migrations/0011.sql
new file mode 100644
index 00000000..54962f37
--- /dev/null
+++ b/bricktracker/sql/migrations/0011.sql
@@ -0,0 +1,60 @@
+-- description: Migrate the Bricktracker parts (and missing parts), and add a bunch of extra fields for later
+
+PRAGMA foreign_keys = ON;
+
+BEGIN TRANSACTION;
+
+-- Create a Bricktracker parts table: an amount of parts linked to a Bricktracker set
+CREATE TABLE "bricktracker_parts" (
+    "id" TEXT NOT NULL,
+    "figure" TEXT,
+    "part" TEXT NOT NULL,
+    "color" INTEGER NOT NULL,
+    "spare" BOOLEAN NOT NULL,
+    "quantity" INTEGER NOT NULL,
+    "element" INTEGER,
+    "rebrickable_inventory" INTEGER NOT NULL,
+    "missing" INTEGER NOT NULL DEFAULT 0,
+    "damaged" INTEGER NOT NULL DEFAULT 0,
+    PRIMARY KEY("id", "figure", "part", "color", "spare"),
+    FOREIGN KEY("id") REFERENCES "bricktracker_sets"("id"),
+    FOREIGN KEY("figure") REFERENCES "rebrickable_minifigures"("figure"),
+    FOREIGN KEY("part", "color") REFERENCES "rebrickable_parts"("part", "color_id")
+);
+
+-- Insert existing parts into the new table
+INSERT INTO "bricktracker_parts" (
+    "id",
+    "figure",
+    "part",
+    "color",
+    "spare",
+    "quantity",
+    "element",
+    "rebrickable_inventory",
+    "missing"
+)
+SELECT
+    "inventory"."u_id",
+    CASE WHEN SUBSTR("inventory"."set_num", 0, 5) = 'fig-' THEN "inventory"."set_num" ELSE NULL END,
+    "inventory"."part_num",
+    "inventory"."color_id",
+    "inventory"."is_spare",
+    "inventory"."quantity",
+    "inventory"."element_id",
+    "inventory"."id",
+    IFNULL("missing"."quantity", 0)
+FROM "inventory"
+LEFT JOIN "missing"
+ON "inventory"."set_num" IS NOT DISTINCT FROM "missing"."set_num"
+AND "inventory"."id" IS NOT DISTINCT FROM "missing"."id"
+AND "inventory"."part_num" IS NOT DISTINCT FROM "missing"."part_num"
+AND "inventory"."color_id" IS NOT DISTINCT FROM "missing"."color_id"
+AND "inventory"."element_id" IS NOT DISTINCT FROM "missing"."element_id"
+AND "inventory"."u_id" IS NOT DISTINCT FROM "missing"."u_id";
+
+-- Rename the original table (don't delete it yet?)
+ALTER TABLE "inventory" RENAME TO "inventory_old";
+ALTER TABLE "missing" RENAME TO "missing_old";
+
+COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/minifigure/base/base.sql b/bricktracker/sql/minifigure/base/base.sql
index c580b38e..dbfc4289 100644
--- a/bricktracker/sql/minifigure/base/base.sql
+++ b/bricktracker/sql/minifigure/base/base.sql
@@ -1,5 +1,4 @@
 SELECT
-    {% block set %}{% endblock %}
     "bricktracker_minifigures"."quantity",
     "rebrickable_minifigures"."figure",
     "rebrickable_minifigures"."number",
diff --git a/bricktracker/sql/minifigure/list/all.sql b/bricktracker/sql/minifigure/list/all.sql
index 498062d5..ca23068b 100644
--- a/bricktracker/sql/minifigure/list/all.sql
+++ b/bricktracker/sql/minifigure/list/all.sql
@@ -16,16 +16,17 @@ COUNT("bricktracker_minifigures"."id") AS "total_sets"
 -- LEFT JOIN + SELECT to avoid messing the total
 LEFT JOIN (
     SELECT
-        "missing"."set_num",
-        "missing"."u_id",
-        SUM("missing"."quantity") AS total
-    FROM "missing"
+        "bricktracker_parts"."id",
+        "bricktracker_parts"."figure",
+        SUM("bricktracker_parts"."missing") AS total
+    FROM "bricktracker_parts"
+    WHERE "bricktracker_parts"."figure" IS NOT NULL
     GROUP BY
-        "missing"."set_num",
-        "missing"."u_id"
+        "bricktracker_parts"."id",
+        "bricktracker_parts"."figure"
 ) missing_join
-ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "missing_join"."u_id"
-AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "missing_join"."set_num"
+ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "missing_join"."id"
+AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "missing_join"."figure"
 {% endblock %}
 
 {% block group %}
diff --git a/bricktracker/sql/minifigure/list/last.sql b/bricktracker/sql/minifigure/list/last.sql
index cacc2f7f..372610d4 100644
--- a/bricktracker/sql/minifigure/list/last.sql
+++ b/bricktracker/sql/minifigure/list/last.sql
@@ -1,13 +1,13 @@
 {% extends 'minifigure/base/base.sql' %}
 
 {% block total_missing %}
-SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
+SUM("bricktracker_parts"."missing") AS "total_missing",
 {% endblock %}
 
 {% block join %}
-LEFT JOIN "missing"
-ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "missing"."set_num"
-AND "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "missing"."u_id"
+LEFT JOIN "bricktracker_parts"
+ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_parts"."id"
+AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "bricktracker_parts"."figure"
 {% endblock %}
 
 {% block group %}
diff --git a/bricktracker/sql/minifigure/list/missing_part.sql b/bricktracker/sql/minifigure/list/missing_part.sql
index 3fe42107..32144bd9 100644
--- a/bricktracker/sql/minifigure/list/missing_part.sql
+++ b/bricktracker/sql/minifigure/list/missing_part.sql
@@ -1,26 +1,24 @@
 {% extends 'minifigure/base/base.sql' %}
 
 {% block total_missing %}
-SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
+SUM("bricktracker_parts"."missing") AS "total_missing",
 {% endblock %}
 
 {% block join %}
-LEFT JOIN "missing"
-ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "missing"."set_num"
-AND "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "missing"."u_id"
+LEFT JOIN "bricktracker_parts"
+ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_parts"."id"
+AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "bricktracker_parts"."figure"
 {% endblock %}
 
 {% block where %}
 WHERE "rebrickable_minifigures"."figure" IN (
-    SELECT
-        "missing"."set_num"
-    FROM "missing"
-
-    WHERE "missing"."color_id" IS NOT DISTINCT FROM :color_id
-    AND "missing"."element_id" IS NOT DISTINCT FROM :element_id
-    AND "missing"."part_num" IS NOT DISTINCT FROM :part_num
-
-    GROUP BY "missing"."set_num"
+    SELECT "bricktracker_parts"."figure"
+    FROM "bricktracker_parts"
+    WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part
+    AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color
+    AND "bricktracker_parts"."figure" IS NOT NULL
+    AND "bricktracker_parts"."missing" > 0
+    GROUP BY "bricktracker_parts"."figure"
 )
 {% endblock %}
 
diff --git a/bricktracker/sql/minifigure/list/using_part.sql b/bricktracker/sql/minifigure/list/using_part.sql
index e701d8d6..d6ea0d1c 100644
--- a/bricktracker/sql/minifigure/list/using_part.sql
+++ b/bricktracker/sql/minifigure/list/using_part.sql
@@ -6,15 +6,12 @@ SUM("bricktracker_minifigures"."quantity") AS "total_quantity",
 
 {% block where %}
 WHERE "rebrickable_minifigures"."figure" IN (
-    SELECT
-        "inventory"."set_num"
-    FROM "inventory"
-
-    WHERE "inventory"."color_id" IS NOT DISTINCT FROM :color_id
-    AND "inventory"."element_id" IS NOT DISTINCT FROM :element_id
-    AND "inventory"."part_num" IS NOT DISTINCT FROM :part_num
-
-    GROUP BY "inventory"."set_num"
+    SELECT "bricktracker_parts"."figure"
+    FROM "bricktracker_parts"
+    WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part
+    AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color
+    AND "bricktracker_parts"."figure" IS NOT NULL
+    GROUP BY "bricktracker_parts"."figure"
 )
 {% endblock %}
 
diff --git a/bricktracker/sql/minifigure/select/generic.sql b/bricktracker/sql/minifigure/select/generic.sql
index 16dc56d6..f5bacd7c 100644
--- a/bricktracker/sql/minifigure/select/generic.sql
+++ b/bricktracker/sql/minifigure/select/generic.sql
@@ -1,7 +1,7 @@
 {% extends 'minifigure/base/base.sql' %}
 
 {% block total_missing %}
-SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
+IFNULL("missing_join"."total", 0) AS "total_missing",
 {% endblock %}
 
 {% block total_quantity %}
@@ -13,9 +13,16 @@ COUNT(DISTINCT "bricktracker_minifigures"."id") AS "total_sets"
 {% endblock %}
 
 {% block join %}
-LEFT JOIN "missing"
-ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "missing"."set_num"
-AND "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "missing"."u_id"
+-- LEFT JOIN + SELECT to avoid messing the total
+LEFT JOIN (
+    SELECT
+        "bricktracker_parts"."figure",
+        SUM("bricktracker_parts"."missing") AS "total"
+    FROM "bricktracker_parts"
+    WHERE "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure
+    GROUP BY "bricktracker_parts"."figure"
+) "missing_join"
+ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "missing_join"."figure"
 {% endblock %}
 
 {% block where %}
diff --git a/bricktracker/sql/minifigure/select/specific.sql b/bricktracker/sql/minifigure/select/specific.sql
index 00a66af0..970494f7 100644
--- a/bricktracker/sql/minifigure/select/specific.sql
+++ b/bricktracker/sql/minifigure/select/specific.sql
@@ -1,6 +1,6 @@
 {% extends 'minifigure/base/base.sql' %}
 
 {% block where %}
-WHERE "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM :figure
-AND "bricktracker_minifigures"."id" IS NOT DISTINCT FROM :id
+WHERE "bricktracker_minifigures"."id" IS NOT DISTINCT FROM :id
+AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM :figure
 {% endblock %}
diff --git a/bricktracker/sql/missing/delete/from_set.sql b/bricktracker/sql/missing/delete/from_set.sql
deleted file mode 100644
index ceedc789..00000000
--- a/bricktracker/sql/missing/delete/from_set.sql
+++ /dev/null
@@ -1,4 +0,0 @@
-DELETE FROM "missing"
-WHERE "missing"."set_num" IS NOT DISTINCT FROM :set_num
-AND "missing"."id" IS NOT DISTINCT FROM :id
-AND "missing"."u_id" IS NOT DISTINCT FROM :u_id
diff --git a/bricktracker/sql/missing/insert.sql b/bricktracker/sql/missing/insert.sql
deleted file mode 100644
index c6450287..00000000
--- a/bricktracker/sql/missing/insert.sql
+++ /dev/null
@@ -1,20 +0,0 @@
-INSERT INTO "missing" (
-    "set_num",
-    "id",
-    "part_num",
-    "part_img_url_id",
-    "color_id",
-    "quantity",
-    "element_id",
-    "u_id"
-)
-VALUES(
-    :set_num,
-    :id,
-    :part_num,
-    :part_img_url_id,
-    :color_id,
-    :quantity,
-    :element_id,
-    :u_id
-)
diff --git a/bricktracker/sql/missing/update/from_set.sql b/bricktracker/sql/missing/update/from_set.sql
deleted file mode 100644
index 335dd065..00000000
--- a/bricktracker/sql/missing/update/from_set.sql
+++ /dev/null
@@ -1,5 +0,0 @@
-UPDATE "missing"
-SET "quantity" = :quantity
-WHERE "missing"."set_num" IS NOT DISTINCT FROM :set_num
-AND "missing"."id" IS NOT DISTINCT FROM :id
-AND "missing"."u_id" IS NOT DISTINCT FROM :u_id
diff --git a/bricktracker/sql/part/base/base.sql b/bricktracker/sql/part/base/base.sql
new file mode 100644
index 00000000..d9226b39
--- /dev/null
+++ b/bricktracker/sql/part/base/base.sql
@@ -0,0 +1,56 @@
+SELECT
+    "bricktracker_parts"."id",
+    "bricktracker_parts"."figure",
+    "bricktracker_parts"."part",
+    "bricktracker_parts"."color",
+    "bricktracker_parts"."spare",
+    "bricktracker_parts"."quantity",
+    "bricktracker_parts"."element",
+    --"bricktracker_parts"."rebrickable_inventory",
+    "bricktracker_parts"."missing",
+    "bricktracker_parts"."damaged",
+    --"rebrickable_parts"."part",
+    --"rebrickable_parts"."color_id",
+    "rebrickable_parts"."color_name",
+    "rebrickable_parts"."color_rgb",
+    "rebrickable_parts"."color_transparent",
+    "rebrickable_parts"."name",
+    --"rebrickable_parts"."category",
+    "rebrickable_parts"."image",
+    "rebrickable_parts"."image_id",
+    "rebrickable_parts"."url",
+    --"rebrickable_parts"."print",
+    {% block total_missing %}
+    NULL AS "total_missing", -- dummy for order: total_missing
+    {% endblock %}
+    {% block total_quantity %}
+    NULL AS "total_quantity", -- dummy for order: total_quantity
+    {% endblock %}
+    {% block total_spare %}
+    NULL AS "total_spare", -- dummy for order: total_spare
+    {% endblock %}
+    {% block total_sets %}
+    NULL AS "total_sets", -- dummy for order: total_sets
+    {% endblock %}
+    {% block total_minifigures %}
+    NULL AS "total_minifigures" -- dummy for order: total_minifigures
+    {% endblock %}
+FROM "bricktracker_parts"
+
+INNER JOIN "rebrickable_parts"
+ON "bricktracker_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part"
+AND "bricktracker_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id"
+
+{% block join %}{% endblock %}
+
+{% block where %}{% endblock %}
+
+{% block group %}{% endblock %}
+
+{% if order %}
+ORDER BY {{ order }}
+{% endif %}
+
+{% if limit %}
+LIMIT {{ limit }}
+{% endif %}
diff --git a/bricktracker/sql/part/base/select.sql b/bricktracker/sql/part/base/select.sql
deleted file mode 100644
index 3966a756..00000000
--- a/bricktracker/sql/part/base/select.sql
+++ /dev/null
@@ -1,43 +0,0 @@
-SELECT
-    "inventory"."set_num",
-    "inventory"."id",
-    "inventory"."part_num",
-    "inventory"."name",
-    "inventory"."part_img_url",
-    "inventory"."part_img_url_id",
-    "inventory"."color_id",
-    "inventory"."color_name",
-    "inventory"."quantity",
-    "inventory"."is_spare",
-    "inventory"."element_id",
-    "inventory"."u_id",
-    {% block total_missing %}
-    NULL AS "total_missing", -- dummy for order: total_missing
-    {% endblock %}
-    {% block total_quantity %}
-    NULL AS "total_quantity", -- dummy for order: total_quantity
-    {% endblock %}
-    {% block total_spare %}
-    NULL AS "total_spare", -- dummy for order: total_spare
-    {% endblock %}
-    {% block total_sets %}
-    NULL AS "total_sets", -- dummy for order: total_sets
-    {% endblock %}
-    {% block total_minifigures %}
-    NULL AS "total_minifigures" -- dummy for order: total_minifigures
-    {% endblock %}
-FROM "inventory"
-
-{% block join %}{% endblock %}
-
-{% block where %}{% endblock %}
-
-{% block group %}{% endblock %}
-
-{% if order %}
-ORDER BY {{ order }}
-{% endif %}
-
-{% if limit %}
-LIMIT {{ limit }}
-{% endif %}
diff --git a/bricktracker/sql/part/insert.sql b/bricktracker/sql/part/insert.sql
index 39b2d145..e127386f 100644
--- a/bricktracker/sql/part/insert.sql
+++ b/bricktracker/sql/part/insert.sql
@@ -1,27 +1,19 @@
-INSERT INTO inventory (
-    "set_num",
+INSERT INTO "bricktracker_parts" (
     "id",
-    "part_num",
-    "name",
-    "part_img_url",
-    "part_img_url_id",
-    "color_id",
-    "color_name",
+    "figure",
+    "part",
+    "color",
+    "spare",
     "quantity",
-    "is_spare",
-    "element_id",
-    "u_id"
+    "element",
+    "rebrickable_inventory"
 ) VALUES (
-    :set_num,
     :id,
-    :part_num,
-    :name,
-    :part_img_url,
-    :part_img_url_id,
-    :color_id,
-    :color_name,
+    :figure,
+    :part,
+    :color,
+    :spare,
     :quantity,
-    :is_spare,
-    :element_id,
-    :u_id
+    :element,
+    :rebrickable_inventory
 )
diff --git a/bricktracker/sql/part/list/all.sql b/bricktracker/sql/part/list/all.sql
index c5bbf690..c1d0ed1f 100644
--- a/bricktracker/sql/part/list/all.sql
+++ b/bricktracker/sql/part/list/all.sql
@@ -1,15 +1,15 @@
-{% extends 'part/base/select.sql' %}
+{% extends 'part/base/base.sql' %}
 
 {% block total_missing %}
-SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
+SUM("bricktracker_parts"."missing") AS "total_missing",
 {% endblock %}
 
 {% block total_quantity %}
-SUM("inventory"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity",
+SUM("bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity",
 {% endblock %}
 
 {% block total_sets %}
-COUNT(DISTINCT "bricktracker_minifigures"."id") AS "total_sets",
+COUNT(DISTINCT "bricktracker_parts"."id") AS "total_sets",
 {% endblock %}
 
 {% block total_minifigures %}
@@ -17,24 +17,14 @@ SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_minifigures"
 {% endblock %}
 
 {% block join %}
-LEFT JOIN "missing"
-ON "inventory"."set_num" IS NOT DISTINCT FROM "missing"."set_num"
-AND "inventory"."id" IS NOT DISTINCT FROM "missing"."id"
-AND "inventory"."part_num" IS NOT DISTINCT FROM "missing"."part_num"
-AND "inventory"."color_id" IS NOT DISTINCT FROM "missing"."color_id"
-AND "inventory"."element_id" IS NOT DISTINCT FROM "missing"."element_id"
-AND "inventory"."u_id" IS NOT DISTINCT FROM "missing"."u_id"
-
 LEFT JOIN "bricktracker_minifigures"
-ON "inventory"."set_num" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
-AND "inventory"."u_id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id"
+ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id"
+AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
 {% endblock %}
 
 {% block group %}
 GROUP BY
-    "inventory"."part_num",
-    "inventory"."name",
-    "inventory"."color_id",
-    "inventory"."is_spare",
-    "inventory"."element_id"
+    "bricktracker_parts"."part",
+    "bricktracker_parts"."color",
+    "bricktracker_parts"."spare"
 {% endblock %}
diff --git a/bricktracker/sql/part/list/from_minifigure.sql b/bricktracker/sql/part/list/from_minifigure.sql
index cf4135f5..c8409382 100644
--- a/bricktracker/sql/part/list/from_minifigure.sql
+++ b/bricktracker/sql/part/list/from_minifigure.sql
@@ -1,28 +1,17 @@
 
-{% extends 'part/base/select.sql' %}
+{% extends 'part/base/base.sql' %}
 
 {% block total_missing %}
-SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
-{% endblock %}
-
-{% block join %}
-LEFT JOIN "missing"
-ON "missing"."set_num" IS NOT DISTINCT FROM "inventory"."set_num"
-AND "missing"."id" IS NOT DISTINCT FROM "inventory"."id"
-AND "missing"."part_num" IS NOT DISTINCT FROM "inventory"."part_num"
-AND "missing"."color_id" IS NOT DISTINCT FROM "inventory"."color_id"
-AND "missing"."element_id" IS NOT DISTINCT FROM "inventory"."element_id"
+SUM("bricktracker_parts"."missing") AS "total_missing",
 {% endblock %}
 
 {% block where %}
-WHERE "inventory"."set_num" IS NOT DISTINCT FROM :set_num
+WHERE "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure
 {% endblock %}
 
 {% block group %}
 GROUP BY
-    "inventory"."part_num",
-    "inventory"."name",
-    "inventory"."color_id",
-    "inventory"."is_spare",
-    "inventory"."element_id"
+    "bricktracker_parts"."part",
+    "bricktracker_parts"."color",
+    "bricktracker_parts"."spare"
 {% endblock %}
diff --git a/bricktracker/sql/part/list/from_set.sql b/bricktracker/sql/part/list/from_set.sql
deleted file mode 100644
index 2646eeb8..00000000
--- a/bricktracker/sql/part/list/from_set.sql
+++ /dev/null
@@ -1,21 +0,0 @@
-
-{% extends 'part/base/select.sql' %}
-
-{% block total_missing %}
-IFNULL("missing"."quantity", 0) AS "total_missing",
-{% endblock %}
-
-{% block join %}
-LEFT JOIN "missing"
-ON "inventory"."set_num" IS NOT DISTINCT FROM "missing"."set_num"
-AND "inventory"."id" IS NOT DISTINCT FROM "missing"."id"
-AND "inventory"."part_num" IS NOT DISTINCT FROM "missing"."part_num"
-AND "inventory"."color_id" IS NOT DISTINCT FROM "missing"."color_id"
-AND "inventory"."element_id" IS NOT DISTINCT FROM "missing"."element_id"
-AND "inventory"."u_id" IS NOT DISTINCT FROM "missing"."u_id"
-{% endblock %}
-
-{% block where %}
-WHERE "inventory"."u_id" IS NOT DISTINCT FROM :u_id
-AND "inventory"."set_num" IS NOT DISTINCT FROM :set_num
-{% endblock %}
diff --git a/bricktracker/sql/part/list/missing.sql b/bricktracker/sql/part/list/missing.sql
index 8f17ae36..9d3446e4 100644
--- a/bricktracker/sql/part/list/missing.sql
+++ b/bricktracker/sql/part/list/missing.sql
@@ -1,11 +1,11 @@
-{% extends 'part/base/select.sql' %}
+{% extends 'part/base/base.sql' %}
 
 {% block total_missing %}
-SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
+SUM("bricktracker_parts"."missing") AS "total_missing",
 {% endblock %}
 
 {% block total_sets %}
-COUNT("inventory"."u_id")  - COUNT("bricktracker_minifigures"."id") AS "total_sets",
+COUNT("bricktracker_parts"."id")  - COUNT("bricktracker_parts"."figure") AS "total_sets",
 {% endblock %}
 
 {% block total_minifigures %}
@@ -13,24 +13,18 @@ SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_minifigures"
 {% endblock %}
 
 {% block join %}
-INNER JOIN "missing"
-ON "missing"."set_num" IS NOT DISTINCT FROM "inventory"."set_num"
-AND "missing"."id" IS NOT DISTINCT FROM "inventory"."id"
-AND "missing"."part_num" IS NOT DISTINCT FROM "inventory"."part_num"
-AND "missing"."color_id" IS NOT DISTINCT FROM "inventory"."color_id"
-AND "missing"."element_id" IS NOT DISTINCT FROM "inventory"."element_id"
-AND "missing"."u_id" IS NOT DISTINCT FROM "inventory"."u_id"
-
 LEFT JOIN "bricktracker_minifigures"
-ON "inventory"."set_num" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
-AND "inventory"."u_id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id"
+ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id"
+AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
+{% endblock %}
+
+{% block where %}
+WHERE "bricktracker_parts"."missing" > 0
 {% endblock %}
 
 {% block group %}
 GROUP BY
-    "inventory"."part_num",
-    "inventory"."name",
-    "inventory"."color_id",
-    "inventory"."is_spare",
-    "inventory"."element_id"
+    "bricktracker_parts"."part",
+    "bricktracker_parts"."color",
+    "bricktracker_parts"."spare"
 {% endblock %}
diff --git a/bricktracker/sql/part/list/specific.sql b/bricktracker/sql/part/list/specific.sql
new file mode 100644
index 00000000..d3e291a3
--- /dev/null
+++ b/bricktracker/sql/part/list/specific.sql
@@ -0,0 +1,11 @@
+
+{% extends 'part/base/base.sql' %}
+
+{% block total_missing %}
+IFNULL("bricktracker_parts"."missing", 0) AS "total_missing",
+{% endblock %}
+
+{% block where %}
+WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM :id
+AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure
+{% endblock %}
diff --git a/bricktracker/sql/part/select/generic.sql b/bricktracker/sql/part/select/generic.sql
index eb7e194c..a1760d67 100644
--- a/bricktracker/sql/part/select/generic.sql
+++ b/bricktracker/sql/part/select/generic.sql
@@ -1,40 +1,30 @@
-{% extends 'part/base/select.sql' %}
+{% extends 'part/base/base.sql' %}
 
 {% block total_missing %}
-SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
+SUM("bricktracker_parts"."missing") AS "total_missing",
 {% endblock %}
 
 {% block total_quantity %}
-SUM((NOT "inventory"."is_spare") * "inventory"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity",
+SUM((NOT "bricktracker_parts"."spare") * "bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity",
 {% endblock %}
 
 {% block total_spare %}
-SUM("inventory"."is_spare" * "inventory"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_spare",
+SUM("bricktracker_parts"."spare" * "bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_spare",
 {% endblock %}
 
 {% block join %}
-LEFT JOIN "missing"
-ON "inventory"."set_num" IS NOT DISTINCT FROM "missing"."set_num"
-AND "inventory"."id" IS NOT DISTINCT FROM "missing"."id"
-AND "inventory"."part_num" IS NOT DISTINCT FROM "missing"."part_num"
-AND "inventory"."color_id" IS NOT DISTINCT FROM "missing"."color_id"
-AND "inventory"."element_id" IS NOT DISTINCT FROM "missing"."element_id"
-AND "inventory"."u_id" IS NOT DISTINCT FROM "missing"."u_id"
-
 LEFT JOIN "bricktracker_minifigures"
-ON "inventory"."set_num" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
-AND "inventory"."u_id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id"
+ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id"
+AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
 {% endblock %}
 
 {% block where %}
-WHERE "inventory"."part_num" IS NOT DISTINCT FROM :part_num
-AND "inventory"."color_id" IS NOT DISTINCT FROM :color_id
-AND "inventory"."element_id" IS NOT DISTINCT FROM :element_id
+WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part
+AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color
 {% endblock %}
 
 {% block group %}
 GROUP BY
-    "inventory"."part_num",
-    "inventory"."color_id",
-    "inventory"."element_id"
+    "bricktracker_parts"."part",
+    "bricktracker_parts"."color"
 {% endblock %}
diff --git a/bricktracker/sql/part/select/specific.sql b/bricktracker/sql/part/select/specific.sql
index ebdd5f5e..c74a535d 100644
--- a/bricktracker/sql/part/select/specific.sql
+++ b/bricktracker/sql/part/select/specific.sql
@@ -1,24 +1,18 @@
-{% extends 'part/base/select.sql' %}
-
-{% block join %}
-LEFT JOIN "missing"
-ON "inventory"."set_num" IS NOT DISTINCT FROM "missing"."set_num"
-AND "inventory"."id" IS NOT DISTINCT FROM "missing"."id"
-AND "inventory"."u_id" IS NOT DISTINCT FROM "missing"."u_id"
-{% endblock %}
+{% extends 'part/base/base.sql' %}
 
 {% block where %}
-WHERE "inventory"."u_id" IS NOT DISTINCT FROM :u_id
-AND "inventory"."set_num" IS NOT DISTINCT FROM :set_num
-AND "inventory"."id" IS NOT DISTINCT FROM :id
+WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM :id
+AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure
+AND "bricktracker_parts"."part" IS NOT DISTINCT FROM :part
+AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color
+AND "bricktracker_parts"."spare" IS NOT DISTINCT FROM :spare
 {% endblock %}
 
 {% block group %}
 GROUP BY
-    "inventory"."set_num",
-    "inventory"."id",
-    "inventory"."part_num",
-    "inventory"."color_id",
-    "inventory"."element_id",
-    "inventory"."u_id"
+    "bricktracker_parts"."id",
+    "bricktracker_parts"."figure",
+    "bricktracker_parts"."part",
+    "bricktracker_parts"."color",
+    "bricktracker_parts"."spare"
 {% endblock %}
diff --git a/bricktracker/sql/part/update/missing.sql b/bricktracker/sql/part/update/missing.sql
new file mode 100644
index 00000000..c1dd3682
--- /dev/null
+++ b/bricktracker/sql/part/update/missing.sql
@@ -0,0 +1,7 @@
+UPDATE "bricktracker_parts"
+SET "missing" = :missing
+WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM :id
+AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure
+AND "bricktracker_parts"."part" IS NOT DISTINCT FROM :part
+AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color
+AND "bricktracker_parts"."spare" IS NOT DISTINCT FROM :spare
diff --git a/bricktracker/sql/rebrickable/part/insert.sql b/bricktracker/sql/rebrickable/part/insert.sql
new file mode 100644
index 00000000..d989258c
--- /dev/null
+++ b/bricktracker/sql/rebrickable/part/insert.sql
@@ -0,0 +1,25 @@
+INSERT OR IGNORE INTO "rebrickable_parts" (
+    "part",
+    "color_id",
+    "color_name",
+    "color_rgb",
+    "color_transparent",
+    "name",
+    "category",
+    "image",
+    "image_id",
+    "url",
+    "print"
+) VALUES (
+    :part,
+    :color_id,
+    :color_name,
+    :color_rgb,
+    :color_transparent,
+    :name,
+    :category,
+    :image,
+    :image_id,
+    :url,
+    :print
+)
diff --git a/bricktracker/sql/rebrickable/part/list.sql b/bricktracker/sql/rebrickable/part/list.sql
new file mode 100644
index 00000000..026f465c
--- /dev/null
+++ b/bricktracker/sql/rebrickable/part/list.sql
@@ -0,0 +1,13 @@
+SELECT
+    "rebrickable_parts"."part",
+    "rebrickable_parts"."color_id",
+    "rebrickable_parts"."color_name",
+    "rebrickable_parts"."color_rgb",
+    "rebrickable_parts"."color_transparent",
+    "rebrickable_parts"."name",
+    "rebrickable_parts"."category",
+    "rebrickable_parts"."image",
+    "rebrickable_parts"."image_id",
+    "rebrickable_parts"."url",
+    "rebrickable_parts"."print"
+FROM "rebrickable_parts"
diff --git a/bricktracker/sql/rebrickable/part/select.sql b/bricktracker/sql/rebrickable/part/select.sql
new file mode 100644
index 00000000..54f6305e
--- /dev/null
+++ b/bricktracker/sql/rebrickable/part/select.sql
@@ -0,0 +1,16 @@
+SELECT
+    "rebrickable_parts"."part",
+    "rebrickable_parts"."color_id",
+    "rebrickable_parts"."color_name",
+    "rebrickable_parts"."color_rgb",
+    "rebrickable_parts"."color_transparent",
+    "rebrickable_parts"."name",
+    "rebrickable_parts"."category",
+    "rebrickable_parts"."image",
+    "rebrickable_parts"."image_id",
+    "rebrickable_parts"."url",
+    "rebrickable_parts"."print"
+FROM "rebrickable_parts"
+
+WHERE "rebrickable_minifigures"."part" IS NOT DISTINCT FROM :figure
+AND "rebrickable_minifigures"."color_id" IS NOT DISTINCT FROM :color
diff --git a/bricktracker/sql/schema/drop.sql b/bricktracker/sql/schema/drop.sql
index 1d39d990..78ea32ce 100644
--- a/bricktracker/sql/schema/drop.sql
+++ b/bricktracker/sql/schema/drop.sql
@@ -1,15 +1,20 @@
 BEGIN transaction;
 
 DROP TABLE IF EXISTS "bricktracker_minifigures";
+DROP TABLE IF EXISTS "bricktracker_parts";
 DROP TABLE IF EXISTS "bricktracker_sets";
 DROP TABLE IF EXISTS "bricktracker_set_checkboxes";
 DROP TABLE IF EXISTS "bricktracker_set_statuses";
+DROP TABLE IF EXISTS "bricktracker_set_storages";
 DROP TABLE IF EXISTS "bricktracker_wishes";
 DROP TABLE IF EXISTS "inventory";
+DROP TABLE IF EXISTS "inventory_old";
 DROP TABLE IF EXISTS "minifigures";
 DROP TABLE IF EXISTS "minifigures_old";
 DROP TABLE IF EXISTS "missing";
+DROP TABLE IF EXISTS "missing_old";
 DROP TABLE IF EXISTS "rebrickable_minifigures";
+DROP TABLE IF EXISTS "rebrickable_parts";
 DROP TABLE IF EXISTS "rebrickable_sets";
 DROP TABLE IF EXISTS "sets";
 DROP TABLE IF EXISTS "sets_old";
diff --git a/bricktracker/sql/set/base/full.sql b/bricktracker/sql/set/base/full.sql
index 092e4871..70730ff6 100644
--- a/bricktracker/sql/set/base/full.sql
+++ b/bricktracker/sql/set/base/full.sql
@@ -21,13 +21,13 @@ ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_statuses"."id
 -- LEFT JOIN + SELECT to avoid messing the total
 LEFT JOIN (
     SELECT
-        "missing"."u_id",
-        SUM("missing"."quantity") AS "total"
-    FROM "missing"
+        "bricktracker_parts"."id",
+        SUM("bricktracker_parts"."missing") AS "total"
+    FROM "bricktracker_parts"
     {% block where_missing %}{% endblock %}
-    GROUP BY "u_id"
+    GROUP BY "bricktracker_parts"."id"
 ) "missing_join"
-ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "missing_join"."u_id"
+ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "missing_join"."id"
 
 -- LEFT JOIN + SELECT to avoid messing the total
 LEFT JOIN (
diff --git a/bricktracker/sql/set/delete/set.sql b/bricktracker/sql/set/delete/set.sql
index b477f81c..49b0e884 100644
--- a/bricktracker/sql/set/delete/set.sql
+++ b/bricktracker/sql/set/delete/set.sql
@@ -12,10 +12,7 @@ WHERE "bricktracker_set_statuses"."id" IS NOT DISTINCT FROM '{{ id }}';
 DELETE FROM "bricktracker_minifigures"
 WHERE "bricktracker_minifigures"."id" IS NOT DISTINCT FROM '{{ id }}';
 
-DELETE FROM "missing"
-WHERE "missing"."u_id" IS NOT DISTINCT FROM '{{ id }}';
-
-DELETE FROM "inventory"
-WHERE "inventory"."u_id" IS NOT DISTINCT FROM '{{ id }}';
+DELETE FROM "bricktracker_parts"
+WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM '{{ id }}';
 
 COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/set/list/missing_minifigure.sql b/bricktracker/sql/set/list/missing_minifigure.sql
index 2f19bfe0..51a615de 100644
--- a/bricktracker/sql/set/list/missing_minifigure.sql
+++ b/bricktracker/sql/set/list/missing_minifigure.sql
@@ -2,12 +2,10 @@
 
 {% block where %}
 WHERE "bricktracker_sets"."id" IN (
-    SELECT
-        "missing"."u_id"
-    FROM "missing"
-
-    WHERE "missing"."set_num" IS NOT DISTINCT FROM :figure
-
-    GROUP BY "missing"."u_id"
+    SELECT "bricktracker_parts"."id"
+    FROM "bricktracker_parts"
+    WHERE "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure
+    AND "bricktracker_parts"."missing" > 0
+    GROUP BY "bricktracker_parts"."id"
 )
 {% endblock %}
diff --git a/bricktracker/sql/set/list/missing_part.sql b/bricktracker/sql/set/list/missing_part.sql
index 781754c2..9438b67d 100644
--- a/bricktracker/sql/set/list/missing_part.sql
+++ b/bricktracker/sql/set/list/missing_part.sql
@@ -2,14 +2,11 @@
 
 {% block where %}
 WHERE "bricktracker_sets"."id" IN (
-    SELECT
-        "missing"."u_id"
-    FROM "missing"
-
-    WHERE "missing"."color_id" IS NOT DISTINCT FROM :color_id
-    AND "missing"."element_id" IS NOT DISTINCT FROM :element_id
-    AND "missing"."part_num" IS NOT DISTINCT FROM :part_num
-
-    GROUP BY "missing"."u_id"
+    SELECT "bricktracker_parts"."id"
+    FROM "bricktracker_parts"
+    WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part
+    AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color
+    AND "bricktracker_parts"."missing" > 0
+    GROUP BY "bricktracker_parts"."id"
 )
 {% endblock %}
diff --git a/bricktracker/sql/set/list/using_minifigure.sql b/bricktracker/sql/set/list/using_minifigure.sql
index 711866b2..00a1fb0b 100644
--- a/bricktracker/sql/set/list/using_minifigure.sql
+++ b/bricktracker/sql/set/list/using_minifigure.sql
@@ -2,12 +2,9 @@
 
 {% block where %}
 WHERE "bricktracker_sets"."id" IN (
-    SELECT
-        "inventory"."u_id"
-    FROM "inventory"
-
-    WHERE "inventory"."set_num" IS NOT DISTINCT FROM :figure
-
-    GROUP BY "inventory"."u_id"
+    SELECT "bricktracker_parts"."id"
+    FROM "bricktracker_parts"
+    WHERE "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure
+    GROUP BY "bricktracker_parts"."id"
 )
 {% endblock %}
diff --git a/bricktracker/sql/set/list/using_part.sql b/bricktracker/sql/set/list/using_part.sql
index 8877cff7..a0371737 100644
--- a/bricktracker/sql/set/list/using_part.sql
+++ b/bricktracker/sql/set/list/using_part.sql
@@ -2,14 +2,10 @@
 
 {% block where %}
 WHERE "bricktracker_sets"."id" IN (
-    SELECT
-        "inventory"."u_id"
-    FROM "inventory"
-
-    WHERE "inventory"."color_id" IS NOT DISTINCT FROM :color_id
-    AND "inventory"."element_id" IS NOT DISTINCT FROM :element_id
-    AND "inventory"."part_num" IS NOT DISTINCT FROM :part_num
-
-    GROUP BY "inventory"."u_id"
+    SELECT "bricktracker_parts"."id"
+    FROM "bricktracker_parts"
+    WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part
+    AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color
+    GROUP BY "bricktracker_parts"."id"
 )
 {% endblock %}
diff --git a/bricktracker/sql/set/select/full.sql b/bricktracker/sql/set/select/full.sql
index 80d11614..0d12ae8d 100644
--- a/bricktracker/sql/set/select/full.sql
+++ b/bricktracker/sql/set/select/full.sql
@@ -1,7 +1,7 @@
 {% extends 'set/base/full.sql' %}
 
 {% block where_missing %}
-WHERE "missing"."u_id" IS NOT DISTINCT FROM :id
+WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM :id
 {% endblock %}
 
 {% block where_minifigures %}
diff --git a/bricktracker/sql_counter.py b/bricktracker/sql_counter.py
index f2d1cc52..2e5c072e 100644
--- a/bricktracker/sql_counter.py
+++ b/bricktracker/sql_counter.py
@@ -3,15 +3,20 @@ from typing import Tuple
 # Some table aliases to make it look cleaner (id: (name, icon))
 ALIASES: dict[str, Tuple[str, str]] = {
     'bricktracker_minifigures': ('Bricktracker minifigures', 'group-line'),
+    'bricktracker_parts': ('Bricktracker parts', 'shapes-line'),
     'bricktracker_set_checkboxes': ('Bricktracker set checkboxes', 'checkbox-line'),  # noqa: E501
-    'bricktracker_set_statuses': ('Bricktracker sets status', 'checkbox-line'),
+    'bricktracker_set_statuses': ('Bricktracker sets status', 'checkbox-circle-line'),  # noqa: E501
+    'bricktracker_set_storages': ('Bricktracker sets storages', 'archive-2-line'),  # noqa: E501
     'bricktracker_sets': ('Bricktracker sets', 'hashtag'),
     'bricktracker_wishes': ('Bricktracker wishes', 'gift-line'),
     'inventory': ('Parts', 'shapes-line'),
+    'inventory_old': ('Parts (legacy)', 'shapes-line'),
     'minifigures': ('Minifigures', 'group-line'),
     'minifigures_old': ('Minifigures (legacy)', 'group-line'),
     'missing': ('Missing', 'error-warning-line'),
+    'missing_old': ('Missing (legacy)', 'error-warning-line'),
     'rebrickable_minifigures': ('Rebrickable minifigures', 'group-line'),
+    'rebrickable_parts': ('Rebrickable parts', 'shapes-line'),
     'rebrickable_sets': ('Rebrickable sets', 'hashtag'),
     'sets': ('Sets', 'hashtag'),
     'sets_old': ('Sets (legacy)', 'hashtag'),
diff --git a/bricktracker/version.py b/bricktracker/version.py
index 4424778d..172ecf1d 100644
--- a/bricktracker/version.py
+++ b/bricktracker/version.py
@@ -1,4 +1,4 @@
 from typing import Final
 
 __version__: Final[str] = '1.2.0'
-__database_version__: Final[int] = 9
+__database_version__: Final[int] = 11
diff --git a/bricktracker/views/part.py b/bricktracker/views/part.py
index 25051228..5f20997c 100644
--- a/bricktracker/views/part.py
+++ b/bricktracker/views/part.py
@@ -30,31 +30,26 @@ def missing() -> str:
 
 
 # Part details
-@part_page.route('/<number>/<int:color>/details', defaults={'element': None}, methods=['GET'])  # noqa: E501
-@part_page.route('/<number>/<int:color>/<int:element>/details', methods=['GET'])  # noqa: E501
+@part_page.route('/<part>/<int:color>/details', methods=['GET'])  # noqa: E501
 @exception_handler(__file__)
-def details(*, number: str, color: int, element: int | None) -> str:
+def details(*, part: str, color: int) -> str:
     return render_template(
         'part.html',
-        item=BrickPart().select_generic(number, color, element_id=element),
+        item=BrickPart().select_generic(part, color),
         sets_using=BrickSetList().using_part(
-            number,
-            color,
-            element_id=element
+            part,
+            color
         ),
         sets_missing=BrickSetList().missing_part(
-            number,
-            color,
-            element_id=element
+            part,
+            color
         ),
         minifigures_using=BrickMinifigureList().using_part(
-            number,
-            color,
-            element_id=element
+            part,
+            color
         ),
         minifigures_missing=BrickMinifigureList().missing_part(
-            number,
-            color,
-            element_id=element
+            part,
+            color
         ),
     )
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index 3e2304c4..0b8d843a 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -107,16 +107,34 @@ def details(*, id: str) -> str:
     )
 
 
-# Update the missing pieces of a minifig part
-@set_page.route('/<id>/minifigures/<figure>/parts/<part>/missing', methods=['POST'])  # noqa: E501
+# Update the missing pieces of a part
+@set_page.route('/<id>/parts/<part>/<int:color>/<int:spare>/missing', defaults={'figure': None}, methods=['POST'])  # noqa: E501
+@set_page.route('/<id>/minifigures/<figure>/parts/<part>/<int:color>/<int:spare>/missing', methods=['POST'])  # noqa: E501
 @login_required
 @exception_handler(__file__, json=True)
-def missing_minifigure_part(*, id: str, figure: str, part: str) -> Response:
+def missing_part(
+    *,
+    id: str,
+    figure: str | None,
+    part: str,
+    color: int,
+    spare: int,
+) -> Response:
+    from pprint import pprint
+    pprint(locals())
+
     brickset = BrickSet().select_specific(id)
-    brickminifigure = BrickMinifigure().select_specific(brickset, figure)
+
+    if figure is not None:
+        brickminifigure = BrickMinifigure().select_specific(brickset, figure)
+    else:
+        brickminifigure = None
+
     brickpart = BrickPart().select_specific(
         brickset,
         part,
+        color,
+        spare,
         minifigure=brickminifigure,
     )
 
@@ -125,35 +143,14 @@ def missing_minifigure_part(*, id: str, figure: str, part: str) -> Response:
     brickpart.update_missing(missing)
 
     # Info
-    logger.info('Set {set} ({id}): updated minifigure ({figure}) part ({part}) missing count to {missing}'.format(  # noqa: E501
+    logger.info('Set {set} ({id}): updated part ({part} color: {color}, spare: {spare}, minifigure: {figure}) missing count to {missing}'.format(  # noqa: E501
         set=brickset.fields.set,
         id=brickset.fields.id,
-        figure=brickminifigure.fields.figure,
-        part=brickpart.fields.id,
-        missing=missing,
-    ))
-
-    return jsonify({'missing': missing})
-
-
-# Update the missing pieces of a part
-@set_page.route('/<id>/parts/<part>/missing', methods=['POST'])
-@login_required
-@exception_handler(__file__, json=True)
-def missing_part(*, id: str, part: str) -> Response:
-    brickset = BrickSet().select_specific(id)
-    brickpart = BrickPart().select_specific(brickset, part)
-
-    missing = request.json.get('missing', '')  # type: ignore
-
-    brickpart.update_missing(missing)
-
-    # Info
-    logger.info('Set {set} ({id}): updated part ({part}) missing count to {missing}'.format(  # noqa: E501
-        set=brickset.fields.set,
-        id=brickset.fields.id,
-        part=brickpart.fields.id,
-        missing=missing,
+        figure=figure,
+        part=brickpart.fields.part,
+        color=brickpart.fields.color,
+        spare=brickpart.fields.spare,
+        missing=brickpart.fields.missing,
     ))
 
     return jsonify({'missing': missing})
diff --git a/templates/macro/card.html b/templates/macro/card.html
index 3b52220c..1cc92ac6 100644
--- a/templates/macro/card.html
+++ b/templates/macro/card.html
@@ -1,10 +1,10 @@
-{% macro header(item, name, solo=false, number=none, color=none, icon='hashtag') %}
+{% macro header(item, name, solo=false, identifier=none, color=none, icon='hashtag') %}
   <div class="card-header">
     {% if not solo %}
       <a class="text-decoration-none text-reset" href="{{ item.url() }}" data-bs-toggle="tooltip" title="{{ name }}">
     {% endif %}
     <h5 class="mb-0 {% if not solo %}fs-6 text-nowrap overflow-x-hidden text-truncate{% endif %}">
-      {% if number %}<span class="badge text-bg-secondary fw-normal"><i class="ri-{{ icon }}"></i>{{ number }}</span>{% endif %}
+      {% if identifier %}<span class="badge text-bg-secondary fw-normal"><i class="ri-{{ icon }}"></i>{{ identifier }}</span>{% endif %}
       {% if color %}<span class="badge text-bg-light fw-normal border"><i class="ri-palette-line"></i> {{ color }}</span>{% endif %}
       {{ name }}
     </h5>
diff --git a/templates/minifigure/card.html b/templates/minifigure/card.html
index b1949ef7..1b701938 100644
--- a/templates/minifigure/card.html
+++ b/templates/minifigure/card.html
@@ -3,7 +3,7 @@
 {% import 'macro/card.html' as card %}
 
 <div class="card mb-3 flex-fill {% if solo %}card-solo{% endif %}">
-  {{ card.header(item, item.fields.name, solo=solo, number=item.fields.number, icon='user-line') }}
+  {{ card.header(item, item.fields.name, solo=solo, identifier=item.fields.figure, icon='user-line') }}
   {{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.figure, medium=true) }}
   <div class="card-body border-bottom {% if not solo %}p-1{% endif %}">
     {% if last %}
diff --git a/templates/part/card.html b/templates/part/card.html
index 2f86664e..8c23b59e 100644
--- a/templates/part/card.html
+++ b/templates/part/card.html
@@ -3,8 +3,8 @@
 {% import 'macro/card.html' as card %}
 
 <div class="card mb-3 flex-fill card-solo">
-  {{ card.header(item, item.fields.name, solo=solo, number=item.fields.part_num, color=item.fields.color_name, icon='shapes-line') }}
-  {{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.part_img_url_id, medium=true) }}
+  {{ card.header(item, item.fields.name, solo=solo, identifier=item.fields.part, color=item.fields.color, icon='shapes-line') }}
+  {{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.image_id, medium=true) }}
   <div class="card-body border-bottom {% if not solo %}p-1{% endif %}">
     {{ badge.total_sets(sets_using | length, solo=solo, last=last) }}
     {{ badge.total_minifigures(minifigures_using | length, solo=solo, last=last) }}
diff --git a/templates/part/table.html b/templates/part/table.html
index 1fca2648..78d0efe6 100644
--- a/templates/part/table.html
+++ b/templates/part/table.html
@@ -6,10 +6,10 @@
     <tbody>
       {% for item in table_collection %}
       <tr>
-        {{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.part_num, accordion=solo) }}
+        {{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.part, accordion=solo) }}
         <td>
           <a class="text-reset" href="{{ item.url() }}">{{ item.fields.name }}</a>
-          {% if item.fields.is_spare %}<span class="badge rounded-pill text-bg-warning fw-normal"><i class="ri-loop-left-line"></i> Spare</span>{% endif %}
+          {% if item.fields.spare %}<span class="badge rounded-pill text-bg-warning fw-normal"><i class="ri-loop-left-line"></i> Spare</span>{% endif %}
           {% if all %}
             {{ table.rebrickable(item) }}
             {{ table.bricklink(item) }}
@@ -24,15 +24,15 @@
           {% endif %}
         {% endif %}
         {% if not no_missing %}
-          <td {% if not all %}id="sort-part-{{ item.fields.u_id }}-{{ item.fields.id }}" data-sort="{{ item.fields.total_missing }}"{% endif %} class="table-td-missing">
+          <td {% if not all %}id="sort-part-{{ item.fields.id }}-{{ item.html_id() }}" data-sort="{{ item.fields.total_missing }}"{% endif %} class="table-td-missing">
             {% if all or read_only_missing %}
               {{ item.fields.total_missing }}
             {% else %}
             <div class="input-group">
               {% if g.login.is_authenticated() %}
                 <input class="form-control form-control-sm flex-shrink-1" type="text" {% if item.fields.total_missing %}value="{{ item.fields.total_missing }}"{% endif %}
-                  onchange="change_part_missing_amount(this, '{{ item.fields.u_id }}', '{{ item.fields.id }}', '{{ item.url_for_missing() }}')" autocomplete="off">
-                <span id="status-part-{{ item.fields.u_id }}-{{ item.fields.id }}" class="input-group-text ri-save-line"></span>
+                  onchange="change_part_missing_amount(this, '{{ item.fields.id }}', '{{ item.html_id() }}', '{{ item.url_for_missing() }}')" autocomplete="off">
+                <span id="status-part-{{ item.fields.id }}-{{ item.html_id() }}" class="input-group-text ri-save-line"></span>
                 {% else %}
                 <input class="form-control form-control-sm" type="text" {% if item.fields.total_missing %}value="{{ item.fields.total_missing }}"{% endif %}
                   disabled autocomplete="off">
diff --git a/templates/set/card.html b/templates/set/card.html
index e9612eb8..20bfe4a5 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -11,8 +11,8 @@
     {% for checkbox in brickset_checkboxes %}data-{{ checkbox.as_dataset() }}="{{ item.fields[checkbox.as_column()] }}" {% endfor %}
   {% endif %}
 >
-  {{ card.header(item, item.fields.name, solo=solo, number=item.fields.number) }}
-  {{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.number) }}
+  {{ card.header(item, item.fields.name, solo=solo, identifier=item.fields.set) }}
+  {{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.set) }}
   <div class="card-body border-bottom-0 {% if not solo %}p-1{% endif %}">
     {{ badge.theme(item.theme.name, solo=solo, last=last) }}
     {{ badge.year(item.fields.year, solo=solo, last=last) }}
diff --git a/templates/set/mini.html b/templates/set/mini.html
index 6bdc028e..6305e0ec 100644
--- a/templates/set/mini.html
+++ b/templates/set/mini.html
@@ -2,7 +2,7 @@
 {% import 'macro/card.html' as card %}
 
 <div class="card mb-3">
-  {{ card.header(item, item.fields.name, solo=true, number=item.fields.set) }}
+  {{ card.header(item, item.fields.name, solo=true, identifier=item.fields.set) }}
   {{ card.image(item, solo=true, last=false, caption=item.fields.name, alt=item.fields.set) }}
   <div class="card-body p-1">
     {{ badge.theme(item.theme.name) }}

From 482817fd962fe3c86e9e7675d24d00847ca1bc21 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 28 Jan 2025 21:10:14 +0100
Subject: [PATCH 026/154] Add purchase location to the database

---
 bricktracker/sql/migrations/0007.sql | 13 ++++++++++---
 1 file changed, 10 insertions(+), 3 deletions(-)

diff --git a/bricktracker/sql/migrations/0007.sql b/bricktracker/sql/migrations/0007.sql
index a75f3f2e..f955bb9d 100644
--- a/bricktracker/sql/migrations/0007.sql
+++ b/bricktracker/sql/migrations/0007.sql
@@ -9,9 +9,14 @@ ALTER TABLE "bricktracker_sets" RENAME TO "bricktracker_sets_old";
 
 -- Create a Bricktracker set storage table for later
 CREATE TABLE "bricktracker_set_storages" (
-    "id" TEXT NOT NULL,
     "name" TEXT NOT NULL,
-    PRIMARY KEY("id")
+    PRIMARY KEY("name")
+);
+
+-- Create a Bricktracker set storage table for later
+CREATE TABLE "bricktracker_set_purchase_locations" (
+    "name" TEXT NOT NULL,
+    PRIMARY KEY("name")
 );
 
 -- Re-Create a Bricktracker set table with the simplified name
@@ -22,10 +27,12 @@ CREATE TABLE "bricktracker_sets" (
     "theme" TEXT, -- Custom theme name
     "storage" TEXT, -- Storage bin location
     "purchase_date" INTEGER, -- Purchase data
+    "purchase_location" TEXT, -- Purchase location
     "purchase_price" REAL, -- Purchase price
     PRIMARY KEY("id"),
     FOREIGN KEY("set") REFERENCES "rebrickable_sets"("set"),
-    FOREIGN KEY("storage") REFERENCES "bricktracker_set_storages"("id")
+    FOREIGN KEY("storage") REFERENCES "bricktracker_set_storages"("name"),
+    FOREIGN KEY("purchase_location") REFERENCES "bricktracker_set_purchase_locations"("name")
 );
 
 -- Insert existing sets into the new table

From fc6ff5dd497cd53a4820d7167fb0fc42eb3e959e Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 28 Jan 2025 23:07:12 +0100
Subject: [PATCH 027/154] Add a refresh mode for sets

---
 bricktracker/minifigure.py                    | 10 +--
 bricktracker/minifigure_list.py               | 10 ++-
 bricktracker/part.py                          |  7 +-
 bricktracker/part_list.py                     |  3 +-
 bricktracker/rebrickable_minifigure.py        | 19 ++----
 bricktracker/rebrickable_part.py              | 21 +++---
 bricktracker/rebrickable_set.py               | 13 ++--
 bricktracker/record.py                        |  8 +--
 bricktracker/set.py                           | 59 ++++++++++++-----
 bricktracker/set_checkbox.py                  |  7 +-
 .../sql/rebrickable/minifigure/insert.sql     |  6 ++
 bricktracker/sql/rebrickable/part/insert.sql  | 13 ++++
 bricktracker/sql/rebrickable/set/insert.sql   | 12 ++++
 bricktracker/views/set.py                     | 15 +++++
 static/scripts/socket/set.js                  |  8 ++-
 templates/add.html                            |  4 +-
 templates/bulk.html                           |  2 +-
 templates/refresh.html                        | 64 +++++++++++++++++++
 templates/set/card.html                       |  3 +
 templates/set/socket.html                     | 23 ++++---
 20 files changed, 224 insertions(+), 83 deletions(-)
 create mode 100644 templates/refresh.html

diff --git a/bricktracker/minifigure.py b/bricktracker/minifigure.py
index e0318a05..1ad6aa6a 100644
--- a/bricktracker/minifigure.py
+++ b/bricktracker/minifigure.py
@@ -20,7 +20,7 @@ class BrickMinifigure(RebrickableMinifigure):
     select_query: str = 'minifigure/select/specific'
 
     # Import a minifigure into the database
-    def download(self, socket: 'BrickSocket') -> bool:
+    def download(self, socket: 'BrickSocket', refresh: bool = False) -> bool:
         if self.brickset is None:
             raise ErrorException('Importing a minifigure from Rebrickable outside of a set is not supported')  # noqa: E501
 
@@ -33,8 +33,9 @@ class BrickMinifigure(RebrickableMinifigure):
                 )
             )
 
-            # Insert into database
-            self.insert(commit=False)
+            if not refresh:
+                # Insert into database
+                self.insert(commit=False)
 
             # Insert the rebrickable set into database
             self.insert_rebrickable()
@@ -43,7 +44,8 @@ class BrickMinifigure(RebrickableMinifigure):
             if not BrickPartList.download(
                 socket,
                 self.brickset,
-                minifigure=self
+                minifigure=self,
+                refresh=refresh
             ):
                 return False
 
diff --git a/bricktracker/minifigure_list.py b/bricktracker/minifigure_list.py
index a59fee57..790018a7 100644
--- a/bricktracker/minifigure_list.py
+++ b/bricktracker/minifigure_list.py
@@ -134,7 +134,13 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
 
     # Import the minifigures from Rebrickable
     @staticmethod
-    def download(socket: 'BrickSocket', brickset: 'BrickSet', /) -> bool:
+    def download(
+        socket: 'BrickSocket',
+        brickset: 'BrickSet',
+        /,
+        *,
+        refresh: bool = False
+    ) -> bool:
         try:
             socket.auto_progress(
                 message='Set {set}: loading minifigures from Rebrickable'.format(  # noqa: E501
@@ -157,7 +163,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
 
             # Process each minifigure
             for minifigure in minifigures:
-                if not minifigure.download(socket):
+                if not minifigure.download(socket, refresh=refresh):
                     return False
 
             return True
diff --git a/bricktracker/part.py b/bricktracker/part.py
index 7e82c454..af901b2b 100644
--- a/bricktracker/part.py
+++ b/bricktracker/part.py
@@ -34,7 +34,7 @@ class BrickPart(RebrickablePart):
             self.kind = 'Set'
 
     # Import a part into the database
-    def download(self, socket: 'BrickSocket') -> bool:
+    def download(self, socket: 'BrickSocket', refresh: bool = False) -> bool:
         if self.brickset is None:
             raise ErrorException('Importing a part from Rebrickable outside of a set is not supported')  # noqa: E501
 
@@ -48,8 +48,9 @@ class BrickPart(RebrickablePart):
                 )
             )
 
-            # Insert into database
-            self.insert(commit=False)
+            if not refresh:
+                # Insert into database
+                self.insert(commit=False)
 
             # Insert the rebrickable set into database
             self.insert_rebrickable()
diff --git a/bricktracker/part_list.py b/bricktracker/part_list.py
index 0074b9bf..667c26e4 100644
--- a/bricktracker/part_list.py
+++ b/bricktracker/part_list.py
@@ -139,6 +139,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
         /,
         *,
         minifigure: 'BrickMinifigure | None' = None,
+        refresh: bool = False
     ) -> bool:
         if minifigure is not None:
             identifier = minifigure.fields.figure
@@ -174,7 +175,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
 
             # Process each part
             for part in inventory:
-                if not part.download(socket):
+                if not part.download(socket, refresh=refresh):
                     return False
 
         except Exception as e:
diff --git a/bricktracker/rebrickable_minifigure.py b/bricktracker/rebrickable_minifigure.py
index 973b9fb9..30d61eef 100644
--- a/bricktracker/rebrickable_minifigure.py
+++ b/bricktracker/rebrickable_minifigure.py
@@ -38,27 +38,22 @@ class RebrickableMinifigure(BrickRecord):
             self.ingest(record)
 
     # Insert the minifigure from Rebrickable
-    def insert_rebrickable(self, /) -> bool:
+    def insert_rebrickable(self, /) -> None:
         if self.brickset is None:
             raise ErrorException('Importing a minifigure from Rebrickable outside of a set is not supported')  # noqa: E501
 
         # Insert the Rebrickable minifigure to the database
-        rows, _ = self.insert(
+        self.insert(
             commit=False,
             no_defer=True,
             override_query=RebrickableMinifigure.insert_query
         )
 
-        inserted = rows > 0
-
-        if inserted:
-            if not current_app.config['USE_REMOTE_IMAGES']:
-                RebrickableImage(
-                    self.brickset,
-                    minifigure=self,
-                ).download()
-
-        return inserted
+        if not current_app.config['USE_REMOTE_IMAGES']:
+            RebrickableImage(
+                self.brickset,
+                minifigure=self,
+            ).download()
 
     # Return a dict with common SQL parameters for a minifigure
     def sql_parameters(self, /) -> dict[str, Any]:
diff --git a/bricktracker/rebrickable_part.py b/bricktracker/rebrickable_part.py
index 93c6b34e..704990c6 100644
--- a/bricktracker/rebrickable_part.py
+++ b/bricktracker/rebrickable_part.py
@@ -48,28 +48,23 @@ class RebrickablePart(BrickRecord):
             self.ingest(record)
 
     # Insert the part from Rebrickable
-    def insert_rebrickable(self, /) -> bool:
+    def insert_rebrickable(self, /) -> None:
         if self.brickset is None:
             raise ErrorException('Importing a part from Rebrickable outside of a set is not supported')  # noqa: E501
 
         # Insert the Rebrickable part to the database
-        rows, _ = self.insert(
+        self.insert(
             commit=False,
             no_defer=True,
             override_query=RebrickablePart.insert_query
         )
 
-        inserted = rows > 0
-
-        if inserted:
-            if not current_app.config['USE_REMOTE_IMAGES']:
-                RebrickableImage(
-                    self.brickset,
-                    minifigure=self.minifigure,
-                    part=self,
-                ).download()
-
-        return inserted
+        if not current_app.config['USE_REMOTE_IMAGES']:
+            RebrickableImage(
+                self.brickset,
+                minifigure=self.minifigure,
+                part=self,
+            ).download()
 
     # Return a dict with common SQL parameters for a part
     def sql_parameters(self, /) -> dict[str, Any]:
diff --git a/bricktracker/rebrickable_set.py b/bricktracker/rebrickable_set.py
index 1cd4b8d6..fbf10f1a 100644
--- a/bricktracker/rebrickable_set.py
+++ b/bricktracker/rebrickable_set.py
@@ -47,21 +47,16 @@ class RebrickableSet(BrickRecord):
             self.ingest(record)
 
     # Insert the set from Rebrickable
-    def insert_rebrickable(self, /) -> bool:
+    def insert_rebrickable(self, /) -> None:
         # Insert the Rebrickable set to the database
-        rows, _ = self.insert(
+        self.insert(
             commit=False,
             no_defer=True,
             override_query=RebrickableSet.insert_query
         )
 
-        inserted = rows > 0
-
-        if inserted:
-            if not current_app.config['USE_REMOTE_IMAGES']:
-                RebrickableImage(self).download()
-
-        return inserted
+        if not current_app.config['USE_REMOTE_IMAGES']:
+            RebrickableImage(self).download()
 
     # Ingest a set
     def ingest(self, record: Row | dict[str, Any], /):
diff --git a/bricktracker/record.py b/bricktracker/record.py
index 08651d2f..f7cc8892 100644
--- a/bricktracker/record.py
+++ b/bricktracker/record.py
@@ -1,5 +1,5 @@
 from sqlite3 import Row
-from typing import Any, ItemsView, Tuple
+from typing import Any, ItemsView
 
 from .fields import BrickRecordFields
 from .sql import BrickSQL
@@ -31,14 +31,14 @@ class BrickRecord(object):
         commit=True,
         no_defer=False,
         override_query: str | None = None
-    ) -> Tuple[int, str]:
+    ) -> None:
         if override_query:
             query = override_query
         else:
             query = self.insert_query
 
         database = BrickSQL()
-        rows, q = database.execute(
+        database.execute(
             query,
             parameters=self.sql_parameters(),
             defer=not commit and not no_defer,
@@ -47,8 +47,6 @@ class BrickRecord(object):
         if commit:
             database.commit()
 
-        return rows, q
-
     # Shorthand to field items
     def items(self, /) -> ItemsView[str, Any]:
         return self.fields.__dict__.items()
diff --git a/bricktracker/set.py b/bricktracker/set.py
index 63d41281..28f0341c 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -47,21 +47,25 @@ class BrickSet(RebrickableSet):
                 increment_total=True,
             )
 
+            # Grabbing the refresh flag
+            refresh: bool = bool(data.get('refresh', False))
+
             # Generate an UUID for self
             self.fields.id = str(uuid4())
 
-            # Insert into database
-            self.insert(commit=False)
+            if not refresh:
+                # Insert into database
+                self.insert(commit=False)
 
             # Insert the rebrickable set into database
             self.insert_rebrickable()
 
             # Load the inventory
-            if not BrickPartList.download(socket, self):
+            if not BrickPartList.download(socket, self, refresh=refresh):
                 return False
 
             # Load the minifigures
-            if not BrickMinifigureList.download(socket, self):
+            if not BrickMinifigureList.download(socket, self, refresh=refresh):
                 return False
 
             # Commit the transaction to the database
@@ -74,20 +78,34 @@ class BrickSet(RebrickableSet):
 
             BrickSQL().commit()
 
-            # Info
-            logger.info('Set {set}: imported (id: {id})'.format(
-                set=self.fields.set,
-                id=self.fields.id,
-            ))
-
-            # Complete
-            socket.complete(
-                message='Set {set}: imported (<a href="{url}">Go to the set</a>)'.format(  # noqa: E501
+            if refresh:
+                # Info
+                logger.info('Set {set}: imported (id: {id})'.format(
                     set=self.fields.set,
-                    url=self.url()
-                ),
-                download=True
-            )
+                    id=self.fields.id,
+                ))
+
+                # Complete
+                socket.complete(
+                    message='Set {set}: refreshed'.format(  # noqa: E501
+                        set=self.fields.set,
+                    ),
+                    download=True
+                )
+            else:
+                # Info
+                logger.info('Set {set}: refreshed'.format(
+                    set=self.fields.set,
+                ))
+
+                # Complete
+                socket.complete(
+                    message='Set {set}: imported (<a href="{url}">Go to the set</a>)'.format(  # noqa: E501
+                        set=self.fields.set,
+                        url=self.url()
+                    ),
+                    download=True
+                )
 
         except Exception as e:
             socket.fail(
@@ -192,3 +210,10 @@ class BrickSet(RebrickableSet):
             )
         else:
             return ''
+
+    # Compute the url for the refresh button
+    def url_for_refresh(self, /) -> str:
+        return url_for(
+            'set.refresh',
+            id=self.fields.id,
+        )
diff --git a/bricktracker/set_checkbox.py b/bricktracker/set_checkbox.py
index ea6d6d2b..38a10f0d 100644
--- a/bricktracker/set_checkbox.py
+++ b/bricktracker/set_checkbox.py
@@ -1,5 +1,5 @@
 from sqlite3 import Row
-from typing import Any, Self, Tuple
+from typing import Any, Self
 from uuid import uuid4
 
 from flask import url_for
@@ -60,7 +60,7 @@ class BrickSetCheckbox(BrickRecord):
         return self
 
     # Insert into database
-    def insert(self, **_) -> Tuple[int, str]:
+    def insert(self, **_) -> None:
         # Generate an ID for the checkbox (with underscores to make it
         # column name friendly)
         self.fields.id = str(uuid4()).replace('-', '_')
@@ -72,9 +72,6 @@ class BrickSetCheckbox(BrickRecord):
             displayed_on_grid=self.fields.displayed_on_grid
         )
 
-        # To accomodate the parent().insert we have overriden
-        return 0, ''
-
     # Rename the checkbox
     def rename(self, /) -> None:
         # Update the name
diff --git a/bricktracker/sql/rebrickable/minifigure/insert.sql b/bricktracker/sql/rebrickable/minifigure/insert.sql
index 06719257..6c0ac8e8 100644
--- a/bricktracker/sql/rebrickable/minifigure/insert.sql
+++ b/bricktracker/sql/rebrickable/minifigure/insert.sql
@@ -9,3 +9,9 @@ INSERT OR IGNORE INTO "rebrickable_minifigures" (
     :name,
     :image
 )
+ON CONFLICT("figure")
+DO UPDATE SET
+"number" = :number,
+"name" = :name,
+"image" = :image
+WHERE "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM :figure
diff --git a/bricktracker/sql/rebrickable/part/insert.sql b/bricktracker/sql/rebrickable/part/insert.sql
index d989258c..fcec4ef0 100644
--- a/bricktracker/sql/rebrickable/part/insert.sql
+++ b/bricktracker/sql/rebrickable/part/insert.sql
@@ -23,3 +23,16 @@ INSERT OR IGNORE INTO "rebrickable_parts" (
     :url,
     :print
 )
+ON CONFLICT("part", "color_id")
+DO UPDATE SET
+"color_name" = :color_name,
+"color_rgb" = :color_rgb,
+"color_transparent" = :color_transparent,
+"name" = :name,
+"category" = :category,
+"image" = :image,
+"image_id" = :image_id,
+"url" = :url,
+"print" = :print
+WHERE "rebrickable_parts"."part" IS NOT DISTINCT FROM :part
+AND "rebrickable_parts"."color_id" IS NOT DISTINCT FROM :color_id
\ No newline at end of file
diff --git a/bricktracker/sql/rebrickable/set/insert.sql b/bricktracker/sql/rebrickable/set/insert.sql
index 88b2b44f..39e69646 100644
--- a/bricktracker/sql/rebrickable/set/insert.sql
+++ b/bricktracker/sql/rebrickable/set/insert.sql
@@ -21,3 +21,15 @@ INSERT OR IGNORE INTO "rebrickable_sets" (
     :url,
     :last_modified
 )
+ON CONFLICT("set")
+DO UPDATE SET
+    "number" = :number,
+    "version" = :version,
+    "name" = :name,
+    "year" = :year,
+    "theme_id" = :theme_id,
+    "number_of_parts" = :number_of_parts,
+    "image" = :image,
+    "url" = :url,
+    "last_modified" = :last_modified
+WHERE "rebrickable_sets"."set" IS NOT DISTINCT FROM :set
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index 0b8d843a..117ff03b 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -2,6 +2,7 @@ import logging
 
 from flask import (
     Blueprint,
+    current_app,
     jsonify,
     render_template,
     redirect,
@@ -17,6 +18,7 @@ from ..part import BrickPart
 from ..set import BrickSet
 from ..set_checkbox_list import BrickSetCheckboxList
 from ..set_list import BrickSetList
+from ..socket import MESSAGES
 
 logger = logging.getLogger(__name__)
 
@@ -154,3 +156,16 @@ def missing_part(
     ))
 
     return jsonify({'missing': missing})
+
+
+# Refresh a set
+@set_page.route('/<id>/refresh', methods=['GET'])
+@exception_handler(__file__)
+def refresh(*, id: str) -> str:
+    return render_template(
+        'refresh.html',
+        item=BrickSet().select_specific(id),
+        path=current_app.config['SOCKET_PATH'],
+        namespace=current_app.config['SOCKET_NAMESPACE'],
+        messages=MESSAGES
+    )
diff --git a/static/scripts/socket/set.js b/static/scripts/socket/set.js
index 41056b8b..4f6bf978 100644
--- a/static/scripts/socket/set.js
+++ b/static/scripts/socket/set.js
@@ -1,8 +1,11 @@
 // Set Socket class
 class BrickSetSocket extends BrickSocket {
-    constructor(id, path, namespace, messages, bulk=false) {
+    constructor(id, path, namespace, messages, bulk=false, refresh=false) {
         super(id, path, namespace, messages, bulk);
 
+        // Refresh mode
+        this.refresh = true
+
         // Listeners
         this.add_listener = undefined;
         this.input_listener = undefined;
@@ -82,7 +85,7 @@ class BrickSetSocket extends BrickSocket {
                 this.read_set_list();
             }
 
-            if (this.bulk || (this.html_no_confim && this.html_no_confim.checked)) {
+            if (this.bulk || this.refresh || (this.html_no_confim && this.html_no_confim.checked)) {
                 this.import_set(true);
             } else {
                 this.load_set();
@@ -140,6 +143,7 @@ class BrickSetSocket extends BrickSocket {
 
             this.socket.emit(this.messages.IMPORT_SET, {
                 set: (set !== undefined) ? set : this.html_input.value,
+                refresh: this.refresh
             });
         } else {
             this.fail("Could not find the input field for the set number");
diff --git a/templates/add.html b/templates/add.html
index 140eec62..5316ea17 100644
--- a/templates/add.html
+++ b/templates/add.html
@@ -68,5 +68,7 @@
     </div>
   </div>
 </div>
-{% include 'set/socket.html' %}
+{% with id='add' %}
+  {% include 'set/socket.html' %}
+{% endwith %}
 {% endblock %}
diff --git a/templates/bulk.html b/templates/bulk.html
index 6e6e5d8a..00d47797 100644
--- a/templates/bulk.html
+++ b/templates/bulk.html
@@ -58,7 +58,7 @@
     </div>
   </div>
 </div>
-{% with bulk=true %}
+{% with id='add', bulk=true %}
   {% include 'set/socket.html' %}
 {% endwith %}
 {% endblock %}
diff --git a/templates/refresh.html b/templates/refresh.html
new file mode 100644
index 00000000..5add93d8
--- /dev/null
+++ b/templates/refresh.html
@@ -0,0 +1,64 @@
+{% extends 'base.html' %}
+
+{% block title %} - Refresh set {{ item.fields.set }}{% endblock %}
+
+{% block main %}
+<div class="container">
+  <div class="alert alert-primary" role="alert">
+    <h4 class="alert-heading">Refreshing from Rebrickable</h4>
+    <p class="mb-0">This will refresh all the Rebrickable data (set, minifigures, parts) associated with this set.</p>
+  </div>
+  <div class="row">
+    <div class="col-12">
+      <div class="card mb-3">
+        <div class="card-header">
+          <h5 class="mb-0"><i class="ri-refresh-line"></i> Refresh a set</h5>
+        </div>
+        <div class="card-body">
+          <div id="refresh-fail" class="alert alert-danger d-none" role="alert"></div>
+          <div id="refresh-complete" class="alert alert-success d-none" role="alert"></div>
+          <div class="mb-3">
+            <label for="refresh-set" class="form-label">Set number</label>
+            <input type="text" class="form-control" id="refresh-set" value="{{ item.fields.set }}">
+          </div>
+          <hr>
+          <div class="mb-3">
+            <p>
+              Progress <span id="refresh-count"></span>
+              <span id="refresh-spinner" class="d-none">
+                <span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
+                <span class="visually-hidden" role="status">Loading...</span>
+              </span>
+            </p>
+            <div id="refresh-progress" class="progress" role="progressbar" aria-label="Refresh a set progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
+              <div id="refresh-progress-bar" class="progress-bar" style="width: 0%"></div>
+            </div>
+            <p id="refresh-progress-message" class="text-center d-none"></p>
+          </div>
+          <div id="refresh-card" class="d-flex justify-content-center">
+            <div class="card mb-3 col-6">
+              <div class="card-header">
+                <h5 class="mb-0">
+                  <span class="badge text-bg-secondary fw-normal"><i class="ri-hashtag"></i> <span id="refresh-card-set">{{ item.fields.set }}</span></span>
+                  <span id="refresh-card-name">{{ item.fields.name }}</span>
+                </h5>
+              </div>
+              <div id="refresh-card-image-container" class="card-img" style="background-image: url({{ item.url_for_image() }})">
+                <img id="refresh-card-image" src="{{ item.url_for_image() }}">
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="card-footer text-end">
+          <span id="refresh-status-icon" class="me-1"></span><span id="refresh-status" class="me-1"></span>
+          <a href="{{ url_for('set.details', id=item.fields.id) }}" class="btn btn-primary" role="button"><i class="ri-hashtag"></i> Back to the set details</a>
+          <button id="refresh" type="button" class="btn btn-primary"><i class="ri-refresh-line"></i> Refresh</button>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+{% with id='refresh', refresh=true %}
+  {% include 'set/socket.html' %}
+{% endwith %}
+{% endblock %}
diff --git a/templates/set/card.html b/templates/set/card.html
index 20bfe4a5..02d77b30 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -63,6 +63,9 @@
         {% endfor %}
       {% endif %}
       {% if g.login.is_authenticated() %}
+        {{ accordion.header('Management', 'management', 'set-details', icon='settings-4-line', class='text-end') }}
+            <a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh the set</a>
+        {{ accordion.footer() }}
         {{ accordion.header('Danger zone', 'danger-zone', 'set-details', expanded=delete, danger=true, class='text-end') }}
           {% if delete %}
             <form action="{{ item.url_for_do_delete() }}" method="post">
diff --git a/templates/set/socket.html b/templates/set/socket.html
index a566a95d..c4000a82 100644
--- a/templates/set/socket.html
+++ b/templates/set/socket.html
@@ -1,12 +1,19 @@
 <script type="text/javascript">
   document.addEventListener("DOMContentLoaded", () => {
-    new BrickSetSocket('add', '{{ path }}', '{{ namespace }}', {
-      COMPLETE: '{{ messages['COMPLETE'] }}',
-      FAIL: '{{ messages['FAIL'] }}',
-      IMPORT_SET: '{{ messages['IMPORT_SET'] }}',
-      LOAD_SET: '{{ messages['LOAD_SET'] }}',
-      PROGRESS: '{{ messages['PROGRESS'] }}',
-      SET_LOADED: '{{ messages['SET_LOADED'] }}',
-    }{% if bulk %}, true{% endif %});
+    new BrickSetSocket(
+      '{{ id }}',
+      '{{ path }}',
+      '{{ namespace }}',
+      {
+        COMPLETE: '{{ messages['COMPLETE'] }}',
+        FAIL: '{{ messages['FAIL'] }}',
+        IMPORT_SET: '{{ messages['IMPORT_SET'] }}',
+        LOAD_SET: '{{ messages['LOAD_SET'] }}',
+        PROGRESS: '{{ messages['PROGRESS'] }}',
+        SET_LOADED: '{{ messages['SET_LOADED'] }}',
+      },
+      {% if bulk %}true{% else %}false{% endif %},
+      {% if refresh %}true{% else %}false{% endif %}
+    );
   });
 </script>

From 71ccfcd23d50a8c8c677cbe0afdf1127c56063e7 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 28 Jan 2025 23:07:54 +0100
Subject: [PATCH 028/154] Remove leftover debug prints

---
 bricktracker/views/set.py | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index 117ff03b..44eabed4 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -122,9 +122,6 @@ def missing_part(
     color: int,
     spare: int,
 ) -> Response:
-    from pprint import pprint
-    pprint(locals())
-
     brickset = BrickSet().select_specific(id)
 
     if figure is not None:

From fe13cfdb081327e01e974a8f446f119d2e054005 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 28 Jan 2025 23:31:20 +0100
Subject: [PATCH 029/154] Collapsible grid controls

---
 templates/sets.html | 22 +++++++++++-----------
 1 file changed, 11 insertions(+), 11 deletions(-)

diff --git a/templates/sets.html b/templates/sets.html
index ea08c2d5..c62d3f3b 100644
--- a/templates/sets.html
+++ b/templates/sets.html
@@ -9,14 +9,14 @@
     <div class="col-12 flex-grow-1">
       <label class="visually-hidden" for="grid-search">Search</label>
       <div class="input-group">
-        <span class="input-group-text">Search</span>
+        <span class="input-group-text"><i class="ri-search-line"></i><span class="ms-1 d-none d-xl-inline"> Search</span></span>
         <input id="grid-search" class="form-control form-control-sm" type="text" placeholder="Set name, set number, set theme or number of parts..." value="">
       </div>
     </div>
     <div class="col-12 flex-grow-1">
       <label class="visually-hidden" for="grid-filter">Filter</label>
       <div class="input-group">
-        <span class="input-group-text">Filter</span>
+        <span class="input-group-text"><i class="ri-dropdown-list"></i><span class="ms-1 d-none d-xl-inline"> Filter</span></span>
         <select id="grid-filter" class="form-select form-select-sm" autocomplete="off">
           <option value="" selected>All sets</option>
           <option value="-has-missing">Set is complete</option>
@@ -37,23 +37,23 @@
     </div>
     <div class="col-12">
       <div id="grid-sort" class="input-group">
-        <span class="input-group-text">Sort</span>
+        <span class="input-group-text"><i class="ri-sort-asc"></i><span class="ms-1 d-none d-xxl-inline"> Sort</span></span>
         <button id="sort-number" type="button" class="btn btn-sm btn-outline-primary"
-          data-sort-target="div#grid>div" data-sort-attribute="number" data-sort-natural="true">Num.</button>
+          data-sort-target="div#grid>div" data-sort-attribute="number" data-sort-natural="true"><i class="ri-hashtag"></i><span class="d-none d-xxl-inline"> Number</span></button>
         <button id="sort-name" type="button" class="btn btn-sm btn-outline-primary"
-          data-sort-target="div#grid>div" data-sort-attribute="name">Name</button>
+          data-sort-target="div#grid>div" data-sort-attribute="name"><i class="ri-pencil-line"></i><span class="d-none d-xxl-inline"> Name</span></button>
         <button id="sort-theme" type="button" class="btn btn-sm btn-outline-primary"
-          data-sort-target="div#grid>div" data-sort-attribute="theme">Theme</button>
+          data-sort-target="div#grid>div" data-sort-attribute="theme"><i class="ri-price-tag-3-line"></i><span class="d-none d-xxl-inline"> Theme</span></button>
         <button id="sort-year" type="button" class="btn btn-sm btn-outline-primary"
-          data-sort-target="div#grid>div" data-sort-attribute="year">Year</button>
+          data-sort-target="div#grid>div" data-sort-attribute="year"><i class="ri-calendar-line"></i><span class="d-none d-xxl-inline"> Year</span></button>
         <button id="sort-minifigure" type="button" class="btn btn-sm btn-outline-primary"
-          data-sort-target="div#grid>div" data-sort-attribute="minifigures" data-sort-desc="true">Fig.</button>
+          data-sort-target="div#grid>div" data-sort-attribute="minifigures" data-sort-desc="true"><i class="ri-group-line"></i><span class="d-none d-xxl-inline"> Figures</span></button>
         <button id="sort-parts" type="button" class="btn btn-sm btn-outline-primary"
-          data-sort-target="div#grid>div" data-sort-attribute="parts" data-sort-desc="true">Parts</button>
+          data-sort-target="div#grid>div" data-sort-attribute="parts" data-sort-desc="true"><i class="ri-shapes-line"></i><span class="d-none d-xxl-inline"> Parts</span></button>
         <button id="sort-missing" type="button" class="btn btn-sm btn-outline-primary"
-          data-sort-target="div#grid>div" data-sort-attribute="missing" data-sort-desc="true">Miss.</button>
+          data-sort-target="div#grid>div" data-sort-attribute="missing" data-sort-desc="true"><i class="ri-error-warning-line"></i><span class="d-none d-xxl-inline"> Missing</span></button>
         <button id="sort-clear" type="button" class="btn btn-sm btn-outline-dark"
-          data-sort-target="div#grid>div" data-sort-clear="true"><i class="ri-filter-off-line"></i></button>
+          data-sort-target="div#grid>div" data-sort-clear="true"><i class="ri-filter-off-line"></i><span class="d-none d-xxl-inline"> Clear</span></button>
       </div>
     </div>
   </div>

From d93723ab4e5a6733202930481172aaf10ff7143f Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 09:04:09 +0100
Subject: [PATCH 030/154] Use Rebrickable URL for cosmetics if available

---
 bricktracker/rebrickable_part.py | 15 +++++++++++----
 1 file changed, 11 insertions(+), 4 deletions(-)

diff --git a/bricktracker/rebrickable_part.py b/bricktracker/rebrickable_part.py
index 704990c6..b0282be4 100644
--- a/bricktracker/rebrickable_part.py
+++ b/bricktracker/rebrickable_part.py
@@ -138,10 +138,17 @@ class RebrickablePart(BrickRecord):
     def url_for_rebrickable(self, /) -> str:
         if current_app.config['REBRICKABLE_LINKS']:
             try:
-                return current_app.config['REBRICKABLE_LINK_PART_PATTERN'].format(  # noqa: E501
-                    part=self.fields.part,
-                    color=self.fields.color,
-                )
+                if self.fields.url is not None:
+                    # The URL does not contain color info...
+                    return '{url}{color}'.format(
+                        url=self.fields.url,
+                        color=self.fields.color
+                    )
+                else:
+                    return current_app.config['REBRICKABLE_LINK_PART_PATTERN'].format(  # noqa: E501
+                        part=self.fields.part,
+                        color=self.fields.color,
+                    )
             except Exception:
                 pass
 

From d08b7bb063d9df455d02c7494668ff1df41de117 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 10:36:43 +0100
Subject: [PATCH 031/154] Display RGB color, transparency and prints for parts

---
 bricktracker/rebrickable_part.py    |  8 ++++++++
 bricktracker/sql/part/base/base.sql |  2 +-
 static/styles.css                   |  6 ++++++
 templates/macro/card.html           | 13 +++++++++++--
 templates/part/card.html            |  2 +-
 templates/part/table.html           |  5 ++++-
 6 files changed, 31 insertions(+), 5 deletions(-)

diff --git a/bricktracker/rebrickable_part.py b/bricktracker/rebrickable_part.py
index b0282be4..8224d98f 100644
--- a/bricktracker/rebrickable_part.py
+++ b/bricktracker/rebrickable_part.py
@@ -134,6 +134,14 @@ class RebrickablePart(BrickRecord):
             spare=self.fields.spare,
         )
 
+    # Compute the url for the original of the printed part
+    def url_for_print(self, /) -> str:
+        return url_for(
+            'part.details',
+            part=self.fields.print,
+            color=self.fields.color,
+        )
+
     # Compute the url for the rebrickable page
     def url_for_rebrickable(self, /) -> str:
         if current_app.config['REBRICKABLE_LINKS']:
diff --git a/bricktracker/sql/part/base/base.sql b/bricktracker/sql/part/base/base.sql
index d9226b39..7849d4cc 100644
--- a/bricktracker/sql/part/base/base.sql
+++ b/bricktracker/sql/part/base/base.sql
@@ -19,7 +19,7 @@ SELECT
     "rebrickable_parts"."image",
     "rebrickable_parts"."image_id",
     "rebrickable_parts"."url",
-    --"rebrickable_parts"."print",
+    "rebrickable_parts"."print",
     {% block total_missing %}
     NULL AS "total_missing", -- dummy for order: total_missing
     {% endblock %}
diff --git a/static/styles.css b/static/styles.css
index 9ac95de8..af00525a 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -59,3 +59,9 @@
 .sortable thead th {
     font-weight: bold !important;
 }
+
+.color-rgb {
+    display: inline-block;
+    width: 15px;
+    height: 15px;
+}
\ No newline at end of file
diff --git a/templates/macro/card.html b/templates/macro/card.html
index 1cc92ac6..7f2924ff 100644
--- a/templates/macro/card.html
+++ b/templates/macro/card.html
@@ -1,11 +1,20 @@
-{% macro header(item, name, solo=false, identifier=none, color=none, icon='hashtag') %}
+{% macro header(item, name, solo=false, identifier=none, icon='hashtag') %}
   <div class="card-header">
     {% if not solo %}
       <a class="text-decoration-none text-reset" href="{{ item.url() }}" data-bs-toggle="tooltip" title="{{ name }}">
     {% endif %}
     <h5 class="mb-0 {% if not solo %}fs-6 text-nowrap overflow-x-hidden text-truncate{% endif %}">
       {% if identifier %}<span class="badge text-bg-secondary fw-normal"><i class="ri-{{ icon }}"></i>{{ identifier }}</span>{% endif %}
-      {% if color %}<span class="badge text-bg-light fw-normal border"><i class="ri-palette-line"></i> {{ color }}</span>{% endif %}
+      {% if item.fields.color_name %}
+        <span class="badge bg-white text-black fw-normal border"><i class="ri-palette-line"></i>
+          {% if item.fields.color_rgb %}
+            <span class="color-rgb align-bottom border border-black" style="background-color: #{{ item.fields.color_rgb }};"></span>
+          {% endif %}
+          {{ item.fields.color_name }}
+        </span>
+      {% endif %}
+      {% if item.fields.color_transparent %}<span class="badge text-bg-light fw-normal border"><i class="ri-blur-off-line"></i> Transparent</span>{% endif %}
+      {% if item.fields.print %}<span class="badge text-bg-light fw-normal border"><a class="text-reset" href="{{ item.url_for_print() }}"><i class="ri-paint-brush-line"></i> Print</a></span>{% endif %}
       {{ name }}
     </h5>
     {% if not solo %}
diff --git a/templates/part/card.html b/templates/part/card.html
index 8c23b59e..9a14f4ae 100644
--- a/templates/part/card.html
+++ b/templates/part/card.html
@@ -3,7 +3,7 @@
 {% import 'macro/card.html' as card %}
 
 <div class="card mb-3 flex-fill card-solo">
-  {{ card.header(item, item.fields.name, solo=solo, identifier=item.fields.part, color=item.fields.color, icon='shapes-line') }}
+  {{ card.header(item, item.fields.name, solo=solo, identifier=item.fields.part, icon='shapes-line') }}
   {{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.image_id, medium=true) }}
   <div class="card-body border-bottom {% if not solo %}p-1{% endif %}">
     {{ badge.total_sets(sets_using | length, solo=solo, last=last) }}
diff --git a/templates/part/table.html b/templates/part/table.html
index 78d0efe6..f9f8c34d 100644
--- a/templates/part/table.html
+++ b/templates/part/table.html
@@ -15,7 +15,10 @@
             {{ table.bricklink(item) }}
           {% endif %}
         </td>
-        <td>{{ item.fields.color_name }}</td>
+        <td>
+          {% if item.fields.color_rgb %}<span class="color-rgb align-middle border border-black" style="background-color: #{{ item.fields.color_rgb }};"></span>{% endif %}
+          <span class="align-middle">{{ item.fields.color_name }}</span>
+        </td>
         {% if not no_quantity %}
           {% if all %}
             <td>{{ item.fields.total_quantity }}</td>

From e033dec988149c90f0d431d5fb10cf6fe3f61065 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 10:37:22 +0100
Subject: [PATCH 032/154] Use data-sort to sort colums with complex data

---
 templates/part/table.html | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/templates/part/table.html b/templates/part/table.html
index f9f8c34d..87b02f6d 100644
--- a/templates/part/table.html
+++ b/templates/part/table.html
@@ -7,7 +7,7 @@
       {% for item in table_collection %}
       <tr>
         {{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.part, accordion=solo) }}
-        <td>
+        <td data-sort="{{ item.fields.name }}">
           <a class="text-reset" href="{{ item.url() }}">{{ item.fields.name }}</a>
           {% if item.fields.spare %}<span class="badge rounded-pill text-bg-warning fw-normal"><i class="ri-loop-left-line"></i> Spare</span>{% endif %}
           {% if all %}
@@ -15,7 +15,7 @@
             {{ table.bricklink(item) }}
           {% endif %}
         </td>
-        <td>
+        <td data-sort="{{ item.fields.color_name }}">
           {% if item.fields.color_rgb %}<span class="color-rgb align-middle border border-black" style="background-color: #{{ item.fields.color_rgb }};"></span>{% endif %}
           <span class="align-middle">{{ item.fields.color_name }}</span>
         </td>

From a2aafbf93a984d08443ac16bf3783ae867197da6 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 11:43:18 +0100
Subject: [PATCH 033/154] Visual fix for Any/No color

---
 static/styles.css         | 13 +++++++++++++
 templates/macro/card.html |  2 +-
 templates/part/table.html |  2 +-
 3 files changed, 15 insertions(+), 2 deletions(-)

diff --git a/static/styles.css b/static/styles.css
index af00525a..ee82ffe0 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -64,4 +64,17 @@
     display: inline-block;
     width: 15px;
     height: 15px;
+}
+
+.color-rgb-table {
+    width: 20px !important;
+    height: 20px !important;
+}
+
+.color-any {
+    background:
+        linear-gradient(217deg, rgb(255 0 0 / 80%), rgb(255 0 0 / 0%) 70.71%),
+        linear-gradient(127deg, rgb(0 255 0 / 80%), rgb(0 255 0 / 0%) 70.71%),
+        linear-gradient(336deg, rgb(0 0 255 / 80%), rgb(0 0 255 / 0%) 70.71%)
+    ;
 }
\ No newline at end of file
diff --git a/templates/macro/card.html b/templates/macro/card.html
index 7f2924ff..3c26de7b 100644
--- a/templates/macro/card.html
+++ b/templates/macro/card.html
@@ -8,7 +8,7 @@
       {% if item.fields.color_name %}
         <span class="badge bg-white text-black fw-normal border"><i class="ri-palette-line"></i>
           {% if item.fields.color_rgb %}
-            <span class="color-rgb align-bottom border border-black" style="background-color: #{{ item.fields.color_rgb }};"></span>
+            <span class="color-rgb {% if item.fields.color == 9999 %}color-any{% endif %} align-bottom border border-black" {% if item.fields.color != 9999 %}style="background-color: #{{ item.fields.color_rgb }};"{% endif %}></span>
           {% endif %}
           {{ item.fields.color_name }}
         </span>
diff --git a/templates/part/table.html b/templates/part/table.html
index 87b02f6d..8e1da015 100644
--- a/templates/part/table.html
+++ b/templates/part/table.html
@@ -16,7 +16,7 @@
           {% endif %}
         </td>
         <td data-sort="{{ item.fields.color_name }}">
-          {% if item.fields.color_rgb %}<span class="color-rgb align-middle border border-black" style="background-color: #{{ item.fields.color_rgb }};"></span>{% endif %}
+          {% if item.fields.color_rgb %}<span class="color-rgb color-rgb-table {% if item.fields.color == 9999 %}color-any{% endif %} align-middle border border-black" {% if item.fields.color != 9999 %}style="background-color: #{{ item.fields.color_rgb }};"{% endif %}></span>{% endif %}
           <span class="align-middle">{{ item.fields.color_name }}</span>
         </td>
         {% if not no_quantity %}

From 468cc7ede9e0ec719a7f133ca9d0929665ab5c3c Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 14:03:48 +0100
Subject: [PATCH 034/154] Display prints based on a part

---
 bricktracker/part_list.py                 | 37 ++++++++++++++++++++++-
 bricktracker/sql/part/list/from_print.sql | 17 +++++++++++
 bricktracker/views/part.py                |  5 ++-
 templates/macro/card.html                 | 20 ++++++------
 templates/part/card.html                  | 27 ++++++++++-------
 5 files changed, 84 insertions(+), 22 deletions(-)
 create mode 100644 bricktracker/sql/part/list/from_print.sql

diff --git a/bricktracker/part_list.py b/bricktracker/part_list.py
index 667c26e4..833ae61c 100644
--- a/bricktracker/part_list.py
+++ b/bricktracker/part_list.py
@@ -26,6 +26,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
     last_query: str = 'part/list/last'
     minifigure_query: str = 'part/list/from_minifigure'
     missing_query: str = 'part/list/missing'
+    print_query: str = 'part/list/from_print'
     select_query: str = 'part/list/specific'
 
     def __init__(self, /):
@@ -103,6 +104,40 @@ class BrickPartList(BrickRecordList[BrickPart]):
 
         return self
 
+    # Load generic parts from a print
+    def from_print(
+        self,
+        brickpart: BrickPart,
+        /,
+    ) -> Self:
+        # Save the part and print
+        if brickpart.fields.print is not None:
+            self.fields.print = brickpart.fields.print
+        else:
+            self.fields.print = brickpart.fields.part
+
+        self.fields.part = brickpart.fields.part
+        self.fields.color = brickpart.fields.color
+
+        # Load the parts from the database
+        for record in self.select(
+            override_query=self.print_query,
+            order=self.order
+        ):
+            part = BrickPart(
+                record=record,
+            )
+
+            if (
+                current_app.config['SKIP_SPARE_PARTS'] and
+                part.fields.spare
+            ):
+                continue
+
+            self.records.append(part)
+
+        return self
+
     # Load missing parts
     def missing(self, /) -> Self:
         for record in self.select(
@@ -117,7 +152,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
 
     # Return a dict with common SQL parameters for a parts list
     def sql_parameters(self, /) -> dict[str, Any]:
-        parameters: dict[str, Any] = {}
+        parameters: dict[str, Any] = super().sql_parameters()
 
         # Set id
         if self.brickset is not None:
diff --git a/bricktracker/sql/part/list/from_print.sql b/bricktracker/sql/part/list/from_print.sql
new file mode 100644
index 00000000..f996864c
--- /dev/null
+++ b/bricktracker/sql/part/list/from_print.sql
@@ -0,0 +1,17 @@
+
+{% extends 'part/base/base.sql' %}
+
+{% block total_missing %}
+{% endblock %}
+
+{% block where %}
+WHERE "rebrickable_parts"."print" IS NOT DISTINCT FROM :print
+AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color
+AND "bricktracker_parts"."part" IS DISTINCT FROM :part
+{% endblock %}
+
+{% block group %}
+GROUP BY
+    "bricktracker_parts"."part",
+    "bricktracker_parts"."color"
+{% endblock %}
diff --git a/bricktracker/views/part.py b/bricktracker/views/part.py
index 5f20997c..dbcfe0dd 100644
--- a/bricktracker/views/part.py
+++ b/bricktracker/views/part.py
@@ -33,9 +33,11 @@ def missing() -> str:
 @part_page.route('/<part>/<int:color>/details', methods=['GET'])  # noqa: E501
 @exception_handler(__file__)
 def details(*, part: str, color: int) -> str:
+    brickpart = BrickPart().select_generic(part, color)
+
     return render_template(
         'part.html',
-        item=BrickPart().select_generic(part, color),
+        item=brickpart,
         sets_using=BrickSetList().using_part(
             part,
             color
@@ -52,4 +54,5 @@ def details(*, part: str, color: int) -> str:
             part,
             color
         ),
+        similar_prints=BrickPartList().from_print(brickpart)
     )
diff --git a/templates/macro/card.html b/templates/macro/card.html
index 3c26de7b..ae8bce01 100644
--- a/templates/macro/card.html
+++ b/templates/macro/card.html
@@ -5,16 +5,18 @@
     {% endif %}
     <h5 class="mb-0 {% if not solo %}fs-6 text-nowrap overflow-x-hidden text-truncate{% endif %}">
       {% if identifier %}<span class="badge text-bg-secondary fw-normal"><i class="ri-{{ icon }}"></i>{{ identifier }}</span>{% endif %}
-      {% if item.fields.color_name %}
-        <span class="badge bg-white text-black fw-normal border"><i class="ri-palette-line"></i>
-          {% if item.fields.color_rgb %}
-            <span class="color-rgb {% if item.fields.color == 9999 %}color-any{% endif %} align-bottom border border-black" {% if item.fields.color != 9999 %}style="background-color: #{{ item.fields.color_rgb }};"{% endif %}></span>
-          {% endif %}
-          {{ item.fields.color_name }}
-        </span>
+      {% if solo %}
+        {% if item.fields.color_name %}
+          <span class="badge bg-white text-black fw-normal border"><i class="ri-palette-line"></i>
+            {% if item.fields.color_rgb %}
+              <span class="color-rgb {% if item.fields.color == 9999 %}color-any{% endif %} align-bottom border border-black" {% if item.fields.color != 9999 %}style="background-color: #{{ item.fields.color_rgb }};"{% endif %}></span>
+            {% endif %}
+            {{ item.fields.color_name }}
+          </span>
+        {% endif %}
+        {% if item.fields.color_transparent %}<span class="badge text-bg-light fw-normal border"><i class="ri-blur-off-line"></i> Transparent</span>{% endif %}
+        {% if item.fields.print %}<span class="badge text-bg-light fw-normal border"><a class="text-reset" href="{{ item.url_for_print() }}"><i class="ri-paint-brush-line"></i> Print</a></span>{% endif %}
       {% endif %}
-      {% if item.fields.color_transparent %}<span class="badge text-bg-light fw-normal border"><i class="ri-blur-off-line"></i> Transparent</span>{% endif %}
-      {% if item.fields.print %}<span class="badge text-bg-light fw-normal border"><a class="text-reset" href="{{ item.url_for_print() }}"><i class="ri-paint-brush-line"></i> Print</a></span>{% endif %}
       {{ name }}
     </h5>
     {% if not solo %}
diff --git a/templates/part/card.html b/templates/part/card.html
index 9a14f4ae..43dfcdc5 100644
--- a/templates/part/card.html
+++ b/templates/part/card.html
@@ -2,23 +2,28 @@
 {% import 'macro/badge.html' as badge %}
 {% import 'macro/card.html' as card %}
 
-<div class="card mb-3 flex-fill card-solo">
+<div class="card mb-3 flex-fill {% if solo %}card-solo{% endif %}">
   {{ card.header(item, item.fields.name, solo=solo, identifier=item.fields.part, icon='shapes-line') }}
   {{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.image_id, medium=true) }}
-  <div class="card-body border-bottom {% if not solo %}p-1{% endif %}">
+  <div class="card-body border-bottom-0 {% if not solo %}p-1{% endif %}">
     {{ badge.total_sets(sets_using | length, solo=solo, last=last) }}
     {{ badge.total_minifigures(minifigures_using | length, solo=solo, last=last) }}
     {{ badge.total_quantity(item.fields.total_quantity, solo=solo, last=last) }}
     {{ badge.total_spare(item.fields.total_spare, solo=solo, last=last) }}
     {{ badge.total_missing(item.fields.total_missing, solo=solo, last=last) }}
-    {{ badge.rebrickable(item, solo=solo, last=last) }}
-    {{ badge.bricklink(item, solo=solo, last=last) }}
+    {% if not last %}
+      {{ badge.rebrickable(item, solo=solo, last=last) }}
+      {{ badge.bricklink(item, solo=solo, last=last) }}
+    {% endif %}
   </div>
-  <div class="accordion accordion-flush" id="part-details">
-    {{ accordion.cards(sets_using, 'Sets using this part', 'sets-using-inventory', 'part-details', 'set/card.html', icon='hashtag') }}
-    {{ accordion.cards(sets_missing, 'Sets missing this part', 'sets-missing-inventory', 'part-details', 'set/card.html', icon='error-warning-line') }}
-    {{ accordion.cards(minifigures_using, 'Minifigures using this part', 'minifigures-using-inventory', 'part-details', 'minifigure/card.html', icon='group-line') }}
-    {{ accordion.cards(minifigures_missing, 'Minifigures missing this part', 'minifigures-missing-inventory', 'part-details', 'minifigure/card.html', icon='error-warning-line') }}
-  </div>
-  <div class="card-footer"></div>
+  {% if solo %}
+    <div class="accordion accordion-flush border-top" id="part-details">
+      {{ accordion.cards(sets_using, 'Sets using this part', 'sets-using-inventory', 'part-details', 'set/card.html', icon='hashtag') }}
+      {{ accordion.cards(sets_missing, 'Sets missing this part', 'sets-missing-inventory', 'part-details', 'set/card.html', icon='error-warning-line') }}
+      {{ accordion.cards(minifigures_using, 'Minifigures using this part', 'minifigures-using-inventory', 'part-details', 'minifigure/card.html', icon='group-line') }}
+      {{ accordion.cards(minifigures_missing, 'Minifigures missing this part', 'minifigures-missing-inventory', 'part-details', 'minifigure/card.html', icon='error-warning-line') }}
+      {{ accordion.cards(similar_prints, 'Prints using the same base', 'similar-prints', 'part-details', 'part/card.html', icon='palette-line') }}
+    </div>
+    <div class="card-footer"></div>
+  {% endif %}
 </div>

From cf11e4d71878588dfc2bb1b1090b35f99010a780 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 15:12:10 +0100
Subject: [PATCH 035/154] Move the dynamic input into a macro

---
 templates/macro/form.html | 15 +++++++++++++++
 templates/part/table.html | 11 ++---------
 2 files changed, 17 insertions(+), 9 deletions(-)

diff --git a/templates/macro/form.html b/templates/macro/form.html
index f9c5a5d8..cd126e9f 100644
--- a/templates/macro/form.html
+++ b/templates/macro/form.html
@@ -15,3 +15,18 @@
     {{ text }}
   {% endif %}
 {% endmacro %}
+
+{% macro input(id, html_id, url, value) %}
+  <input class="form-control form-control-sm flex-shrink-1" type="text" value="{% if value %}{{ value }}{% endif %}"
+  {% if g.login.is_authenticated() %}
+      onchange="change_part_missing_amount(this, '{{ id }}', '{{ html_id }}', '{{ url }}')"
+  {% else %}
+      disabled
+  {% endif %}
+      autocomplete="off">
+  {% if g.login.is_authenticated() %}
+    <span id="status-part-{{ id }}-{{ html_id }}" class="input-group-text ri-save-line"></span>
+  {% else %}
+    <span class="input-group-text ri-prohibited-line"></span>
+  {% endif %}
+{% endmacro %}
diff --git a/templates/part/table.html b/templates/part/table.html
index 8e1da015..b7139ede 100644
--- a/templates/part/table.html
+++ b/templates/part/table.html
@@ -1,3 +1,4 @@
+{% import 'macro/form.html' as form %}
 {% import 'macro/table.html' as table %}
 
 <div class="table-responsive-sm">
@@ -32,15 +33,7 @@
               {{ item.fields.total_missing }}
             {% else %}
             <div class="input-group">
-              {% if g.login.is_authenticated() %}
-                <input class="form-control form-control-sm flex-shrink-1" type="text" {% if item.fields.total_missing %}value="{{ item.fields.total_missing }}"{% endif %}
-                  onchange="change_part_missing_amount(this, '{{ item.fields.id }}', '{{ item.html_id() }}', '{{ item.url_for_missing() }}')" autocomplete="off">
-                <span id="status-part-{{ item.fields.id }}-{{ item.html_id() }}" class="input-group-text ri-save-line"></span>
-                {% else %}
-                <input class="form-control form-control-sm" type="text" {% if item.fields.total_missing %}value="{{ item.fields.total_missing }}"{% endif %}
-                  disabled autocomplete="off">
-                <span class="input-group-text ri-prohibited-line"></span>
-              {% endif %}
+              {{ form.input(item.fields.id, item.html_id(), item.url_for_missing(), item.fields.total_missing) }}
             </div>
             {% endif %}
           </td>

From f44192a1148b04ddc74f8bf4d3a30658280638eb Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 15:57:19 +0100
Subject: [PATCH 036/154] Add visually hidden label for dynamic input, move
 read-only logic in the macro

---
 bricktracker/part.py      |  2 +-
 static/styles.css         |  2 +-
 templates/macro/form.html | 13 ++++++++++---
 templates/part/table.html | 10 ++--------
 4 files changed, 14 insertions(+), 13 deletions(-)

diff --git a/bricktracker/part.py b/bricktracker/part.py
index af901b2b..c847db58 100644
--- a/bricktracker/part.py
+++ b/bricktracker/part.py
@@ -73,7 +73,7 @@ class BrickPart(RebrickablePart):
 
     # A identifier for HTML component
     def html_id(self) -> str:
-        components: list[str] = []
+        components: list[str] = ['part']
 
         if self.fields.figure is not None:
             components.append(self.fields.figure)
diff --git a/static/styles.css b/static/styles.css
index ee82ffe0..0651e203 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -46,7 +46,7 @@
     object-fit:contain;
 }
 
-.table-td-missing {
+.table-td-input {
     max-width: 150px;
 }
 
diff --git a/templates/macro/form.html b/templates/macro/form.html
index cd126e9f..f88b6731 100644
--- a/templates/macro/form.html
+++ b/templates/macro/form.html
@@ -16,8 +16,13 @@
   {% endif %}
 {% endmacro %}
 
-{% macro input(id, html_id, url, value) %}
-  <input class="form-control form-control-sm flex-shrink-1" type="text" value="{% if value %}{{ value }}{% endif %}"
+{% macro input(name, id, prefix, url, value, all=none, read_only=none) %}
+  {% if all or read_only %}
+    {{ value }}
+  {% else %}
+    <label class="visually-hidden" for="{{ prefix }}-{{ id }}">{{ name }}</label>
+    <div class="input-group">
+      <input class="form-control form-control-sm flex-shrink-1" type="text" id="{{ prefix }}-{{ id }}" value="{% if value %}{{ value }}{% endif %}"
   {% if g.login.is_authenticated() %}
       onchange="change_part_missing_amount(this, '{{ id }}', '{{ html_id }}', '{{ url }}')"
   {% else %}
@@ -25,8 +30,10 @@
   {% endif %}
       autocomplete="off">
   {% if g.login.is_authenticated() %}
-    <span id="status-part-{{ id }}-{{ html_id }}" class="input-group-text ri-save-line"></span>
+        <span id="status-{{ prefix }}-{{ id }}" class="input-group-text ri-save-line"></span>
   {% else %}
     <span class="input-group-text ri-prohibited-line"></span>
+      {% endif %}
+    </div>
   {% endif %}
 {% endmacro %}
diff --git a/templates/part/table.html b/templates/part/table.html
index b7139ede..692a7dc6 100644
--- a/templates/part/table.html
+++ b/templates/part/table.html
@@ -28,14 +28,8 @@
           {% endif %}
         {% endif %}
         {% if not no_missing %}
-          <td {% if not all %}id="sort-part-{{ item.fields.id }}-{{ item.html_id() }}" data-sort="{{ item.fields.total_missing }}"{% endif %} class="table-td-missing">
-            {% if all or read_only_missing %}
-              {{ item.fields.total_missing }}
-            {% else %}
-            <div class="input-group">
-              {{ form.input(item.fields.id, item.html_id(), item.url_for_missing(), item.fields.total_missing) }}
-            </div>
-            {% endif %}
+          <td data-sort="{{ item.fields.total_missing }}" class="table-td-input">
+            {{ form.input('Missing', item.fields.id, item.html_id(), item.url_for_missing(), item.fields.total_missing, all=all, read_only=read_only_missing) }}
           </td>
         {% endif %}
         {% if all %}

From e2b8b51db8d4a5e37a77df6dd936092c10b4f9d2 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 15:58:41 +0100
Subject: [PATCH 037/154] Move dynamic input to BrickChanger

---
 bricktracker/part.py      | 4 +++-
 bricktracker/views/set.py | 6 ++----
 static/scripts/changer.js | 8 ++++++--
 templates/macro/form.html | 2 +-
 4 files changed, 12 insertions(+), 8 deletions(-)

diff --git a/bricktracker/part.py b/bricktracker/part.py
index c847db58..d81bb85c 100644
--- a/bricktracker/part.py
+++ b/bricktracker/part.py
@@ -143,7 +143,9 @@ class BrickPart(RebrickablePart):
         return self
 
     # Update the missing part
-    def update_missing(self, missing: Any, /) -> None:
+    def update_missing(self, json: Any | None, /) -> None:
+        missing = json.get('value', '')  # type: ignore
+
         # We need a positive integer
         try:
             missing = int(missing)
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index 44eabed4..d82896c0 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -137,9 +137,7 @@ def missing_part(
         minifigure=brickminifigure,
     )
 
-    missing = request.json.get('missing', '')  # type: ignore
-
-    brickpart.update_missing(missing)
+    brickpart.update_missing(request.json)
 
     # Info
     logger.info('Set {set} ({id}): updated part ({part} color: {color}, spare: {spare}, minifigure: {figure}) missing count to {missing}'.format(  # noqa: E501
@@ -152,7 +150,7 @@ def missing_part(
         missing=brickpart.fields.missing,
     ))
 
-    return jsonify({'missing': missing})
+    return jsonify({'missing': brickpart.fields.missing})
 
 
 # Refresh a set
diff --git a/static/scripts/changer.js b/static/scripts/changer.js
index 224e24ba..e3a32f84 100644
--- a/static/scripts/changer.js
+++ b/static/scripts/changer.js
@@ -15,8 +15,10 @@ class BrickChanger {
         // Register an event depending on the type
         if (this.html_type == "checkbox") {
             var listener = "change";
+        } else if(this.html_type == "text") {
+            var listener = "change";
         } else {
-            var listener = "click";
+            throw Error("Unsupported input type for BrickChanger");
         }
 
         this.html_element.addEventListener(listener, ((changer) => (e) => {
@@ -70,8 +72,10 @@ class BrickChanger {
             // Grab the value depending on the type
             if (this.html_type == "checkbox") {
                 var value = this.html_element.checked;
-            } else {
+            } else if(this.html_type == "text") {
                 var value = this.html_element.value;
+            } else {
+                throw Error("Unsupported input type for BrickChanger");
             }
 
             const response = await fetch(this.url, {
diff --git a/templates/macro/form.html b/templates/macro/form.html
index f88b6731..4844822d 100644
--- a/templates/macro/form.html
+++ b/templates/macro/form.html
@@ -24,7 +24,7 @@
     <div class="input-group">
       <input class="form-control form-control-sm flex-shrink-1" type="text" id="{{ prefix }}-{{ id }}" value="{% if value %}{{ value }}{% endif %}"
   {% if g.login.is_authenticated() %}
-      onchange="change_part_missing_amount(this, '{{ id }}', '{{ html_id }}', '{{ url }}')"
+        data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}"
   {% else %}
       disabled
   {% endif %}

From b142ff5bed6c3ac6387ef12557d47f97b199cdc9 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 15:59:00 +0100
Subject: [PATCH 038/154] Fix missing logic to handle empty string from dynamic
 input

---
 bricktracker/part.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/bricktracker/part.py b/bricktracker/part.py
index d81bb85c..495059f3 100644
--- a/bricktracker/part.py
+++ b/bricktracker/part.py
@@ -148,6 +148,9 @@ class BrickPart(RebrickablePart):
 
         # We need a positive integer
         try:
+            if missing == '':
+                missing = 0
+
             missing = int(missing)
 
             if missing < 0:

From f016e65b69bc94d1ce6aeccc1f6be4700570810a Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 16:11:48 +0100
Subject: [PATCH 039/154] Rename read_only_missing to a more generic read_only

---
 templates/macro/accordion.html | 2 +-
 templates/minifigure.html      | 2 +-
 templates/minifigure/card.html | 2 +-
 templates/part/table.html      | 2 +-
 4 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/templates/macro/accordion.html b/templates/macro/accordion.html
index fbfd01b5..417ef474 100644
--- a/templates/macro/accordion.html
+++ b/templates/macro/accordion.html
@@ -43,7 +43,7 @@
   {% endif %}
 {% endmacro %}
 
-{% macro table(table_collection, title, id, parent, target, quantity=none, icon=none, image=none, alt=none, details=none, no_missing=none, read_only_missing=none) %}
+{% macro table(table_collection, title, id, parent, target, quantity=none, icon=none, image=none, alt=none, details=none, no_missing=none, read_only=none) %}
   {% set size=table_collection | length %}
   {% if size %}
     {{ header(title, id, parent, quantity=quantity, icon=icon, class='p-0', image=image, alt=alt) }}
diff --git a/templates/minifigure.html b/templates/minifigure.html
index 52ed7a1a..33b6322d 100644
--- a/templates/minifigure.html
+++ b/templates/minifigure.html
@@ -6,7 +6,7 @@
 <div class="container">
   <div class="row">
     <div class="col-12">
-      {% with solo=true, read_only_missing=true %}
+      {% with solo=true, read_only=true %}
         {% include 'minifigure/card.html' %}
       {% endwith %}
     </div>
diff --git a/templates/minifigure/card.html b/templates/minifigure/card.html
index 1b701938..446c1647 100644
--- a/templates/minifigure/card.html
+++ b/templates/minifigure/card.html
@@ -19,7 +19,7 @@
   </div>
   {% if solo %}
     <div class="accordion accordion-flush" id="minifigure-details">
-      {{ accordion.table(item.generic_parts(), 'Parts', item.fields.figure, 'minifigure-details', 'part/table.html', icon='shapes-line', alt=item.fields.figure, read_only_missing=read_only_missing)}}
+      {{ accordion.table(item.generic_parts(), 'Parts', item.fields.figure, 'minifigure-details', 'part/table.html', icon='shapes-line', alt=item.fields.figure, read_only=read_only)}}
       {{ accordion.cards(using, 'Sets using this minifigure', 'using-inventory', 'minifigure-details', 'set/card.html', icon='hashtag') }}
       {{ accordion.cards(missing, 'Sets missing parts of this minifigure', 'missing-inventory', 'minifigure-details', 'set/card.html', icon='error-warning-line') }}
     </div>
diff --git a/templates/part/table.html b/templates/part/table.html
index 692a7dc6..cd16f9be 100644
--- a/templates/part/table.html
+++ b/templates/part/table.html
@@ -29,7 +29,7 @@
         {% endif %}
         {% if not no_missing %}
           <td data-sort="{{ item.fields.total_missing }}" class="table-td-input">
-            {{ form.input('Missing', item.fields.id, item.html_id(), item.url_for_missing(), item.fields.total_missing, all=all, read_only=read_only_missing) }}
+            {{ form.input('Missing', item.fields.id, item.html_id(), item.url_for_missing(), item.fields.total_missing, all=all, read_only=read_only) }}
           </td>
         {% endif %}
         {% if all %}

From cb58ef83cccbfbb7966e4af51cc1eccef003b87f Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 16:14:52 +0100
Subject: [PATCH 040/154] Add a clear button for dynamic input

---
 static/scripts/changer.js |  8 ++++++++
 templates/macro/form.html | 29 +++++++++++++++--------------
 2 files changed, 23 insertions(+), 14 deletions(-)

diff --git a/static/scripts/changer.js b/static/scripts/changer.js
index e3a32f84..8cb005f7 100644
--- a/static/scripts/changer.js
+++ b/static/scripts/changer.js
@@ -3,6 +3,7 @@ class BrickChanger {
     constructor(prefix, id, url, parent = undefined) {
         this.prefix = prefix
         this.html_element = document.getElementById(`${prefix}-${id}`);
+        this.html_clear = document.getElementById(`clear-${prefix}-${id}`);
         this.html_status = document.getElementById(`status-${prefix}-${id}`);
         this.html_type = this.html_element.getAttribute("type");
         this.url = url;
@@ -24,6 +25,13 @@ class BrickChanger {
         this.html_element.addEventListener(listener, ((changer) => (e) => {
             changer.change();
         })(this));
+
+        if (this.html_clear) {
+            this.html_clear.addEventListener("click", ((changer) => (e) => {
+                changer.html_element.value = "";
+                changer.change();
+            })(this));
+        }
     }
 
     // Clean the status
diff --git a/templates/macro/form.html b/templates/macro/form.html
index 4844822d..499c2205 100644
--- a/templates/macro/form.html
+++ b/templates/macro/form.html
@@ -1,12 +1,12 @@
 {% macro checkbox(prefix, id, text, url, checked, delete=false) %}
   {% if g.login.is_authenticated() %}
     <input class="form-check-input" type="checkbox" id="{{ prefix }}-{{ id }}" {% if checked %}checked{% endif %}
-        {% if not delete %}
-          data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}" data-changer-parent="set"
-        {% else %}
-          disabled
-        {% endif %}
-    autocomplete="off">
+    {% if not delete %}
+      data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}" data-changer-parent="set"
+    {% else %}
+      disabled
+    {% endif %}
+      autocomplete="off">
     <label class="form-check-label" for="{{ prefix }}-{{ id }}">
       {{ text }} <i id="status-{{ prefix }}-{{ id }}" class="mb-1"></i>
     </label>
@@ -23,16 +23,17 @@
     <label class="visually-hidden" for="{{ prefix }}-{{ id }}">{{ name }}</label>
     <div class="input-group">
       <input class="form-control form-control-sm flex-shrink-1" type="text" id="{{ prefix }}-{{ id }}" value="{% if value %}{{ value }}{% endif %}"
-  {% if g.login.is_authenticated() %}
+      {% if g.login.is_authenticated() %}
         data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}"
-  {% else %}
-      disabled
-  {% endif %}
-      autocomplete="off">
-  {% if g.login.is_authenticated() %}
+      {% else %}
+        disabled
+      {% endif %}
+        autocomplete="off">
+      {% if g.login.is_authenticated() %}
         <span id="status-{{ prefix }}-{{ id }}" class="input-group-text ri-save-line"></span>
-  {% else %}
-    <span class="input-group-text ri-prohibited-line"></span>
+        <button id="clear-{{ prefix }}-{{ id }}" type="button" class="btn btn-sm btn-light btn-outline-danger border"><i class="ri-eraser-line"></i></button>
+      {% else %}
+        <span class="input-group-text ri-prohibited-line"></span>
       {% endif %}
     </div>
   {% endif %}

From 130b3fa84a34a9c33a77bd0b67e470bb02af4d37 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 17:35:15 +0100
Subject: [PATCH 041/154] Fix undefined id variable used when a checkbox does
 not exist

---
 bricktracker/set_checkbox_list.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/bricktracker/set_checkbox_list.py b/bricktracker/set_checkbox_list.py
index 0f32240b..6564e8d0 100644
--- a/bricktracker/set_checkbox_list.py
+++ b/bricktracker/set_checkbox_list.py
@@ -58,7 +58,7 @@ class BrickSetCheckboxList(BrickRecordList[BrickSetCheckbox]):
         if id not in self.checkboxes:
             raise NotFoundException(
                 'Checkbox with ID {id} was not found in the database'.format(
-                    id=self.fields.id,
+                    id=id,
                 ),
             )
 

From b8d600333913fc4083f212f8f20108a17b6d24b8 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 17:35:54 +0100
Subject: [PATCH 042/154] Add a tooltip with an error message on the visual
 status

---
 static/scripts/changer.js | 18 +++++++++++++++---
 1 file changed, 15 insertions(+), 3 deletions(-)

diff --git a/static/scripts/changer.js b/static/scripts/changer.js
index 8cb005f7..89f012da 100644
--- a/static/scripts/changer.js
+++ b/static/scripts/changer.js
@@ -1,10 +1,12 @@
 // Generic state changer with visual feedback
+// Tooltips require boostrap.Tooltip
 class BrickChanger {
     constructor(prefix, id, url, parent = undefined) {
         this.prefix = prefix
         this.html_element = document.getElementById(`${prefix}-${id}`);
         this.html_clear = document.getElementById(`clear-${prefix}-${id}`);
         this.html_status = document.getElementById(`status-${prefix}-${id}`);
+        this.html_status_tooltip = undefined;
         this.html_type = this.html_element.getAttribute("type");
         this.url = url;
 
@@ -46,14 +48,24 @@ class BrickChanger {
             if (to_remove.length) {
                 this.html_status.classList.remove(...to_remove);
             }
+
+            if (this.html_status_tooltip) {
+                this.html_status_tooltip.dispose();
+                this.html_status_tooltip = undefined;
+            }
         }
     }
 
     // Set the status to Error
-    status_error() {
+    status_error(message) {
         if (this.html_status) {
             this.status_clean();
             this.html_status.classList.add("ri-alert-line", "text-danger");
+
+            this.html_status_tooltip = new bootstrap.Tooltip(this.html_status, {
+                "title": message,
+            })
+            this.html_status_tooltip.show();
         }
     }
 
@@ -98,7 +110,7 @@ class BrickChanger {
             });
 
             if (!response.ok) {
-                throw new Error(`Response status: ${response.status}`);
+                throw new Error(`Response status: ${response.status} (${response.statusText})`);
             }
 
             const json = await response.json();
@@ -121,7 +133,7 @@ class BrickChanger {
         } catch (error) {
             console.log(error.message);
 
-            this.status_error();
+            this.status_error(error.message);
         }
     }
 }

From acbd58ca7149481a924aac3e1d1531da0b34f538 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 17:41:34 +0100
Subject: [PATCH 043/154] Add missing @login_required for set deletion

---
 bricktracker/views/set.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index d82896c0..809d46b4 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -73,6 +73,7 @@ def delete(*, id: str) -> str:
 
 # Actually delete of a set
 @set_page.route('/<id>/delete', methods=['POST'])
+@login_required
 @exception_handler(__file__, post_redirect='set.delete')
 def do_delete(*, id: str) -> Response:
     brickset = BrickSet().select_light(id)
@@ -89,6 +90,7 @@ def do_delete(*, id: str) -> Response:
 
 # Set is deleted
 @set_page.route('/<id>/deleted', methods=['GET'])
+@login_required
 @exception_handler(__file__)
 def deleted(*, id: str) -> str:
     return render_template(
@@ -155,6 +157,7 @@ def missing_part(
 
 # Refresh a set
 @set_page.route('/<id>/refresh', methods=['GET'])
+@login_required
 @exception_handler(__file__)
 def refresh(*, id: str) -> str:
     return render_template(

From 69c7dbaefeb0c783f80734c2c8989d98c2c81855 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 17:42:13 +0100
Subject: [PATCH 044/154] Don't display the set management section when
 deleting it

---
 templates/set/card.html | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

diff --git a/templates/set/card.html b/templates/set/card.html
index 02d77b30..0abb2c16 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -61,11 +61,13 @@
         {% for minifigure in item.minifigures() %}
           {{ accordion.table(minifigure.parts(), minifigure.fields.name, minifigure.fields.figure, 'set-details', 'part/table.html', quantity=minifigure.fields.quantity, icon='group-line', image=minifigure.url_for_image(), alt=minifigure.fields.figure, details=minifigure.url())}}
         {% endfor %}
+        {% if g.login.is_authenticated() %}
+          {{ accordion.header('Management', 'management', 'set-details', icon='settings-4-line', class='text-end') }}
+              <a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh the set</a>
+          {{ accordion.footer() }}
+        {% endif %}
       {% endif %}
       {% if g.login.is_authenticated() %}
-        {{ accordion.header('Management', 'management', 'set-details', icon='settings-4-line', class='text-end') }}
-            <a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh the set</a>
-        {{ accordion.footer() }}
         {{ accordion.header('Danger zone', 'danger-zone', 'set-details', expanded=delete, danger=true, class='text-end') }}
           {% if delete %}
             <form action="{{ item.url_for_do_delete() }}" method="post">

From 160ab066b2b0c3c5591de3c4b1c26b48b5511a70 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 17:44:46 +0100
Subject: [PATCH 045/154] Update container versions

---
 compose.legacy.yml | 2 +-
 compose.yaml       | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/compose.legacy.yml b/compose.legacy.yml
index 0647bcb2..2d48e3ba 100644
--- a/compose.legacy.yml
+++ b/compose.legacy.yml
@@ -2,7 +2,7 @@ services:
   bricktracker:
     container_name: BrickTracker
     restart: unless-stopped
-    image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.1.1
+    image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.2.0
     ports:
       - "3333:3333"
     volumes:
diff --git a/compose.yaml b/compose.yaml
index 861b8a9e..6c38ed91 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -2,7 +2,7 @@ services:
   bricktracker:
     container_name: BrickTracker
     restart: unless-stopped
-    image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.1.1
+    image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.2.0
     ports:
       - "3333:3333"
     volumes:

From 56ad9fba13a67abe5b26f8d1a1300516333894e8 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 17:59:59 +0100
Subject: [PATCH 046/154] url_for_missing should be part of BrickPart, not
 RebrickablePart

---
 bricktracker/part.py             | 19 +++++++++++++++++++
 bricktracker/rebrickable_part.py | 17 -----------------
 2 files changed, 19 insertions(+), 17 deletions(-)

diff --git a/bricktracker/part.py b/bricktracker/part.py
index 495059f3..ac85a114 100644
--- a/bricktracker/part.py
+++ b/bricktracker/part.py
@@ -2,6 +2,8 @@ import logging
 from typing import Any, Self, TYPE_CHECKING
 import traceback
 
+from flask import url_for
+
 from .exceptions import ErrorException, NotFoundException
 from .rebrickable_part import RebrickablePart
 from .sql import BrickSQL
@@ -169,3 +171,20 @@ class BrickPart(RebrickablePart):
             'part/update/missing',
             parameters=self.sql_parameters()
         )
+
+    # Compute the url for missing part
+    def url_for_missing(self, /) -> str:
+        # Different URL for a minifigure part
+        if self.minifigure is not None:
+            figure = self.minifigure.fields.figure
+        else:
+            figure = None
+
+        return url_for(
+            'set.missing_part',
+            id=self.fields.id,
+            figure=figure,
+            part=self.fields.part,
+            color=self.fields.color,
+            spare=self.fields.spare,
+        )
diff --git a/bricktracker/rebrickable_part.py b/bricktracker/rebrickable_part.py
index 8224d98f..eea6d083 100644
--- a/bricktracker/rebrickable_part.py
+++ b/bricktracker/rebrickable_part.py
@@ -117,23 +117,6 @@ class RebrickablePart(BrickRecord):
             else:
                 return self.fields.image
 
-    # Compute the url for missing part
-    def url_for_missing(self, /) -> str:
-        # Different URL for a minifigure part
-        if self.minifigure is not None:
-            figure = self.minifigure.fields.figure
-        else:
-            figure = None
-
-        return url_for(
-            'set.missing_part',
-            id=self.fields.id,
-            figure=figure,
-            part=self.fields.part,
-            color=self.fields.color,
-            spare=self.fields.spare,
-        )
-
     # Compute the url for the original of the printed part
     def url_for_print(self, /) -> str:
         return url_for(

From 728e0050b310852671f08cf2ad939f19b8d31df8 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 18:12:39 +0100
Subject: [PATCH 047/154] Fix functions definition

---
 bricktracker/migrations/0007.py | 2 +-
 bricktracker/part.py            | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/bricktracker/migrations/0007.py b/bricktracker/migrations/0007.py
index fb5b7235..7cf3c9e8 100644
--- a/bricktracker/migrations/0007.py
+++ b/bricktracker/migrations/0007.py
@@ -5,7 +5,7 @@ if TYPE_CHECKING:
 
 
 # Grab the list of checkboxes to create a list of SQL columns
-def migration_0007(self: 'BrickSQL') -> dict[str, Any]:
+def migration_0007(self: 'BrickSQL', /) -> dict[str, Any]:
     records = self.fetchall('checkbox/list')
 
     return {
diff --git a/bricktracker/part.py b/bricktracker/part.py
index ac85a114..58f65100 100644
--- a/bricktracker/part.py
+++ b/bricktracker/part.py
@@ -74,7 +74,7 @@ class BrickPart(RebrickablePart):
         return True
 
     # A identifier for HTML component
-    def html_id(self) -> str:
+    def html_id(self, /) -> str:
         components: list[str] = ['part']
 
         if self.fields.figure is not None:

From 257bccc3395960b48b14f01329c523b618dbc7ed Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 18:14:44 +0100
Subject: [PATCH 048/154] Move set management to its own file

---
 templates/set/card.html       | 6 +-----
 templates/set/management.html | 5 +++++
 2 files changed, 6 insertions(+), 5 deletions(-)
 create mode 100644 templates/set/management.html

diff --git a/templates/set/card.html b/templates/set/card.html
index 0abb2c16..fee30404 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -61,11 +61,7 @@
         {% for minifigure in item.minifigures() %}
           {{ accordion.table(minifigure.parts(), minifigure.fields.name, minifigure.fields.figure, 'set-details', 'part/table.html', quantity=minifigure.fields.quantity, icon='group-line', image=minifigure.url_for_image(), alt=minifigure.fields.figure, details=minifigure.url())}}
         {% endfor %}
-        {% if g.login.is_authenticated() %}
-          {{ accordion.header('Management', 'management', 'set-details', icon='settings-4-line', class='text-end') }}
-              <a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh the set</a>
-          {{ accordion.footer() }}
-        {% endif %}
+        {% include 'set/management.html' %}
       {% endif %}
       {% if g.login.is_authenticated() %}
         {{ accordion.header('Danger zone', 'danger-zone', 'set-details', expanded=delete, danger=true, class='text-end') }}
diff --git a/templates/set/management.html b/templates/set/management.html
new file mode 100644
index 00000000..b27c9d05
--- /dev/null
+++ b/templates/set/management.html
@@ -0,0 +1,5 @@
+{% if g.login.is_authenticated() %}
+{{ accordion.header('Management', 'management', 'set-details', icon='settings-4-line', class='text-end') }}
+    <a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh the set data</a>
+{{ accordion.footer() }}
+{% endif %}

From b2d2019bfd978edf96a7b273bcea73f53a08393d Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 19:28:00 +0100
Subject: [PATCH 049/154] Set theme override

---
 bricktracker/rebrickable_set.py       | 10 ++++-
 bricktracker/set.py                   | 63 ++++++++++++++++++++++++++-
 bricktracker/sql/set/base/base.sql    |  1 +
 bricktracker/sql/set/update/theme.sql |  3 ++
 bricktracker/theme.py                 | 10 ++---
 bricktracker/theme_list.py            |  9 ++--
 bricktracker/views/set.py             | 19 ++++++++
 templates/set/management.html         |  8 +++-
 8 files changed, 111 insertions(+), 12 deletions(-)
 create mode 100644 bricktracker/sql/set/update/theme.sql

diff --git a/bricktracker/rebrickable_set.py b/bricktracker/rebrickable_set.py
index fbf10f1a..d2878d6c 100644
--- a/bricktracker/rebrickable_set.py
+++ b/bricktracker/rebrickable_set.py
@@ -11,10 +11,10 @@ from .parser import parse_set
 from .rebrickable import Rebrickable
 from .rebrickable_image import RebrickableImage
 from .record import BrickRecord
+from .theme import BrickTheme
 from .theme_list import BrickThemeList
 if TYPE_CHECKING:
     from .socket import BrickSocket
-    from .theme import BrickTheme
 
 logger = logging.getLogger(__name__)
 
@@ -66,7 +66,13 @@ class RebrickableSet(BrickRecord):
         if not hasattr(self.fields, 'theme_id'):
             self.fields.theme_id = 0
 
-        self.theme = BrickThemeList().get(self.fields.theme_id)
+        if not hasattr(self.fields, 'theme_name'):
+            self.fields.theme_name = None
+
+        self.theme = BrickThemeList().get(
+            str(self.fields.theme_id),
+            name=self.fields.theme_name
+        )
 
         # Resolve instructions
         if self.resolve_instructions:
diff --git a/bricktracker/set.py b/bricktracker/set.py
index 28f0341c..893acc05 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -1,11 +1,12 @@
 import logging
+from sqlite3 import Row
 import traceback
 from typing import Any, Self, TYPE_CHECKING
 from uuid import uuid4
 
 from flask import current_app, url_for
 
-from .exceptions import DatabaseException, NotFoundException
+from .exceptions import DatabaseException, ErrorException, NotFoundException
 from .minifigure_list import BrickMinifigureList
 from .part_list import BrickPartList
 from .rebrickable_set import RebrickableSet
@@ -121,6 +122,29 @@ class BrickSet(RebrickableSet):
 
         return True
 
+    # Ingest a set
+    def ingest(self, record: Row | dict[str, Any], /):
+        # Super charge the record with theme override
+        if 'theme' in record.keys() and record['theme'] is not None:
+            if isinstance(record, Row):
+                record = dict(record)
+
+            record['theme_id'] = record['theme']
+            record['theme_name'] = record['theme']
+
+        super().ingest(record)
+
+    # A identifier for HTML component
+    def html_id(self, prefix: str | None = None, /) -> str:
+        components: list[str] = []
+
+        if prefix is not None:
+            components.append(prefix)
+
+        components.append(self.fields.id)
+
+        return '-'.join(components)
+
     # Minifigures
     def minifigures(self, /) -> BrickMinifigureList:
         return BrickMinifigureList().from_set(self)
@@ -185,6 +209,36 @@ class BrickSet(RebrickableSet):
                 id=self.fields.id,
             ))
 
+    # Update theme
+    def update_theme(self, json: Any | None, /) -> None:
+        theme: str | None = json.get('value', '')  # type: ignore
+
+        # We need a string
+        try:
+            theme = str(theme)
+            theme = theme.strip()
+        except Exception:
+            raise ErrorException('"{theme}" is not a valid string'.format(
+                theme=theme
+            ))
+
+        if theme == '':
+            theme = None
+
+        self.fields.theme = theme
+
+        # Update the status
+        rows, _ = BrickSQL().execute_and_commit(
+            'set/update/theme',
+            parameters=self.sql_parameters()
+        )
+
+        if rows != 1:
+            raise DatabaseException('Could not update the theme override for set {set} ({id})'.format(  # noqa: E501
+                set=self.fields.set,
+                id=self.fields.id,
+            ))
+
     # Self url
     def url(self, /) -> str:
         return url_for('set.details', id=self.fields.id)
@@ -217,3 +271,10 @@ class BrickSet(RebrickableSet):
             'set.refresh',
             id=self.fields.id,
         )
+
+    # Compute the url for the theme override
+    def url_for_theme(self, /) -> str:
+        return url_for(
+            'set.update_theme',
+            id=self.fields.id,
+        )
diff --git a/bricktracker/sql/set/base/base.sql b/bricktracker/sql/set/base/base.sql
index 8b1f4c88..7bc1bc37 100644
--- a/bricktracker/sql/set/base/base.sql
+++ b/bricktracker/sql/set/base/base.sql
@@ -1,5 +1,6 @@
 SELECT
     {% block id %}{% endblock %}
+    "bricktracker_sets"."theme",
     "rebrickable_sets"."set",
     "rebrickable_sets"."number",
     "rebrickable_sets"."version",
diff --git a/bricktracker/sql/set/update/theme.sql b/bricktracker/sql/set/update/theme.sql
new file mode 100644
index 00000000..1d884edf
--- /dev/null
+++ b/bricktracker/sql/set/update/theme.sql
@@ -0,0 +1,3 @@
+UPDATE "bricktracker_sets"
+SET "theme" = :theme
+WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM :id
diff --git a/bricktracker/theme.py b/bricktracker/theme.py
index 3ee1068b..df677f4d 100644
--- a/bricktracker/theme.py
+++ b/bricktracker/theme.py
@@ -1,14 +1,14 @@
 # Lego set theme
 class BrickTheme(object):
-    id: int
+    id: str
     name: str
-    parent: int | None
+    parent: str | None
 
-    def __init__(self, id: str | int, name: str, parent: str | None = None, /):
-        self.id = int(id)
+    def __init__(self, id: str, name: str, parent: str | None = None, /):
+        self.id = id
         self.name = name
 
         if parent is not None and parent != '':
-            self.parent = int(parent)
+            self.parent = parent
         else:
             self.parent = None
diff --git a/bricktracker/theme_list.py b/bricktracker/theme_list.py
index 22cac8ee..af1667ed 100644
--- a/bricktracker/theme_list.py
+++ b/bricktracker/theme_list.py
@@ -17,7 +17,7 @@ logger = logging.getLogger(__name__)
 
 # Lego sets themes
 class BrickThemeList(object):
-    themes: dict[int, BrickTheme]
+    themes: dict[str, BrickTheme]
     mtime: datetime | None
     size: int | None
     exception: Exception | None
@@ -57,12 +57,15 @@ class BrickThemeList(object):
                 BrickThemeList.mtime = None
 
     # Get a theme
-    def get(self, id: int, /) -> BrickTheme:
+    def get(self, id: str, /, *, name: str | None = None) -> BrickTheme:
         # Seed a fake entry if missing
         if id not in self.themes:
+            if name is None:
+                name = 'Unknown ({id})'.format(id=id)
+
             BrickThemeList.themes[id] = BrickTheme(
                 id,
-                'Unknown ({id})'.format(id=id)
+                name,
             )
 
         return self.themes[id]
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index 809d46b4..59289ff0 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -167,3 +167,22 @@ def refresh(*, id: str) -> str:
         namespace=current_app.config['SOCKET_NAMESPACE'],
         messages=MESSAGES
     )
+
+
+# Change the theme override
+@set_page.route('/<id>/theme', methods=['POST'])
+@login_required
+@exception_handler(__file__, json=True)
+def update_theme(*, id: str) -> Response:
+    brickset = BrickSet().select_light(id)
+
+    brickset.update_theme(request.json)
+
+    # Info
+    logger.info('Set {set} ({id}): theme override changed to "{theme}"'.format(  # noqa: E501
+        set=brickset.fields.set,
+        id=brickset.fields.id,
+        theme=brickset.fields.theme,
+    ))
+
+    return jsonify({'value': brickset.fields.theme})
diff --git a/templates/set/management.html b/templates/set/management.html
index b27c9d05..b2999728 100644
--- a/templates/set/management.html
+++ b/templates/set/management.html
@@ -1,5 +1,11 @@
 {% if g.login.is_authenticated() %}
-{{ accordion.header('Management', 'management', 'set-details', icon='settings-4-line', class='text-end') }}
+{{ accordion.header('Management', 'management', 'set-details', expanded=true, icon='settings-4-line') }}
+    <h5>Theme override</h5>
+    <p>
+      You can override the current theme ({{ badge.theme(item.theme.name, solo=solo, last=last) }}) with any string you want.
+      {{ form.input('Theme', item.fields.id, item.html_id('theme'), item.url_for_theme(), item.fields.theme, all=all, read_only=read_only) }}
+    </p>
+    <h5 class="border-bottom">Data</h5>
     <a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh the set data</a>
 {{ accordion.footer() }}
 {% endif %}

From 51f729a18b74e36c18fdc4abc0f194280ecfb91c Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 19:28:23 +0100
Subject: [PATCH 050/154] Fix variable type hint

---
 bricktracker/part.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/bricktracker/part.py b/bricktracker/part.py
index 58f65100..64d71df8 100644
--- a/bricktracker/part.py
+++ b/bricktracker/part.py
@@ -146,7 +146,7 @@ class BrickPart(RebrickablePart):
 
     # Update the missing part
     def update_missing(self, json: Any | None, /) -> None:
-        missing = json.get('value', '')  # type: ignore
+        missing: str | int = json.get('value', '')  # type: ignore
 
         # We need a positive integer
         try:

From 3893f2aa19ab37b4e9d0fb1347b08bc54ccc6c45 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 21:19:56 +0100
Subject: [PATCH 051/154] Theme override nobody cares actually

---
 bricktracker/rebrickable_set.py       | 10 ++----
 bricktracker/set.py                   | 52 +--------------------------
 bricktracker/sql/migrations/0007.sql  |  1 -
 bricktracker/sql/set/base/base.sql    |  1 -
 bricktracker/sql/set/update/theme.sql |  3 --
 bricktracker/theme.py                 | 10 +++---
 bricktracker/theme_list.py            |  9 ++---
 bricktracker/views/set.py             | 19 ----------
 templates/set/management.html         |  5 ---
 9 files changed, 11 insertions(+), 99 deletions(-)
 delete mode 100644 bricktracker/sql/set/update/theme.sql

diff --git a/bricktracker/rebrickable_set.py b/bricktracker/rebrickable_set.py
index d2878d6c..fbf10f1a 100644
--- a/bricktracker/rebrickable_set.py
+++ b/bricktracker/rebrickable_set.py
@@ -11,10 +11,10 @@ from .parser import parse_set
 from .rebrickable import Rebrickable
 from .rebrickable_image import RebrickableImage
 from .record import BrickRecord
-from .theme import BrickTheme
 from .theme_list import BrickThemeList
 if TYPE_CHECKING:
     from .socket import BrickSocket
+    from .theme import BrickTheme
 
 logger = logging.getLogger(__name__)
 
@@ -66,13 +66,7 @@ class RebrickableSet(BrickRecord):
         if not hasattr(self.fields, 'theme_id'):
             self.fields.theme_id = 0
 
-        if not hasattr(self.fields, 'theme_name'):
-            self.fields.theme_name = None
-
-        self.theme = BrickThemeList().get(
-            str(self.fields.theme_id),
-            name=self.fields.theme_name
-        )
+        self.theme = BrickThemeList().get(self.fields.theme_id)
 
         # Resolve instructions
         if self.resolve_instructions:
diff --git a/bricktracker/set.py b/bricktracker/set.py
index 893acc05..fa05b0b3 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -1,12 +1,11 @@
 import logging
-from sqlite3 import Row
 import traceback
 from typing import Any, Self, TYPE_CHECKING
 from uuid import uuid4
 
 from flask import current_app, url_for
 
-from .exceptions import DatabaseException, ErrorException, NotFoundException
+from .exceptions import DatabaseException, NotFoundException
 from .minifigure_list import BrickMinifigureList
 from .part_list import BrickPartList
 from .rebrickable_set import RebrickableSet
@@ -122,18 +121,6 @@ class BrickSet(RebrickableSet):
 
         return True
 
-    # Ingest a set
-    def ingest(self, record: Row | dict[str, Any], /):
-        # Super charge the record with theme override
-        if 'theme' in record.keys() and record['theme'] is not None:
-            if isinstance(record, Row):
-                record = dict(record)
-
-            record['theme_id'] = record['theme']
-            record['theme_name'] = record['theme']
-
-        super().ingest(record)
-
     # A identifier for HTML component
     def html_id(self, prefix: str | None = None, /) -> str:
         components: list[str] = []
@@ -209,36 +196,6 @@ class BrickSet(RebrickableSet):
                 id=self.fields.id,
             ))
 
-    # Update theme
-    def update_theme(self, json: Any | None, /) -> None:
-        theme: str | None = json.get('value', '')  # type: ignore
-
-        # We need a string
-        try:
-            theme = str(theme)
-            theme = theme.strip()
-        except Exception:
-            raise ErrorException('"{theme}" is not a valid string'.format(
-                theme=theme
-            ))
-
-        if theme == '':
-            theme = None
-
-        self.fields.theme = theme
-
-        # Update the status
-        rows, _ = BrickSQL().execute_and_commit(
-            'set/update/theme',
-            parameters=self.sql_parameters()
-        )
-
-        if rows != 1:
-            raise DatabaseException('Could not update the theme override for set {set} ({id})'.format(  # noqa: E501
-                set=self.fields.set,
-                id=self.fields.id,
-            ))
-
     # Self url
     def url(self, /) -> str:
         return url_for('set.details', id=self.fields.id)
@@ -271,10 +228,3 @@ class BrickSet(RebrickableSet):
             'set.refresh',
             id=self.fields.id,
         )
-
-    # Compute the url for the theme override
-    def url_for_theme(self, /) -> str:
-        return url_for(
-            'set.update_theme',
-            id=self.fields.id,
-        )
diff --git a/bricktracker/sql/migrations/0007.sql b/bricktracker/sql/migrations/0007.sql
index f955bb9d..89ef71f8 100644
--- a/bricktracker/sql/migrations/0007.sql
+++ b/bricktracker/sql/migrations/0007.sql
@@ -24,7 +24,6 @@ CREATE TABLE "bricktracker_sets" (
     "id" TEXT NOT NULL,
     "set" TEXT NOT NULL,
     "description" TEXT,
-    "theme" TEXT, -- Custom theme name
     "storage" TEXT, -- Storage bin location
     "purchase_date" INTEGER, -- Purchase data
     "purchase_location" TEXT, -- Purchase location
diff --git a/bricktracker/sql/set/base/base.sql b/bricktracker/sql/set/base/base.sql
index 7bc1bc37..8b1f4c88 100644
--- a/bricktracker/sql/set/base/base.sql
+++ b/bricktracker/sql/set/base/base.sql
@@ -1,6 +1,5 @@
 SELECT
     {% block id %}{% endblock %}
-    "bricktracker_sets"."theme",
     "rebrickable_sets"."set",
     "rebrickable_sets"."number",
     "rebrickable_sets"."version",
diff --git a/bricktracker/sql/set/update/theme.sql b/bricktracker/sql/set/update/theme.sql
deleted file mode 100644
index 1d884edf..00000000
--- a/bricktracker/sql/set/update/theme.sql
+++ /dev/null
@@ -1,3 +0,0 @@
-UPDATE "bricktracker_sets"
-SET "theme" = :theme
-WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM :id
diff --git a/bricktracker/theme.py b/bricktracker/theme.py
index df677f4d..3ee1068b 100644
--- a/bricktracker/theme.py
+++ b/bricktracker/theme.py
@@ -1,14 +1,14 @@
 # Lego set theme
 class BrickTheme(object):
-    id: str
+    id: int
     name: str
-    parent: str | None
+    parent: int | None
 
-    def __init__(self, id: str, name: str, parent: str | None = None, /):
-        self.id = id
+    def __init__(self, id: str | int, name: str, parent: str | None = None, /):
+        self.id = int(id)
         self.name = name
 
         if parent is not None and parent != '':
-            self.parent = parent
+            self.parent = int(parent)
         else:
             self.parent = None
diff --git a/bricktracker/theme_list.py b/bricktracker/theme_list.py
index af1667ed..22cac8ee 100644
--- a/bricktracker/theme_list.py
+++ b/bricktracker/theme_list.py
@@ -17,7 +17,7 @@ logger = logging.getLogger(__name__)
 
 # Lego sets themes
 class BrickThemeList(object):
-    themes: dict[str, BrickTheme]
+    themes: dict[int, BrickTheme]
     mtime: datetime | None
     size: int | None
     exception: Exception | None
@@ -57,15 +57,12 @@ class BrickThemeList(object):
                 BrickThemeList.mtime = None
 
     # Get a theme
-    def get(self, id: str, /, *, name: str | None = None) -> BrickTheme:
+    def get(self, id: int, /) -> BrickTheme:
         # Seed a fake entry if missing
         if id not in self.themes:
-            if name is None:
-                name = 'Unknown ({id})'.format(id=id)
-
             BrickThemeList.themes[id] = BrickTheme(
                 id,
-                name,
+                'Unknown ({id})'.format(id=id)
             )
 
         return self.themes[id]
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index 59289ff0..809d46b4 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -167,22 +167,3 @@ def refresh(*, id: str) -> str:
         namespace=current_app.config['SOCKET_NAMESPACE'],
         messages=MESSAGES
     )
-
-
-# Change the theme override
-@set_page.route('/<id>/theme', methods=['POST'])
-@login_required
-@exception_handler(__file__, json=True)
-def update_theme(*, id: str) -> Response:
-    brickset = BrickSet().select_light(id)
-
-    brickset.update_theme(request.json)
-
-    # Info
-    logger.info('Set {set} ({id}): theme override changed to "{theme}"'.format(  # noqa: E501
-        set=brickset.fields.set,
-        id=brickset.fields.id,
-        theme=brickset.fields.theme,
-    ))
-
-    return jsonify({'value': brickset.fields.theme})
diff --git a/templates/set/management.html b/templates/set/management.html
index b2999728..38074808 100644
--- a/templates/set/management.html
+++ b/templates/set/management.html
@@ -1,10 +1,5 @@
 {% if g.login.is_authenticated() %}
 {{ accordion.header('Management', 'management', 'set-details', expanded=true, icon='settings-4-line') }}
-    <h5>Theme override</h5>
-    <p>
-      You can override the current theme ({{ badge.theme(item.theme.name, solo=solo, last=last) }}) with any string you want.
-      {{ form.input('Theme', item.fields.id, item.html_id('theme'), item.url_for_theme(), item.fields.theme, all=all, read_only=read_only) }}
-    </p>
     <h5 class="border-bottom">Data</h5>
     <a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh the set data</a>
 {{ accordion.footer() }}

From aed7a520bd30ac1603050b872cab1ff3ffa74408 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 22:44:08 +0100
Subject: [PATCH 052/154] Parametrable error names

---
 bricktracker/views/error.py      | 80 ++++++++++++++++++++++++--------
 bricktracker/views/exceptions.py |  2 +
 2 files changed, 63 insertions(+), 19 deletions(-)

diff --git a/bricktracker/views/error.py b/bricktracker/views/error.py
index c034ea85..67a97a19 100644
--- a/bricktracker/views/error.py
+++ b/bricktracker/views/error.py
@@ -1,7 +1,7 @@
 import logging
 from sqlite3 import Error, OperationalError
 import traceback
-from typing import Tuple
+from typing import Any, Tuple
 
 from flask import jsonify, redirect, request, render_template, url_for
 from werkzeug.wrappers.response import Response
@@ -33,12 +33,16 @@ def error(
     *,
     json: bool = False,
     post_redirect: str | None = None,
+    error_name: str = 'error',
     **kwargs,
 ) -> str | Tuple[str | Response, int] | Response:
     # Back to the index if no error (not sure if this can happen)
     if error is None:
         if json:
-            return jsonify({'error': 'error() called without an error'})
+            return json_error(
+                'error() called without an error',
+                error_name=error_name
+            )
         else:
             return redirect(url_for('index.index'))
 
@@ -56,6 +60,7 @@ def error(
             error,
             json=json,
             post_redirect=post_redirect,
+            error_name=error_name,
             **kwargs
         )
 
@@ -71,13 +76,17 @@ def error(
         logger.debug(cleaned_exception(error))
 
         if json:
-            return jsonify({'error': str(error)})
+            return json_error(
+                str(error),
+                error_name=error_name
+            )
         elif post_redirect is not None:
-            return redirect(url_for(
+            return redirect_error(
                 post_redirect,
                 error=str(error),
+                error_name=error_name,
                 **kwargs,
-            ))
+            )
         else:
             return render_template(
                 'error.html',
@@ -96,18 +105,20 @@ def error(
             line = None
 
         if json:
-            return jsonify({
-                'error': 'Exception: {error}'.format(error=str(error)),
-                'name': type(error).__name__,
-                'line': line,
-                'file': file,
-            }), 500
+            return json_error(
+                'Exception: {error}'.format(error=str(error)),
+                error_name=error_name,
+                name=type(error).__name__,
+                line=line,
+                file=file
+            ), 500
         elif post_redirect is not None:
-            return redirect(url_for(
+            return redirect_error(
                 post_redirect,
                 error=str(error),
+                error_name=error_name,
                 **kwargs,
-            ))
+            )
         else:
             return render_template(
                 'exception.html',
@@ -125,6 +136,7 @@ def error_404(
     *,
     json: bool = False,
     post_redirect: str | None = None,
+    error_name: str = 'error',
     **kwargs,
 ) -> Tuple[str | Response, int]:
     # Warning
@@ -134,14 +146,44 @@ def error_404(
     ))
 
     if json:
-        return jsonify({
-            'error': 'Not found: {error}'.format(error=str(error))
-        }), 404
+        return json_error(
+            'Not found: {error}'.format(error=str(error)),
+            error_name=error_name
+        ), 404
     elif post_redirect is not None:
-        return redirect(url_for(
+        return redirect_error(
             post_redirect,
             error=str(error),
-            **kwargs
-        )), 404
+            error_name=error_name,
+            **kwargs,
+        ), 404
     else:
         return render_template('404.html', error=str(error)), 404
+
+
+# JSON error with parametric error name
+def json_error(
+    error: str,
+    error_name: str = 'error',
+    **parameters: Any
+) -> Response:
+    parameters[error_name] = error
+
+    return jsonify(parameters)
+
+
+# Redirect error with parametric error name
+def redirect_error(
+    url: str,
+    error: str,
+    error_name: str = 'error',
+    **kwargs
+) -> Response:
+    error_parameter: dict[str, str] = {}
+    error_parameter[error_name] = str(error)
+
+    return redirect(url_for(
+        url,
+        **error_parameter,
+        **kwargs
+    ))
diff --git a/bricktracker/views/exceptions.py b/bricktracker/views/exceptions.py
index b78c390e..aa01b79c 100644
--- a/bricktracker/views/exceptions.py
+++ b/bricktracker/views/exceptions.py
@@ -28,6 +28,7 @@ def exception_handler(
     *,
     json: bool = False,
     post_redirect: str | None = None,
+    error_name: str = 'error',
     **superkwargs,
 ) -> Callable[[ViewCallable], ViewCallable]:
     def outer(function: ViewCallable, /) -> ViewCallable:
@@ -42,6 +43,7 @@ def exception_handler(
                     file,
                     json=json,
                     post_redirect=post_redirect,
+                    error_name=error_name,
                     **kwargs,
                     **superkwargs,
                 )

From bba741b4a59b627b6a22fbc5ed76a24539091547 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Wed, 29 Jan 2025 22:49:17 +0100
Subject: [PATCH 053/154] Rename database_error

---
 bricktracker/views/admin/admin.py     |  2 +-
 bricktracker/views/admin/database.py  | 34 ++++++++++++++++++++-------
 templates/admin/database.html         |  2 +-
 templates/admin/database/delete.html  |  2 +-
 templates/admin/database/drop.html    |  2 +-
 templates/admin/database/import.html  |  2 +-
 templates/admin/database/upgrade.html |  2 +-
 7 files changed, 31 insertions(+), 15 deletions(-)

diff --git a/bricktracker/views/admin/admin.py b/bricktracker/views/admin/admin.py
index 847e42a7..5d6a4e4c 100644
--- a/bricktracker/views/admin/admin.py
+++ b/bricktracker/views/admin/admin.py
@@ -83,7 +83,7 @@ def admin() -> str:
         configuration=BrickConfigurationList.list(),
         brickset_checkboxes=brickset_checkboxes,
         database_counters=database_counters,
-        database_error=request.args.get('error'),
+        database_error=request.args.get('database_error'),
         database_exception=database_exception,
         database_upgrade_needed=database_upgrade_needed,
         database_version=database_version,
diff --git a/bricktracker/views/admin/database.py b/bricktracker/views/admin/database.py
index bd0f2137..e2fc4bc2 100644
--- a/bricktracker/views/admin/database.py
+++ b/bricktracker/views/admin/database.py
@@ -38,14 +38,18 @@ def delete() -> str:
     return render_template(
         'admin.html',
         delete_database=True,
-        error=request.args.get('error')
+        database_error=request.args.get('database_error')
     )
 
 
 # Actually delete the database
 @admin_database_page.route('/delete', methods=['POST'])
 @login_required
-@exception_handler(__file__, post_redirect='admin_database.delete')
+@exception_handler(
+    __file__,
+    post_redirect='admin_database.delete',
+    error_name='database_error'
+)
 def do_delete() -> Response:
     BrickSQL.delete()
 
@@ -89,14 +93,18 @@ def drop() -> str:
     return render_template(
         'admin.html',
         drop_database=True,
-        error=request.args.get('error')
+        database_error=request.args.get('database_error')
     )
 
 
 # Actually drop the database
 @admin_database_page.route('/drop', methods=['POST'])
 @login_required
-@exception_handler(__file__, post_redirect='admin_database.drop')
+@exception_handler(
+    __file__,
+    post_redirect='admin_database.drop',
+    error_name='database_error'
+)
 def do_drop() -> Response:
     BrickSQL.drop()
 
@@ -108,7 +116,11 @@ def do_drop() -> Response:
 # Actually upgrade the database
 @admin_database_page.route('/upgrade', methods=['POST'])
 @login_required
-@exception_handler(__file__, post_redirect='admin_database.upgrade')
+@exception_handler(
+    __file__,
+    post_redirect='admin_database.upgrade',
+    error_name='database_error'
+)
 def do_upgrade() -> Response:
     BrickSQL(failsafe=True).upgrade()
 
@@ -125,14 +137,18 @@ def upload() -> str:
     return render_template(
         'admin.html',
         import_database=True,
-        error=request.args.get('error')
+        database_error=request.args.get('database_error')
     )
 
 
 # Actually import a database
 @admin_database_page.route('/import', methods=['POST'])
 @login_required
-@exception_handler(__file__, post_redirect='admin_database.upload')
+@exception_handler(
+    __file__,
+    post_redirect='admin_database.upload',
+    error_name='database_error'
+)
 def do_upload() -> Response:
     file = upload_helper(
         'database',
@@ -153,7 +169,7 @@ def do_upload() -> Response:
 # Upgrade the database
 @admin_database_page.route('/upgrade', methods=['GET'])
 @login_required
-@exception_handler(__file__, post_redirect='admin.admin')
+@exception_handler(__file__)
 def upgrade() -> str | Response:
     database = BrickSQL(failsafe=True)
 
@@ -166,5 +182,5 @@ def upgrade() -> str | Response:
         migrations=BrickSQLMigrationList().pending(
             database.version
         ),
-        error=request.args.get('error')
+        database_error=request.args.get('database_error')
     )
diff --git a/templates/admin/database.html b/templates/admin/database.html
index 97043653..7a15325a 100644
--- a/templates/admin/database.html
+++ b/templates/admin/database.html
@@ -36,7 +36,7 @@
 {{ accordion.footer() }}
 
 {{ accordion.header('Database danger zone', 'database-danger', 'admin', danger=true, class='text-end') }}
-{% if error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ error }}.</div>{% endif %}
+{% if database_error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ database_error }}.</div>{% endif %}
 <a href="{{ url_for('admin_database.upload') }}" class="btn btn-warning" role="button"><i class="ri-upload-line"></i> Import a database file</a>
 <a href="{{ url_for('admin_database.drop') }}" class="btn btn-danger" role="button"><i class="ri-close-line"></i> Drop the database</a>
 <a href="{{ url_for('admin_database.delete') }}" class="btn btn-danger" role="button"><i class="ri-delete-bin-2-line"></i> Delete the database file</a>
diff --git a/templates/admin/database/delete.html b/templates/admin/database/delete.html
index 9a2d2861..9bbbf2f0 100644
--- a/templates/admin/database/delete.html
+++ b/templates/admin/database/delete.html
@@ -2,7 +2,7 @@
 
 {{ accordion.header('Database danger zone', 'database-danger', 'admin', expanded=true, danger=true, class='text-end') }}
 <form action="{{ url_for('admin_database.do_delete') }}" method="post">
-  {% if error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ error }}.</div>{% endif %}
+  {% if database_error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ database_error }}.</div>{% endif %}
   <div class="alert alert-danger text-center" role="alert">You are about to <strong>delete the database file</strong>. This action is irreversible.</div>
   <a class="btn btn-danger" href="{{ url_for('admin.admin') }}" role="button"><i class="ri-arrow-left-long-line"></i> Back to the admin</a>
   <button type="submit" class="btn btn-danger"><i class="ri-delete-bin-2-line"></i> Delete <strong>the database file</strong></button>
diff --git a/templates/admin/database/drop.html b/templates/admin/database/drop.html
index e0f5b2e9..1ebe0d58 100644
--- a/templates/admin/database/drop.html
+++ b/templates/admin/database/drop.html
@@ -2,7 +2,7 @@
 
 {{ accordion.header('Database danger zone', 'database-danger', 'admin', expanded=true, danger=true, class='text-end') }}
 <form action="{{ url_for('admin_database.do_drop') }}" method="post">
-  {% if error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ error }}.</div>{% endif %}
+  {% if database_error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ database_error }}.</div>{% endif %}
   <div class="alert alert-danger text-center" role="alert">You are about to <strong>drop all the tables from the database</strong>. This action is irreversible.</div>
   <a class="btn btn-danger" href="{{ url_for('admin.admin') }}" role="button"><i class="ri-arrow-left-long-line"></i> Back to the admin</a>
   <button type="submit" class="btn btn-danger"><i class="ri-close-line"></i> Drop <strong>the whole database</strong></button>
diff --git a/templates/admin/database/import.html b/templates/admin/database/import.html
index f7a77634..a6d5e9c6 100644
--- a/templates/admin/database/import.html
+++ b/templates/admin/database/import.html
@@ -2,7 +2,7 @@
 
 {{ accordion.header('Database danger zone', 'database-danger', 'admin', expanded=true, danger=true) }}
 <form action="{{ url_for('admin_database.do_upload') }}" method="post" enctype="multipart/form-data">
-  {% if error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ error }}.</div>{% endif %}
+  {% if database_error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ database_error }}.</div>{% endif %}
   <div class="alert alert-warning text-center" role="alert">You are about to <strong>import a database file</strong>. This will replace the <strong>whole content</strong> of the database. This action is irreversible.</div>
   <div class="mb-3">
     <label for="database" class="form-label">New database file</label>
diff --git a/templates/admin/database/upgrade.html b/templates/admin/database/upgrade.html
index 2739c79c..29e59ef1 100644
--- a/templates/admin/database/upgrade.html
+++ b/templates/admin/database/upgrade.html
@@ -2,7 +2,7 @@
 
 {{ accordion.header('Database', 'database', 'admin', expanded=true, icon='database-2-line') }}
 <form action="{{ url_for('admin_database.do_upgrade') }}" method="post">
-  {% if error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ error }}.</div>{% endif %}
+  {% if database_error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ database_error }}.</div>{% endif %}
   <div class="alert alert-warning text-center" role="alert">
     You are about to <strong>upgrade your database file</strong>. This action is irreversible.<br>
     The process shold be lossless, but just to be sure, grab a copy of your database before proceeding.<br>

From 4fc96ec38fd7e94e38abb36d04ecbb260dd1fd9e Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Thu, 30 Jan 2025 09:33:54 +0100
Subject: [PATCH 054/154] Rename checkox_error

---
 bricktracker/views/admin/admin.py    |  1 +
 bricktracker/views/admin/checkbox.py | 22 ++++++++++++++++++----
 templates/admin/checkbox.html        |  3 +--
 3 files changed, 20 insertions(+), 6 deletions(-)

diff --git a/bricktracker/views/admin/admin.py b/bricktracker/views/admin/admin.py
index 5d6a4e4c..d5586eab 100644
--- a/bricktracker/views/admin/admin.py
+++ b/bricktracker/views/admin/admin.py
@@ -82,6 +82,7 @@ def admin() -> str:
         'admin.html',
         configuration=BrickConfigurationList.list(),
         brickset_checkboxes=brickset_checkboxes,
+        checkbox_error=request.args.get('checkbox_error'),
         database_counters=database_counters,
         database_error=request.args.get('database_error'),
         database_exception=database_exception,
diff --git a/bricktracker/views/admin/checkbox.py b/bricktracker/views/admin/checkbox.py
index 1dd58bbb..134c8861 100644
--- a/bricktracker/views/admin/checkbox.py
+++ b/bricktracker/views/admin/checkbox.py
@@ -27,7 +27,12 @@ admin_checkbox_page = Blueprint(
 # Add a checkbox
 @admin_checkbox_page.route('/add', methods=['POST'])
 @login_required
-@exception_handler(__file__, post_redirect='admin.admin', open_checkbox=True)
+@exception_handler(
+    __file__,
+    post_redirect='admin.admin',
+    error_name='checkbox_error',
+    open_checkbox=True
+)
 def add() -> Response:
     BrickSetCheckbox().from_form(request.form).insert()
 
@@ -45,14 +50,18 @@ def delete(*, id: str) -> str:
         'admin.html',
         delete_checkbox=True,
         checkbox=BrickSetCheckbox().select_specific(id),
-        error=request.args.get('error')
+        error=request.args.get('checkbox_error')
     )
 
 
 # Actually delete the checkbox
 @admin_checkbox_page.route('<id>/delete', methods=['POST'])
 @login_required
-@exception_handler(__file__, post_redirect='admin_checkbox.delete')
+@exception_handler(
+    __file__,
+    post_redirect='admin_checkbox.delete',
+    error_name='checkbox_error'
+)
 def do_delete(*, id: str) -> Response:
     checkbox = BrickSetCheckbox().select_specific(id)
     checkbox.delete()
@@ -88,7 +97,12 @@ def update_status(*, id: str, name: str) -> Response:
 # Rename the checkbox
 @admin_checkbox_page.route('<id>/rename', methods=['POST'])
 @login_required
-@exception_handler(__file__, post_redirect='admin.admin', open_checkbox=True)
+@exception_handler(
+    __file__,
+    post_redirect='admin.admin',
+    error_name='checkbox_error',
+    open_checkbox=True
+)
 def rename(*, id: str) -> Response:
     checkbox = BrickSetCheckbox().select_specific(id)
     checkbox.from_form(request.form).rename()
diff --git a/templates/admin/checkbox.html b/templates/admin/checkbox.html
index dbe53606..0ba484ab 100644
--- a/templates/admin/checkbox.html
+++ b/templates/admin/checkbox.html
@@ -1,8 +1,7 @@
 {% import 'macro/accordion.html' as accordion %}
 
 {{ accordion.header('Checkboxes', 'checkbox', 'admin', expanded=open_checkbox, icon='checkbox-line', class='p-0') }}
-{% if error %}<div class="alert alert-danger m-2" role="alert"><strong>Error:</strong> {{ error }}.</div>{% endif %}
-{% if database_error %}<div class="alert alert-danger m-2" role="alert"><strong>Error:</strong> {{ database_error }}.</div>{% endif %}
+{% if checkbox_error %}<div class="alert alert-danger m-2" role="alert"><strong>Error:</strong> {{ checkbox_error }}.</div>{% endif %}
 <ul class="list-group list-group-flush">
   {% if brickset_checkboxes | length %}
     {% for checkbox in brickset_checkboxes %}

From a832ff27f799b0144a1b9c2339f171c5d3a7272a Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Thu, 30 Jan 2025 12:17:25 +0100
Subject: [PATCH 055/154] Create a Metadata object as a base for checkboxes

---
 bricktracker/metadata.py                      | 197 ++++++++++++++++++
 bricktracker/set.py                           |  27 +--
 bricktracker/set_checkbox.py                  | 134 ++----------
 .../sql/checkbox/{add.sql => insert.sql}      |   0
 .../checkbox/update/{name.sql => field.sql}   |   2 +-
 bricktracker/sql/checkbox/update/status.sql   |   3 -
 bricktracker/sql/set/update/status.sql        |   8 +-
 bricktracker/views/admin/checkbox.py          |  22 +-
 bricktracker/views/set.py                     |  20 +-
 templates/admin/checkbox.html                 |   2 +-
 templates/set/card.html                       |   2 +-
 11 files changed, 231 insertions(+), 186 deletions(-)
 create mode 100644 bricktracker/metadata.py
 rename bricktracker/sql/checkbox/{add.sql => insert.sql} (100%)
 rename bricktracker/sql/checkbox/update/{name.sql => field.sql} (80%)
 delete mode 100644 bricktracker/sql/checkbox/update/status.sql

diff --git a/bricktracker/metadata.py b/bricktracker/metadata.py
new file mode 100644
index 00000000..df93c93f
--- /dev/null
+++ b/bricktracker/metadata.py
@@ -0,0 +1,197 @@
+import logging
+from sqlite3 import Row
+from typing import Any, Self, TYPE_CHECKING
+from uuid import uuid4
+
+from flask import url_for
+
+from .exceptions import DatabaseException, ErrorException, NotFoundException
+from .record import BrickRecord
+from .sql import BrickSQL
+if TYPE_CHECKING:
+    from .set import BrickSet
+
+logger = logging.getLogger(__name__)
+
+
+# Lego set metadata (customizable list of entries that can be checked)
+class BrickMetadata(BrickRecord):
+    kind: str
+    prefix: str
+
+    # Set state endpoint
+    set_state_endpoint: str
+
+    # Queries
+    delete_query: str
+    insert_query: str
+    select_query: str
+    update_field_query: str
+    update_set_state_query: str
+
+    def __init__(
+        self,
+        /,
+        *,
+        record: Row | dict[str, Any] | None = None,
+    ):
+        super().__init__()
+
+        # Ingest the record if it has one
+        if record is not None:
+            self.ingest(record)
+
+    # SQL column name
+    def as_column(self, /) -> str:
+        return '{prefix}_{id}'.format(id=self.fields.id, prefix=self.prefix)
+
+    # HTML dataset name
+    def as_dataset(self, /) -> str:
+        return '{id}'.format(
+            id=self.as_column().replace('_', '-')
+        )
+
+    # Delete from database
+    def delete(self, /) -> None:
+        BrickSQL().executescript(
+            self.delete_query,
+            id=self.fields.id,
+        )
+
+    # Insert into database
+    def insert(self, /, **context) -> None:
+        self.safe()
+
+        # Generate an ID for the metadata (with underscores to make it
+        # column name friendly)
+        self.fields.id = str(uuid4()).replace('-', '_')
+
+        BrickSQL().executescript(
+            self.insert_query,
+            id=self.fields.id,
+            name=self.fields.safe_name,
+            **context
+        )
+
+    # Rename the entry
+    def rename(self, /) -> None:
+        self.safe()
+
+        self.update_field('name', value=self.fields.name)
+
+    # Make the name "safe"
+    # Security: eh.
+    def safe(self, /) -> None:
+        # Prevent self-ownage with accidental quote escape
+        self.fields.safe_name = self.fields.name.replace("'", "''")
+
+    # URL to change the selected state of this metadata item for a set
+    def url_for_set_state(self, id: str, /) -> str:
+        return url_for(
+            self.set_state_endpoint,
+            id=id,
+            metadata_id=self.fields.id
+        )
+
+    # Select a specific checkbox (with an id)
+    def select_specific(self, id: str, /) -> Self:
+        # Save the parameters to the fields
+        self.fields.id = id
+
+        # Load from database
+        if not self.select():
+            raise NotFoundException(
+                '{kind} with ID {id} was not found in the database'.format(
+                    kind=self.kind.capitalize(),
+                    id=self.fields.id,
+                ),
+            )
+
+        return self
+
+    # Update a field
+    def update_field(
+        self,
+        field: str,
+        /,
+        *,
+        json: Any | None = None,
+        value: Any | None = None
+    ) -> Any:
+        if value is None:
+            value = json.get('value', None)  # type: ignore
+
+        if value is None:
+            raise ErrorException('"{field}" of a {kind} cannot be set to an empty value'.format(  # noqa: E501
+                field=field,
+                kind=self.kind
+            ))
+
+        if field == 'id' or not hasattr(self.fields, field):
+            raise NotFoundException('"{field}" is not a field of a {kind}'.format(  # noqa: E501
+                kind=self.kind,
+                field=field
+            ))
+
+        parameters = self.sql_parameters()
+        parameters['value'] = value
+
+        # Update the status
+        rows, _ = BrickSQL().execute_and_commit(
+            self.update_field_query,
+            parameters=parameters,
+            field=field,
+        )
+
+        if rows != 1:
+            raise DatabaseException('Could not update the field "{field}" for {kind} {name} ({id})'.format(  # noqa: E501
+                field=field,
+                kind=self.kind,
+                name=self.fields.name,
+                id=self.fields.id,
+            ))
+
+        # Info
+        logger.info('{kind} "{name}" ({id}): field "{field}" changed to "{value}"'.format(  # noqa: E501
+            kind=self.kind.capitalize(),
+            name=self.fields.name,
+            id=self.fields.id,
+            field=field,
+            value=value,
+        ))
+
+        return value
+
+    # Update the selected state of this metadata item for a set
+    def update_set_state(self, brickset: 'BrickSet', json: Any | None) -> Any:
+        state: bool = json.get('value', False)  # type: ignore
+
+        parameters = self.sql_parameters()
+        parameters['set_id'] = brickset.fields.id
+        parameters['state'] = state
+
+        # Update the status
+        rows, _ = BrickSQL().execute_and_commit(
+            self.update_set_state_query,
+            parameters=parameters,
+            name=self.as_column(),
+        )
+
+        if rows != 1:
+            raise DatabaseException('Could not update the {kind} "{name}" state for set {set} ({id})'.format(  # noqa: E501
+                kind=self.kind,
+                name=self.fields.name,
+                set=brickset.fields.set,
+                id=brickset.fields.id,
+            ))
+
+        # Info
+        logger.info('{kind} "{name}" state change to "{state}" for set {set} ({id})'.format(  # noqa: E501
+            kind=self.kind,
+            name=self.fields.name,
+            state=state,
+            set=brickset.fields.set,
+            id=brickset.fields.id,
+        ))
+
+        return state
diff --git a/bricktracker/set.py b/bricktracker/set.py
index fa05b0b3..6b2da47a 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -5,11 +5,10 @@ from uuid import uuid4
 
 from flask import current_app, url_for
 
-from .exceptions import DatabaseException, NotFoundException
+from .exceptions import NotFoundException
 from .minifigure_list import BrickMinifigureList
 from .part_list import BrickPartList
 from .rebrickable_set import RebrickableSet
-from .set_checkbox import BrickSetCheckbox
 from .set_checkbox_list import BrickSetCheckboxList
 from .sql import BrickSQL
 if TYPE_CHECKING:
@@ -172,30 +171,6 @@ class BrickSet(RebrickableSet):
 
         return self
 
-    # Update a status
-    def update_status(
-        self,
-        checkbox: BrickSetCheckbox,
-        status: bool,
-        /
-    ) -> None:
-        parameters = self.sql_parameters()
-        parameters['status'] = status
-
-        # Update the status
-        rows, _ = BrickSQL().execute_and_commit(
-            'set/update/status',
-            parameters=parameters,
-            name=checkbox.as_column(),
-        )
-
-        if rows != 1:
-            raise DatabaseException('Could not update the status "{status}" for set {set} ({id})'.format(  # noqa: E501
-                status=checkbox.fields.name,
-                set=self.fields.set,
-                id=self.fields.id,
-            ))
-
     # Self url
     def url(self, /) -> str:
         return url_for('set.details', id=self.fields.id)
diff --git a/bricktracker/set_checkbox.py b/bricktracker/set_checkbox.py
index 38a10f0d..f38b5f6e 100644
--- a/bricktracker/set_checkbox.py
+++ b/bricktracker/set_checkbox.py
@@ -1,139 +1,39 @@
-from sqlite3 import Row
-from typing import Any, Self
-from uuid import uuid4
+from typing import Self
 
-from flask import url_for
-
-from .exceptions import DatabaseException, ErrorException, NotFoundException
-from .record import BrickRecord
-from .sql import BrickSQL
+from .exceptions import ErrorException
+from .metadata import BrickMetadata
 
 
 # Lego set checkbox
-class BrickSetCheckbox(BrickRecord):
+class BrickSetCheckbox(BrickMetadata):
+    kind: str = 'checkbox'
+    prefix: str = 'status'
+
+    # Set state endpoint
+    set_state_endpoint: str = 'set.update_status'
+
     # Queries
+    delete_query: str = 'checkbox/delete'
+    insert_query: str = 'checkbox/insert'
     select_query: str = 'checkbox/select'
-
-    def __init__(
-        self,
-        /,
-        *,
-        record: Row | dict[str, Any] | None = None,
-    ):
-        super().__init__()
-
-        # Ingest the record if it has one
-        if record is not None:
-            self.ingest(record)
-
-    # SQL column name
-    def as_column(self) -> str:
-        return 'status_{id}'.format(id=self.fields.id)
-
-    # HTML dataset name
-    def as_dataset(self) -> str:
-        return '{id}'.format(
-            id=self.as_column().replace('_', '-')
-        )
-
-    # Delete from database
-    def delete(self) -> None:
-        BrickSQL().executescript(
-            'checkbox/delete',
-            id=self.fields.id,
-        )
+    update_field_query: str = 'checkbox/update/field'
+    update_set_state_query: str = 'set/update/status'
 
     # Grab data from a form
-    def from_form(self, form: dict[str, str]) -> Self:
+    def from_form(self, form: dict[str, str], /) -> Self:
         name = form.get('name', None)
         grid = form.get('grid', None)
 
         if name is None or name == '':
             raise ErrorException('Checkbox name cannot be empty')
 
-        # Security: eh.
-        # Prevent self-ownage with accidental quote escape
         self.fields.name = name
-        self.fields.safe_name = self.fields.name.replace("'", "''")
         self.fields.displayed_on_grid = grid == 'on'
 
         return self
 
     # Insert into database
-    def insert(self, **_) -> None:
-        # Generate an ID for the checkbox (with underscores to make it
-        # column name friendly)
-        self.fields.id = str(uuid4()).replace('-', '_')
-
-        BrickSQL().executescript(
-            'checkbox/add',
-            id=self.fields.id,
-            name=self.fields.safe_name,
+    def insert(self, /, **_) -> None:
+        super().insert(
             displayed_on_grid=self.fields.displayed_on_grid
         )
-
-    # Rename the checkbox
-    def rename(self, /) -> None:
-        # Update the name
-        rows, _ = BrickSQL().execute_and_commit(
-            'checkbox/update/name',
-            parameters=self.sql_parameters(),
-        )
-
-        if rows != 1:
-            raise DatabaseException('Could not update the name for checkbox {name} ({id})'.format(  # noqa: E501
-                name=self.fields.name,
-                id=self.fields.id,
-            ))
-
-    # URL to change the status
-    def status_url(self, id: str) -> str:
-        return url_for(
-            'set.update_status',
-            id=id,
-            checkbox_id=self.fields.id
-        )
-
-    # Select a specific checkbox (with an id)
-    def select_specific(self, id: str, /) -> Self:
-        # Save the parameters to the fields
-        self.fields.id = id
-
-        # Load from database
-        if not self.select():
-            raise NotFoundException(
-                'Checkbox with ID {id} was not found in the database'.format(
-                    id=self.fields.id,
-                ),
-            )
-
-        return self
-
-    # Update a status
-    def update_status(
-        self,
-        name: str,
-        status: bool,
-        /
-    ) -> None:
-        if not hasattr(self.fields, name) or name in ['id', 'name']:
-            raise NotFoundException('{name} is not a field of a checkbox'.format(  # noqa: E501
-                name=name
-            ))
-
-        parameters = self.sql_parameters()
-        parameters['status'] = status
-
-        # Update the status
-        rows, _ = BrickSQL().execute_and_commit(
-            'checkbox/update/status',
-            parameters=parameters,
-            name=name,
-        )
-
-        if rows != 1:
-            raise DatabaseException('Could not update the status "{status}" for checkbox {name} ({id})'.format(  # noqa: E501
-                status=name,
-                name=self.fields.name,
-                id=self.fields.id,
-            ))
diff --git a/bricktracker/sql/checkbox/add.sql b/bricktracker/sql/checkbox/insert.sql
similarity index 100%
rename from bricktracker/sql/checkbox/add.sql
rename to bricktracker/sql/checkbox/insert.sql
diff --git a/bricktracker/sql/checkbox/update/name.sql b/bricktracker/sql/checkbox/update/field.sql
similarity index 80%
rename from bricktracker/sql/checkbox/update/name.sql
rename to bricktracker/sql/checkbox/update/field.sql
index 19fccc03..a65e3c09 100644
--- a/bricktracker/sql/checkbox/update/name.sql
+++ b/bricktracker/sql/checkbox/update/field.sql
@@ -1,3 +1,3 @@
 UPDATE "bricktracker_set_checkboxes"
-SET "name" = :safe_name
+SET "{{field}}" = :value
 WHERE "bricktracker_set_checkboxes"."id" IS NOT DISTINCT FROM :id
diff --git a/bricktracker/sql/checkbox/update/status.sql b/bricktracker/sql/checkbox/update/status.sql
deleted file mode 100644
index 3c04c22d..00000000
--- a/bricktracker/sql/checkbox/update/status.sql
+++ /dev/null
@@ -1,3 +0,0 @@
-UPDATE "bricktracker_set_checkboxes"
-SET "{{name}}" = :status
-WHERE "bricktracker_set_checkboxes"."id" IS NOT DISTINCT FROM :id
diff --git a/bricktracker/sql/set/update/status.sql b/bricktracker/sql/set/update/status.sql
index 4fc78e46..7697ca5c 100644
--- a/bricktracker/sql/set/update/status.sql
+++ b/bricktracker/sql/set/update/status.sql
@@ -2,9 +2,9 @@ INSERT INTO "bricktracker_set_statuses" (
     "id",
     "{{name}}"
 ) VALUES (
-    :id,
-    :status
+    :set_id,
+    :state
 )
 ON CONFLICT("id")
-DO UPDATE SET "{{name}}" = :status
-WHERE "bricktracker_set_statuses"."id" IS NOT DISTINCT FROM :id
+DO UPDATE SET "{{name}}" = :state
+WHERE "bricktracker_set_statuses"."id" IS NOT DISTINCT FROM :set_id
diff --git a/bricktracker/views/admin/checkbox.py b/bricktracker/views/admin/checkbox.py
index 134c8861..6db6c5df 100644
--- a/bricktracker/views/admin/checkbox.py
+++ b/bricktracker/views/admin/checkbox.py
@@ -1,5 +1,3 @@
-import logging
-
 from flask import (
     Blueprint,
     jsonify,
@@ -15,8 +13,6 @@ from ..exceptions import exception_handler
 from ...reload import reload
 from ...set_checkbox import BrickSetCheckbox
 
-logger = logging.getLogger(__name__)
-
 admin_checkbox_page = Blueprint(
     'admin_checkbox',
     __name__,
@@ -71,23 +67,13 @@ def do_delete(*, id: str) -> Response:
     return redirect(url_for('admin.admin', open_checkbox=True))
 
 
-# Change the status of a checkbox
-@admin_checkbox_page.route('/<id>/status/<name>', methods=['POST'])
+# Change the field of a checkbox
+@admin_checkbox_page.route('/<id>/field/<name>', methods=['POST'])
 @login_required
 @exception_handler(__file__, json=True)
-def update_status(*, id: str, name: str) -> Response:
-    value: bool = request.json.get('value', False)  # type: ignore
-
+def update_field(*, id: str, name: str) -> Response:
     checkbox = BrickSetCheckbox().select_specific(id)
-    checkbox.update_status(name, value)
-
-    # Info
-    logger.info('Checkbox {name} ({id}): status "{status}" changed to "{state}"'.format(  # noqa: E501
-        name=checkbox.fields.name,
-        id=checkbox.fields.id,
-        status=name,
-        state=value,
-    ))
+    value = checkbox.update_field(name, json=request.json)
 
     reload()
 
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index 809d46b4..bb3c2348 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -37,26 +37,16 @@ def list() -> str:
 
 
 # Change the status of a checkbox
-@set_page.route('/<id>/status/<checkbox_id>', methods=['POST'])
+@set_page.route('/<id>/status/<metadata_id>', methods=['POST'])
 @login_required
 @exception_handler(__file__, json=True)
-def update_status(*, id: str, checkbox_id: str) -> Response:
-    value: bool = request.json.get('value', False)  # type: ignore
-
+def update_status(*, id: str, metadata_id: str) -> Response:
     brickset = BrickSet().select_light(id)
-    checkbox = BrickSetCheckboxList().get(checkbox_id)
+    checkbox = BrickSetCheckboxList().get(metadata_id)
 
-    brickset.update_status(checkbox, value)
+    state = checkbox.update_set_state(brickset, request.json)
 
-    # Info
-    logger.info('Set {set} ({id}): status "{status}" changed to "{state}"'.format(  # noqa: E501
-        set=brickset.fields.set,
-        id=brickset.fields.id,
-        status=checkbox.fields.name,
-        state=value,
-    ))
-
-    return jsonify({'value': value})
+    return jsonify({'value': state})
 
 
 # Ask for deletion of a set
diff --git a/templates/admin/checkbox.html b/templates/admin/checkbox.html
index 0ba484ab..22a3215b 100644
--- a/templates/admin/checkbox.html
+++ b/templates/admin/checkbox.html
@@ -18,7 +18,7 @@
           <div class="col-12">
             <div class="form-check">
               <input class="form-check-input" type="checkbox" id="grid-{{ checkbox.fields.id }}"
-                data-changer-id="{{ checkbox.fields.id }}" data-changer-prefix="grid" data-changer-url="{{ url_for('admin_checkbox.update_status', id=checkbox.fields.id, name='displayed_on_grid')}}"
+                data-changer-id="{{ checkbox.fields.id }}" data-changer-prefix="grid" data-changer-url="{{ url_for('admin_checkbox.update_field', id=checkbox.fields.id, name='displayed_on_grid')}}"
                 {% if checkbox.fields.displayed_on_grid %}checked{% endif %} autocomplete="off">
               <label class="form-check-label" for="grid-{{ checkbox.fields.id }}">
                 <i class="ri-grid-line"></i> Displayed on the Set Grid
diff --git a/templates/set/card.html b/templates/set/card.html
index fee30404..6a658c98 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -30,7 +30,7 @@
     <ul class="list-group list-group-flush card-check border-bottom-0">
       {% for checkbox in brickset_checkboxes %}
         <li class="list-group-item {% if not solo %}p-1{% endif %}">
-          {{ form.checkbox(checkbox.as_dataset(), item.fields.id, checkbox.fields.name, checkbox.status_url(item.fields.id), item.fields[checkbox.as_column()], delete=delete) }}
+          {{ form.checkbox(checkbox.as_dataset(), item.fields.id, checkbox.fields.name, checkbox.url_for_set_state(item.fields.id), item.fields[checkbox.as_column()], delete=delete) }}
         </li>
       {% endfor %}
     </ul>

From 8f5d59394c542c54680c5bc13d15add71db435f2 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Thu, 30 Jan 2025 12:42:37 +0100
Subject: [PATCH 056/154] Remove the 404 code from post redirect as it will
 cause the browser to not redirect

---
 bricktracker/views/error.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/bricktracker/views/error.py b/bricktracker/views/error.py
index 67a97a19..3a1c7427 100644
--- a/bricktracker/views/error.py
+++ b/bricktracker/views/error.py
@@ -138,7 +138,7 @@ def error_404(
     post_redirect: str | None = None,
     error_name: str = 'error',
     **kwargs,
-) -> Tuple[str | Response, int]:
+) -> Response | Tuple[str | Response, int]:
     # Warning
     logger.warning('Not found: {error} (path: {path})'.format(
         error=str(error),
@@ -156,7 +156,7 @@ def error_404(
             error=str(error),
             error_name=error_name,
             **kwargs,
-        ), 404
+        )
     else:
         return render_template('404.html', error=str(error)), 404
 

From 050b1993da41ec035f68a28c99526f6a4a24965a Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Thu, 30 Jan 2025 12:56:14 +0100
Subject: [PATCH 057/154] Don't rely on SQL files for migration patches as
 their existence is not guaranteed

---
 bricktracker/migrations/0007.py | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/bricktracker/migrations/0007.py b/bricktracker/migrations/0007.py
index 7cf3c9e8..9b0f5892 100644
--- a/bricktracker/migrations/0007.py
+++ b/bricktracker/migrations/0007.py
@@ -5,8 +5,10 @@ if TYPE_CHECKING:
 
 
 # Grab the list of checkboxes to create a list of SQL columns
-def migration_0007(self: 'BrickSQL', /) -> dict[str, Any]:
-    records = self.fetchall('checkbox/list')
+def migration_0007(sql: 'BrickSQL', /) -> dict[str, Any]:
+    # Don't realy on sql files as they could be removed in the future
+    sql.cursor.execute('SELECT "bricktracker_set_checkboxes"."id" FROM "bricktracker_set_checkboxes')  # noqa: E501
+    records = sql.cursor.fetchall()
 
     return {
         'sources': ', '.join([

From 7d16e491c8ea063f0fb75afdd1918255fd0878e7 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Thu, 30 Jan 2025 15:03:16 +0100
Subject: [PATCH 058/154] Rename checkboxes (too generic) to status (and some
 bug fixes)

---
 bricktracker/app.py                           |   4 +-
 bricktracker/metadata.py                      |   2 +-
 bricktracker/migrations/0007.py               |   2 +-
 bricktracker/record_list.py                   |   4 +-
 bricktracker/reload.py                        |   6 +-
 bricktracker/set.py                           |   4 +-
 bricktracker/set_list.py                      |   6 +-
 .../{set_checkbox.py => set_status.py}        |  18 ++--
 ...et_checkbox_list.py => set_status_list.py} |  42 ++++----
 bricktracker/sql.py                           |   7 +-
 bricktracker/sql/checkbox/base.sql            |   7 --
 bricktracker/sql/checkbox/delete.sql          |   9 --
 bricktracker/sql/checkbox/list.sql            |   1 -
 bricktracker/sql/checkbox/select.sql          |   5 -
 bricktracker/sql/checkbox/update/field.sql    |   3 -
 bricktracker/sql/migrations/0012.sql          |   7 ++
 bricktracker/sql/schema/drop.sql              |   1 +
 bricktracker/sql/set/metadata/status/base.sql |   7 ++
 .../sql/set/metadata/status/delete.sql        |   9 ++
 .../metadata/status}/insert.sql               |   2 +-
 bricktracker/sql/set/metadata/status/list.sql |   1 +
 .../sql/set/metadata/status/select.sql        |   5 +
 .../sql/set/metadata/status/update/field.sql  |   3 +
 .../status/update/state.sql}                  |   0
 bricktracker/sql_counter.py                   |   7 +-
 bricktracker/version.py                       |   2 +-
 bricktracker/views/admin/admin.py             |  18 ++--
 bricktracker/views/admin/checkbox.py          |  98 ------------------
 bricktracker/views/admin/status.py            |  98 ++++++++++++++++++
 bricktracker/views/index.py                   |   4 +-
 bricktracker/views/set.py                     |  12 +--
 docs/DOCS.md                                  |   2 +-
 docs/checkbox.md                              |  58 -----------
 .../images/{checkbox-01.png => status-01.png} | Bin
 .../images/{checkbox-02.png => status-02.png} | Bin
 .../images/{checkbox-03.png => status-03.png} | Bin
 .../images/{checkbox-04.png => status-04.png} | Bin
 .../images/{checkbox-05.png => status-05.png} | Bin
 .../images/{checkbox-06.png => status-06.png} | Bin
 .../images/{checkbox-07.png => status-07.png} | Bin
 docs/set-statuses.md                          |  67 ++++++++++++
 templates/admin.html                          |   6 +-
 .../admin/{checkbox.html => status.html}      |  30 +++---
 .../admin/{checkbox => status}/delete.html    |  14 +--
 templates/set/card.html                       |   8 +-
 templates/sets.html                           |   6 +-
 46 files changed, 304 insertions(+), 281 deletions(-)
 rename bricktracker/{set_checkbox.py => set_status.py} (60%)
 rename bricktracker/{set_checkbox_list.py => set_status_list.py} (51%)
 delete mode 100644 bricktracker/sql/checkbox/base.sql
 delete mode 100644 bricktracker/sql/checkbox/delete.sql
 delete mode 100644 bricktracker/sql/checkbox/list.sql
 delete mode 100644 bricktracker/sql/checkbox/select.sql
 delete mode 100644 bricktracker/sql/checkbox/update/field.sql
 create mode 100644 bricktracker/sql/migrations/0012.sql
 create mode 100644 bricktracker/sql/set/metadata/status/base.sql
 create mode 100644 bricktracker/sql/set/metadata/status/delete.sql
 rename bricktracker/sql/{checkbox => set/metadata/status}/insert.sql (84%)
 create mode 100644 bricktracker/sql/set/metadata/status/list.sql
 create mode 100644 bricktracker/sql/set/metadata/status/select.sql
 create mode 100644 bricktracker/sql/set/metadata/status/update/field.sql
 rename bricktracker/sql/set/{update/status.sql => metadata/status/update/state.sql} (100%)
 delete mode 100644 bricktracker/views/admin/checkbox.py
 create mode 100644 bricktracker/views/admin/status.py
 delete mode 100644 docs/checkbox.md
 rename docs/images/{checkbox-01.png => status-01.png} (100%)
 rename docs/images/{checkbox-02.png => status-02.png} (100%)
 rename docs/images/{checkbox-03.png => status-03.png} (100%)
 rename docs/images/{checkbox-04.png => status-04.png} (100%)
 rename docs/images/{checkbox-05.png => status-05.png} (100%)
 rename docs/images/{checkbox-06.png => status-06.png} (100%)
 rename docs/images/{checkbox-07.png => status-07.png} (100%)
 create mode 100644 docs/set-statuses.md
 rename templates/admin/{checkbox.html => status.html} (54%)
 rename templates/admin/{checkbox => status}/delete.html (53%)

diff --git a/bricktracker/app.py b/bricktracker/app.py
index a55a9f77..f6054bcb 100644
--- a/bricktracker/app.py
+++ b/bricktracker/app.py
@@ -13,7 +13,7 @@ from bricktracker.sql import close
 from bricktracker.version import __version__
 from bricktracker.views.add import add_page
 from bricktracker.views.admin.admin import admin_page
-from bricktracker.views.admin.checkbox import admin_checkbox_page
+from bricktracker.views.admin.status import admin_status_page
 from bricktracker.views.admin.database import admin_database_page
 from bricktracker.views.admin.image import admin_image_page
 from bricktracker.views.admin.instructions import admin_instructions_page
@@ -78,7 +78,7 @@ def setup_app(app: Flask) -> None:
 
     # Register admin routes
     app.register_blueprint(admin_page)
-    app.register_blueprint(admin_checkbox_page)
+    app.register_blueprint(admin_status_page)
     app.register_blueprint(admin_database_page)
     app.register_blueprint(admin_image_page)
     app.register_blueprint(admin_instructions_page)
diff --git a/bricktracker/metadata.py b/bricktracker/metadata.py
index df93c93f..e21bfdd4 100644
--- a/bricktracker/metadata.py
+++ b/bricktracker/metadata.py
@@ -93,7 +93,7 @@ class BrickMetadata(BrickRecord):
             metadata_id=self.fields.id
         )
 
-    # Select a specific checkbox (with an id)
+    # Select a specific metadata (with an id)
     def select_specific(self, id: str, /) -> Self:
         # Save the parameters to the fields
         self.fields.id = id
diff --git a/bricktracker/migrations/0007.py b/bricktracker/migrations/0007.py
index 9b0f5892..81e4535b 100644
--- a/bricktracker/migrations/0007.py
+++ b/bricktracker/migrations/0007.py
@@ -7,7 +7,7 @@ if TYPE_CHECKING:
 # Grab the list of checkboxes to create a list of SQL columns
 def migration_0007(sql: 'BrickSQL', /) -> dict[str, Any]:
     # Don't realy on sql files as they could be removed in the future
-    sql.cursor.execute('SELECT "bricktracker_set_checkboxes"."id" FROM "bricktracker_set_checkboxes')  # noqa: E501
+    sql.cursor.execute('SELECT "bricktracker_set_checkboxes"."id" FROM "bricktracker_set_checkboxes"')  # noqa: E501
     records = sql.cursor.fetchall()
 
     return {
diff --git a/bricktracker/record_list.py b/bricktracker/record_list.py
index 07989916..22752032 100644
--- a/bricktracker/record_list.py
+++ b/bricktracker/record_list.py
@@ -8,13 +8,13 @@ if TYPE_CHECKING:
     from .part import BrickPart
     from .rebrickable_set import RebrickableSet
     from .set import BrickSet
-    from .set_checkbox import BrickSetCheckbox
+    from .set_status import BrickSetStatus
     from .wish import BrickWish
 
 T = TypeVar(
     'T',
     'BrickSet',
-    'BrickSetCheckbox',
+    'BrickSetStatus',
     'BrickPart',
     'BrickMinifigure',
     'BrickWish',
diff --git a/bricktracker/reload.py b/bricktracker/reload.py
index 62564df1..28de7bff 100644
--- a/bricktracker/reload.py
+++ b/bricktracker/reload.py
@@ -1,6 +1,6 @@
 from .instructions_list import BrickInstructionsList
 from .retired_list import BrickRetiredList
-from .set_checkbox_list import BrickSetCheckboxList
+from .set_status_list import BrickSetStatusList
 from .theme_list import BrickThemeList
 
 
@@ -11,8 +11,8 @@ def reload() -> None:
         # Reload the instructions
         BrickInstructionsList(force=True)
 
-        # Reload the checkboxes
-        BrickSetCheckboxList(force=True)
+        # Reload the set statuses
+        BrickSetStatusList(force=True)
 
         # Reload retired sets
         BrickRetiredList(force=True)
diff --git a/bricktracker/set.py b/bricktracker/set.py
index 6b2da47a..1b11a943 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -9,7 +9,7 @@ from .exceptions import NotFoundException
 from .minifigure_list import BrickMinifigureList
 from .part_list import BrickPartList
 from .rebrickable_set import RebrickableSet
-from .set_checkbox_list import BrickSetCheckboxList
+from .set_status_list import BrickSetStatusList
 from .sql import BrickSQL
 if TYPE_CHECKING:
     from .socket import BrickSocket
@@ -161,7 +161,7 @@ class BrickSet(RebrickableSet):
 
         # Load from database
         if not self.select(
-            statuses=BrickSetCheckboxList().as_columns(solo=True)
+            statuses=BrickSetStatusList().as_columns(solo=True)
         ):
             raise NotFoundException(
                 'Set with ID {id} was not found in the database'.format(
diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py
index 349af66b..47d31b21 100644
--- a/bricktracker/set_list.py
+++ b/bricktracker/set_list.py
@@ -3,7 +3,7 @@ from typing import Self
 from flask import current_app
 
 from .record_list import BrickRecordList
-from .set_checkbox_list import BrickSetCheckboxList
+from .set_status_list import BrickSetStatusList
 from .set import BrickSet
 
 
@@ -37,7 +37,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
         # Load the sets from the database
         for record in self.select(
             order=self.order,
-            statuses=BrickSetCheckboxList().as_columns()
+            statuses=BrickSetStatusList().as_columns()
         ):
             brickset = BrickSet(record=record)
 
@@ -73,7 +73,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
         for record in self.select(
             order=order,
             limit=limit,
-            statuses=BrickSetCheckboxList().as_columns()
+            statuses=BrickSetStatusList().as_columns()
         ):
             brickset = BrickSet(record=record)
 
diff --git a/bricktracker/set_checkbox.py b/bricktracker/set_status.py
similarity index 60%
rename from bricktracker/set_checkbox.py
rename to bricktracker/set_status.py
index f38b5f6e..3b874f52 100644
--- a/bricktracker/set_checkbox.py
+++ b/bricktracker/set_status.py
@@ -4,20 +4,20 @@ from .exceptions import ErrorException
 from .metadata import BrickMetadata
 
 
-# Lego set checkbox
-class BrickSetCheckbox(BrickMetadata):
-    kind: str = 'checkbox'
+# Lego set status metadata
+class BrickSetStatus(BrickMetadata):
+    kind: str = 'status'
     prefix: str = 'status'
 
     # Set state endpoint
     set_state_endpoint: str = 'set.update_status'
 
     # Queries
-    delete_query: str = 'checkbox/delete'
-    insert_query: str = 'checkbox/insert'
-    select_query: str = 'checkbox/select'
-    update_field_query: str = 'checkbox/update/field'
-    update_set_state_query: str = 'set/update/status'
+    delete_query: str = 'set/metadata/status/delete'
+    insert_query: str = 'set/metadata/status/insert'
+    select_query: str = 'set/metadata/status/select'
+    update_field_query: str = 'set/metadata/status/update/field'
+    update_set_state_query: str = 'set/metadata/status/update/state'
 
     # Grab data from a form
     def from_form(self, form: dict[str, str], /) -> Self:
@@ -25,7 +25,7 @@ class BrickSetCheckbox(BrickMetadata):
         grid = form.get('grid', None)
 
         if name is None or name == '':
-            raise ErrorException('Checkbox name cannot be empty')
+            raise ErrorException('Status name cannot be empty')
 
         self.fields.name = name
         self.fields.displayed_on_grid = grid == 'on'
diff --git a/bricktracker/set_checkbox_list.py b/bricktracker/set_status_list.py
similarity index 51%
rename from bricktracker/set_checkbox_list.py
rename to bricktracker/set_status_list.py
index 6564e8d0..12ce85a1 100644
--- a/bricktracker/set_checkbox_list.py
+++ b/bricktracker/set_status_list.py
@@ -3,39 +3,39 @@ import logging
 from .exceptions import NotFoundException
 from .fields import BrickRecordFields
 from .record_list import BrickRecordList
-from .set_checkbox import BrickSetCheckbox
+from .set_status import BrickSetStatus
 
 logger = logging.getLogger(__name__)
 
 
-# Lego sets checkbox list
-class BrickSetCheckboxList(BrickRecordList[BrickSetCheckbox]):
-    checkboxes: dict[str, BrickSetCheckbox]
+# Lego sets status list
+class BrickSetStatusList(BrickRecordList[BrickSetStatus]):
+    statuses: dict[str, BrickSetStatus]
 
     # Queries
-    select_query = 'checkbox/list'
+    select_query = 'set/metadata/status/list'
 
     def __init__(self, /, *, force: bool = False):
-        # Load checkboxes only if there is none already loaded
+        # Load statuses only if there is none already loaded
         records = getattr(self, 'records', None)
 
         if records is None or force:
             # Don't use super()__init__ as it would mask class variables
             self.fields = BrickRecordFields()
 
-            logger.info('Loading set checkboxes list')
+            logger.info('Loading set statuses list')
 
-            BrickSetCheckboxList.records = []
-            BrickSetCheckboxList.checkboxes = {}
+            BrickSetStatusList.records = []
+            BrickSetStatusList.statuses = {}
 
-            # Load the checkboxes from the database
+            # Load the statuses from the database
             for record in self.select():
-                checkbox = BrickSetCheckbox(record=record)
+                status = BrickSetStatus(record=record)
 
-                BrickSetCheckboxList.records.append(checkbox)
-                BrickSetCheckboxList.checkboxes[checkbox.fields.id] = checkbox
+                BrickSetStatusList.records.append(status)
+                BrickSetStatusList.statuses[status.fields.id] = status
 
-    # Return the checkboxes as columns for a select
+    # Return the statuses as columns for a select
     def as_columns(
         self,
         /,
@@ -53,19 +53,19 @@ class BrickSetCheckboxList(BrickRecordList[BrickSetCheckbox]):
             if solo or record.fields.displayed_on_grid
         ])
 
-    # Grab a specific checkbox
-    def get(self, id: str, /) -> BrickSetCheckbox:
-        if id not in self.checkboxes:
+    # Grab a specific status
+    def get(self, id: str, /) -> BrickSetStatus:
+        if id not in self.statuses:
             raise NotFoundException(
-                'Checkbox with ID {id} was not found in the database'.format(
+                'Status with ID {id} was not found in the database'.format(
                     id=id,
                 ),
             )
 
-        return self.checkboxes[id]
+        return self.statuses[id]
 
-    # Get the list of checkboxes depending on the context
-    def list(self, /, *, all: bool = False) -> list[BrickSetCheckbox]:
+    # Get the list of statuses depending on the context
+    def list(self, /, *, all: bool = False) -> list[BrickSetStatus]:
         return [
             record
             for record
diff --git a/bricktracker/sql.py b/bricktracker/sql.py
index 9e47d9a4..18f7e991 100644
--- a/bricktracker/sql.py
+++ b/bricktracker/sql.py
@@ -318,13 +318,18 @@ class BrickSQL(object):
                         ),
                         package='bricktracker'
                     )
+                except Exception:
+                    module = None
 
+                # If a module has been loaded, we need to fail if an error
+                # occured while executing the migration function
+                if module is not None:
                     function = getattr(module, 'migration_{name}'.format(
                         name=pending.name
                     ))
 
                     context: dict[str, Any] = function(self)
-                except Exception:
+                else:
                     context: dict[str, Any] = {}
 
                 self.executescript(pending.get_query(), **context)
diff --git a/bricktracker/sql/checkbox/base.sql b/bricktracker/sql/checkbox/base.sql
deleted file mode 100644
index 9726a6c1..00000000
--- a/bricktracker/sql/checkbox/base.sql
+++ /dev/null
@@ -1,7 +0,0 @@
-SELECT
-    "bricktracker_set_checkboxes"."id",
-    "bricktracker_set_checkboxes"."name",
-    "bricktracker_set_checkboxes"."displayed_on_grid"
-FROM "bricktracker_set_checkboxes"
-
-{% block where %}{% endblock %}
\ No newline at end of file
diff --git a/bricktracker/sql/checkbox/delete.sql b/bricktracker/sql/checkbox/delete.sql
deleted file mode 100644
index 6eae9d0b..00000000
--- a/bricktracker/sql/checkbox/delete.sql
+++ /dev/null
@@ -1,9 +0,0 @@
-BEGIN TRANSACTION;
-
-ALTER TABLE "bricktracker_set_statuses"
-DROP COLUMN "status_{{ id }}";
-
-DELETE FROM "bricktracker_set_checkboxes"
-WHERE "bricktracker_set_checkboxes"."id" IS NOT DISTINCT FROM '{{ id }}';
-
-COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/checkbox/list.sql b/bricktracker/sql/checkbox/list.sql
deleted file mode 100644
index 7420eb33..00000000
--- a/bricktracker/sql/checkbox/list.sql
+++ /dev/null
@@ -1 +0,0 @@
-{% extends 'checkbox/base.sql' %}
diff --git a/bricktracker/sql/checkbox/select.sql b/bricktracker/sql/checkbox/select.sql
deleted file mode 100644
index 76557a8b..00000000
--- a/bricktracker/sql/checkbox/select.sql
+++ /dev/null
@@ -1,5 +0,0 @@
-{% extends 'checkbox/base.sql' %}
-
-{% block where %}
-WHERE "bricktracker_set_checkboxes"."id" IS NOT DISTINCT FROM :id
-{% endblock %}
\ No newline at end of file
diff --git a/bricktracker/sql/checkbox/update/field.sql b/bricktracker/sql/checkbox/update/field.sql
deleted file mode 100644
index a65e3c09..00000000
--- a/bricktracker/sql/checkbox/update/field.sql
+++ /dev/null
@@ -1,3 +0,0 @@
-UPDATE "bricktracker_set_checkboxes"
-SET "{{field}}" = :value
-WHERE "bricktracker_set_checkboxes"."id" IS NOT DISTINCT FROM :id
diff --git a/bricktracker/sql/migrations/0012.sql b/bricktracker/sql/migrations/0012.sql
new file mode 100644
index 00000000..d656e29c
--- /dev/null
+++ b/bricktracker/sql/migrations/0012.sql
@@ -0,0 +1,7 @@
+-- description: Rename checkboxes to status metadata
+
+BEGIN TRANSACTION;
+
+ALTER TABLE "bricktracker_set_checkboxes" RENAME TO "bricktracker_metadata_statuses";
+
+COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/schema/drop.sql b/bricktracker/sql/schema/drop.sql
index 78ea32ce..abc85229 100644
--- a/bricktracker/sql/schema/drop.sql
+++ b/bricktracker/sql/schema/drop.sql
@@ -1,5 +1,6 @@
 BEGIN transaction;
 
+DROP TABLE IF EXISTS "bricktracker_metadata_statuses";
 DROP TABLE IF EXISTS "bricktracker_minifigures";
 DROP TABLE IF EXISTS "bricktracker_parts";
 DROP TABLE IF EXISTS "bricktracker_sets";
diff --git a/bricktracker/sql/set/metadata/status/base.sql b/bricktracker/sql/set/metadata/status/base.sql
new file mode 100644
index 00000000..b1b2167d
--- /dev/null
+++ b/bricktracker/sql/set/metadata/status/base.sql
@@ -0,0 +1,7 @@
+SELECT
+    "bricktracker_metadata_statuses"."id",
+    "bricktracker_metadata_statuses"."name",
+    "bricktracker_metadata_statuses"."displayed_on_grid"
+FROM "bricktracker_metadata_statuses"
+
+{% block where %}{% endblock %}
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/status/delete.sql b/bricktracker/sql/set/metadata/status/delete.sql
new file mode 100644
index 00000000..cac284e2
--- /dev/null
+++ b/bricktracker/sql/set/metadata/status/delete.sql
@@ -0,0 +1,9 @@
+BEGIN TRANSACTION;
+
+ALTER TABLE "bricktracker_set_statuses"
+DROP COLUMN "status_{{ id }}";
+
+DELETE FROM "bricktracker_metadata_statuses"
+WHERE "bricktracker_metadata_statuses"."id" IS NOT DISTINCT FROM '{{ id }}';
+
+COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/checkbox/insert.sql b/bricktracker/sql/set/metadata/status/insert.sql
similarity index 84%
rename from bricktracker/sql/checkbox/insert.sql
rename to bricktracker/sql/set/metadata/status/insert.sql
index 5de9c179..2704d720 100644
--- a/bricktracker/sql/checkbox/insert.sql
+++ b/bricktracker/sql/set/metadata/status/insert.sql
@@ -3,7 +3,7 @@ BEGIN TRANSACTION;
 ALTER TABLE "bricktracker_set_statuses"
 ADD COLUMN "status_{{ id }}" BOOLEAN NOT NULL DEFAULT 0;
 
-INSERT INTO "bricktracker_set_checkboxes" (
+INSERT INTO "bricktracker_metadata_statuses" (
     "id",
     "name",
     "displayed_on_grid"
diff --git a/bricktracker/sql/set/metadata/status/list.sql b/bricktracker/sql/set/metadata/status/list.sql
new file mode 100644
index 00000000..2fe9994b
--- /dev/null
+++ b/bricktracker/sql/set/metadata/status/list.sql
@@ -0,0 +1 @@
+{% extends 'set/metadata/status/base.sql' %}
diff --git a/bricktracker/sql/set/metadata/status/select.sql b/bricktracker/sql/set/metadata/status/select.sql
new file mode 100644
index 00000000..09afe875
--- /dev/null
+++ b/bricktracker/sql/set/metadata/status/select.sql
@@ -0,0 +1,5 @@
+{% extends 'set/metadata/status/base.sql' %}
+
+{% block where %}
+WHERE "bricktracker_metadata_statuses"."id" IS NOT DISTINCT FROM :id
+{% endblock %}
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/status/update/field.sql b/bricktracker/sql/set/metadata/status/update/field.sql
new file mode 100644
index 00000000..d17681ec
--- /dev/null
+++ b/bricktracker/sql/set/metadata/status/update/field.sql
@@ -0,0 +1,3 @@
+UPDATE "bricktracker_metadata_statuses"
+SET "{{field}}" = :value
+WHERE "bricktracker_metadata_statuses"."id" IS NOT DISTINCT FROM :id
diff --git a/bricktracker/sql/set/update/status.sql b/bricktracker/sql/set/metadata/status/update/state.sql
similarity index 100%
rename from bricktracker/sql/set/update/status.sql
rename to bricktracker/sql/set/metadata/status/update/state.sql
diff --git a/bricktracker/sql_counter.py b/bricktracker/sql_counter.py
index 2e5c072e..28c03a32 100644
--- a/bricktracker/sql_counter.py
+++ b/bricktracker/sql_counter.py
@@ -2,11 +2,12 @@ from typing import Tuple
 
 # Some table aliases to make it look cleaner (id: (name, icon))
 ALIASES: dict[str, Tuple[str, str]] = {
+    'bricktracker_metadata_statuses': ('Bricktracker set status metadata', 'checkbox-line'),  # noqa: E501
     'bricktracker_minifigures': ('Bricktracker minifigures', 'group-line'),
     'bricktracker_parts': ('Bricktracker parts', 'shapes-line'),
-    'bricktracker_set_checkboxes': ('Bricktracker set checkboxes', 'checkbox-line'),  # noqa: E501
-    'bricktracker_set_statuses': ('Bricktracker sets status', 'checkbox-circle-line'),  # noqa: E501
-    'bricktracker_set_storages': ('Bricktracker sets storages', 'archive-2-line'),  # noqa: E501
+    'bricktracker_set_checkboxes': ('Bricktracker set checkboxes (legacy)', 'checkbox-line'),  # noqa: E501
+    'bricktracker_set_statuses': ('Bricktracker set statuses', 'checkbox-line'),  # noqa: E501
+    'bricktracker_set_storages': ('Bricktracker set storages', 'archive-2-line'),  # noqa: E501
     'bricktracker_sets': ('Bricktracker sets', 'hashtag'),
     'bricktracker_wishes': ('Bricktracker wishes', 'gift-line'),
     'inventory': ('Parts', 'shapes-line'),
diff --git a/bricktracker/version.py b/bricktracker/version.py
index 172ecf1d..11dd9c9a 100644
--- a/bricktracker/version.py
+++ b/bricktracker/version.py
@@ -1,4 +1,4 @@
 from typing import Final
 
 __version__: Final[str] = '1.2.0'
-__database_version__: Final[int] = 11
+__database_version__: Final[int] = 12
diff --git a/bricktracker/views/admin/admin.py b/bricktracker/views/admin/admin.py
index d5586eab..a7f8ce70 100644
--- a/bricktracker/views/admin/admin.py
+++ b/bricktracker/views/admin/admin.py
@@ -8,8 +8,8 @@ from ..exceptions import exception_handler
 from ...instructions_list import BrickInstructionsList
 from ...rebrickable_image import RebrickableImage
 from ...retired_list import BrickRetiredList
-from ...set_checkbox import BrickSetCheckbox
-from ...set_checkbox_list import BrickSetCheckboxList
+from ...set_status import BrickSetStatus
+from ...set_status_list import BrickSetStatusList
 from ...sql_counter import BrickCounter
 from ...sql import BrickSQL
 from ...theme_list import BrickThemeList
@@ -24,11 +24,11 @@ admin_page = Blueprint('admin', __name__, url_prefix='/admin')
 @login_required
 @exception_handler(__file__)
 def admin() -> str:
-    brickset_checkboxes: list[BrickSetCheckbox] = []
     database_counters: list[BrickCounter] = []
     database_exception: Exception | None = None
     database_upgrade_needed: bool = False
     database_version: int = -1
+    metadata_statuses: list[BrickSetStatus] = []
     nil_minifigure_name: str = ''
     nil_minifigure_url: str = ''
     nil_part_name: str = ''
@@ -41,7 +41,7 @@ def admin() -> str:
         database_version = database.version
         database_counters = BrickSQL().count_records()
 
-        brickset_checkboxes = BrickSetCheckboxList().list(all=True)
+        metadata_statuses = BrickSetStatusList().list(all=True)
     except Exception as e:
         database_exception = e
 
@@ -62,38 +62,38 @@ def admin() -> str:
         'PARTS_FOLDER'
     )
 
-    open_checkbox = request.args.get('open_checkbox', None)
     open_image = request.args.get('open_image', None)
     open_instructions = request.args.get('open_instructions', None)
     open_logout = request.args.get('open_logout', None)
     open_retired = request.args.get('open_retired', None)
+    open_status = request.args.get('open_status', None)
     open_theme = request.args.get('open_theme', None)
 
     open_database = (
-        open_checkbox is None and
         open_image is None and
         open_instructions is None and
         open_logout is None and
         open_retired is None and
+        open_status is None and
         open_theme is None
     )
 
     return render_template(
         'admin.html',
         configuration=BrickConfigurationList.list(),
-        brickset_checkboxes=brickset_checkboxes,
-        checkbox_error=request.args.get('checkbox_error'),
+        status_error=request.args.get('status_error'),
         database_counters=database_counters,
         database_error=request.args.get('database_error'),
         database_exception=database_exception,
         database_upgrade_needed=database_upgrade_needed,
         database_version=database_version,
         instructions=BrickInstructionsList(),
+        metadata_statuses=metadata_statuses,
         nil_minifigure_name=nil_minifigure_name,
         nil_minifigure_url=nil_minifigure_url,
         nil_part_name=nil_part_name,
         nil_part_url=nil_part_url,
-        open_checkbox=open_checkbox,
+        open_status=open_status,
         open_database=open_database,
         open_image=open_image,
         open_instructions=open_instructions,
diff --git a/bricktracker/views/admin/checkbox.py b/bricktracker/views/admin/checkbox.py
deleted file mode 100644
index 6db6c5df..00000000
--- a/bricktracker/views/admin/checkbox.py
+++ /dev/null
@@ -1,98 +0,0 @@
-from flask import (
-    Blueprint,
-    jsonify,
-    redirect,
-    request,
-    render_template,
-    url_for,
-)
-from flask_login import login_required
-from werkzeug.wrappers.response import Response
-
-from ..exceptions import exception_handler
-from ...reload import reload
-from ...set_checkbox import BrickSetCheckbox
-
-admin_checkbox_page = Blueprint(
-    'admin_checkbox',
-    __name__,
-    url_prefix='/admin/checkbox'
-)
-
-
-# Add a checkbox
-@admin_checkbox_page.route('/add', methods=['POST'])
-@login_required
-@exception_handler(
-    __file__,
-    post_redirect='admin.admin',
-    error_name='checkbox_error',
-    open_checkbox=True
-)
-def add() -> Response:
-    BrickSetCheckbox().from_form(request.form).insert()
-
-    reload()
-
-    return redirect(url_for('admin.admin', open_checkbox=True))
-
-
-# Delete the checkbox
-@admin_checkbox_page.route('<id>/delete', methods=['GET'])
-@login_required
-@exception_handler(__file__)
-def delete(*, id: str) -> str:
-    return render_template(
-        'admin.html',
-        delete_checkbox=True,
-        checkbox=BrickSetCheckbox().select_specific(id),
-        error=request.args.get('checkbox_error')
-    )
-
-
-# Actually delete the checkbox
-@admin_checkbox_page.route('<id>/delete', methods=['POST'])
-@login_required
-@exception_handler(
-    __file__,
-    post_redirect='admin_checkbox.delete',
-    error_name='checkbox_error'
-)
-def do_delete(*, id: str) -> Response:
-    checkbox = BrickSetCheckbox().select_specific(id)
-    checkbox.delete()
-
-    reload()
-
-    return redirect(url_for('admin.admin', open_checkbox=True))
-
-
-# Change the field of a checkbox
-@admin_checkbox_page.route('/<id>/field/<name>', methods=['POST'])
-@login_required
-@exception_handler(__file__, json=True)
-def update_field(*, id: str, name: str) -> Response:
-    checkbox = BrickSetCheckbox().select_specific(id)
-    value = checkbox.update_field(name, json=request.json)
-
-    reload()
-
-    return jsonify({'value': value})
-
-
-# Rename the checkbox
-@admin_checkbox_page.route('<id>/rename', methods=['POST'])
-@login_required
-@exception_handler(
-    __file__,
-    post_redirect='admin.admin',
-    error_name='checkbox_error',
-    open_checkbox=True
-)
-def rename(*, id: str) -> Response:
-    checkbox = BrickSetCheckbox().select_specific(id)
-    checkbox.from_form(request.form).rename()
-
-    reload()
-
-    return redirect(url_for('admin.admin', open_checkbox=True))
diff --git a/bricktracker/views/admin/status.py b/bricktracker/views/admin/status.py
new file mode 100644
index 00000000..8178d693
--- /dev/null
+++ b/bricktracker/views/admin/status.py
@@ -0,0 +1,98 @@
+from flask import (
+    Blueprint,
+    jsonify,
+    redirect,
+    request,
+    render_template,
+    url_for,
+)
+from flask_login import login_required
+from werkzeug.wrappers.response import Response
+
+from ..exceptions import exception_handler
+from ...reload import reload
+from ...set_status import BrickSetStatus
+
+admin_status_page = Blueprint(
+    'admin_status',
+    __name__,
+    url_prefix='/admin/status'
+)
+
+
+# Add a metadata status
+@admin_status_page.route('/add', methods=['POST'])
+@login_required
+@exception_handler(
+    __file__,
+    post_redirect='admin.admin',
+    error_name='status_error',
+    open_status=True
+)
+def add() -> Response:
+    BrickSetStatus().from_form(request.form).insert()
+
+    reload()
+
+    return redirect(url_for('admin.admin', open_status=True))
+
+
+# Delete the metadata status
+@admin_status_page.route('<id>/delete', methods=['GET'])
+@login_required
+@exception_handler(__file__)
+def delete(*, id: str) -> str:
+    return render_template(
+        'admin.html',
+        delete_status=True,
+        status=BrickSetStatus().select_specific(id),
+        error=request.args.get('status_error')
+    )
+
+
+# Actually delete the metadata status
+@admin_status_page.route('<id>/delete', methods=['POST'])
+@login_required
+@exception_handler(
+    __file__,
+    post_redirect='admin_status.delete',
+    error_name='status_error'
+)
+def do_delete(*, id: str) -> Response:
+    status = BrickSetStatus().select_specific(id)
+    status.delete()
+
+    reload()
+
+    return redirect(url_for('admin.admin', open_status=True))
+
+
+# Change the field of a metadata status
+@admin_status_page.route('/<id>/field/<name>', methods=['POST'])
+@login_required
+@exception_handler(__file__, json=True)
+def update_field(*, id: str, name: str) -> Response:
+    status = BrickSetStatus().select_specific(id)
+    value = status.update_field(name, json=request.json)
+
+    reload()
+
+    return jsonify({'value': value})
+
+
+# Rename the metadata status
+@admin_status_page.route('<id>/rename', methods=['POST'])
+@login_required
+@exception_handler(
+    __file__,
+    post_redirect='admin.admin',
+    error_name='status_error',
+    open_status=True
+)
+def rename(*, id: str) -> Response:
+    status = BrickSetStatus().select_specific(id)
+    status.from_form(request.form).rename()
+
+    reload()
+
+    return redirect(url_for('admin.admin', open_status=True))
diff --git a/bricktracker/views/index.py b/bricktracker/views/index.py
index c1f08118..3e3a8802 100644
--- a/bricktracker/views/index.py
+++ b/bricktracker/views/index.py
@@ -2,7 +2,7 @@ from flask import Blueprint, render_template
 
 from .exceptions import exception_handler
 from ..minifigure_list import BrickMinifigureList
-from ..set_checkbox_list import BrickSetCheckboxList
+from ..set_status_list import BrickSetStatusList
 from ..set_list import BrickSetList
 
 index_page = Blueprint('index', __name__)
@@ -16,5 +16,5 @@ def index() -> str:
         'index.html',
         brickset_collection=BrickSetList().last(),
         minifigure_collection=BrickMinifigureList().last(),
-        brickset_checkboxes=BrickSetCheckboxList().list(),
+        brickset_statuses=BrickSetStatusList().list(),
     )
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index bb3c2348..17fcff98 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -16,7 +16,7 @@ from .exceptions import exception_handler
 from ..minifigure import BrickMinifigure
 from ..part import BrickPart
 from ..set import BrickSet
-from ..set_checkbox_list import BrickSetCheckboxList
+from ..set_status_list import BrickSetStatusList
 from ..set_list import BrickSetList
 from ..socket import MESSAGES
 
@@ -32,19 +32,19 @@ def list() -> str:
     return render_template(
         'sets.html',
         collection=BrickSetList().all(),
-        brickset_checkboxes=BrickSetCheckboxList().list(),
+        brickset_statuses=BrickSetStatusList().list(),
     )
 
 
-# Change the status of a checkbox
+# Change the status of a status
 @set_page.route('/<id>/status/<metadata_id>', methods=['POST'])
 @login_required
 @exception_handler(__file__, json=True)
 def update_status(*, id: str, metadata_id: str) -> Response:
     brickset = BrickSet().select_light(id)
-    checkbox = BrickSetCheckboxList().get(metadata_id)
+    status = BrickSetStatusList().get(metadata_id)
 
-    state = checkbox.update_set_state(brickset, request.json)
+    state = status.update_set_state(brickset, request.json)
 
     return jsonify({'value': state})
 
@@ -97,7 +97,7 @@ def details(*, id: str) -> str:
         'set.html',
         item=BrickSet().select_specific(id),
         open_instructions=request.args.get('open_instructions'),
-        brickset_checkboxes=BrickSetCheckboxList().list(all=True),
+        brickset_statuses=BrickSetStatusList().list(all=True),
     )
 
 
diff --git a/docs/DOCS.md b/docs/DOCS.md
index 28deffec..f13165d2 100644
--- a/docs/DOCS.md
+++ b/docs/DOCS.md
@@ -15,7 +15,7 @@ This page helps you navigate the documentation of BrickTracker.
 
 - [First steps](first-steps.md)
 - [Managing your sets](set.md)
-- [Managing your set checkboxes](checkbox.md)
+- [Managing your set statuses](set-statuses.md)
 
 ## Specific procedures
 
diff --git a/docs/checkbox.md b/docs/checkbox.md
deleted file mode 100644
index 3071e67f..00000000
--- a/docs/checkbox.md
+++ /dev/null
@@ -1,58 +0,0 @@
-# Manage your set chechboxes
-
-> **Note**
-> The following page is based on version `1.1.0` of BrickTracker.
-
-They are useful to store "yes/no" info about a set and quickly set it. Once clicked the change is immediatly stored in the database. A visual indicator tells you the change was succesful.
-
-![](images/checkbox-01.png)
-
-## Default checkboxes
-
-The original version of BrickTracker defined the following checkboxes
-
-- Minifigures are collected
-- Set is checked
-- Set is collected and boxed
-
-## Visibility
-
-The checkboxes are **never visible** on the front page. The display here tries to be as minimalistic as possible.
-
-Prior to version `1.1.0`, the checkboxes were visible both on the Grid view (**Sets**) and the details of a set.
-
-![](images/checkbox-02.png)
-![](images/checkbox-03.png)
-
-From version `1.1.0`, it is possible to decide if a checkbox is visible from the Grid or not. It will always be visible in a set details.
-
-### Change the visibility of a checkbox
-
-To change the visibility of a checkbox, head to the **Admin page** and open the **Checkboxes** section.
-
-![](images/checkbox-04.png)
-
-Simply click on the **Displayed on the Set Grid** checkbox to select whether it is displayed or not. The change is immediately saved to the database.
-
-![](images/checkbox-05.png)
-
-In this example, we have decided to have no checkbox visible on the Grid view.
-
-![](images/checkbox-06.png)
-
-## Management
-
-Starting version `1.1.0`, you can manage the checkboxes for the **Checkboxes** section of the **Admin page**.
-
-![](images/checkbox-04.png)
-
-From there you can do the following:
-
-- Add a new checkbox: use the last line of the list and press the **Add** button
-- Rename an existing checkbox: use the **Name** field to change the name and press the **Rename** button
-- Change the Grid display of an existing checkbox: tick or untick the **Displayed on the Set Grid** checkbox
-- Delete an existing checkbox: use the **Delete** button and confirm on the following screen
-
-It is possible to delete all the checkboxes, they are an optional component of a set.
-
-![](images/checkbox-07.png)
diff --git a/docs/images/checkbox-01.png b/docs/images/status-01.png
similarity index 100%
rename from docs/images/checkbox-01.png
rename to docs/images/status-01.png
diff --git a/docs/images/checkbox-02.png b/docs/images/status-02.png
similarity index 100%
rename from docs/images/checkbox-02.png
rename to docs/images/status-02.png
diff --git a/docs/images/checkbox-03.png b/docs/images/status-03.png
similarity index 100%
rename from docs/images/checkbox-03.png
rename to docs/images/status-03.png
diff --git a/docs/images/checkbox-04.png b/docs/images/status-04.png
similarity index 100%
rename from docs/images/checkbox-04.png
rename to docs/images/status-04.png
diff --git a/docs/images/checkbox-05.png b/docs/images/status-05.png
similarity index 100%
rename from docs/images/checkbox-05.png
rename to docs/images/status-05.png
diff --git a/docs/images/checkbox-06.png b/docs/images/status-06.png
similarity index 100%
rename from docs/images/checkbox-06.png
rename to docs/images/status-06.png
diff --git a/docs/images/checkbox-07.png b/docs/images/status-07.png
similarity index 100%
rename from docs/images/checkbox-07.png
rename to docs/images/status-07.png
diff --git a/docs/set-statuses.md b/docs/set-statuses.md
new file mode 100644
index 00000000..8739b41e
--- /dev/null
+++ b/docs/set-statuses.md
@@ -0,0 +1,67 @@
+# Manage your set statuses
+
+> **Note**
+> The following page is based on version `1.1.0` of BrickTracker.
+
+> **Note**
+> On version `1.2.0`, this feature has been renommed from `Checkboxes` to `Set statuses`. It works exactly the same.
+
+They are useful to store "yes/no" info about a set and quickly set it. Once clicked the change is immediatly stored in the database. A visual indicator tells you the change was succesful.
+
+![](images/status-01.png)
+
+## Default statuses
+
+The original version of BrickTracker defined the following statuses
+
+- Minifigures are collected
+- Set is checked
+- Set is collected and boxed
+
+## Visibility
+
+The statuses are **never visible** on the front page. The display here tries to be as minimalistic as possible.
+
+Prior to version `1.1.0`, the statuses were visible both on the Grid view (**Sets**) and the details of a set.
+
+![](images/status-02.png)
+![](images/status-03.png)
+
+From version `1.1.0`, it is possible to decide if a status is visible from the Grid or not. It will always be visible in a set details.
+
+### Change the visibility of a status
+
+> **Note**
+> On version `1.2.0`, the Admin page section has been renamed from `Checkboxes` to `Set statuses`. It works exactly the same.
+
+To change the visibility of a status, head to the **Admin page** and open the **Set statuses** section.
+
+![](images/status-04.png)
+
+Simply click on the **Displayed on the Set Grid** status to select whether it is displayed or not. The change is immediately saved to the database.
+
+![](images/status-05.png)
+
+In this example, we have decided to have no status visible on the Grid view.
+
+![](images/status-06.png)
+
+## Management
+
+> **Note**
+> On version `1.2.0`, the Admin page section has been renamed from `Checkboxes` to `Set statuses`. It works exactly the same.
+
+Starting version `1.1.0`, you can manage the set statuses for the **Set statuses** section of the **Admin page**.
+
+![](images/status-04.png)
+
+From there you can do the following:
+
+- Add a new set status: use the last line of the list and press the **Add** button
+- Rename an existing set status: use the **Name** field to change the name and press the **Rename** button
+- Change the Grid display of an existing status: tick or untick the **Displayed on the Set Grid** checkbox
+- Delete an existing set status: use the **Delete** button and confirm on the following screen
+
+It is possible to delete all the set statuses, they are an optional component of a set.
+
+![](images/status-07.png)
diff --git a/templates/admin.html b/templates/admin.html
index dc872566..b2a54f3a 100644
--- a/templates/admin.html
+++ b/templates/admin.html
@@ -12,8 +12,8 @@
             <h5 class="mb-0"><i class="ri-settings-4-line"></i> Administration</h5>
           </div>
           <div class="accordion accordion-flush" id="admin">
-            {% if delete_checkbox %}
-            {% include 'admin/checkbox/delete.html' %}
+            {% if delete_status %}
+            {% include 'admin/status/delete.html' %}
             {% elif delete_database %}
             {% include 'admin/database/delete.html' %}
             {% elif drop_database %}
@@ -30,7 +30,7 @@
               {% endif %}
               {% include 'admin/theme.html' %}
               {% include 'admin/retired.html' %}
-              {% include 'admin/checkbox.html' %}
+              {% include 'admin/status.html' %}
               {% include 'admin/database.html' %}
               {% include 'admin/configuration.html' %}
             {% endif %}
diff --git a/templates/admin/checkbox.html b/templates/admin/status.html
similarity index 54%
rename from templates/admin/checkbox.html
rename to templates/admin/status.html
index 22a3215b..a73462ad 100644
--- a/templates/admin/checkbox.html
+++ b/templates/admin/status.html
@@ -1,42 +1,42 @@
 {% import 'macro/accordion.html' as accordion %}
 
-{{ accordion.header('Checkboxes', 'checkbox', 'admin', expanded=open_checkbox, icon='checkbox-line', class='p-0') }}
-{% if checkbox_error %}<div class="alert alert-danger m-2" role="alert"><strong>Error:</strong> {{ checkbox_error }}.</div>{% endif %}
+{{ accordion.header('Set statuses', 'status', 'admin', expanded=open_status, icon='checkbox-line', class='p-0') }}
+{% if status_error %}<div class="alert alert-danger m-2" role="alert"><strong>Error:</strong> {{ status_error }}.</div>{% endif %}
 <ul class="list-group list-group-flush">
-  {% if brickset_checkboxes | length %}
-    {% for checkbox in brickset_checkboxes %}
+  {% if metadata_statuses | length %}
+    {% for status in metadata_statuses %}
       <li class="list-group-item">
-        <form action="{{ url_for('admin_checkbox.rename', id=checkbox.fields.id) }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
+        <form action="{{ url_for('admin_status.rename', id=status.fields.id) }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
           <div class="col-12 flex-grow-1">
-            <label class="visually-hidden" for="name-{{ checkbox.fields.id }}">Name</label>
+            <label class="visually-hidden" for="name-{{ status.fields.id }}">Name</label>
             <div class="input-group">
               <div class="input-group-text">Name</div>
-              <input type="text" class="form-control" id="name-{{ checkbox.fields.id }}" name="name" value="{{ checkbox.fields.name }}">
+              <input type="text" class="form-control" id="name-{{ status.fields.id }}" name="name" value="{{ status.fields.name }}">
               <button type="submit" class="btn btn-primary"><i class="ri-edit-line"></i> Rename</button>
             </div>
           </div>
           <div class="col-12">
             <div class="form-check">
-              <input class="form-check-input" type="checkbox" id="grid-{{ checkbox.fields.id }}"
-                data-changer-id="{{ checkbox.fields.id }}" data-changer-prefix="grid" data-changer-url="{{ url_for('admin_checkbox.update_field', id=checkbox.fields.id, name='displayed_on_grid')}}"
-                {% if checkbox.fields.displayed_on_grid %}checked{% endif %} autocomplete="off">
-              <label class="form-check-label" for="grid-{{ checkbox.fields.id }}">
+              <input class="form-check-input" type="checkbox" id="grid-{{ status.fields.id }}"
+                data-changer-id="{{ status.fields.id }}" data-changer-prefix="grid" data-changer-url="{{ url_for('admin_status.update_field', id=status.fields.id, name='displayed_on_grid')}}"
+                {% if status.fields.displayed_on_grid %}checked{% endif %} autocomplete="off">
+              <label class="form-check-label" for="grid-{{ status.fields.id }}">
                 <i class="ri-grid-line"></i> Displayed on the Set Grid
-                <i id="status-grid-{{ checkbox.fields.id }}" class="mb-1"></i>
+                <i id="status-grid-{{ status.fields.id }}" class="mb-1"></i>
               </label>
             </div>
           </div>
           <div class="col-12">
-            <a href="{{ url_for('admin_checkbox.delete', id=checkbox.fields.id) }}" class="btn btn-danger" role="button"><i class="ri-delete-bin-2-line"></i> Delete</a>
+            <a href="{{ url_for('admin_status.delete', id=status.fields.id) }}" class="btn btn-danger" role="button"><i class="ri-delete-bin-2-line"></i> Delete</a>
           </div>
         </form>
       </li>
     {% endfor %}
   {% else %}
-    <li class="list-group-item"><i class="ri-error-warning-line"></i> No checkbox found.</li>
+    <li class="list-group-item"><i class="ri-error-warning-line"></i> No status found.</li>
   {% endif %}
   <li class="list-group-item">
-    <form action="{{ url_for('admin_checkbox.add') }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
+    <form action="{{ url_for('admin_status.add') }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
       <div class="col-12 flex-grow-1">
         <label class="visually-hidden" for="name">Name</label>
         <div class="input-group">
diff --git a/templates/admin/checkbox/delete.html b/templates/admin/status/delete.html
similarity index 53%
rename from templates/admin/checkbox/delete.html
rename to templates/admin/status/delete.html
index 49d507ad..31998c4b 100644
--- a/templates/admin/checkbox/delete.html
+++ b/templates/admin/status/delete.html
@@ -1,25 +1,25 @@
 {% import 'macro/accordion.html' as accordion %}
 
-{{ accordion.header('Checkbox danger zone', 'checkbox-danger', 'admin', expanded=true, danger=true, class='text-end') }}
-<form action="{{ url_for('admin_checkbox.do_delete', id=checkbox.fields.id) }}" method="post">
+{{ accordion.header('Set statuses danger zone', 'status-danger', 'admin', expanded=true, danger=true, class='text-end') }}
+<form action="{{ url_for('admin_status.do_delete', id=status.fields.id) }}" method="post">
   {% if error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ error }}.</div>{% endif %}
-  <div class="alert alert-danger text-center" role="alert">You are about to <strong>delete a checkbox</strong>. This action is irreversible.</div>
+  <div class="alert alert-danger text-center" role="alert">You are about to <strong>delete a set status</strong>. This action is irreversible.</div>
   <div class="row row-cols-lg-auto g-3 align-items-center">
     <div class="col-12 flex-grow-1">
       <div class="input-group">
         <div class="input-group-text">Name</div>
-        <input type="text" class="form-control" value="{{ checkbox.fields.name }}" disabled>
+        <input type="text" class="form-control" value="{{ status.fields.name }}" disabled>
       </div>
     </div>
     <div class="col-12">
       <div class="form-check">
-        <input class="form-check-input" type="checkbox" {% if checkbox.fields.displayed_on_grid %}checked{% endif %} disabled>
+        <input class="form-check-input" type="checkbox" {% if status.fields.displayed_on_grid %}checked{% endif %} disabled>
         <span class="form-check-label">Displayed on the Set Grid</span>
       </div>
     </div>
   </div>
   <hr class="border-bottom">
-  <a class="btn btn-danger" href="{{ url_for('admin.admin', open_checkbox=true) }}" role="button"><i class="ri-arrow-left-long-line"></i> Back to the admin</a>
-  <button type="submit" class="btn btn-danger"><i class="ri-delete-bin-2-line"></i> Delete <strong>the checkbox</strong></button>
+  <a class="btn btn-danger" href="{{ url_for('admin.admin', open_status=true) }}" role="button"><i class="ri-arrow-left-long-line"></i> Back to the admin</a>
+  <button type="submit" class="btn btn-danger"><i class="ri-delete-bin-2-line"></i> Delete <strong>the set status</strong></button>
 </form>
 {{ accordion.footer() }}
diff --git a/templates/set/card.html b/templates/set/card.html
index 6a658c98..fea4fc04 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -8,7 +8,7 @@
     data-index="{{ index }}" data-number="{{ item.fields.set }}" data-name="{{ item.fields.name | lower }}" data-parts="{{ item.fields.number_of_parts }}"
     data-year="{{ item.fields.year }}" data-theme="{{ item.theme.name | lower }}" data-minifigures="{{ item.fields.total_minifigures }}" data-has-minifigures="{{ (item.fields.total_minifigures > 0) | int }}"
     data-has-missing="{{ (item.fields.total_missing > 0) | int }}" data-has-missing-instructions="{{ (not (item.instructions | length)) | int }}" data-missing="{{ item.fields.total_missing }}"
-    {% for checkbox in brickset_checkboxes %}data-{{ checkbox.as_dataset() }}="{{ item.fields[checkbox.as_column()] }}" {% endfor %}
+    {% for status in brickset_statuses %}data-{{ status.as_dataset() }}="{{ item.fields[status.as_column()] }}" {% endfor %}
   {% endif %}
 >
   {{ card.header(item, item.fields.name, solo=solo, identifier=item.fields.set) }}
@@ -26,11 +26,11 @@
       {{ badge.rebrickable(item, solo=solo, last=last) }}
     {% endif %}
   </div>
-  {% if not tiny and brickset_checkboxes | length %}
+  {% if not tiny and brickset_statuses | length %}
     <ul class="list-group list-group-flush card-check border-bottom-0">
-      {% for checkbox in brickset_checkboxes %}
+      {% for status in brickset_statuses %}
         <li class="list-group-item {% if not solo %}p-1{% endif %}">
-          {{ form.checkbox(checkbox.as_dataset(), item.fields.id, checkbox.fields.name, checkbox.url_for_set_state(item.fields.id), item.fields[checkbox.as_column()], delete=delete) }}
+          {{ form.checkbox(status.as_dataset(), item.fields.id, status.fields.name, status.url_for_set_state(item.fields.id), item.fields[status.as_column()], delete=delete) }}
         </li>
       {% endfor %}
     </ul>
diff --git a/templates/sets.html b/templates/sets.html
index c62d3f3b..c43925fa 100644
--- a/templates/sets.html
+++ b/templates/sets.html
@@ -22,9 +22,9 @@
           <option value="-has-missing">Set is complete</option>
           <option value="has-missing">Set has missing pieces</option>
           <option value="has-missing-instructions">Set has missing instructions</option>
-          {% for checkbox in brickset_checkboxes %}
-            <option value="{{ checkbox.as_dataset() }}">{{ checkbox.fields.name }}</option>
-            <option value="-{{ checkbox.as_dataset() }}">NOT: {{ checkbox.fields.name }}</option>
+          {% for status in brickset_statuses %}
+            <option value="{{ status.as_dataset() }}">{{ status.fields.name }}</option>
+            <option value="-{{ status.as_dataset() }}">NOT: {{ status.fields.name }}</option>
           {% endfor %}
         </select>
         <select id="grid-theme" class="form-select form-select-sm" autocomplete="off">

From 344d4fb5753d1fa8220168050b6b00a494653aa8 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Thu, 30 Jan 2025 16:23:47 +0100
Subject: [PATCH 059/154] Metadata list

---
 bricktracker/metadata_list.py     | 77 +++++++++++++++++++++++++++++++
 bricktracker/reload.py            |  3 +-
 bricktracker/set.py               |  3 +-
 bricktracker/set_list.py          |  5 +-
 bricktracker/set_status_list.py   | 64 ++++---------------------
 bricktracker/views/admin/admin.py |  2 +-
 bricktracker/views/index.py       |  3 +-
 bricktracker/views/set.py         |  7 +--
 8 files changed, 99 insertions(+), 65 deletions(-)
 create mode 100644 bricktracker/metadata_list.py

diff --git a/bricktracker/metadata_list.py b/bricktracker/metadata_list.py
new file mode 100644
index 00000000..80bb3df0
--- /dev/null
+++ b/bricktracker/metadata_list.py
@@ -0,0 +1,77 @@
+import logging
+from typing import Type
+
+from .exceptions import NotFoundException
+from .fields import BrickRecordFields
+from .record_list import BrickRecordList
+from .set_status import BrickSetStatus
+
+logger = logging.getLogger(__name__)
+
+T = BrickSetStatus
+
+
+# Lego sets metadata list
+class BrickMetadataList(BrickRecordList[T]):
+    kind: str
+    mapping: dict[str, T]
+    model: Type[T]
+
+    # Database table
+    table: str
+
+    # Queries
+    select_query: str
+
+    def __init__(self, model: Type[T], /, *, force: bool = False):
+        # Load statuses only if there is none already loaded
+        records = getattr(self, 'records', None)
+
+        if records is None or force:
+            # Don't use super()__init__ as it would mask class variables
+            self.fields = BrickRecordFields()
+
+            logger.info('Loading {kind} list'.format(
+                kind=self.kind
+            ))
+
+            self.__class__.records = []
+            self.__class__.mapping = {}
+
+            # Load the statuses from the database
+            for record in self.select():
+                status = model(record=record)
+
+                self.__class__.records.append(status)
+                self.__class__.mapping[status.fields.id] = status
+
+    # Return the items as columns for a select
+    def as_columns(self, /, **kwargs) -> str:
+        return ', '.join([
+            '"{table}"."{column}"'.format(
+                table=self.table,
+                column=record.as_column(),
+            )
+            for record
+            in self.filter(**kwargs)
+        ])
+
+    # Filter the list of records (this one does nothing)
+    def filter(self) -> list[T]:
+        return self.records
+
+    # Grab a specific status
+    def get(self, id: str, /) -> T:
+        if id not in self.mapping:
+            raise NotFoundException(
+                '{kind} with ID {id} was not found in the database'.format(
+                    kind=self.kind.capitalize(),
+                    id=id,
+                ),
+            )
+
+        return self.mapping[id]
+
+    # Get the list of statuses depending on the context
+    def list(self, /, **kwargs) -> list[T]:
+        return self.filter(**kwargs)
diff --git a/bricktracker/reload.py b/bricktracker/reload.py
index 28de7bff..259cffad 100644
--- a/bricktracker/reload.py
+++ b/bricktracker/reload.py
@@ -1,5 +1,6 @@
 from .instructions_list import BrickInstructionsList
 from .retired_list import BrickRetiredList
+from .set_status import BrickSetStatus
 from .set_status_list import BrickSetStatusList
 from .theme_list import BrickThemeList
 
@@ -12,7 +13,7 @@ def reload() -> None:
         BrickInstructionsList(force=True)
 
         # Reload the set statuses
-        BrickSetStatusList(force=True)
+        BrickSetStatusList(BrickSetStatus, force=True)
 
         # Reload retired sets
         BrickRetiredList(force=True)
diff --git a/bricktracker/set.py b/bricktracker/set.py
index 1b11a943..32bf8daa 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -9,6 +9,7 @@ from .exceptions import NotFoundException
 from .minifigure_list import BrickMinifigureList
 from .part_list import BrickPartList
 from .rebrickable_set import RebrickableSet
+from .set_status import BrickSetStatus
 from .set_status_list import BrickSetStatusList
 from .sql import BrickSQL
 if TYPE_CHECKING:
@@ -161,7 +162,7 @@ class BrickSet(RebrickableSet):
 
         # Load from database
         if not self.select(
-            statuses=BrickSetStatusList().as_columns(solo=True)
+            statuses=BrickSetStatusList(BrickSetStatus).as_columns(all=True)
         ):
             raise NotFoundException(
                 'Set with ID {id} was not found in the database'.format(
diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py
index 47d31b21..251cfdcd 100644
--- a/bricktracker/set_list.py
+++ b/bricktracker/set_list.py
@@ -3,6 +3,7 @@ from typing import Self
 from flask import current_app
 
 from .record_list import BrickRecordList
+from .set_status import BrickSetStatus
 from .set_status_list import BrickSetStatusList
 from .set import BrickSet
 
@@ -37,7 +38,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
         # Load the sets from the database
         for record in self.select(
             order=self.order,
-            statuses=BrickSetStatusList().as_columns()
+            statuses=BrickSetStatusList(BrickSetStatus).as_columns()
         ):
             brickset = BrickSet(record=record)
 
@@ -73,7 +74,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
         for record in self.select(
             order=order,
             limit=limit,
-            statuses=BrickSetStatusList().as_columns()
+            statuses=BrickSetStatusList(BrickSetStatus).as_columns()
         ):
             brickset = BrickSet(record=record)
 
diff --git a/bricktracker/set_status_list.py b/bricktracker/set_status_list.py
index 12ce85a1..b96f2139 100644
--- a/bricktracker/set_status_list.py
+++ b/bricktracker/set_status_list.py
@@ -1,71 +1,23 @@
 import logging
 
-from .exceptions import NotFoundException
-from .fields import BrickRecordFields
-from .record_list import BrickRecordList
+from .metadata_list import BrickMetadataList
 from .set_status import BrickSetStatus
 
 logger = logging.getLogger(__name__)
 
 
 # Lego sets status list
-class BrickSetStatusList(BrickRecordList[BrickSetStatus]):
-    statuses: dict[str, BrickSetStatus]
+class BrickSetStatusList(BrickMetadataList):
+    kind: str = 'set statuses'
+
+    # Database table
+    table: str = 'bricktracker_set_statuses'
 
     # Queries
     select_query = 'set/metadata/status/list'
 
-    def __init__(self, /, *, force: bool = False):
-        # Load statuses only if there is none already loaded
-        records = getattr(self, 'records', None)
-
-        if records is None or force:
-            # Don't use super()__init__ as it would mask class variables
-            self.fields = BrickRecordFields()
-
-            logger.info('Loading set statuses list')
-
-            BrickSetStatusList.records = []
-            BrickSetStatusList.statuses = {}
-
-            # Load the statuses from the database
-            for record in self.select():
-                status = BrickSetStatus(record=record)
-
-                BrickSetStatusList.records.append(status)
-                BrickSetStatusList.statuses[status.fields.id] = status
-
-    # Return the statuses as columns for a select
-    def as_columns(
-        self,
-        /,
-        *,
-        solo: bool = False,
-        table: str = 'bricktracker_set_statuses'
-    ) -> str:
-        return ', '.join([
-            '"{table}"."{column}"'.format(
-                table=table,
-                column=record.as_column(),
-            )
-            for record
-            in self.records
-            if solo or record.fields.displayed_on_grid
-        ])
-
-    # Grab a specific status
-    def get(self, id: str, /) -> BrickSetStatus:
-        if id not in self.statuses:
-            raise NotFoundException(
-                'Status with ID {id} was not found in the database'.format(
-                    id=id,
-                ),
-            )
-
-        return self.statuses[id]
-
-    # Get the list of statuses depending on the context
-    def list(self, /, *, all: bool = False) -> list[BrickSetStatus]:
+    # Filter the list of set status
+    def filter(self, all: bool = False) -> list[BrickSetStatus]:
         return [
             record
             for record
diff --git a/bricktracker/views/admin/admin.py b/bricktracker/views/admin/admin.py
index a7f8ce70..c18a74b2 100644
--- a/bricktracker/views/admin/admin.py
+++ b/bricktracker/views/admin/admin.py
@@ -41,7 +41,7 @@ def admin() -> str:
         database_version = database.version
         database_counters = BrickSQL().count_records()
 
-        metadata_statuses = BrickSetStatusList().list(all=True)
+        metadata_statuses = BrickSetStatusList(BrickSetStatus).list(all=True)
     except Exception as e:
         database_exception = e
 
diff --git a/bricktracker/views/index.py b/bricktracker/views/index.py
index 3e3a8802..f8fe7b71 100644
--- a/bricktracker/views/index.py
+++ b/bricktracker/views/index.py
@@ -2,6 +2,7 @@ from flask import Blueprint, render_template
 
 from .exceptions import exception_handler
 from ..minifigure_list import BrickMinifigureList
+from ..set_status import BrickSetStatus
 from ..set_status_list import BrickSetStatusList
 from ..set_list import BrickSetList
 
@@ -16,5 +17,5 @@ def index() -> str:
         'index.html',
         brickset_collection=BrickSetList().last(),
         minifigure_collection=BrickMinifigureList().last(),
-        brickset_statuses=BrickSetStatusList().list(),
+        brickset_statuses=BrickSetStatusList(BrickSetStatus).list(),
     )
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index 17fcff98..9f1990cc 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -16,6 +16,7 @@ from .exceptions import exception_handler
 from ..minifigure import BrickMinifigure
 from ..part import BrickPart
 from ..set import BrickSet
+from ..set_status import BrickSetStatus
 from ..set_status_list import BrickSetStatusList
 from ..set_list import BrickSetList
 from ..socket import MESSAGES
@@ -32,7 +33,7 @@ def list() -> str:
     return render_template(
         'sets.html',
         collection=BrickSetList().all(),
-        brickset_statuses=BrickSetStatusList().list(),
+        brickset_statuses=BrickSetStatusList(BrickSetStatus).list(),
     )
 
 
@@ -42,7 +43,7 @@ def list() -> str:
 @exception_handler(__file__, json=True)
 def update_status(*, id: str, metadata_id: str) -> Response:
     brickset = BrickSet().select_light(id)
-    status = BrickSetStatusList().get(metadata_id)
+    status = BrickSetStatusList(BrickSetStatus).get(metadata_id)
 
     state = status.update_set_state(brickset, request.json)
 
@@ -97,7 +98,7 @@ def details(*, id: str) -> str:
         'set.html',
         item=BrickSet().select_specific(id),
         open_instructions=request.args.get('open_instructions'),
-        brickset_statuses=BrickSetStatusList().list(all=True),
+        brickset_statuses=BrickSetStatusList(BrickSetStatus).list(all=True),
     )
 
 

From fc3c92e9a33b0aea670b102e7375fc4fe970e980 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Thu, 30 Jan 2025 16:35:26 +0100
Subject: [PATCH 060/154] Remove metadata prefix, it's identical to kind

---
 bricktracker/metadata.py   | 6 ++++--
 bricktracker/set_status.py | 1 -
 2 files changed, 4 insertions(+), 3 deletions(-)

diff --git a/bricktracker/metadata.py b/bricktracker/metadata.py
index e21bfdd4..ac13c64a 100644
--- a/bricktracker/metadata.py
+++ b/bricktracker/metadata.py
@@ -17,7 +17,6 @@ logger = logging.getLogger(__name__)
 # Lego set metadata (customizable list of entries that can be checked)
 class BrickMetadata(BrickRecord):
     kind: str
-    prefix: str
 
     # Set state endpoint
     set_state_endpoint: str
@@ -43,7 +42,10 @@ class BrickMetadata(BrickRecord):
 
     # SQL column name
     def as_column(self, /) -> str:
-        return '{prefix}_{id}'.format(id=self.fields.id, prefix=self.prefix)
+        return '{kind}_{id}'.format(
+            id=self.fields.id,
+            kind=self.kind.lower()
+        )
 
     # HTML dataset name
     def as_dataset(self, /) -> str:
diff --git a/bricktracker/set_status.py b/bricktracker/set_status.py
index 3b874f52..0165c501 100644
--- a/bricktracker/set_status.py
+++ b/bricktracker/set_status.py
@@ -7,7 +7,6 @@ from .metadata import BrickMetadata
 # Lego set status metadata
 class BrickSetStatus(BrickMetadata):
     kind: str = 'status'
-    prefix: str = 'status'
 
     # Set state endpoint
     set_state_endpoint: str = 'set.update_status'

From d15d7ffb61e5ac5f908aa634720a95db5104e10a Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Thu, 30 Jan 2025 16:38:11 +0100
Subject: [PATCH 061/154] Move from_form function about name to the base
 metadata class

---
 bricktracker/metadata.py   | 11 +++++++++++
 bricktracker/set_status.py |  8 ++------
 2 files changed, 13 insertions(+), 6 deletions(-)

diff --git a/bricktracker/metadata.py b/bricktracker/metadata.py
index ac13c64a..c7a9678b 100644
--- a/bricktracker/metadata.py
+++ b/bricktracker/metadata.py
@@ -60,6 +60,17 @@ class BrickMetadata(BrickRecord):
             id=self.fields.id,
         )
 
+    # Grab data from a form
+    def from_form(self, form: dict[str, str], /) -> Self:
+        name = form.get('name', None)
+
+        if name is None or name == '':
+            raise ErrorException('Status name cannot be empty')
+
+        self.fields.name = name
+
+        return self
+
     # Insert into database
     def insert(self, /, **context) -> None:
         self.safe()
diff --git a/bricktracker/set_status.py b/bricktracker/set_status.py
index 0165c501..d114d651 100644
--- a/bricktracker/set_status.py
+++ b/bricktracker/set_status.py
@@ -1,6 +1,5 @@
 from typing import Self
 
-from .exceptions import ErrorException
 from .metadata import BrickMetadata
 
 
@@ -20,13 +19,10 @@ class BrickSetStatus(BrickMetadata):
 
     # Grab data from a form
     def from_form(self, form: dict[str, str], /) -> Self:
-        name = form.get('name', None)
+        super().from_form(form)
+
         grid = form.get('grid', None)
 
-        if name is None or name == '':
-            raise ErrorException('Status name cannot be empty')
-
-        self.fields.name = name
         self.fields.displayed_on_grid = grid == 'on'
 
         return self

From 637be0d272f2e124752bacf8be9751b03635bab9 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Thu, 30 Jan 2025 16:54:22 +0100
Subject: [PATCH 062/154] Fix admin status error

---
 bricktracker/views/admin/status.py | 2 +-
 templates/admin/status/delete.html | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/bricktracker/views/admin/status.py b/bricktracker/views/admin/status.py
index 8178d693..49037f3d 100644
--- a/bricktracker/views/admin/status.py
+++ b/bricktracker/views/admin/status.py
@@ -46,7 +46,7 @@ def delete(*, id: str) -> str:
         'admin.html',
         delete_status=True,
         status=BrickSetStatus().select_specific(id),
-        error=request.args.get('status_error')
+        status_error=request.args.get('status_error')
     )
 
 
diff --git a/templates/admin/status/delete.html b/templates/admin/status/delete.html
index 31998c4b..76e2f715 100644
--- a/templates/admin/status/delete.html
+++ b/templates/admin/status/delete.html
@@ -2,7 +2,7 @@
 
 {{ accordion.header('Set statuses danger zone', 'status-danger', 'admin', expanded=true, danger=true, class='text-end') }}
 <form action="{{ url_for('admin_status.do_delete', id=status.fields.id) }}" method="post">
-  {% if error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ error }}.</div>{% endif %}
+  {% if status_error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ status_error }}.</div>{% endif %}
   <div class="alert alert-danger text-center" role="alert">You are about to <strong>delete a set status</strong>. This action is irreversible.</div>
   <div class="row row-cols-lg-auto g-3 align-items-center">
     <div class="col-12 flex-grow-1">

From d6a729b5a522ecf2a8038937da4cd8e7bb942c83 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Thu, 30 Jan 2025 17:39:55 +0100
Subject: [PATCH 063/154] Move the checkbox logic inside the macro

---
 templates/macro/form.html | 13 +++++++------
 templates/set/card.html   |  4 +---
 2 files changed, 8 insertions(+), 9 deletions(-)

diff --git a/templates/macro/form.html b/templates/macro/form.html
index 499c2205..379309f7 100644
--- a/templates/macro/form.html
+++ b/templates/macro/form.html
@@ -1,17 +1,18 @@
-{% macro checkbox(prefix, id, text, url, checked, delete=false) %}
+{% macro checkbox(item, metadata, delete=false) %}
   {% if g.login.is_authenticated() %}
-    <input class="form-check-input" type="checkbox" id="{{ prefix }}-{{ id }}" {% if checked %}checked{% endif %}
+    {% set prefix=metadata.as_dataset() %}
+    <input class="form-check-input" type="checkbox" id="{{ prefix }}-{{ item.fields.id }}" {% if item.fields[metadata.as_column()] %}checked{% endif %}
     {% if not delete %}
-      data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}" data-changer-parent="set"
+      data-changer-id="{{ item.fields.id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ metadata.url_for_set_state(item.fields.id) }}" data-changer-parent="set"
     {% else %}
       disabled
     {% endif %}
       autocomplete="off">
-    <label class="form-check-label" for="{{ prefix }}-{{ id }}">
-      {{ text }} <i id="status-{{ prefix }}-{{ id }}" class="mb-1"></i>
+    <label class="form-check-label" for="{{ prefix }}-{{ item.fields.id }}">
+      {{ metadata.fields.name }} <i id="status-{{ prefix }}-{{ item.fields.id }}" class="mb-1"></i>
     </label>
   {% else %}
-    <input class="form-check-input text-reset" type="checkbox" {% if checked %}checked{% endif %} disabled>
+    <input class="form-check-input text-reset" type="checkbox" {% if item.fields[metadata.as_column()] %}checked{% endif %} disabled>
     {{ text }}
   {% endif %}
 {% endmacro %}
diff --git a/templates/set/card.html b/templates/set/card.html
index fea4fc04..19e493cb 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -29,9 +29,7 @@
   {% if not tiny and brickset_statuses | length %}
     <ul class="list-group list-group-flush card-check border-bottom-0">
       {% for status in brickset_statuses %}
-        <li class="list-group-item {% if not solo %}p-1{% endif %}">
-          {{ form.checkbox(status.as_dataset(), item.fields.id, status.fields.name, status.url_for_set_state(item.fields.id), item.fields[status.as_column()], delete=delete) }}
-        </li>
+        <li class="list-group-item {% if not solo %}p-1{% endif %}">{{ form.checkbox(item, status, delete=delete) }}</li>
       {% endfor %}
     </ul>
   {% endif %}

From cf641b3199efb6573251e4f40d0085228fc422a4 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Thu, 30 Jan 2025 21:57:42 +0100
Subject: [PATCH 064/154] Separate the filters from the search and sort in the
 set grid

---
 static/scripts/grid.js | 14 +++++------
 templates/sets.html    | 53 ++++++++++++++++++++++++------------------
 2 files changed, 37 insertions(+), 30 deletions(-)

diff --git a/static/scripts/grid.js b/static/scripts/grid.js
index 42b8ac3b..7f40b0ab 100644
--- a/static/scripts/grid.js
+++ b/static/scripts/grid.js
@@ -57,7 +57,7 @@ class BrickGrid {
         this.html_grid = document.getElementById(this.id);
         this.html_sort = document.getElementById(`${this.id}-sort`);
         this.html_search = document.getElementById(`${this.id}-search`);
-        this.html_filter = document.getElementById(`${this.id}-filter`);
+        this.html_status = document.getElementById(`${this.id}-status`);
         this.html_theme = document.getElementById(`${this.id}-theme`);
 
         // Sort buttons
@@ -83,8 +83,8 @@ class BrickGrid {
             })(this));
         }
 
-        if (this.html_filter) {
-            this.html_filter.addEventListener("change", ((grid) => () => {
+        if (this.html_status) {
+            this.html_status.addEventListener("change", ((grid) => () => {
                 grid.filter();
             })(this));
         }
@@ -147,12 +147,12 @@ class BrickGrid {
         }
 
         // Check if there is a set filter
-        if (this.html_filter && this.html_filter.value != "") {
-            if (this.html_filter.value.startsWith("-")) {
-                filters["filter"] = this.html_filter.value.substring(1);
+        if (this.html_status && this.html_status.value != "") {
+            if (this.html_status.value.startsWith("-")) {
+                filters["filter"] = this.html_status.value.substring(1);
                 filters["filter-target"] = "0";
             } else {
-                filters["filter"] = this.html_filter.value;
+                filters["filter"] = this.html_status.value;
                 filters["filter-target"] = "1";
             }
         }
diff --git a/templates/sets.html b/templates/sets.html
index c43925fa..e7003c36 100644
--- a/templates/sets.html
+++ b/templates/sets.html
@@ -13,28 +13,6 @@
         <input id="grid-search" class="form-control form-control-sm" type="text" placeholder="Set name, set number, set theme or number of parts..." value="">
       </div>
     </div>
-    <div class="col-12 flex-grow-1">
-      <label class="visually-hidden" for="grid-filter">Filter</label>
-      <div class="input-group">
-        <span class="input-group-text"><i class="ri-dropdown-list"></i><span class="ms-1 d-none d-xl-inline"> Filter</span></span>
-        <select id="grid-filter" class="form-select form-select-sm" autocomplete="off">
-          <option value="" selected>All sets</option>
-          <option value="-has-missing">Set is complete</option>
-          <option value="has-missing">Set has missing pieces</option>
-          <option value="has-missing-instructions">Set has missing instructions</option>
-          {% for status in brickset_statuses %}
-            <option value="{{ status.as_dataset() }}">{{ status.fields.name }}</option>
-            <option value="-{{ status.as_dataset() }}">NOT: {{ status.fields.name }}</option>
-          {% endfor %}
-        </select>
-        <select id="grid-theme" class="form-select form-select-sm" autocomplete="off">
-          <option value="" selected>All themes</option>
-          {% for theme in collection.themes %}
-          <option value="{{ theme | lower }}">{{ theme }}</option>
-          {% endfor %}
-        </select>
-      </div>
-    </div>
     <div class="col-12">
       <div id="grid-sort" class="input-group">
         <span class="input-group-text"><i class="ri-sort-asc"></i><span class="ms-1 d-none d-xxl-inline"> Sort</span></span>
@@ -57,7 +35,36 @@
       </div>
     </div>
   </div>
-
+  <div class="row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
+    <div class="col-12 flex-grow-1">
+      <label class="visually-hidden" for="grid-status">Status</label>
+      <div class="input-group">
+        <span class="input-group-text"><i class="ri-checkbox-line"></i><span class="ms-1 d-none d-xl-inline"> Status</span></span>
+        <select id="grid-status" class="form-select form-select-sm" autocomplete="off">
+          <option value="" selected>All</option>
+          <option value="-has-missing">Set is complete</option>
+          <option value="has-missing">Set has missing pieces</option>
+          <option value="has-missing-instructions">Set has missing instructions</option>
+          {% for status in brickset_statuses %}
+            <option value="{{ status.as_dataset() }}">{{ status.fields.name }}</option>
+            <option value="-{{ status.as_dataset() }}">NOT: {{ status.fields.name }}</option>
+          {% endfor %}
+        </select>
+      </div>
+    </div>
+    <div class="col-12 flex-grow-1">
+      <label class="visually-hidden" for="grid-theme">Theme</label>
+      <div class="input-group">
+        <span class="input-group-text"><i class="ri-price-tag-3-line"></i><span class="ms-1 d-none d-xl-inline"> Theme</span></span>
+        <select id="grid-theme" class="form-select form-select-sm" autocomplete="off">
+          <option value="" selected>All</option>
+          {% for theme in collection.themes %}
+          <option value="{{ theme | lower }}">{{ theme }}</option>
+          {% endfor %}
+        </select>
+      </div>
+    </div>
+  </div>
   <div class="row" data-grid="true" id="grid">
   {% for item in collection %}
     <div class="col-md-6 col-xl-3 d-flex align-items-stretch">

From 2eb8ebfeca39c49b6e4932873bc4309e2bb71d30 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Thu, 30 Jan 2025 22:07:09 +0100
Subject: [PATCH 065/154] Remove sort-target attribute, handle it internally

---
 static/scripts/grid.js | 14 +++++++-------
 templates/sets.html    | 16 ++++++++--------
 2 files changed, 15 insertions(+), 15 deletions(-)

diff --git a/static/scripts/grid.js b/static/scripts/grid.js
index 7f40b0ab..fbd4e08a 100644
--- a/static/scripts/grid.js
+++ b/static/scripts/grid.js
@@ -50,8 +50,9 @@ class BrickGridSortButton {
 
 // Grid class
 class BrickGrid {
-    constructor(grid) {
+    constructor(grid, target = "div#grid>div") {
         this.id = grid.id;
+        this.target = target;
 
         // Grid elements (built based on the initial id)
         this.html_grid = document.getElementById(this.id);
@@ -72,7 +73,7 @@ class BrickGrid {
         this.html_clear = document.querySelector("button[data-sort-clear]")
         if (this.html_clear) {
             this.html_clear.addEventListener("click", ((grid) => (e) => {
-                grid.clear(e.currentTarget)
+                grid.clear();
             })(this))
         }
 
@@ -117,7 +118,7 @@ class BrickGrid {
     }
 
     // Clear
-    clear(current) {
+    clear() {
         // Cleanup all
         for (const [id, button] of Object.entries(this.html_sort_buttons)) {
             button.toggle();
@@ -129,7 +130,7 @@ class BrickGrid {
         document.cookie = `sort-order=""; Path=/; SameSite=strict`;
 
         // Reset sorting
-        tinysort(current.dataset.sortTarget, {
+        tinysort(this.target, {
             selector: "div",
             attr: "data-index",
             order: "asc",
@@ -204,7 +205,6 @@ class BrickGrid {
 
     // Sort
     sort(current, no_flip=false) {
-        const target = current.data.sortTarget;
         const attribute = current.data.sortAttribute;
         const natural = current.data.sortNatural;
 
@@ -217,7 +217,7 @@ class BrickGrid {
         }
 
         // Sort
-        if (target && attribute) {
+        if (attribute) {
             let order = current.data.sortOrder;
 
             // First ordering
@@ -242,7 +242,7 @@ class BrickGrid {
             document.cookie = `sort-order="${encodeURIComponent(order)}"; Path=/; SameSite=strict`;
 
             // Do the sorting
-            tinysort(target, {
+            tinysort(this.target, {
                 selector: "div",
                 attr: "data-" + attribute,
                 natural: natural == "true",
diff --git a/templates/sets.html b/templates/sets.html
index e7003c36..cfcbbfcc 100644
--- a/templates/sets.html
+++ b/templates/sets.html
@@ -17,21 +17,21 @@
       <div id="grid-sort" class="input-group">
         <span class="input-group-text"><i class="ri-sort-asc"></i><span class="ms-1 d-none d-xxl-inline"> Sort</span></span>
         <button id="sort-number" type="button" class="btn btn-sm btn-outline-primary"
-          data-sort-target="div#grid>div" data-sort-attribute="number" data-sort-natural="true"><i class="ri-hashtag"></i><span class="d-none d-xxl-inline"> Number</span></button>
+          data-sort-attribute="number" data-sort-natural="true"><i class="ri-hashtag"></i><span class="d-none d-xxl-inline"> Set</span></button>
         <button id="sort-name" type="button" class="btn btn-sm btn-outline-primary"
-          data-sort-target="div#grid>div" data-sort-attribute="name"><i class="ri-pencil-line"></i><span class="d-none d-xxl-inline"> Name</span></button>
+          data-sort-attribute="name"><i class="ri-pencil-line"></i><span class="d-none d-xxl-inline"> Name</span></button>
         <button id="sort-theme" type="button" class="btn btn-sm btn-outline-primary"
-          data-sort-target="div#grid>div" data-sort-attribute="theme"><i class="ri-price-tag-3-line"></i><span class="d-none d-xxl-inline"> Theme</span></button>
+          data-sort-attribute="theme"><i class="ri-price-tag-3-line"></i><span class="d-none d-xxl-inline"> Theme</span></button>
         <button id="sort-year" type="button" class="btn btn-sm btn-outline-primary"
-          data-sort-target="div#grid>div" data-sort-attribute="year"><i class="ri-calendar-line"></i><span class="d-none d-xxl-inline"> Year</span></button>
+          data-sort-attribute="year"><i class="ri-calendar-line"></i><span class="d-none d-xxl-inline"> Year</span></button>
         <button id="sort-minifigure" type="button" class="btn btn-sm btn-outline-primary"
-          data-sort-target="div#grid>div" data-sort-attribute="minifigures" data-sort-desc="true"><i class="ri-group-line"></i><span class="d-none d-xxl-inline"> Figures</span></button>
+          data-sort-attribute="minifigures" data-sort-desc="true"><i class="ri-group-line"></i><span class="d-none d-xxl-inline"> Figures</span></button>
         <button id="sort-parts" type="button" class="btn btn-sm btn-outline-primary"
-          data-sort-target="div#grid>div" data-sort-attribute="parts" data-sort-desc="true"><i class="ri-shapes-line"></i><span class="d-none d-xxl-inline"> Parts</span></button>
+          data-sort-attribute="parts" data-sort-desc="true"><i class="ri-shapes-line"></i><span class="d-none d-xxl-inline"> Parts</span></button>
         <button id="sort-missing" type="button" class="btn btn-sm btn-outline-primary"
-          data-sort-target="div#grid>div" data-sort-attribute="missing" data-sort-desc="true"><i class="ri-error-warning-line"></i><span class="d-none d-xxl-inline"> Missing</span></button>
+          data-sort-attribute="missing" data-sort-desc="true"><i class="ri-error-warning-line"></i><span class="d-none d-xxl-inline"> Missing</span></button>
         <button id="sort-clear" type="button" class="btn btn-sm btn-outline-dark"
-          data-sort-target="div#grid>div" data-sort-clear="true"><i class="ri-filter-off-line"></i><span class="d-none d-xxl-inline"> Clear</span></button>
+          data-sort-clear="true"><i class="ri-filter-off-line"></i><span class="d-none d-xxl-inline"> Clear</span></button>
       </div>
     </div>
   </div>

From f854a01925986ab223edb7e9ba62cb3aeef8d3c0 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Thu, 30 Jan 2025 22:10:47 +0100
Subject: [PATCH 066/154] Split the grid javascript code

---
 static/scripts/{ => grid}/grid.js | 50 -------------------------------
 static/scripts/grid/sort.js       | 49 ++++++++++++++++++++++++++++++
 templates/base.html               |  3 +-
 3 files changed, 51 insertions(+), 51 deletions(-)
 rename static/scripts/{ => grid}/grid.js (85%)
 create mode 100644 static/scripts/grid/sort.js

diff --git a/static/scripts/grid.js b/static/scripts/grid/grid.js
similarity index 85%
rename from static/scripts/grid.js
rename to static/scripts/grid/grid.js
index fbd4e08a..8a929e4d 100644
--- a/static/scripts/grid.js
+++ b/static/scripts/grid/grid.js
@@ -1,53 +1,3 @@
-// Sort button
-class BrickGridSortButton {
-    constructor(button, grid) {
-        this.button = button;
-        this.grid = grid;
-        this.data = this.button.dataset;
-
-        // Setup
-        button.addEventListener("click", ((grid, button) => (e) => {
-            grid.sort(button);
-        })(grid, this));
-    }
-
-    // Active
-    active() {
-        this.button.classList.remove("btn-outline-primary");
-        this.button.classList.add("btn-primary");
-    }
-
-    // Inactive
-    inactive() {
-        delete this.button.dataset.sortOrder;
-        this.button.classList.remove("btn-primary");
-        this.button.classList.add("btn-outline-primary");
-    }
-
-    // Toggle sorting
-    toggle(order) {
-        // Cleanup
-        delete this.button.dataset.sortOrder;
-
-        let icon = this.button.querySelector("i.ri");
-        if (icon) {
-            this.button.removeChild(icon);
-        }
-
-        // Set order
-        if (order) {
-            this.active();
-
-            this.button.dataset.sortOrder = order;
-
-            icon = document.createElement("i");
-            icon.classList.add("ri", "ms-1", `ri-sort-${order}`);
-
-            this.button.append(icon);
-        }
-    }
-}
-
 // Grid class
 class BrickGrid {
     constructor(grid, target = "div#grid>div") {
diff --git a/static/scripts/grid/sort.js b/static/scripts/grid/sort.js
new file mode 100644
index 00000000..dcb23f02
--- /dev/null
+++ b/static/scripts/grid/sort.js
@@ -0,0 +1,49 @@
+// Sort button
+class BrickGridSortButton {
+    constructor(button, grid) {
+        this.button = button;
+        this.grid = grid;
+        this.data = this.button.dataset;
+
+        // Setup
+        button.addEventListener("click", ((grid, button) => (e) => {
+            grid.sort(button);
+        })(grid, this));
+    }
+
+    // Active
+    active() {
+        this.button.classList.remove("btn-outline-primary");
+        this.button.classList.add("btn-primary");
+    }
+
+    // Inactive
+    inactive() {
+        delete this.button.dataset.sortOrder;
+        this.button.classList.remove("btn-primary");
+        this.button.classList.add("btn-outline-primary");
+    }
+
+    // Toggle sorting
+    toggle(order) {
+        // Cleanup
+        delete this.button.dataset.sortOrder;
+
+        let icon = this.button.querySelector("i.ri");
+        if (icon) {
+            this.button.removeChild(icon);
+        }
+
+        // Set order
+        if (order) {
+            this.active();
+
+            this.button.dataset.sortOrder = order;
+
+            icon = document.createElement("i");
+            icon.classList.add("ri", "ms-1", `ri-sort-${order}`);
+
+            this.button.append(icon);
+        }
+    }
+}
diff --git a/templates/base.html b/templates/base.html
index 6d89d3df..846a861a 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -80,7 +80,8 @@
     <script src="https://cdn.jsdelivr.net/npm/simple-datatables@9.2.1/dist/umd/simple-datatables.min.js"></script>
     <!-- BrickTracker scripts -->
     <script src="{{ url_for('static', filename='scripts/changer.js') }}"></script>
-    <script src="{{ url_for('static', filename='scripts/grid.js') }}"></script>
+    <script src="{{ url_for('static', filename='scripts/grid/grid.js') }}"></script>
+    <script src="{{ url_for('static', filename='scripts/grid/sort.js') }}"></script>
     <script src="{{ url_for('static', filename='scripts/set.js') }}"></script>
     <script src="{{ url_for('static', filename='scripts/socket/socket.js') }}"></script>
     <script src="{{ url_for('static', filename='scripts/socket/instructions.js') }}"></script>

From d80728d1334053b425bd05288b7b76dd0a87bb83 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Thu, 30 Jan 2025 22:30:14 +0100
Subject: [PATCH 067/154] Create dedicated javascript object for Grid sort

---
 static/scripts/grid/grid.js        | 105 +--------------------
 static/scripts/grid/sort.js        | 143 +++++++++++++++++++++--------
 static/scripts/grid/sort_button.js |  49 ++++++++++
 templates/base.html                |   1 +
 4 files changed, 158 insertions(+), 140 deletions(-)
 create mode 100644 static/scripts/grid/sort_button.js

diff --git a/static/scripts/grid/grid.js b/static/scripts/grid/grid.js
index 8a929e4d..e11c2bd3 100644
--- a/static/scripts/grid/grid.js
+++ b/static/scripts/grid/grid.js
@@ -6,26 +6,12 @@ class BrickGrid {
 
         // Grid elements (built based on the initial id)
         this.html_grid = document.getElementById(this.id);
-        this.html_sort = document.getElementById(`${this.id}-sort`);
         this.html_search = document.getElementById(`${this.id}-search`);
         this.html_status = document.getElementById(`${this.id}-status`);
         this.html_theme = document.getElementById(`${this.id}-theme`);
 
-        // Sort buttons
-        this.html_sort_buttons = {};
-        if (this.html_sort) {
-            this.html_sort.querySelectorAll("button[data-sort-attribute]").forEach(button => {
-                this.html_sort_buttons[button.id] = new BrickGridSortButton(button, this);
-            });
-        }
-
-        // Clear button
-        this.html_clear = document.querySelector("button[data-sort-clear]")
-        if (this.html_clear) {
-            this.html_clear.addEventListener("click", ((grid) => (e) => {
-                grid.clear();
-            })(this))
-        }
+        // Sort setup
+        this.sort = new BrickGridSort(this);
 
         // Filter setup
         if (this.html_search) {
@@ -46,46 +32,6 @@ class BrickGrid {
             })(this));
         }
 
-        // Cookie setup
-        const cookies = document.cookie.split(";").reduce((acc, cookieString) => {
-            const [key, value] = cookieString.split("=").map(s => s.trim().replace(/^"|"$/g, ""));
-            if (key && value) {
-                acc[key] = decodeURIComponent(value);
-            }
-            return acc;
-        }, {});
-
-        // Initial sort
-        if ("sort-id" in cookies && cookies["sort-id"] in this.html_sort_buttons) {
-            const current = this.html_sort_buttons[cookies["sort-id"]];
-
-            if("sort-order" in cookies) {
-                current.button.setAttribute("data-sort-order", cookies["sort-order"]);
-            }
-
-            this.sort(current, true);
-        }
-    }
-
-    // Clear
-    clear() {
-        // Cleanup all
-        for (const [id, button] of Object.entries(this.html_sort_buttons)) {
-            button.toggle();
-            button.inactive();
-        }
-
-        // Clear cookies
-        document.cookie = `sort-id=""; Path=/; SameSite=strict`;
-        document.cookie = `sort-order=""; Path=/; SameSite=strict`;
-
-        // Reset sorting
-        tinysort(this.target, {
-            selector: "div",
-            attr: "data-index",
-            order: "asc",
-        });
-
     }
 
     // Filter
@@ -153,53 +99,6 @@ class BrickGrid {
         }
     }
 
-    // Sort
-    sort(current, no_flip=false) {
-        const attribute = current.data.sortAttribute;
-        const natural = current.data.sortNatural;
-
-        // Cleanup all
-        for (const [id, button] of Object.entries(this.html_sort_buttons)) {
-            if (button != current) {
-                button.toggle();
-                button.inactive();
-            }
-        }
-
-        // Sort
-        if (attribute) {
-            let order = current.data.sortOrder;
-
-            // First ordering
-            if (!no_flip) {
-                if(!order) {
-                    if (current.data.sortDesc) {
-                        order = "desc"
-                    } else {
-                        order = "asc"
-                    }
-                } else {
-                    // Flip the sorting order
-                    order = (order == "desc") ? "asc" : "desc";
-                }
-            }
-
-            // Toggle the ordering
-            current.toggle(order);
-
-            // Store cookies
-            document.cookie = `sort-id="${encodeURIComponent(current.button.id)}"; Path=/; SameSite=strict`;
-            document.cookie = `sort-order="${encodeURIComponent(order)}"; Path=/; SameSite=strict`;
-
-            // Do the sorting
-            tinysort(this.target, {
-                selector: "div",
-                attr: "data-" + attribute,
-                natural: natural == "true",
-                order: order,
-            });
-        }
-    }
 }
 
 // Helper to setup the grids
diff --git a/static/scripts/grid/sort.js b/static/scripts/grid/sort.js
index dcb23f02..0fc4e5fe 100644
--- a/static/scripts/grid/sort.js
+++ b/static/scripts/grid/sort.js
@@ -1,49 +1,118 @@
-// Sort button
-class BrickGridSortButton {
-    constructor(button, grid) {
-        this.button = button;
+// Grid sort
+class BrickGridSort {
+    constructor(grid) {
         this.grid = grid;
-        this.data = this.button.dataset;
 
-        // Setup
-        button.addEventListener("click", ((grid, button) => (e) => {
-            grid.sort(button);
-        })(grid, this));
+        // Grid sort elements (built based on the initial id)
+        this.html_sort = document.getElementById(`${this.grid.id}-sort`);
+
+        if (this.html_sort) {
+            // Cookie names
+            this.cookie_id = `${this.grid.id}-sort-id`;
+            this.cookie_order = `${this.grid.id}-sort-order`;
+
+            // Sort buttons
+            this.html_sort_buttons = {};
+            this.html_sort.querySelectorAll("button[data-sort-attribute]").forEach(button => {
+                this.html_sort_buttons[button.id] = new BrickGridSortButton(button, this);
+            });
+
+            // Clear button
+            this.html_clear = this.html_sort.querySelector("button[data-sort-clear]")
+            if (this.html_clear) {
+                this.html_clear.addEventListener("click", ((grid) => () => {
+                    grid.clear();
+                })(this))
+            }
+
+            // Cookie setup
+            const cookies = document.cookie.split(";").reduce((acc, cookieString) => {
+                const [key, value] = cookieString.split("=").map(s => s.trim().replace(/^"|"$/g, ""));
+                if (key && value) {
+                    acc[key] = decodeURIComponent(value);
+                }
+                return acc;
+            }, {});
+
+            // Initial sort
+            if (this.cookie_id in cookies && cookies[this.cookie_id] in this.html_sort_buttons) {
+                const current = this.html_sort_buttons[cookies[this.cookie_id]];
+
+                if(this.cookie_order in cookies) {
+                    current.button.setAttribute("data-sort-order", cookies[this.cookie_order]);
+                }
+
+                this.sort(current, true);
+            }
+        }
     }
 
-    // Active
-    active() {
-        this.button.classList.remove("btn-outline-primary");
-        this.button.classList.add("btn-primary");
-    }
-
-    // Inactive
-    inactive() {
-        delete this.button.dataset.sortOrder;
-        this.button.classList.remove("btn-primary");
-        this.button.classList.add("btn-outline-primary");
-    }
-
-    // Toggle sorting
-    toggle(order) {
-        // Cleanup
-        delete this.button.dataset.sortOrder;
-
-        let icon = this.button.querySelector("i.ri");
-        if (icon) {
-            this.button.removeChild(icon);
+    // Clear sort
+    clear() {
+        // Cleanup all
+        for (const [id, button] of Object.entries(this.html_sort_buttons)) {
+            button.toggle();
+            button.inactive();
         }
 
-        // Set order
-        if (order) {
-            this.active();
+        // Clear cookies
+        document.cookie = `${this.cookie_id}=""; Path=/; SameSite=strict`;
+        document.cookie = `${this.cookie_order}=""; Path=/; SameSite=strict`;
 
-            this.button.dataset.sortOrder = order;
+        // Reset sorting
+        tinysort(this.grid.target, {
+            selector: "div",
+            attr: "data-index",
+            order: "asc",
+        });
 
-            icon = document.createElement("i");
-            icon.classList.add("ri", "ms-1", `ri-sort-${order}`);
+    }
 
-            this.button.append(icon);
+    // Sort
+    sort(current, no_flip=false) {
+        const attribute = current.data.sortAttribute;
+        const natural = current.data.sortNatural;
+
+        // Cleanup all
+        for (const [id, button] of Object.entries(this.html_sort_buttons)) {
+            if (button != current) {
+                button.toggle();
+                button.inactive();
+            }
+        }
+
+        // Sort
+        if (attribute) {
+            let order = current.data.sortOrder;
+
+            // First ordering
+            if (!no_flip) {
+                if(!order) {
+                    if (current.data.sortDesc) {
+                        order = "desc";
+                    } else {
+                        order = "asc";
+                    }
+                } else {
+                    // Flip the sorting order
+                    order = (order == "desc") ? "asc" : "desc";
+                }
+            }
+
+            // Toggle the ordering
+            current.toggle(order);
+
+            // Store cookies
+            document.cookie = `${this.cookie_id}="${encodeURIComponent(current.button.id)}"; Path=/; SameSite=strict`;
+            document.cookie = `${this.cookie_order}="${encodeURIComponent(order)}"; Path=/; SameSite=strict`;
+
+            // Do the sorting
+            tinysort(this.grid.target, {
+                selector: "div",
+                attr: "data-" + attribute,
+                natural: natural == "true",
+                order: order,
+            });
         }
     }
 }
diff --git a/static/scripts/grid/sort_button.js b/static/scripts/grid/sort_button.js
new file mode 100644
index 00000000..7d287421
--- /dev/null
+++ b/static/scripts/grid/sort_button.js
@@ -0,0 +1,49 @@
+// Grid sort button
+class BrickGridSortButton {
+    constructor(button, grid) {
+        this.button = button;
+        this.grid = grid;
+        this.data = this.button.dataset;
+
+        // Setup
+        button.addEventListener("click", ((grid, button) => (e) => {
+            grid.sort(button);
+        })(grid, this));
+    }
+
+    // Active
+    active() {
+        this.button.classList.remove("btn-outline-primary");
+        this.button.classList.add("btn-primary");
+    }
+
+    // Inactive
+    inactive() {
+        delete this.button.dataset.sortOrder;
+        this.button.classList.remove("btn-primary");
+        this.button.classList.add("btn-outline-primary");
+    }
+
+    // Toggle sorting
+    toggle(order) {
+        // Cleanup
+        delete this.button.dataset.sortOrder;
+
+        let icon = this.button.querySelector("i.ri");
+        if (icon) {
+            this.button.removeChild(icon);
+        }
+
+        // Set order
+        if (order) {
+            this.active();
+
+            this.button.dataset.sortOrder = order;
+
+            icon = document.createElement("i");
+            icon.classList.add("ri", "ms-1", `ri-sort-${order}`);
+
+            this.button.append(icon);
+        }
+    }
+}
diff --git a/templates/base.html b/templates/base.html
index 846a861a..8aa083d2 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -82,6 +82,7 @@
     <script src="{{ url_for('static', filename='scripts/changer.js') }}"></script>
     <script src="{{ url_for('static', filename='scripts/grid/grid.js') }}"></script>
     <script src="{{ url_for('static', filename='scripts/grid/sort.js') }}"></script>
+    <script src="{{ url_for('static', filename='scripts/grid/sort_button.js') }}"></script>
     <script src="{{ url_for('static', filename='scripts/set.js') }}"></script>
     <script src="{{ url_for('static', filename='scripts/socket/socket.js') }}"></script>
     <script src="{{ url_for('static', filename='scripts/socket/instructions.js') }}"></script>

From 8e3816e2e2e5a6a63eb00d5997c21c00fc5bafb6 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Thu, 30 Jan 2025 22:40:53 +0100
Subject: [PATCH 068/154] Create dedicated object for Grid filter

---
 static/scripts/grid/filter.js | 93 +++++++++++++++++++++++++++++++++++
 static/scripts/grid/grid.js   | 92 ++--------------------------------
 templates/base.html           |  1 +
 3 files changed, 98 insertions(+), 88 deletions(-)
 create mode 100644 static/scripts/grid/filter.js

diff --git a/static/scripts/grid/filter.js b/static/scripts/grid/filter.js
new file mode 100644
index 00000000..a5c46887
--- /dev/null
+++ b/static/scripts/grid/filter.js
@@ -0,0 +1,93 @@
+// Grid filter
+class BrickGridFilter {
+    constructor(grid) {
+        this.grid = grid;
+
+        // Grid sort elements (built based on the initial id)
+        this.html_search = document.getElementById(`${this.grid.id}-search`);
+        this.html_status = document.getElementById(`${this.grid.id}-status`);
+        this.html_theme = document.getElementById(`${this.grid.id}-theme`);
+
+        // Filter setup
+        if (this.html_search) {
+            this.html_search.addEventListener("keyup", ((grid) => () => {
+                grid.filter();
+            })(this));
+        }
+
+        if (this.html_status) {
+            this.html_status.addEventListener("change", ((grid) => () => {
+                grid.filter();
+            })(this));
+        }
+
+        if (this.html_theme) {
+            this.html_theme.addEventListener("change", ((grid) => () => {
+                grid.filter();
+            })(this));
+        }
+    }
+
+    // Filter
+    filter() {
+        var filters = {};
+
+        // Check if there is a search filter
+        if (this.html_search && this.html_search.value != "") {
+            filters["search"] = this.html_search.value.toLowerCase();
+        }
+
+        // Check if there is a set filter
+        if (this.html_status && this.html_status.value != "") {
+            if (this.html_status.value.startsWith("-")) {
+                filters["filter"] = this.html_status.value.substring(1);
+                filters["filter-target"] = "0";
+            } else {
+                filters["filter"] = this.html_status.value;
+                filters["filter-target"] = "1";
+            }
+        }
+
+        // Check if there is a theme filter
+        if (this.html_theme && this.html_theme.value != "") {
+            filters["theme"] = this.html_theme.value;
+        }
+
+        // Filter all cards
+        const cards = this.grid.html_grid.querySelectorAll(`${this.grid.target} > .card`);
+        cards.forEach(current => {
+            // Set filter
+            if ("filter" in filters) {
+                if (current.getAttribute("data-" + filters["filter"]) != filters["filter-target"]) {
+                    current.parentElement.classList.add("d-none");
+                    return;
+                }
+            }
+
+            // Theme filter
+            if ("theme" in filters) {
+                if (current.getAttribute("data-theme") != filters["theme"]) {
+                    current.parentElement.classList.add("d-none");
+                    return;
+                }
+            }
+
+            // Check all searchable fields for a match
+            if ("search" in filters) {
+                for (let attribute of ["data-name", "data-number", "data-parts", "data-theme", "data-year"]) {
+                    if (current.getAttribute(attribute).includes(filters["search"])) {
+                        current.parentElement.classList.remove("d-none");
+                        return;
+                    }
+                }
+
+                // If no match, we need to hide it
+                current.parentElement.classList.add("d-none");
+                return;
+            }
+
+            // If we passed all filters, we need to display it
+            current.parentElement.classList.remove("d-none");
+        });
+    }
+}
diff --git a/static/scripts/grid/grid.js b/static/scripts/grid/grid.js
index e11c2bd3..d0b9dab7 100644
--- a/static/scripts/grid/grid.js
+++ b/static/scripts/grid/grid.js
@@ -6,99 +6,15 @@ class BrickGrid {
 
         // Grid elements (built based on the initial id)
         this.html_grid = document.getElementById(this.id);
-        this.html_search = document.getElementById(`${this.id}-search`);
-        this.html_status = document.getElementById(`${this.id}-status`);
-        this.html_theme = document.getElementById(`${this.id}-theme`);
 
-        // Sort setup
-        this.sort = new BrickGridSort(this);
-
-        // Filter setup
-        if (this.html_search) {
-            this.html_search.addEventListener("keyup", ((grid) => () => {
-                grid.filter();
-            })(this));
-        }
-
-        if (this.html_status) {
-            this.html_status.addEventListener("change", ((grid) => () => {
-                grid.filter();
-            })(this));
-        }
-
-        if (this.html_theme) {
-            this.html_theme.addEventListener("change", ((grid) => () => {
-                grid.filter();
-            })(this));
-        }
-
-    }
-
-    // Filter
-    filter() {
-        var filters = {};
-
-        // Check if there is a search filter
-        if (this.html_search && this.html_search.value != "") {
-            filters["search"] = this.html_search.value.toLowerCase();
-        }
-
-        // Check if there is a set filter
-        if (this.html_status && this.html_status.value != "") {
-            if (this.html_status.value.startsWith("-")) {
-                filters["filter"] = this.html_status.value.substring(1);
-                filters["filter-target"] = "0";
-            } else {
-                filters["filter"] = this.html_status.value;
-                filters["filter-target"] = "1";
-            }
-        }
-
-        // Check if there is a theme filter
-        if (this.html_theme && this.html_theme.value != "") {
-            filters["theme"] = this.html_theme.value;
-        }
-
-        // Filter all cards
         if (this.html_grid) {
-            const cards = this.html_grid.querySelectorAll("div > div.card");
-            cards.forEach(current => {
-                // Set filter
-                if ("filter" in filters) {
-                    if (current.getAttribute("data-" + filters["filter"]) != filters["filter-target"]) {
-                        current.parentElement.classList.add("d-none");
-                        return;
-                    }
-                }
+            // Sort setup
+            this.sort = new BrickGridSort(this);
 
-                // Theme filter
-                if ("theme" in filters) {
-                    if (current.getAttribute("data-theme") != filters["theme"]) {
-                        current.parentElement.classList.add("d-none");
-                        return;
-                    }
-                }
-
-                // Check all searchable fields for a match
-                if ("search" in filters) {
-                    for (let attribute of ["data-name", "data-number", "data-parts", "data-theme", "data-year"]) {
-                        if (current.getAttribute(attribute).includes(filters["search"])) {
-                            current.parentElement.classList.remove("d-none");
-                            return;
-                        }
-                    }
-
-                    // If no match, we need to hide it
-                    current.parentElement.classList.add("d-none");
-                    return;
-                }
-
-                // If we passed all filters, we need to display it
-                current.parentElement.classList.remove("d-none");
-            })
+            // Filter setup
+            this.filter = new BrickGridFilter(this);
         }
     }
-
 }
 
 // Helper to setup the grids
diff --git a/templates/base.html b/templates/base.html
index 8aa083d2..a82a4eaa 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -80,6 +80,7 @@
     <script src="https://cdn.jsdelivr.net/npm/simple-datatables@9.2.1/dist/umd/simple-datatables.min.js"></script>
     <!-- BrickTracker scripts -->
     <script src="{{ url_for('static', filename='scripts/changer.js') }}"></script>
+    <script src="{{ url_for('static', filename='scripts/grid/filter.js') }}"></script>
     <script src="{{ url_for('static', filename='scripts/grid/grid.js') }}"></script>
     <script src="{{ url_for('static', filename='scripts/grid/sort.js') }}"></script>
     <script src="{{ url_for('static', filename='scripts/grid/sort_button.js') }}"></script>

From ca3d4d09d5374ffa20c04956878a41f45d129f90 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Thu, 30 Jan 2025 23:24:22 +0100
Subject: [PATCH 069/154] Make grid filters controlled through data- fields

---
 static/scripts/grid/filter.js | 88 ++++++++++++++++++++---------------
 templates/sets.html           | 10 ++--
 2 files changed, 58 insertions(+), 40 deletions(-)

diff --git a/static/scripts/grid/filter.js b/static/scripts/grid/filter.js
index a5c46887..7406274a 100644
--- a/static/scripts/grid/filter.js
+++ b/static/scripts/grid/filter.js
@@ -5,20 +5,24 @@ class BrickGridFilter {
 
         // Grid sort elements (built based on the initial id)
         this.html_search = document.getElementById(`${this.grid.id}-search`);
-        this.html_status = document.getElementById(`${this.grid.id}-status`);
-        this.html_theme = document.getElementById(`${this.grid.id}-theme`);
+        this.html_filter = document.getElementById(`${this.grid.id}-filter`);
 
-        // Filter setup
+        // Search setup
         if (this.html_search) {
-            this.html_search.addEventListener("keyup", ((grid) => () => {
-                grid.filter();
+            this.html_search.addEventListener("keyup", ((gridfilter) => () => {
+                gridfilter.filter();
             })(this));
         }
 
-        if (this.html_status) {
-            this.html_status.addEventListener("change", ((grid) => () => {
-                grid.filter();
-            })(this));
+        // Filters setup
+        this.selects = [];
+        if (this.html_filter) {
+            this.html_filter.querySelectorAll("select[data-filter]").forEach(select => {
+                select.addEventListener("change", ((gridfilter) => () => {
+                    gridfilter.filter();
+                })(this));
+                this.selects.push(select);
+            });
         }
 
         if (this.html_theme) {
@@ -30,52 +34,62 @@ class BrickGridFilter {
 
     // Filter
     filter() {
-        var filters = {};
+        let options = {
+            search: undefined,
+            filters: [],
+        };
 
         // Check if there is a search filter
         if (this.html_search && this.html_search.value != "") {
-            filters["search"] = this.html_search.value.toLowerCase();
+            options.search = this.html_search.value.toLowerCase();
         }
 
-        // Check if there is a set filter
-        if (this.html_status && this.html_status.value != "") {
-            if (this.html_status.value.startsWith("-")) {
-                filters["filter"] = this.html_status.value.substring(1);
-                filters["filter-target"] = "0";
-            } else {
-                filters["filter"] = this.html_status.value;
-                filters["filter-target"] = "1";
+        // Build filters
+        for (const select of this.selects) {
+            if (select.value != "") {
+                // Multi-attribute filter
+                switch (select.dataset.filter) {
+                    // List contains values
+                    case "solo":
+                        options.filters.push({
+                            attribute: select.dataset.filterAttribute,
+                            value: select.value,
+                        })
+                    break;
+
+                    // List contains attribute name, looking for true/false
+                    case "status":
+                        if (select.value.startsWith("-")) {
+                            options.filters.push({
+                                attribute: select.value.substring(1),
+                                value: "0"
+                            })
+                        } else {
+                            options.filters.push({
+                                attribute: select.value,
+                                value: "1"
+                            })
+                        }
+                    break;
+                }
             }
         }
 
-        // Check if there is a theme filter
-        if (this.html_theme && this.html_theme.value != "") {
-            filters["theme"] = this.html_theme.value;
-        }
-
         // Filter all cards
         const cards = this.grid.html_grid.querySelectorAll(`${this.grid.target} > .card`);
         cards.forEach(current => {
-            // Set filter
-            if ("filter" in filters) {
-                if (current.getAttribute("data-" + filters["filter"]) != filters["filter-target"]) {
-                    current.parentElement.classList.add("d-none");
-                    return;
-                }
-            }
-
-            // Theme filter
-            if ("theme" in filters) {
-                if (current.getAttribute("data-theme") != filters["theme"]) {
+            // Process all filters
+            for (const filter of options.filters) {
+                if (current.getAttribute(`data-${filter.attribute}`) != filter.value) {
                     current.parentElement.classList.add("d-none");
                     return;
                 }
             }
 
             // Check all searchable fields for a match
-            if ("search" in filters) {
+            if (options.search) {
                 for (let attribute of ["data-name", "data-number", "data-parts", "data-theme", "data-year"]) {
-                    if (current.getAttribute(attribute).includes(filters["search"])) {
+                    if (current.getAttribute(attribute).includes(options.search)) {
                         current.parentElement.classList.remove("d-none");
                         return;
                     }
diff --git a/templates/sets.html b/templates/sets.html
index cfcbbfcc..6a07d731 100644
--- a/templates/sets.html
+++ b/templates/sets.html
@@ -35,12 +35,14 @@
       </div>
     </div>
   </div>
-  <div class="row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
+  <div id="grid-filter" class="row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
     <div class="col-12 flex-grow-1">
       <label class="visually-hidden" for="grid-status">Status</label>
       <div class="input-group">
         <span class="input-group-text"><i class="ri-checkbox-line"></i><span class="ms-1 d-none d-xl-inline"> Status</span></span>
-        <select id="grid-status" class="form-select form-select-sm" autocomplete="off">
+        <select id="grid-status" class="form-select form-select-sm"
+          data-filter="status"
+          autocomplete="off">
           <option value="" selected>All</option>
           <option value="-has-missing">Set is complete</option>
           <option value="has-missing">Set has missing pieces</option>
@@ -56,7 +58,9 @@
       <label class="visually-hidden" for="grid-theme">Theme</label>
       <div class="input-group">
         <span class="input-group-text"><i class="ri-price-tag-3-line"></i><span class="ms-1 d-none d-xl-inline"> Theme</span></span>
-        <select id="grid-theme" class="form-select form-select-sm" autocomplete="off">
+        <select id="grid-theme" class="form-select form-select-sm"
+          data-filter="solo" data-filter-attribute="theme"
+          autocomplete="off">
           <option value="" selected>All</option>
           {% for theme in collection.themes %}
           <option value="{{ theme | lower }}">{{ theme }}</option>

From 069ba37e138dd7527c120925757c6657a47b3bee Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Thu, 30 Jan 2025 23:25:42 +0100
Subject: [PATCH 070/154] Fix database counters display

---
 templates/admin/database.html | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/templates/admin/database.html b/templates/admin/database.html
index 7a15325a..86e82c81 100644
--- a/templates/admin/database.html
+++ b/templates/admin/database.html
@@ -19,15 +19,15 @@
 </p>
 {% if database_counters %}
   <h5 class="border-bottom">Records</h5>
-  <div class="d-flex justify-content-start">
-    <ul class="list-group me-2">
+  <div class="row">
+    <ul class="list-group col-4">
       {% for counter in database_counters %}
         <li class="list-group-item d-flex justify-content-between align-items-start {% if counter.legacy %}list-group-item-dark{% endif %}">
           <span><i class="ri-{{ counter.icon }}"></i> {{ counter.name }}</span> <span class="badge {% if counter.legacy %}text-bg-light border{% else %}text-bg-primary{% endif %} rounded-pill ms-2">{{ counter.count }}</span>
         </li>
         {% if not (loop.index % 5) %}
           </ul>
-          <ul class="list-group me-2">
+          <ul class="list-group col-4">
         {% endif %}
       {% endfor %}
     </ul>

From 0e3637e5efd53d5f5d9ef944d6ce14a899cfee2e Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Thu, 30 Jan 2025 23:34:47 +0100
Subject: [PATCH 071/154] Make checkbox clickable in the entire width of their
 container

---
 templates/macro/form.html | 2 +-
 templates/set/card.html   | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/templates/macro/form.html b/templates/macro/form.html
index 379309f7..dfd82112 100644
--- a/templates/macro/form.html
+++ b/templates/macro/form.html
@@ -8,7 +8,7 @@
       disabled
     {% endif %}
       autocomplete="off">
-    <label class="form-check-label" for="{{ prefix }}-{{ item.fields.id }}">
+    <label class="form-check-label flex-grow-1 ms-1" for="{{ prefix }}-{{ item.fields.id }}">
       {{ metadata.fields.name }} <i id="status-{{ prefix }}-{{ item.fields.id }}" class="mb-1"></i>
     </label>
   {% else %}
diff --git a/templates/set/card.html b/templates/set/card.html
index 19e493cb..c9afbdd4 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -29,7 +29,7 @@
   {% if not tiny and brickset_statuses | length %}
     <ul class="list-group list-group-flush card-check border-bottom-0">
       {% for status in brickset_statuses %}
-        <li class="list-group-item {% if not solo %}p-1{% endif %}">{{ form.checkbox(item, status, delete=delete) }}</li>
+        <li class="d-flex list-group-item {% if not solo %}p-1{% endif %} text-nowrap">{{ form.checkbox(item, status, delete=delete) }}</li>
       {% endfor %}
     </ul>
   {% endif %}

From 6fdc933c3297ced49db95abbc9c88dc51b5104c3 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Thu, 30 Jan 2025 23:35:51 +0100
Subject: [PATCH 072/154] Cosmetics

---
 static/scripts/grid/sort.js | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/static/scripts/grid/sort.js b/static/scripts/grid/sort.js
index 0fc4e5fe..93602fec 100644
--- a/static/scripts/grid/sort.js
+++ b/static/scripts/grid/sort.js
@@ -20,8 +20,8 @@ class BrickGridSort {
             // Clear button
             this.html_clear = this.html_sort.querySelector("button[data-sort-clear]")
             if (this.html_clear) {
-                this.html_clear.addEventListener("click", ((grid) => () => {
-                    grid.clear();
+                this.html_clear.addEventListener("click", ((gridsort) => () => {
+                    gridsort.clear();
                 })(this))
             }
 

From 1f73ae2323317d7edbfa93f7e3ac39be2579d5cf Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 10:19:59 +0100
Subject: [PATCH 073/154] Configure the Grid search through data- attributes

---
 static/scripts/grid/filter.js | 35 +++++++++++++++++++++++++++++++----
 templates/sets.html           |  2 +-
 2 files changed, 32 insertions(+), 5 deletions(-)

diff --git a/static/scripts/grid/filter.js b/static/scripts/grid/filter.js
index 7406274a..d0d8903b 100644
--- a/static/scripts/grid/filter.js
+++ b/static/scripts/grid/filter.js
@@ -9,6 +9,19 @@ class BrickGridFilter {
 
         // Search setup
         if (this.html_search) {
+            // Exact attributes
+            if (this.html_search.dataset.searchExact) {
+                this.search_exact = new Set(this.html_search.dataset.searchExact.split(",").map(el => el.trim()));
+            } else {
+                this.search_exact = new Set();
+            }
+
+            // List attributes
+            this.search_list = [];
+            if (this.html_search.dataset.searchList) {
+                this.search_list = this.html_search.dataset.searchList.split(",").map(el => el.trim());
+            }
+
             this.html_search.addEventListener("keyup", ((gridfilter) => () => {
                 gridfilter.filter();
             })(this));
@@ -88,10 +101,24 @@ class BrickGridFilter {
 
             // Check all searchable fields for a match
             if (options.search) {
-                for (let attribute of ["data-name", "data-number", "data-parts", "data-theme", "data-year"]) {
-                    if (current.getAttribute(attribute).includes(options.search)) {
-                        current.parentElement.classList.remove("d-none");
-                        return;
+                // Browse the whole dataset
+                for (const set in current.dataset) {
+                    // Exact attribute
+                    if (this.search_exact.has(set)) {
+                        if (current.dataset[set].includes(options.search)) {
+                            current.parentElement.classList.remove("d-none");
+                            return;
+                        }
+                    } else {
+                        // List search
+                        for (const list of this.search_list) {
+                            if (set.startsWith(this.search_list)) {
+                                if (current.dataset[set].includes(options.search)) {
+                                    current.parentElement.classList.remove("d-none");
+                                    return;
+                                }
+                            }
+                        }
                     }
                 }
 
diff --git a/templates/sets.html b/templates/sets.html
index 6a07d731..e8d70a24 100644
--- a/templates/sets.html
+++ b/templates/sets.html
@@ -10,7 +10,7 @@
       <label class="visually-hidden" for="grid-search">Search</label>
       <div class="input-group">
         <span class="input-group-text"><i class="ri-search-line"></i><span class="ms-1 d-none d-xl-inline"> Search</span></span>
-        <input id="grid-search" class="form-control form-control-sm" type="text" placeholder="Set name, set number, set theme or number of parts..." value="">
+        <input id="grid-search" data-search-exact="name,number,parts,theme,year" class="form-control form-control-sm" type="text" placeholder="Set name, set number, set theme or number of parts..." value="">
       </div>
     </div>
     <div class="col-12">

From 2260774a582d5da3f580852319b6047d5122e701 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 10:54:11 +0100
Subject: [PATCH 074/154] Rename solo and attribute to value and metadata in
 grid filter

---
 static/scripts/grid/filter.js | 6 +++---
 templates/sets.html           | 4 ++--
 2 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/static/scripts/grid/filter.js b/static/scripts/grid/filter.js
index d0d8903b..e2eff7bd 100644
--- a/static/scripts/grid/filter.js
+++ b/static/scripts/grid/filter.js
@@ -63,15 +63,15 @@ class BrickGridFilter {
                 // Multi-attribute filter
                 switch (select.dataset.filter) {
                     // List contains values
-                    case "solo":
+                    case "value":
                         options.filters.push({
                             attribute: select.dataset.filterAttribute,
                             value: select.value,
                         })
                     break;
 
-                    // List contains attribute name, looking for true/false
-                    case "status":
+                    // List contains metadata attribute name, looking for true/false
+                    case "metadata":
                         if (select.value.startsWith("-")) {
                             options.filters.push({
                                 attribute: select.value.substring(1),
diff --git a/templates/sets.html b/templates/sets.html
index e8d70a24..27aff004 100644
--- a/templates/sets.html
+++ b/templates/sets.html
@@ -41,7 +41,7 @@
       <div class="input-group">
         <span class="input-group-text"><i class="ri-checkbox-line"></i><span class="ms-1 d-none d-xl-inline"> Status</span></span>
         <select id="grid-status" class="form-select form-select-sm"
-          data-filter="status"
+          data-filter="metadata"
           autocomplete="off">
           <option value="" selected>All</option>
           <option value="-has-missing">Set is complete</option>
@@ -59,7 +59,7 @@
       <div class="input-group">
         <span class="input-group-text"><i class="ri-price-tag-3-line"></i><span class="ms-1 d-none d-xl-inline"> Theme</span></span>
         <select id="grid-theme" class="form-select form-select-sm"
-          data-filter="solo" data-filter-attribute="theme"
+          data-filter="value" data-filter-attribute="theme"
           autocomplete="off">
           <option value="" selected>All</option>
           {% for theme in collection.themes %}

From e9f97a6f5e328d4313195b73f8085debf73e0045 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 11:05:19 +0100
Subject: [PATCH 075/154] Use a with block rather than set to avoid leaking
 variables

---
 templates/wish/table.html | 35 ++++++++++++++++++-----------------
 1 file changed, 18 insertions(+), 17 deletions(-)

diff --git a/templates/wish/table.html b/templates/wish/table.html
index 73a5eb89..3b037824 100644
--- a/templates/wish/table.html
+++ b/templates/wish/table.html
@@ -19,23 +19,24 @@
     </thead>
     <tbody>
       {% for item in table_collection %}
-      {% set retirement_date = retired.get(item.fields.set) %}
-      <tr>
-        {{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.set) }}
-        <td>{{ item.fields.set }} {{ table.rebrickable(item) }}</td>
-        <td>{{ item.fields.name }}</td>
-        <td>{{ item.theme.name }}</td>
-        <td>{{ item.fields.year }}</td>
-        <td>{{ item.fields.number_of_parts }}</td>
-        <td>{% if retirement_date %}{{ retirement_date }}{% else %}<span class="badge rounded-pill text-bg-light border">Not found</span>{% endif %}</td>
-        {% if g.login.is_authenticated() %}
-        <td>
-          <form action="{{ item.url_for_delete() }}" method="post">
-            <button type="submit" class="btn btn-sm btn-danger"><i class="ri-delete-bin-2-line"></i> Delete</button>
-          </form>
-        </td>
-        {% endif %}
-      </tr>
+        {% with retirement_date = retired.get(item.fields.set) %}
+          <tr>
+            {{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.set) }}
+            <td>{{ item.fields.set }} {{ table.rebrickable(item) }}</td>
+            <td>{{ item.fields.name }}</td>
+            <td>{{ item.theme.name }}</td>
+            <td>{{ item.fields.year }}</td>
+            <td>{{ item.fields.number_of_parts }}</td>
+            <td>{% if retirement_date %}{{ retirement_date }}{% else %}<span class="badge rounded-pill text-bg-light border">Not found</span>{% endif %}</td>
+            {% if g.login.is_authenticated() %}
+            <td>
+              <form action="{{ item.url_for_delete() }}" method="post">
+                <button type="submit" class="btn btn-sm btn-danger"><i class="ri-delete-bin-2-line"></i> Delete</button>
+              </form>
+            </td>
+            {% endif %}
+          </tr>
+        {% endwith %}
       {% endfor %}
     </tbody>
   </table>

From 23515526c829ebdfd68d4362934ad295c9485bc2 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 11:21:10 +0100
Subject: [PATCH 076/154] Make the grid controls normal sized

---
 templates/sets.html | 20 ++++++++++----------
 1 file changed, 10 insertions(+), 10 deletions(-)

diff --git a/templates/sets.html b/templates/sets.html
index 27aff004..2b3271cf 100644
--- a/templates/sets.html
+++ b/templates/sets.html
@@ -16,21 +16,21 @@
     <div class="col-12">
       <div id="grid-sort" class="input-group">
         <span class="input-group-text"><i class="ri-sort-asc"></i><span class="ms-1 d-none d-xxl-inline"> Sort</span></span>
-        <button id="sort-number" type="button" class="btn btn-sm btn-outline-primary"
+        <button id="sort-number" type="button" class="btn btn-outline-primary"
           data-sort-attribute="number" data-sort-natural="true"><i class="ri-hashtag"></i><span class="d-none d-xxl-inline"> Set</span></button>
-        <button id="sort-name" type="button" class="btn btn-sm btn-outline-primary"
+        <button id="sort-name" type="button" class="btn btn-outline-primary"
           data-sort-attribute="name"><i class="ri-pencil-line"></i><span class="d-none d-xxl-inline"> Name</span></button>
-        <button id="sort-theme" type="button" class="btn btn-sm btn-outline-primary"
+        <button id="sort-theme" type="button" class="btn btn-outline-primary"
           data-sort-attribute="theme"><i class="ri-price-tag-3-line"></i><span class="d-none d-xxl-inline"> Theme</span></button>
-        <button id="sort-year" type="button" class="btn btn-sm btn-outline-primary"
+        <button id="sort-year" type="button" class="btn btn-outline-primary"
           data-sort-attribute="year"><i class="ri-calendar-line"></i><span class="d-none d-xxl-inline"> Year</span></button>
-        <button id="sort-minifigure" type="button" class="btn btn-sm btn-outline-primary"
+        <button id="sort-minifigure" type="button" class="btn btn-outline-primary"
           data-sort-attribute="minifigures" data-sort-desc="true"><i class="ri-group-line"></i><span class="d-none d-xxl-inline"> Figures</span></button>
-        <button id="sort-parts" type="button" class="btn btn-sm btn-outline-primary"
+        <button id="sort-parts" type="button" class="btn btn-outline-primary"
           data-sort-attribute="parts" data-sort-desc="true"><i class="ri-shapes-line"></i><span class="d-none d-xxl-inline"> Parts</span></button>
-        <button id="sort-missing" type="button" class="btn btn-sm btn-outline-primary"
+        <button id="sort-missing" type="button" class="btn btn-outline-primary"
           data-sort-attribute="missing" data-sort-desc="true"><i class="ri-error-warning-line"></i><span class="d-none d-xxl-inline"> Missing</span></button>
-        <button id="sort-clear" type="button" class="btn btn-sm btn-outline-dark"
+        <button id="sort-clear" type="button" class="btn btn-outline-dark"
           data-sort-clear="true"><i class="ri-filter-off-line"></i><span class="d-none d-xxl-inline"> Clear</span></button>
       </div>
     </div>
@@ -40,7 +40,7 @@
       <label class="visually-hidden" for="grid-status">Status</label>
       <div class="input-group">
         <span class="input-group-text"><i class="ri-checkbox-line"></i><span class="ms-1 d-none d-xl-inline"> Status</span></span>
-        <select id="grid-status" class="form-select form-select-sm"
+        <select id="grid-status" class="form-select"
           data-filter="metadata"
           autocomplete="off">
           <option value="" selected>All</option>
@@ -58,7 +58,7 @@
       <label class="visually-hidden" for="grid-theme">Theme</label>
       <div class="input-group">
         <span class="input-group-text"><i class="ri-price-tag-3-line"></i><span class="ms-1 d-none d-xl-inline"> Theme</span></span>
-        <select id="grid-theme" class="form-select form-select-sm"
+        <select id="grid-theme" class="form-select"
           data-filter="value" data-filter-attribute="theme"
           autocomplete="off">
           <option value="" selected>All</option>

From 6ec4f160f76d846fc148bea950e25c6129d8e746 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 11:23:38 +0100
Subject: [PATCH 077/154] Make filters collapsible

---
 templates/sets.html | 11 +++++++++--
 1 file changed, 9 insertions(+), 2 deletions(-)

diff --git a/templates/sets.html b/templates/sets.html
index 2b3271cf..c3a93d63 100644
--- a/templates/sets.html
+++ b/templates/sets.html
@@ -31,11 +31,18 @@
         <button id="sort-missing" type="button" class="btn btn-outline-primary"
           data-sort-attribute="missing" data-sort-desc="true"><i class="ri-error-warning-line"></i><span class="d-none d-xxl-inline"> Missing</span></button>
         <button id="sort-clear" type="button" class="btn btn-outline-dark"
-          data-sort-clear="true"><i class="ri-filter-off-line"></i><span class="d-none d-xxl-inline"> Clear</span></button>
+          data-sort-clear="true"><i class="ri-close-circle-line"></i><span class="d-none d-xxl-inline"> Clear</span></button>
+      </div>
+    </div>
+    <div class="col-12">
+      <div class="input-group">
+        <button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#grid-filter" aria-expanded="false" aria-controls="grid-filter">
+          <i class="ri-filter-line"></i> Filters
+        </button>
       </div>
     </div>
   </div>
-  <div id="grid-filter" class="row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
+  <div id="grid-filter" class="collapse row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
     <div class="col-12 flex-grow-1">
       <label class="visually-hidden" for="grid-status">Status</label>
       <div class="input-group">

From 6011173c1f394e7dc9a1e11d8ca9ea1b8619f0dc Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 11:31:38 +0100
Subject: [PATCH 078/154] Make the default collapsed state of grid filters
 configurable through a variable

---
 .env.sample            | 4 ++++
 bricktracker/config.py | 1 +
 templates/sets.html    | 4 ++--
 3 files changed, 7 insertions(+), 2 deletions(-)

diff --git a/.env.sample b/.env.sample
index 46b21317..d6141e63 100644
--- a/.env.sample
+++ b/.env.sample
@@ -226,6 +226,10 @@
 # Default: sets
 # BK_SETS_FOLDER=sets
 
+# Optional: Make the grid filters displayed by default, rather than collapsed
+# Default: false
+# BK_SHOW_GRID_FILTERS=true
+
 # Optional: Skip saving or displaying spare parts
 # Default: false
 # BK_SKIP_SPARE_PARTS=true
diff --git a/bricktracker/config.py b/bricktracker/config.py
index 83cd99a4..cbb64a81 100644
--- a/bricktracker/config.py
+++ b/bricktracker/config.py
@@ -52,6 +52,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
     {'n': 'RETIRED_SETS_PATH', 'd': './retired_sets.csv'},
     {'n': 'SETS_DEFAULT_ORDER', 'd': '"rebrickable_sets"."number" DESC, "rebrickable_sets"."version" ASC'},  # noqa: E501
     {'n': 'SETS_FOLDER', 'd': 'sets', 's': True},
+    {'n': 'SHOW_GRID_FILTERS', 'c': bool},
     {'n': 'SKIP_SPARE_PARTS', 'c': bool},
     {'n': 'SOCKET_NAMESPACE', 'd': 'bricksocket'},
     {'n': 'SOCKET_PATH', 'd': '/bricksocket/'},
diff --git a/templates/sets.html b/templates/sets.html
index c3a93d63..97a7922c 100644
--- a/templates/sets.html
+++ b/templates/sets.html
@@ -36,13 +36,13 @@
     </div>
     <div class="col-12">
       <div class="input-group">
-        <button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#grid-filter" aria-expanded="false" aria-controls="grid-filter">
+        <button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#grid-filter" aria-expanded="{% if config['SHOW_GRID_FILTERS'] %}true{% else %}false{% endif %}" aria-controls="grid-filter">
           <i class="ri-filter-line"></i> Filters
         </button>
       </div>
     </div>
   </div>
-  <div id="grid-filter" class="collapse row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
+  <div id="grid-filter" class="collapse {% if config['SHOW_GRID_FILTERS'] %}show{% endif %} row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
     <div class="col-12 flex-grow-1">
       <label class="visually-hidden" for="grid-status">Status</label>
       <div class="input-group">

From ece15e97fb4323f7e6b2e65615d788f78797bfdc Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 11:44:37 +0100
Subject: [PATCH 079/154] Fix the similar prints icon

---
 templates/part/card.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/templates/part/card.html b/templates/part/card.html
index 43dfcdc5..547ffbc6 100644
--- a/templates/part/card.html
+++ b/templates/part/card.html
@@ -22,7 +22,7 @@
       {{ accordion.cards(sets_missing, 'Sets missing this part', 'sets-missing-inventory', 'part-details', 'set/card.html', icon='error-warning-line') }}
       {{ accordion.cards(minifigures_using, 'Minifigures using this part', 'minifigures-using-inventory', 'part-details', 'minifigure/card.html', icon='group-line') }}
       {{ accordion.cards(minifigures_missing, 'Minifigures missing this part', 'minifigures-missing-inventory', 'part-details', 'minifigure/card.html', icon='error-warning-line') }}
-      {{ accordion.cards(similar_prints, 'Prints using the same base', 'similar-prints', 'part-details', 'part/card.html', icon='palette-line') }}
+      {{ accordion.cards(similar_prints, 'Prints using the same base', 'similar-prints', 'part-details', 'part/card.html', icon='paint-brush-line') }}
     </div>
     <div class="card-footer"></div>
   {% endif %}

From 6262ac7889d7343b2d59f5a805c3c3860d1f4337 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 14:46:50 +0100
Subject: [PATCH 080/154] Use badge macros in the card header

---
 bricktracker/rebrickable_part.py | 13 ++++++++-----
 templates/macro/badge.html       | 23 +++++++++++++++++++++--
 templates/macro/card.html        | 16 +++++-----------
 3 files changed, 34 insertions(+), 18 deletions(-)

diff --git a/bricktracker/rebrickable_part.py b/bricktracker/rebrickable_part.py
index eea6d083..8fdd3cb0 100644
--- a/bricktracker/rebrickable_part.py
+++ b/bricktracker/rebrickable_part.py
@@ -119,11 +119,14 @@ class RebrickablePart(BrickRecord):
 
     # Compute the url for the original of the printed part
     def url_for_print(self, /) -> str:
-        return url_for(
-            'part.details',
-            part=self.fields.print,
-            color=self.fields.color,
-        )
+        if self.fields.print is not None:
+            return url_for(
+                'part.details',
+                part=self.fields.print,
+                color=self.fields.color,
+            )
+        else:
+            return ''
 
     # Compute the url for the rebrickable page
     def url_for_rebrickable(self, /) -> str:
diff --git a/templates/macro/badge.html b/templates/macro/badge.html
index 70b10f1e..722a357b 100644
--- a/templates/macro/badge.html
+++ b/templates/macro/badge.html
@@ -1,14 +1,15 @@
-{% macro badge(check=none, url=none, solo=false, last=false, color='primary', blank=none, icon=none, alt=none, collapsible=none, text=none, tooltip=none) %}
+{% macro badge(check=none, url=none, solo=false, last=false, header=false, color='primary', rgb=none, blank=none, icon=none, alt=none, collapsible=none, text=none, tooltip=none) %}
   {% if check or url %}
     {% if url %}
       <a href="{{ url }}" {% if blank %}target="_blank"{% endif %}
     {% else %}
       <span
     {% endif %}
-      class="badge text-bg-{{ color }} fw-normal mb-1 {% if solo %}fs-6{% endif %}" {% if alt %}alt="{{ alt }}"{% endif %}
+      class="badge text-bg-{{ color }} fw-normal {% if not header %}mb-1{% endif %} {% if solo and not header %}fs-6{% endif %}" {% if alt %}alt="{{ alt }}"{% endif %}
       {% if tooltip %} data-bs-toggle="tooltip" title="{{ tooltip }}"{% endif %}
     >
     {% if icon %}<i class="ri-{{ icon }}"></i>{% endif %}
+    {% if rgb %}<span class="color-rgb {% if rgb == 'any' %}color-any{% endif %} align-bottom border border-black" {% if rgb != 'any' %}style="background-color: #{{ rgb }};"{% endif %}></span>{% endif %}
     {% if collapsible and not last %}<span {% if not solo %}class="d-none d-md-inline"{% endif %}> {{ collapsible }} </span>{% endif %}
     {% if text %}{{ text }}{% endif %}
     {% if url %}
@@ -23,6 +24,20 @@
   {{ badge(url=item.url_for_bricklink(), solo=solo, last=last, blank=true, color='light border', icon='external-link-line', collapsible='Bricklink', alt='Bricklink') }}
 {% endmacro %}
 
+{% macro color(item, icon=none, solo=false, last=false, header=false) %}
+  {% if item.fields.color == 9999 %}
+    {% set rgb = 'any' %}
+  {% else %}
+      {% set rgb = item.fields.color_rgb %}
+  {% endif %}
+  {{ badge(check=item.fields.color_name, solo=solo, last=last, header=header, color=' bg-white text-black border', rgb=rgb, icon='palette-line', collapsible=item.fields.color_name) }}
+  {{ badge(check=item.fields.color_transparent, solo=solo, last=last, header=header, color='light border', icon='blur-off-line', collapsible='Transparent') }}
+{% endmacro %}
+
+{% macro identifier(id, icon=none, solo=false, last=false, header=false) %}
+  {{ badge(check=id, solo=solo, last=last, header=header, color='secondary', icon=icon, text=id) }}
+{% endmacro %}
+
 {% macro instructions(item, solo=false, last=false) %}
   {{ badge(url=item.url_for_instructions(), solo=solo, last=last, blank=true, color='light border', icon='file-line', collapsible='Instructions:', text=item.instructions | length, alt='Instructions') }}
 {% endmacro %}
@@ -35,6 +50,10 @@
   {{ badge(check=quantity, solo=solo, last=last, color='success', icon='close-line', collapsible='Quantity:', text=quantity, alt='Quantity') }}
 {% endmacro %}
 
+{% macro print(item, solo=false, last=false, header=false) %}
+  {{ badge(url=item.url_for_print(), solo=solo, last=last, color='light border', icon='paint-brush-line', collapsible='Print') }}
+{% endmacro %}
+
 {% macro set(set, solo=false, last=false, url=None, id=None) %}
   {% if id %}
       {% set url=url_for('set.details', id=id) %}
diff --git a/templates/macro/card.html b/templates/macro/card.html
index ae8bce01..be6ad741 100644
--- a/templates/macro/card.html
+++ b/templates/macro/card.html
@@ -1,21 +1,15 @@
+{% import 'macro/badge.html' as badge %}
+
 {% macro header(item, name, solo=false, identifier=none, icon='hashtag') %}
   <div class="card-header">
     {% if not solo %}
       <a class="text-decoration-none text-reset" href="{{ item.url() }}" data-bs-toggle="tooltip" title="{{ name }}">
     {% endif %}
     <h5 class="mb-0 {% if not solo %}fs-6 text-nowrap overflow-x-hidden text-truncate{% endif %}">
-      {% if identifier %}<span class="badge text-bg-secondary fw-normal"><i class="ri-{{ icon }}"></i>{{ identifier }}</span>{% endif %}
+      {{ badge.identifier(identifier, icon=icon, solo=solo, header=true) }}
       {% if solo %}
-        {% if item.fields.color_name %}
-          <span class="badge bg-white text-black fw-normal border"><i class="ri-palette-line"></i>
-            {% if item.fields.color_rgb %}
-              <span class="color-rgb {% if item.fields.color == 9999 %}color-any{% endif %} align-bottom border border-black" {% if item.fields.color != 9999 %}style="background-color: #{{ item.fields.color_rgb }};"{% endif %}></span>
-            {% endif %}
-            {{ item.fields.color_name }}
-          </span>
-        {% endif %}
-        {% if item.fields.color_transparent %}<span class="badge text-bg-light fw-normal border"><i class="ri-blur-off-line"></i> Transparent</span>{% endif %}
-        {% if item.fields.print %}<span class="badge text-bg-light fw-normal border"><a class="text-reset" href="{{ item.url_for_print() }}"><i class="ri-paint-brush-line"></i> Print</a></span>{% endif %}
+        {{ badge.color(item, header=true) }}
+        {{ badge.print(item, header=true) }}
       {% endif %}
       {{ name }}
     </h5>

From adb2170d47f7b833002195d652ec6af7a24b45c7 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 14:54:00 +0100
Subject: [PATCH 081/154] Fix print badge for elements no having this field

---
 templates/macro/badge.html | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/templates/macro/badge.html b/templates/macro/badge.html
index 722a357b..3d8a5e26 100644
--- a/templates/macro/badge.html
+++ b/templates/macro/badge.html
@@ -51,7 +51,9 @@
 {% endmacro %}
 
 {% macro print(item, solo=false, last=false, header=false) %}
+  {% if item.fields.print %}
   {{ badge(url=item.url_for_print(), solo=solo, last=last, color='light border', icon='paint-brush-line', collapsible='Print') }}
+  {% endif %}
 {% endmacro %}
 
 {% macro set(set, solo=false, last=false, url=None, id=None) %}

From 47261ed4208399252e4823a872e7df14bd7ce21f Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 14:57:16 +0100
Subject: [PATCH 082/154] Display color and print for part cards not solo

---
 templates/part/card.html | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/templates/part/card.html b/templates/part/card.html
index 547ffbc6..4cb40314 100644
--- a/templates/part/card.html
+++ b/templates/part/card.html
@@ -6,6 +6,10 @@
   {{ card.header(item, item.fields.name, solo=solo, identifier=item.fields.part, icon='shapes-line') }}
   {{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.image_id, medium=true) }}
   <div class="card-body border-bottom-0 {% if not solo %}p-1{% endif %}">
+    {% if not solo %}
+      {{ badge.color(item) }}
+      {{ badge.print(item) }}
+    {% endif %}
     {{ badge.total_sets(sets_using | length, solo=solo, last=last) }}
     {{ badge.total_minifigures(minifigures_using | length, solo=solo, last=last) }}
     {{ badge.total_quantity(item.fields.total_quantity, solo=solo, last=last) }}

From 5fcd76febb30e30cff5c65e4e7a2587f5ea2e606 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 15:29:32 +0100
Subject: [PATCH 083/154] Missing quotes around SQL identifier

---
 bricktracker/sql/minifigure/list/all.sql | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/bricktracker/sql/minifigure/list/all.sql b/bricktracker/sql/minifigure/list/all.sql
index ca23068b..e3ce2bda 100644
--- a/bricktracker/sql/minifigure/list/all.sql
+++ b/bricktracker/sql/minifigure/list/all.sql
@@ -24,7 +24,7 @@ LEFT JOIN (
     GROUP BY
         "bricktracker_parts"."id",
         "bricktracker_parts"."figure"
-) missing_join
+) "missing_join"
 ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "missing_join"."id"
 AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "missing_join"."figure"
 {% endblock %}

From d4037cd953525c238de62c68667775d7a663a0af Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 15:43:32 +0100
Subject: [PATCH 084/154] Fix socket always in refresh mode

---
 static/scripts/socket/set.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/static/scripts/socket/set.js b/static/scripts/socket/set.js
index 4f6bf978..07d7cc7e 100644
--- a/static/scripts/socket/set.js
+++ b/static/scripts/socket/set.js
@@ -4,7 +4,7 @@ class BrickSetSocket extends BrickSocket {
         super(id, path, namespace, messages, bulk);
 
         // Refresh mode
-        this.refresh = true
+        this.refresh = refresh
 
         // Listeners
         this.add_listener = undefined;

From ba8744befb85448f123a0abd6022d1d3ea9d4b8c Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 15:45:28 +0100
Subject: [PATCH 085/154] Merge add and bulk add templates

---
 bricktracker/views/add.py |  5 +--
 templates/add.html        | 33 ++++++++++++--------
 templates/bulk.html       | 64 ---------------------------------------
 3 files changed, 23 insertions(+), 79 deletions(-)
 delete mode 100644 templates/bulk.html

diff --git a/bricktracker/views/add.py b/bricktracker/views/add.py
index 218a0bfa..44f3ddca 100644
--- a/bricktracker/views/add.py
+++ b/bricktracker/views/add.py
@@ -31,8 +31,9 @@ def bulk() -> str:
     BrickConfigurationList.error_unless_is_set('REBRICKABLE_API_KEY')
 
     return render_template(
-        'bulk.html',
+        'add.html',
         path=current_app.config['SOCKET_PATH'],
         namespace=current_app.config['SOCKET_NAMESPACE'],
-        messages=MESSAGES
+        messages=MESSAGES,
+        bulk=True
     )
diff --git a/templates/add.html b/templates/add.html
index 5316ea17..12387398 100644
--- a/templates/add.html
+++ b/templates/add.html
@@ -1,10 +1,10 @@
 {% extends 'base.html' %}
 
-{% block title %} - Add a set{% endblock %}
+{% block title %} - {% if not bulk %}Add a set{% else %}Bulk add sets{% endif %}{% endblock %}
 
 {% block main %}
 <div class="container">
-  {% if not config['HIDE_ADD_BULK_SET'] %}
+  {% if not bulk and not config['HIDE_ADD_BULK_SET'] %}
   <div class="alert alert-primary" role="alert">
     <h4 class="alert-heading">Too many to add?</h4>
     <p class="mb-0">You can import multiple sets at once with <a href="{{ url_for('add.bulk') }}" class="btn btn-primary"><i class="ri-function-add-line"></i> Bulk add</a>.</p>
@@ -14,17 +14,21 @@
     <div class="col-12">
       <div class="card mb-3">
         <div class="card-header">
-          <h5 class="mb-0"><i class="ri-add-circle-line"></i> Add a set</h5>
+          <h5 class="mb-0"><i class="ri-add-circle-line"></i> {% if not bulk %}Add a set{% else %}Bulk add sets{% endif %}</h5>
         </div>
         <div class="card-body">
           <div id="add-fail" class="alert alert-danger d-none" role="alert"></div>
-          <div id="add-complete" class="alert alert-success d-none" role="alert"></div>
+          {% if not bulk %}
+            <div id="add-complete" class="alert alert-success d-none" role="alert"></div>
+          {% else %}
+            <div id="add-complete"></div>
+          {% endif %}
           <div class="mb-3">
-            <label for="add-set" class="form-label">Set number (only one)</label>
-            <input type="text" class="form-control" id="add-set" placeholder="107-1 or 1642-1 or ...">
+            <label for="add-set" class="form-label">{% if not bulk %}Set number (only one){% else %}List of sets (separated by a comma){% endif %}</label>
+            <input type="text" class="form-control" id="add-set" placeholder="{% if not bulk %}107-1 or 1642-1 or ...{% else %}107-1, 1642-1, ...{% endif %}">
           </div>
           <div class="form-check">
-            <input type="checkbox" class="form-check-input" id="add-no-confirm">
+            <input type="checkbox" class="form-check-input" id="add-no-confirm" {% if bulk %}checked disabled{% endif %}>
             <label class="form-check-label" for="add-no-confirm">
               Add without confirmation
             </label>
@@ -54,21 +58,24 @@
               <div id="add-card-image-container" class="card-img">
                 <img id="add-card-image" loading="lazy">
               </div>
-              <div id="add-card-footer" class="card-footer text-end d-none">
-                <button id="add-card-dismiss" type="button" class="btn btn-danger"><i class="ri-close-line"></i> Dismiss</button>
-                <button id="add-card-confirm" type="button" class="btn btn-primary"><i class="ri-check-double-line"></i> Confirm add</button>
-              </div>
+              {% if not bulk %}
+                <div id="add-card-footer" class="card-footer text-end d-none">
+                  <button id="add-card-dismiss" type="button" class="btn btn-danger"><i class="ri-close-line"></i> Dismiss</button>
+                  <button id="add-card-confirm" type="button" class="btn btn-primary"><i class="ri-check-double-line"></i> Confirm add</button>
+                </div>
+              {% endif %}
             </div>
           </div>
         </div>
         <div class="card-footer text-end">
-          <span id="add-status-icon" class="me-1"></span><span id="add-status" class="me-1"></span><button id="add" type="button" class="btn btn-primary"><i class="ri-add-circle-line"></i> Add</button>
+          <span id="add-status-icon" class="me-1"></span><span id="add-status" class="me-1"></span>
+          <button id="add" type="button" class="btn btn-primary">{% if not bulk %}<i class="ri-add-circle-line"></i> Add{% else %}<i class="ri-function-add-line"></i> Bulk add{% endif %}</button>
         </div>
       </div>
     </div>
   </div>
 </div>
-{% with id='add' %}
+{% with id='add', bulk=bulk %}
   {% include 'set/socket.html' %}
 {% endwith %}
 {% endblock %}
diff --git a/templates/bulk.html b/templates/bulk.html
deleted file mode 100644
index 00d47797..00000000
--- a/templates/bulk.html
+++ /dev/null
@@ -1,64 +0,0 @@
-{% extends 'base.html' %}
-
-{% block title %} - Bulk add sets{% endblock %}
-
-{% block main %}
-<div class="container">
-  <div class="row">
-    <div class="col-12">
-      <div class="card mb-3">
-        <div class="card-header">
-          <h5 class="mb-0"><i class="ri-add-circle-line"></i> Bulk add sets</h5>
-        </div>
-        <div class="card-body">
-          <div id="add-fail" class="alert alert-danger d-none" role="alert"></div>
-          <div id="add-complete"></div>
-          <div class="mb-3">
-            <label for="add-set" class="form-label">List of sets (separated by a comma)</label>
-            <input type="text" class="form-control" id="add-set" placeholder="107-1, 1642-1, ...">
-          </div>
-          <div class="form-check">
-            <input type="checkbox" class="form-check-input" id="add-no-confirm" checked disabled>
-            <label class="form-check-label" for="add-no-confirm">
-              Add without confirmation
-            </label>
-          </div>
-          <hr>
-          <div class="mb-3">
-            <p>
-              Progress <span id="add-count"></span>
-              <span id="add-spinner" class="d-none">
-                <span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
-                <span class="visually-hidden" role="status">Loading...</span>
-              </span>
-            </p>
-            <div id="add-progress" class="progress" role="progressbar" aria-label="Add a set progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
-              <div id="add-progress-bar" class="progress-bar" style="width: 0%"></div>
-            </div>
-            <p id="add-progress-message" class="text-center d-none"></p>
-          </div>
-          <div id="add-card" class="d-flex d-none justify-content-center">
-            <div class="card mb-3 col-6">
-              <div class="card-header">
-                <h5 class="mb-0">
-                  <span class="badge text-bg-secondary fw-normal"><i class="ri-hashtag"></i> <span id="add-card-number"></span></span>
-                  <span id="add-card-name"></span>
-                </h5>
-              </div>
-              <div id="add-card-image-container" class="card-img">
-                <img id="add-card-image" loading="lazy">
-              </div>
-            </div>
-          </div>
-        </div>
-        <div class="card-footer text-end">
-          <span id="add-status-icon" class="me-1"></span><span id="add-status" class="me-1"></span><button id="add" type="button" class="btn btn-primary"><i class="ri-function-add-line"></i> Bulk add</button>
-        </div>
-      </div>
-    </div>
-  </div>
-</div>
-{% with id='add', bulk=true %}
-  {% include 'set/socket.html' %}
-{% endwith %}
-{% endblock %}

From b8d4f23a84955cc182cc249044ba9823018b7c24 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 16:34:52 +0100
Subject: [PATCH 086/154] Set owners

---
 bricktracker/app.py                           |  6 +-
 bricktracker/metadata.py                      | 12 ++-
 bricktracker/metadata_list.py                 |  5 +-
 bricktracker/record_list.py                   |  2 +
 bricktracker/reload.py                        |  5 ++
 bricktracker/set.py                           | 10 +++
 bricktracker/set_list.py                      |  4 +
 bricktracker/set_owner.py                     | 16 ++++
 bricktracker/set_owner_list.py                | 17 ++++
 bricktracker/sql/migrations/0013.sql          | 19 +++++
 bricktracker/sql/set/base/base.sql            |  3 +
 bricktracker/sql/set/base/full.sql            |  5 ++
 bricktracker/sql/set/delete/set.sql           |  3 +
 bricktracker/sql/set/metadata/owner/base.sql  |  6 ++
 .../sql/set/metadata/owner/delete.sql         |  9 ++
 .../sql/set/metadata/owner/insert.sql         | 14 ++++
 bricktracker/sql/set/metadata/owner/list.sql  |  1 +
 .../sql/set/metadata/owner/select.sql         |  5 ++
 .../sql/set/metadata/owner/update/field.sql   |  3 +
 .../sql/set/metadata/owner/update/state.sql   | 10 +++
 bricktracker/sql_counter.py                   |  3 +-
 bricktracker/version.py                       |  2 +-
 bricktracker/views/add.py                     |  4 +
 bricktracker/views/admin/admin.py             | 11 ++-
 bricktracker/views/admin/owner.py             | 84 +++++++++++++++++++
 bricktracker/views/index.py                   |  5 +-
 bricktracker/views/set.py                     | 19 ++++-
 static/scripts/socket/set.js                  | 16 ++++
 templates/add.html                            | 13 +++
 templates/admin.html                          |  9 +-
 templates/admin/owner.html                    | 42 ++++++++++
 templates/admin/owner/delete.html             | 19 +++++
 templates/macro/badge.html                    | 11 ++-
 templates/set/card.html                       |  9 ++
 templates/set/management.html                 | 18 +++-
 templates/sets.html                           | 16 +++-
 36 files changed, 418 insertions(+), 18 deletions(-)
 create mode 100644 bricktracker/set_owner.py
 create mode 100644 bricktracker/set_owner_list.py
 create mode 100644 bricktracker/sql/migrations/0013.sql
 create mode 100644 bricktracker/sql/set/metadata/owner/base.sql
 create mode 100644 bricktracker/sql/set/metadata/owner/delete.sql
 create mode 100644 bricktracker/sql/set/metadata/owner/insert.sql
 create mode 100644 bricktracker/sql/set/metadata/owner/list.sql
 create mode 100644 bricktracker/sql/set/metadata/owner/select.sql
 create mode 100644 bricktracker/sql/set/metadata/owner/update/field.sql
 create mode 100644 bricktracker/sql/set/metadata/owner/update/state.sql
 create mode 100644 bricktracker/views/admin/owner.py
 create mode 100644 templates/admin/owner.html
 create mode 100644 templates/admin/owner/delete.html

diff --git a/bricktracker/app.py b/bricktracker/app.py
index f6054bcb..15cb9a3d 100644
--- a/bricktracker/app.py
+++ b/bricktracker/app.py
@@ -13,11 +13,12 @@ from bricktracker.sql import close
 from bricktracker.version import __version__
 from bricktracker.views.add import add_page
 from bricktracker.views.admin.admin import admin_page
-from bricktracker.views.admin.status import admin_status_page
 from bricktracker.views.admin.database import admin_database_page
 from bricktracker.views.admin.image import admin_image_page
 from bricktracker.views.admin.instructions import admin_instructions_page
+from bricktracker.views.admin.owner import admin_owner_page
 from bricktracker.views.admin.retired import admin_retired_page
+from bricktracker.views.admin.status import admin_status_page
 from bricktracker.views.admin.theme import admin_theme_page
 from bricktracker.views.error import error_404
 from bricktracker.views.index import index_page
@@ -78,11 +79,12 @@ def setup_app(app: Flask) -> None:
 
     # Register admin routes
     app.register_blueprint(admin_page)
-    app.register_blueprint(admin_status_page)
     app.register_blueprint(admin_database_page)
     app.register_blueprint(admin_image_page)
     app.register_blueprint(admin_instructions_page)
     app.register_blueprint(admin_retired_page)
+    app.register_blueprint(admin_owner_page)
+    app.register_blueprint(admin_status_page)
     app.register_blueprint(admin_theme_page)
 
     # An helper to make global variables available to the
diff --git a/bricktracker/metadata.py b/bricktracker/metadata.py
index c7a9678b..4b7c54ea 100644
--- a/bricktracker/metadata.py
+++ b/bricktracker/metadata.py
@@ -176,8 +176,16 @@ class BrickMetadata(BrickRecord):
         return value
 
     # Update the selected state of this metadata item for a set
-    def update_set_state(self, brickset: 'BrickSet', json: Any | None) -> Any:
-        state: bool = json.get('value', False)  # type: ignore
+    def update_set_state(
+        self,
+        brickset: 'BrickSet',
+        /,
+        *,
+        json: Any | None = None,
+        state: bool | None = None,
+    ) -> Any:
+        if state is None:
+            state = json.get('value', False)  # type: ignore
 
         parameters = self.sql_parameters()
         parameters['set_id'] = brickset.fields.id
diff --git a/bricktracker/metadata_list.py b/bricktracker/metadata_list.py
index 80bb3df0..bb2e337b 100644
--- a/bricktracker/metadata_list.py
+++ b/bricktracker/metadata_list.py
@@ -1,14 +1,15 @@
 import logging
-from typing import Type
+from typing import Type, TypeVar
 
 from .exceptions import NotFoundException
 from .fields import BrickRecordFields
 from .record_list import BrickRecordList
+from .set_owner import BrickSetOwner
 from .set_status import BrickSetStatus
 
 logger = logging.getLogger(__name__)
 
-T = BrickSetStatus
+T = TypeVar('T', 'BrickSetStatus', 'BrickSetOwner')
 
 
 # Lego sets metadata list
diff --git a/bricktracker/record_list.py b/bricktracker/record_list.py
index 22752032..8927e717 100644
--- a/bricktracker/record_list.py
+++ b/bricktracker/record_list.py
@@ -8,12 +8,14 @@ if TYPE_CHECKING:
     from .part import BrickPart
     from .rebrickable_set import RebrickableSet
     from .set import BrickSet
+    from .set_owner import BrickSetOwner
     from .set_status import BrickSetStatus
     from .wish import BrickWish
 
 T = TypeVar(
     'T',
     'BrickSet',
+    'BrickSetOwner',
     'BrickSetStatus',
     'BrickPart',
     'BrickMinifigure',
diff --git a/bricktracker/reload.py b/bricktracker/reload.py
index 259cffad..73e9e241 100644
--- a/bricktracker/reload.py
+++ b/bricktracker/reload.py
@@ -1,5 +1,7 @@
 from .instructions_list import BrickInstructionsList
 from .retired_list import BrickRetiredList
+from .set_owner import BrickSetOwner
+from .set_owner_list import BrickSetOwnerList
 from .set_status import BrickSetStatus
 from .set_status_list import BrickSetStatusList
 from .theme_list import BrickThemeList
@@ -12,6 +14,9 @@ def reload() -> None:
         # Reload the instructions
         BrickInstructionsList(force=True)
 
+        # Reload the set owners
+        BrickSetOwnerList(BrickSetOwner, force=True)
+
         # Reload the set statuses
         BrickSetStatusList(BrickSetStatus, force=True)
 
diff --git a/bricktracker/set.py b/bricktracker/set.py
index 32bf8daa..f4bf1a26 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -9,6 +9,8 @@ from .exceptions import NotFoundException
 from .minifigure_list import BrickMinifigureList
 from .part_list import BrickPartList
 from .rebrickable_set import RebrickableSet
+from .set_owner import BrickSetOwner
+from .set_owner_list import BrickSetOwnerList
 from .set_status import BrickSetStatus
 from .set_status_list import BrickSetStatusList
 from .sql import BrickSQL
@@ -68,6 +70,13 @@ class BrickSet(RebrickableSet):
             if not BrickMinifigureList.download(socket, self, refresh=refresh):
                 return False
 
+            # Save the owners
+            owners: list[str] = list(data.get('owners', []))
+
+            for id in owners:
+                owner = BrickSetOwnerList(BrickSetOwner).get(id)
+                owner.update_set_state(self, state=True)
+
             # Commit the transaction to the database
             socket.auto_progress(
                 message='Set {set}: writing to the database'.format(
@@ -162,6 +171,7 @@ class BrickSet(RebrickableSet):
 
         # Load from database
         if not self.select(
+            owners=BrickSetOwnerList(BrickSetOwner).as_columns(),
             statuses=BrickSetStatusList(BrickSetStatus).as_columns(all=True)
         ):
             raise NotFoundException(
diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py
index 251cfdcd..6a94185b 100644
--- a/bricktracker/set_list.py
+++ b/bricktracker/set_list.py
@@ -3,6 +3,8 @@ from typing import Self
 from flask import current_app
 
 from .record_list import BrickRecordList
+from .set_owner import BrickSetOwner
+from .set_owner_list import BrickSetOwnerList
 from .set_status import BrickSetStatus
 from .set_status_list import BrickSetStatusList
 from .set import BrickSet
@@ -38,6 +40,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
         # Load the sets from the database
         for record in self.select(
             order=self.order,
+            owners=BrickSetOwnerList(BrickSetOwner).as_columns(),
             statuses=BrickSetStatusList(BrickSetStatus).as_columns()
         ):
             brickset = BrickSet(record=record)
@@ -74,6 +77,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
         for record in self.select(
             order=order,
             limit=limit,
+            owners=BrickSetOwnerList(BrickSetOwner).as_columns(),
             statuses=BrickSetStatusList(BrickSetStatus).as_columns()
         ):
             brickset = BrickSet(record=record)
diff --git a/bricktracker/set_owner.py b/bricktracker/set_owner.py
new file mode 100644
index 00000000..3c07647b
--- /dev/null
+++ b/bricktracker/set_owner.py
@@ -0,0 +1,16 @@
+from .metadata import BrickMetadata
+
+
+# Lego set owner metadata
+class BrickSetOwner(BrickMetadata):
+    kind: str = 'owner'
+
+    # Set state endpoint
+    set_state_endpoint: str = 'set.update_owner'
+
+    # Queries
+    delete_query: str = 'set/metadata/owner/delete'
+    insert_query: str = 'set/metadata/owner/insert'
+    select_query: str = 'set/metadata/owner/select'
+    update_field_query: str = 'set/metadata/owner/update/field'
+    update_set_state_query: str = 'set/metadata/owner/update/state'
diff --git a/bricktracker/set_owner_list.py b/bricktracker/set_owner_list.py
new file mode 100644
index 00000000..13097490
--- /dev/null
+++ b/bricktracker/set_owner_list.py
@@ -0,0 +1,17 @@
+import logging
+
+from .metadata_list import BrickMetadataList
+from .set_owner import BrickSetOwner
+
+logger = logging.getLogger(__name__)
+
+
+# Lego sets owner list
+class BrickSetOwnerList(BrickMetadataList[BrickSetOwner]):
+    kind: str = 'set owners'
+
+    # Database table
+    table: str = 'bricktracker_set_owners'
+
+    # Queries
+    select_query = 'set/metadata/owner/list'
diff --git a/bricktracker/sql/migrations/0013.sql b/bricktracker/sql/migrations/0013.sql
new file mode 100644
index 00000000..33f8a6f0
--- /dev/null
+++ b/bricktracker/sql/migrations/0013.sql
@@ -0,0 +1,19 @@
+-- description: Add set owners
+
+BEGIN TRANSACTION;
+
+-- Create a table to define each set owners: an id and a name
+CREATE TABLE "bricktracker_metadata_owners" (
+    "id" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    PRIMARY KEY("id")
+);
+
+-- Create a table for the set owners
+CREATE TABLE "bricktracker_set_owners" (
+    "id" TEXT NOT NULL,
+    PRIMARY KEY("id"),
+    FOREIGN KEY("id") REFERENCES "bricktracker_sets"("id")
+);
+
+COMMIT;
diff --git a/bricktracker/sql/set/base/base.sql b/bricktracker/sql/set/base/base.sql
index 8b1f4c88..940dab9f 100644
--- a/bricktracker/sql/set/base/base.sql
+++ b/bricktracker/sql/set/base/base.sql
@@ -9,6 +9,9 @@ SELECT
     "rebrickable_sets"."number_of_parts",
     "rebrickable_sets"."image",
     "rebrickable_sets"."url",
+    {% block owners %}
+        {% if owners %}{{ owners }},{% endif %}
+    {% endblock %}
     {% block statuses %}
         {% if statuses %}{{ statuses }},{% endif %}
     {% endblock %}
diff --git a/bricktracker/sql/set/base/full.sql b/bricktracker/sql/set/base/full.sql
index 70730ff6..725b56dc 100644
--- a/bricktracker/sql/set/base/full.sql
+++ b/bricktracker/sql/set/base/full.sql
@@ -13,6 +13,11 @@ IFNULL("minifigures_join"."total", 0) AS "total_minifigures"
 {% endblock %}
 
 {% block join %}
+{% if owners %}
+LEFT JOIN "bricktracker_set_owners"
+ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
+{% endif %}
+
 {% if statuses %}
 LEFT JOIN "bricktracker_set_statuses"
 ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_statuses"."id"
diff --git a/bricktracker/sql/set/delete/set.sql b/bricktracker/sql/set/delete/set.sql
index 49b0e884..2db140de 100644
--- a/bricktracker/sql/set/delete/set.sql
+++ b/bricktracker/sql/set/delete/set.sql
@@ -6,6 +6,9 @@ BEGIN TRANSACTION;
 DELETE FROM "bricktracker_sets"
 WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM '{{ id }}';
 
+DELETE FROM "bricktracker_set_owners"
+WHERE "bricktracker_set_owners"."id" IS NOT DISTINCT FROM '{{ id }}';
+
 DELETE FROM "bricktracker_set_statuses"
 WHERE "bricktracker_set_statuses"."id" IS NOT DISTINCT FROM '{{ id }}';
 
diff --git a/bricktracker/sql/set/metadata/owner/base.sql b/bricktracker/sql/set/metadata/owner/base.sql
new file mode 100644
index 00000000..095ae3d6
--- /dev/null
+++ b/bricktracker/sql/set/metadata/owner/base.sql
@@ -0,0 +1,6 @@
+SELECT
+    "bricktracker_metadata_owners"."id",
+    "bricktracker_metadata_owners"."name"
+FROM "bricktracker_metadata_owners"
+
+{% block where %}{% endblock %}
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/owner/delete.sql b/bricktracker/sql/set/metadata/owner/delete.sql
new file mode 100644
index 00000000..e9df18d8
--- /dev/null
+++ b/bricktracker/sql/set/metadata/owner/delete.sql
@@ -0,0 +1,9 @@
+BEGIN TRANSACTION;
+
+ALTER TABLE "bricktracker_set_owners"
+DROP COLUMN "owner_{{ id }}";
+
+DELETE FROM "bricktracker_metadata_owners"
+WHERE "bricktracker_metadata_owners"."id" IS NOT DISTINCT FROM '{{ id }}';
+
+COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/owner/insert.sql b/bricktracker/sql/set/metadata/owner/insert.sql
new file mode 100644
index 00000000..cc54a2a5
--- /dev/null
+++ b/bricktracker/sql/set/metadata/owner/insert.sql
@@ -0,0 +1,14 @@
+BEGIN TRANSACTION;
+
+ALTER TABLE "bricktracker_set_owners"
+ADD COLUMN "owner_{{ id }}" BOOLEAN NOT NULL DEFAULT 0;
+
+INSERT INTO "bricktracker_metadata_owners" (
+    "id",
+    "name"
+) VALUES (
+    '{{ id }}',
+    '{{ name }}'
+);
+
+COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/owner/list.sql b/bricktracker/sql/set/metadata/owner/list.sql
new file mode 100644
index 00000000..e970cf9c
--- /dev/null
+++ b/bricktracker/sql/set/metadata/owner/list.sql
@@ -0,0 +1 @@
+{% extends 'set/metadata/owner/base.sql' %}
diff --git a/bricktracker/sql/set/metadata/owner/select.sql b/bricktracker/sql/set/metadata/owner/select.sql
new file mode 100644
index 00000000..82245656
--- /dev/null
+++ b/bricktracker/sql/set/metadata/owner/select.sql
@@ -0,0 +1,5 @@
+{% extends 'set/metadata/owner/base.sql' %}
+
+{% block where %}
+WHERE "bricktracker_metadata_owners"."id" IS NOT DISTINCT FROM :id
+{% endblock %}
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/owner/update/field.sql b/bricktracker/sql/set/metadata/owner/update/field.sql
new file mode 100644
index 00000000..5f047a33
--- /dev/null
+++ b/bricktracker/sql/set/metadata/owner/update/field.sql
@@ -0,0 +1,3 @@
+UPDATE "bricktracker_metadata_owners"
+SET "{{field}}" = :value
+WHERE "bricktracker_metadata_owners"."id" IS NOT DISTINCT FROM :id
diff --git a/bricktracker/sql/set/metadata/owner/update/state.sql b/bricktracker/sql/set/metadata/owner/update/state.sql
new file mode 100644
index 00000000..24692075
--- /dev/null
+++ b/bricktracker/sql/set/metadata/owner/update/state.sql
@@ -0,0 +1,10 @@
+INSERT INTO "bricktracker_set_owners" (
+    "id",
+    "{{name}}"
+) VALUES (
+    :set_id,
+    :state
+)
+ON CONFLICT("id")
+DO UPDATE SET "{{name}}" = :state
+WHERE "bricktracker_set_owners"."id" IS NOT DISTINCT FROM :set_id
diff --git a/bricktracker/sql_counter.py b/bricktracker/sql_counter.py
index 28c03a32..30c83d6d 100644
--- a/bricktracker/sql_counter.py
+++ b/bricktracker/sql_counter.py
@@ -6,7 +6,8 @@ ALIASES: dict[str, Tuple[str, str]] = {
     'bricktracker_minifigures': ('Bricktracker minifigures', 'group-line'),
     'bricktracker_parts': ('Bricktracker parts', 'shapes-line'),
     'bricktracker_set_checkboxes': ('Bricktracker set checkboxes (legacy)', 'checkbox-line'),  # noqa: E501
-    'bricktracker_set_statuses': ('Bricktracker set statuses', 'checkbox-line'),  # noqa: E501
+    'bricktracker_set_owners': ('Bricktracker set owners', 'checkbox-line'),  # noqa: E501
+    'bricktracker_set_statuses': ('Bricktracker set statuses', 'user-line'),  # noqa: E501
     'bricktracker_set_storages': ('Bricktracker set storages', 'archive-2-line'),  # noqa: E501
     'bricktracker_sets': ('Bricktracker sets', 'hashtag'),
     'bricktracker_wishes': ('Bricktracker wishes', 'gift-line'),
diff --git a/bricktracker/version.py b/bricktracker/version.py
index 11dd9c9a..996b4f61 100644
--- a/bricktracker/version.py
+++ b/bricktracker/version.py
@@ -1,4 +1,4 @@
 from typing import Final
 
 __version__: Final[str] = '1.2.0'
-__database_version__: Final[int] = 12
+__database_version__: Final[int] = 13
diff --git a/bricktracker/views/add.py b/bricktracker/views/add.py
index 44f3ddca..20607dcf 100644
--- a/bricktracker/views/add.py
+++ b/bricktracker/views/add.py
@@ -3,6 +3,8 @@ from flask_login import login_required
 
 from ..configuration_list import BrickConfigurationList
 from .exceptions import exception_handler
+from ..set_owner import BrickSetOwner
+from ..set_owner_list import BrickSetOwnerList
 from ..socket import MESSAGES
 
 add_page = Blueprint('add', __name__, url_prefix='/add')
@@ -17,6 +19,7 @@ def add() -> str:
 
     return render_template(
         'add.html',
+        brickset_owners=BrickSetOwnerList(BrickSetOwner).list(),
         path=current_app.config['SOCKET_PATH'],
         namespace=current_app.config['SOCKET_NAMESPACE'],
         messages=MESSAGES
@@ -32,6 +35,7 @@ def bulk() -> str:
 
     return render_template(
         'add.html',
+        brickset_owners=BrickSetOwnerList(BrickSetOwner).list(),
         path=current_app.config['SOCKET_PATH'],
         namespace=current_app.config['SOCKET_NAMESPACE'],
         messages=MESSAGES,
diff --git a/bricktracker/views/admin/admin.py b/bricktracker/views/admin/admin.py
index c18a74b2..36037d36 100644
--- a/bricktracker/views/admin/admin.py
+++ b/bricktracker/views/admin/admin.py
@@ -8,6 +8,8 @@ from ..exceptions import exception_handler
 from ...instructions_list import BrickInstructionsList
 from ...rebrickable_image import RebrickableImage
 from ...retired_list import BrickRetiredList
+from ...set_owner import BrickSetOwner
+from ...set_owner_list import BrickSetOwnerList
 from ...set_status import BrickSetStatus
 from ...set_status_list import BrickSetStatusList
 from ...sql_counter import BrickCounter
@@ -28,6 +30,7 @@ def admin() -> str:
     database_exception: Exception | None = None
     database_upgrade_needed: bool = False
     database_version: int = -1
+    metadata_owners: list[BrickSetOwner] = []
     metadata_statuses: list[BrickSetStatus] = []
     nil_minifigure_name: str = ''
     nil_minifigure_url: str = ''
@@ -41,6 +44,7 @@ def admin() -> str:
         database_version = database.version
         database_counters = BrickSQL().count_records()
 
+        metadata_owners = BrickSetOwnerList(BrickSetOwner).list()
         metadata_statuses = BrickSetStatusList(BrickSetStatus).list(all=True)
     except Exception as e:
         database_exception = e
@@ -65,6 +69,7 @@ def admin() -> str:
     open_image = request.args.get('open_image', None)
     open_instructions = request.args.get('open_instructions', None)
     open_logout = request.args.get('open_logout', None)
+    open_owner = request.args.get('open_owner', None)
     open_retired = request.args.get('open_retired', None)
     open_status = request.args.get('open_status', None)
     open_theme = request.args.get('open_theme', None)
@@ -73,6 +78,7 @@ def admin() -> str:
         open_image is None and
         open_instructions is None and
         open_logout is None and
+        open_owner is None and
         open_retired is None and
         open_status is None and
         open_theme is None
@@ -81,13 +87,13 @@ def admin() -> str:
     return render_template(
         'admin.html',
         configuration=BrickConfigurationList.list(),
-        status_error=request.args.get('status_error'),
         database_counters=database_counters,
         database_error=request.args.get('database_error'),
         database_exception=database_exception,
         database_upgrade_needed=database_upgrade_needed,
         database_version=database_version,
         instructions=BrickInstructionsList(),
+        metadata_owners=metadata_owners,
         metadata_statuses=metadata_statuses,
         nil_minifigure_name=nil_minifigure_name,
         nil_minifigure_url=nil_minifigure_url,
@@ -98,8 +104,11 @@ def admin() -> str:
         open_image=open_image,
         open_instructions=open_instructions,
         open_logout=open_logout,
+        open_owner=open_owner,
         open_retired=open_retired,
         open_theme=open_theme,
+        owner_error=request.args.get('owner_error'),
+        status_error=request.args.get('status_error'),
         retired=BrickRetiredList(),
         theme=BrickThemeList(),
     )
diff --git a/bricktracker/views/admin/owner.py b/bricktracker/views/admin/owner.py
new file mode 100644
index 00000000..bfa799e3
--- /dev/null
+++ b/bricktracker/views/admin/owner.py
@@ -0,0 +1,84 @@
+from flask import (
+    Blueprint,
+    redirect,
+    request,
+    render_template,
+    url_for,
+)
+from flask_login import login_required
+from werkzeug.wrappers.response import Response
+
+from ..exceptions import exception_handler
+from ...reload import reload
+from ...set_owner import BrickSetOwner
+
+admin_owner_page = Blueprint(
+    'admin_owner',
+    __name__,
+    url_prefix='/admin/owner'
+)
+
+
+# Add a metadata owner
+@admin_owner_page.route('/add', methods=['POST'])
+@login_required
+@exception_handler(
+    __file__,
+    post_redirect='admin.admin',
+    error_name='owner_error',
+    open_owner=True
+)
+def add() -> Response:
+    BrickSetOwner().from_form(request.form).insert()
+
+    reload()
+
+    return redirect(url_for('admin.admin', open_owner=True))
+
+
+# Delete the metadata owner
+@admin_owner_page.route('<id>/delete', methods=['GET'])
+@login_required
+@exception_handler(__file__)
+def delete(*, id: str) -> str:
+    return render_template(
+        'admin.html',
+        delete_owner=True,
+        owner=BrickSetOwner().select_specific(id),
+        error=request.args.get('owner_error')
+    )
+
+
+# Actually delete the metadata owner
+@admin_owner_page.route('<id>/delete', methods=['POST'])
+@login_required
+@exception_handler(
+    __file__,
+    post_redirect='admin_owner.delete',
+    error_name='owner_error'
+)
+def do_delete(*, id: str) -> Response:
+    owner = BrickSetOwner().select_specific(id)
+    owner.delete()
+
+    reload()
+
+    return redirect(url_for('admin.admin', open_owner=True))
+
+
+# Rename the metadata owner
+@admin_owner_page.route('<id>/rename', methods=['POST'])
+@login_required
+@exception_handler(
+    __file__,
+    post_redirect='admin.admin',
+    error_name='owner_error',
+    open_owner=True
+)
+def rename(*, id: str) -> Response:
+    owner = BrickSetOwner().select_specific(id)
+    owner.from_form(request.form).rename()
+
+    reload()
+
+    return redirect(url_for('admin.admin', open_owner=True))
diff --git a/bricktracker/views/index.py b/bricktracker/views/index.py
index f8fe7b71..3d8a55ec 100644
--- a/bricktracker/views/index.py
+++ b/bricktracker/views/index.py
@@ -2,6 +2,8 @@ from flask import Blueprint, render_template
 
 from .exceptions import exception_handler
 from ..minifigure_list import BrickMinifigureList
+from ..set_owner import BrickSetOwner
+from ..set_owner_list import BrickSetOwnerList
 from ..set_status import BrickSetStatus
 from ..set_status_list import BrickSetStatusList
 from ..set_list import BrickSetList
@@ -16,6 +18,7 @@ def index() -> str:
     return render_template(
         'index.html',
         brickset_collection=BrickSetList().last(),
-        minifigure_collection=BrickMinifigureList().last(),
+        brickset_owners=BrickSetOwnerList(BrickSetOwner).list(),
         brickset_statuses=BrickSetStatusList(BrickSetStatus).list(),
+        minifigure_collection=BrickMinifigureList().last(),
     )
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index 9f1990cc..fd922ec4 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -16,6 +16,8 @@ from .exceptions import exception_handler
 from ..minifigure import BrickMinifigure
 from ..part import BrickPart
 from ..set import BrickSet
+from ..set_owner import BrickSetOwner
+from ..set_owner_list import BrickSetOwnerList
 from ..set_status import BrickSetStatus
 from ..set_status_list import BrickSetStatusList
 from ..set_list import BrickSetList
@@ -33,11 +35,25 @@ def list() -> str:
     return render_template(
         'sets.html',
         collection=BrickSetList().all(),
+        brickset_owners=BrickSetOwnerList(BrickSetOwner).list(),
         brickset_statuses=BrickSetStatusList(BrickSetStatus).list(),
     )
 
 
-# Change the status of a status
+# Change the state of a owner
+@set_page.route('/<id>/owner/<metadata_id>', methods=['POST'])
+@login_required
+@exception_handler(__file__, json=True)
+def update_owner(*, id: str, metadata_id: str) -> Response:
+    brickset = BrickSet().select_light(id)
+    owner = BrickSetOwnerList(BrickSetOwner).get(metadata_id)
+
+    state = owner.update_set_state(brickset, json=request.json)
+
+    return jsonify({'value': state})
+
+
+# Change the state of a status
 @set_page.route('/<id>/status/<metadata_id>', methods=['POST'])
 @login_required
 @exception_handler(__file__, json=True)
@@ -98,6 +114,7 @@ def details(*, id: str) -> str:
         'set.html',
         item=BrickSet().select_specific(id),
         open_instructions=request.args.get('open_instructions'),
+        brickset_owners=BrickSetOwnerList(BrickSetOwner).list(),
         brickset_statuses=BrickSetStatusList(BrickSetStatus).list(all=True),
     )
 
diff --git a/static/scripts/socket/set.js b/static/scripts/socket/set.js
index 07d7cc7e..6459aa66 100644
--- a/static/scripts/socket/set.js
+++ b/static/scripts/socket/set.js
@@ -15,6 +15,7 @@ class BrickSetSocket extends BrickSocket {
         this.html_button = document.getElementById(id);
         this.html_input = document.getElementById(`${id}-set`);
         this.html_no_confim = document.getElementById(`${id}-no-confirm`);
+        this.html_owners = document.getElementById(`${id}-owners`);
 
         // Card elements
         this.html_card = document.getElementById(`${id}-card`);
@@ -139,10 +140,21 @@ class BrickSetSocket extends BrickSocket {
                 this.set_list_last_set = set;
             }
 
+            // Grab the owners
+            const owners = [];
+            if (this.html_owners) {
+                this.html_owners.querySelectorAll('input').forEach(input => {
+                    if (input.checked) {
+                        owners.push(input.value);
+                    }
+                });
+            }
+
             this.spinner(true);
 
             this.socket.emit(this.messages.IMPORT_SET, {
                 set: (set !== undefined) ? set : this.html_input.value,
+                owners: owners,
                 refresh: this.refresh
             });
         } else {
@@ -247,6 +259,10 @@ class BrickSetSocket extends BrickSocket {
             this.html_input.disabled = !enabled;
         }
 
+        if (this.html_owners) {
+            this.html_owners.querySelectorAll('input').forEach(input => input.disabled = !enabled);
+        }
+
         if (this.html_card_confirm) {
             this.html_card_confirm.disabled = !enabled;
         }
diff --git a/templates/add.html b/templates/add.html
index 12387398..59c50299 100644
--- a/templates/add.html
+++ b/templates/add.html
@@ -33,6 +33,19 @@
               Add without confirmation
             </label>
           </div>
+          {% if brickset_owners | length %}
+            <h5 class="border-bottom mt-2">Owners</h5>
+            <div id="add-owners">
+            {% for owner in brickset_owners %}
+              {% with id=owner.as_dataset() %}
+                <div class="form-check">
+                  <input class="form-check-input" type="checkbox" value="{{ owner.fields.id }}" id="{{ id }}" autocomplete="off">
+                  <label class="form-check-label" for="{{ id }}">{{ owner.fields.name }}</label>
+                </div>
+              {% endwith %}
+            {% endfor %}
+            </div>
+          {% endif %}
           <hr>
           <div class="mb-3">
             <p>
diff --git a/templates/admin.html b/templates/admin.html
index b2a54f3a..962730bc 100644
--- a/templates/admin.html
+++ b/templates/admin.html
@@ -12,10 +12,12 @@
             <h5 class="mb-0"><i class="ri-settings-4-line"></i> Administration</h5>
           </div>
           <div class="accordion accordion-flush" id="admin">
-            {% if delete_status %}
-            {% include 'admin/status/delete.html' %}
-            {% elif delete_database %}
+            {% if delete_database %}
             {% include 'admin/database/delete.html' %}
+            {% elif delete_owner %}
+            {% include 'admin/owner/delete.html' %}
+            {% elif delete_status %}
+            {% include 'admin/status/delete.html' %}
             {% elif drop_database %}
             {% include 'admin/database/drop.html' %}
             {% elif import_database %}
@@ -30,6 +32,7 @@
               {% endif %}
               {% include 'admin/theme.html' %}
               {% include 'admin/retired.html' %}
+              {% include 'admin/owner.html' %}
               {% include 'admin/status.html' %}
               {% include 'admin/database.html' %}
               {% include 'admin/configuration.html' %}
diff --git a/templates/admin/owner.html b/templates/admin/owner.html
new file mode 100644
index 00000000..7447a6da
--- /dev/null
+++ b/templates/admin/owner.html
@@ -0,0 +1,42 @@
+{% import 'macro/accordion.html' as accordion %}
+
+{{ accordion.header('Set owners', 'owner', 'admin', expanded=open_owner, icon='user-line', class='p-0') }}
+{% if owner_error %}<div class="alert alert-danger m-2" role="alert"><strong>Error:</strong> {{ owner_error }}.</div>{% endif %}
+<ul class="list-group list-group-flush">
+  {% if metadata_owners | length %}
+    {% for owner in metadata_owners %}
+      <li class="list-group-item">
+        <form action="{{ url_for('admin_owner.rename', id=owner.fields.id) }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
+          <div class="col-12 flex-grow-1">
+            <label class="visually-hidden" for="name-{{ owner.fields.id }}">Name</label>
+            <div class="input-group">
+              <div class="input-group-text">Name</div>
+              <input type="text" class="form-control" id="name-{{ owner.fields.id }}" name="name" value="{{ owner.fields.name }}">
+              <button type="submit" class="btn btn-primary"><i class="ri-edit-line"></i> Rename</button>
+            </div>
+          </div>
+          <div class="col-12">
+            <a href="{{ url_for('admin_owner.delete', id=owner.fields.id) }}" class="btn btn-danger" role="button"><i class="ri-delete-bin-2-line"></i> Delete</a>
+          </div>
+        </form>
+      </li>
+    {% endfor %}
+  {% else %}
+    <li class="list-group-item"><i class="ri-error-warning-line"></i> No owner found.</li>
+  {% endif %}
+  <li class="list-group-item">
+    <form action="{{ url_for('admin_owner.add') }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
+      <div class="col-12 flex-grow-1">
+        <label class="visually-hidden" for="name">Name</label>
+        <div class="input-group">
+          <div class="input-group-text">Name</div>
+          <input type="text" class="form-control" id="name" name="name" value="">
+        </div>
+      </div>
+      <div class="col-12">
+        <button type="submit" class="btn btn-primary"><i class="ri-add-circle-line"></i> Add</button>
+      </div>
+    </form>
+  </li>
+</ul>
+{{ accordion.footer() }}
diff --git a/templates/admin/owner/delete.html b/templates/admin/owner/delete.html
new file mode 100644
index 00000000..56821f35
--- /dev/null
+++ b/templates/admin/owner/delete.html
@@ -0,0 +1,19 @@
+{% import 'macro/accordion.html' as accordion %}
+
+{{ accordion.header('Set owners danger zone', 'owner-danger', 'admin', expanded=true, danger=true, class='text-end') }}
+<form action="{{ url_for('admin_owner.do_delete', id=owner.fields.id) }}" method="post">
+  {% if owner_error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ owner_error }}.</div>{% endif %}
+  <div class="alert alert-danger text-center" role="alert">You are about to <strong>delete a set owner</strong>. This action is irreversible.</div>
+  <div class="row row-cols-lg-auto g-3 align-items-center">
+    <div class="col-12 flex-grow-1">
+      <div class="input-group">
+        <div class="input-group-text">Name</div>
+        <input type="text" class="form-control" value="{{ owner.fields.name }}" disabled>
+      </div>
+    </div>
+  </div>
+  <hr class="border-bottom">
+  <a class="btn btn-danger" href="{{ url_for('admin.admin', open_owner=true) }}" role="button"><i class="ri-arrow-left-long-line"></i> Back to the admin</a>
+  <button type="submit" class="btn btn-danger"><i class="ri-delete-bin-2-line"></i> Delete <strong>the set owner</strong></button>
+</form>
+{{ accordion.footer() }}
diff --git a/templates/macro/badge.html b/templates/macro/badge.html
index 3d8a5e26..2be95ca8 100644
--- a/templates/macro/badge.html
+++ b/templates/macro/badge.html
@@ -50,9 +50,18 @@
   {{ badge(check=quantity, solo=solo, last=last, color='success', icon='close-line', collapsible='Quantity:', text=quantity, alt='Quantity') }}
 {% endmacro %}
 
+{% macro owner(item, owner, solo=false, last=false) %}
+  {% if last  %}
+    {% set tooltip=owner.fields.name %}
+  {% else %}
+    {% set text=owner.fields.name %}
+  {% endif %}
+  {{ badge(check=item.fields[owner.as_column()], solo=solo, last=last, color='light text-success-emphasis bg-success-subtle border border-success-subtle', icon='user-line', text=text, alt='Owner', tooltip=tooltip) }}
+{% endmacro %}
+
 {% macro print(item, solo=false, last=false, header=false) %}
   {% if item.fields.print %}
-  {{ badge(url=item.url_for_print(), solo=solo, last=last, color='light border', icon='paint-brush-line', collapsible='Print') }}
+    {{ badge(url=item.url_for_print(), solo=solo, last=last, color='light border', icon='paint-brush-line', collapsible='Print') }}
   {% endif %}
 {% endmacro %}
 
diff --git a/templates/set/card.html b/templates/set/card.html
index c9afbdd4..9f2f83a7 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -9,6 +9,12 @@
     data-year="{{ item.fields.year }}" data-theme="{{ item.theme.name | lower }}" data-minifigures="{{ item.fields.total_minifigures }}" data-has-minifigures="{{ (item.fields.total_minifigures > 0) | int }}"
     data-has-missing="{{ (item.fields.total_missing > 0) | int }}" data-has-missing-instructions="{{ (not (item.instructions | length)) | int }}" data-missing="{{ item.fields.total_missing }}"
     {% for status in brickset_statuses %}data-{{ status.as_dataset() }}="{{ item.fields[status.as_column()] }}" {% endfor %}
+    {% for owner in brickset_owners %}
+      {% with checked=item.fields[owner.as_column()] %}
+        data-{{ owner.as_dataset() }}="{{ checked }}"
+        {% if checked %} data-owner-{{ loop.index }}="{{ owner.fields.name | lower }}"{% endif %}
+      {% endwith %}
+    {% endfor %}
   {% endif %}
 >
   {{ card.header(item, item.fields.name, solo=solo, identifier=item.fields.set) }}
@@ -19,6 +25,9 @@
     {{ badge.parts(item.fields.number_of_parts, solo=solo, last=last) }}
     {{ badge.total_minifigures(item.fields.total_minifigures, solo=solo, last=last) }}
     {{ badge.total_missing(item.fields.total_missing, solo=solo, last=last) }}
+    {% for owner in brickset_owners %}
+      {{ badge.owner(item, owner, solo=solo, last=last) }}
+    {% endfor %}
     {% if not last %}
       {% if not solo %}
         {{ badge.instructions(item, solo=solo, last=last) }}
diff --git a/templates/set/management.html b/templates/set/management.html
index 38074808..957db816 100644
--- a/templates/set/management.html
+++ b/templates/set/management.html
@@ -1,6 +1,20 @@
 {% if g.login.is_authenticated() %}
+{{ accordion.header('Owners', 'owner', 'set-details', icon='group-line', class='p-0') }}
+  <ul class="list-group list-group-flush">
+  {% if brickset_owners | length %}
+    {% for owner in brickset_owners %}
+      <li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(item, owner, delete=delete) }}</li>
+    {% endfor %}
+  {% else %}
+    <li class="list-group-item list-group-item-action"><i class="ri-error-warning-line"></i> No owner found.</li>
+    {% endif %}
+  </ul>
+  <div class="list-group list-group-flush border-top">
+    <a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_owner=true) }}"><i class="ri-settings-4-line"></i> Manage the set owners</a>
+  </div>
+{{ accordion.footer() }}
 {{ accordion.header('Management', 'management', 'set-details', expanded=true, icon='settings-4-line') }}
-    <h5 class="border-bottom">Data</h5>
-    <a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh the set data</a>
+  <h5 class="border-bottom">Data</h5>
+  <a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh the set data</a>
 {{ accordion.footer() }}
 {% endif %}
diff --git a/templates/sets.html b/templates/sets.html
index 97a7922c..b06c94dd 100644
--- a/templates/sets.html
+++ b/templates/sets.html
@@ -10,7 +10,7 @@
       <label class="visually-hidden" for="grid-search">Search</label>
       <div class="input-group">
         <span class="input-group-text"><i class="ri-search-line"></i><span class="ms-1 d-none d-xl-inline"> Search</span></span>
-        <input id="grid-search" data-search-exact="name,number,parts,theme,year" class="form-control form-control-sm" type="text" placeholder="Set name, set number, set theme or number of parts..." value="">
+        <input id="grid-search" data-search-exact="name,number,parts,theme,year" data-search-list="owner" class="form-control form-control-sm" type="text" placeholder="Set, name, number of parts, theme, year, owner" value="">
       </div>
     </div>
     <div class="col-12">
@@ -75,6 +75,20 @@
         </select>
       </div>
     </div>
+    <div class="col-12 flex-grow-1">
+      <label class="visually-hidden" for="grid-owner">Owner</label>
+      <div class="input-group">
+        <span class="input-group-text"><i class="ri-user-line"></i><span class="ms-1 d-none d-xl-inline"> Owner</span></span>
+        <select id="grid-owner" class="form-select"
+          data-filter="metadata"
+          autocomplete="off">
+          <option value="" selected>All</option>
+          {% for owner in brickset_owners %}
+            <option value="{{ owner.as_dataset() }}">{{ owner.fields.name }}</option>
+          {% endfor %}
+        </select>
+      </div>
+    </div>
   </div>
   <div class="row" data-grid="true" id="grid">
   {% for item in collection %}

From 030345fe6b669225b9056d08f910d8e878e3ac22 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 16:37:42 +0100
Subject: [PATCH 087/154] Fix functions definition

---
 bricktracker/set_list.py | 14 ++------------
 1 file changed, 2 insertions(+), 12 deletions(-)

diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py
index 6a94185b..e071dc63 100644
--- a/bricktracker/set_list.py
+++ b/bricktracker/set_list.py
@@ -103,12 +103,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
         return self
 
     # Sets missing a part
-    def missing_part(
-        self,
-        part: str,
-        color: int,
-        /
-    ) -> Self:
+    def missing_part(self, part: str, color: int, /) -> Self:
         # Save the parameters to the fields
         self.fields.part = part
         self.fields.color = color
@@ -141,12 +136,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
         return self
 
     # Sets using a part
-    def using_part(
-        self,
-        part: str,
-        color: int,
-        /
-    ) -> Self:
+    def using_part(self, part: str, color: int, /) -> Self:
         # Save the parameters to the fields
         self.fields.part = part
         self.fields.color = color

From c02321368aee8c8ca4d790d935f2471b99951106 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 16:38:06 +0100
Subject: [PATCH 088/154] Disable no confirm checkbox when toggling the form

---
 static/scripts/socket/set.js | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/static/scripts/socket/set.js b/static/scripts/socket/set.js
index 6459aa66..01c51062 100644
--- a/static/scripts/socket/set.js
+++ b/static/scripts/socket/set.js
@@ -259,6 +259,10 @@ class BrickSetSocket extends BrickSocket {
             this.html_input.disabled = !enabled;
         }
 
+        if (this.html_no_confim) {
+            this.html_no_confim.disabled = !enabled;
+        }
+
         if (this.html_owners) {
             this.html_owners.querySelectorAll('input').forEach(input => input.disabled = !enabled);
         }

From 739d933900b323b7e330c602f6cf7c10ab36ab14 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 17:52:51 +0100
Subject: [PATCH 089/154] Fix broken list filtering on the grid

---
 static/scripts/grid/filter.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/static/scripts/grid/filter.js b/static/scripts/grid/filter.js
index e2eff7bd..f5b075f9 100644
--- a/static/scripts/grid/filter.js
+++ b/static/scripts/grid/filter.js
@@ -112,7 +112,7 @@ class BrickGridFilter {
                     } else {
                         // List search
                         for (const list of this.search_list) {
-                            if (set.startsWith(this.search_list)) {
+                            if (set.startsWith(list)) {
                                 if (current.dataset[set].includes(options.search)) {
                                     current.parentElement.classList.remove("d-none");
                                     return;

From 5ad94078ed111f08e52cec36408874e28b37a9ea Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 17:56:51 +0100
Subject: [PATCH 090/154] Don't toggle the no confirm button in bulk mode

---
 static/scripts/socket/set.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/static/scripts/socket/set.js b/static/scripts/socket/set.js
index 01c51062..08e70dd8 100644
--- a/static/scripts/socket/set.js
+++ b/static/scripts/socket/set.js
@@ -259,7 +259,7 @@ class BrickSetSocket extends BrickSocket {
             this.html_input.disabled = !enabled;
         }
 
-        if (this.html_no_confim) {
+        if (!this.bulk && this.html_no_confim) {
             this.html_no_confim.disabled = !enabled;
         }
 

From f34bbe0602dc4e7354ed4aa4d089e90376ac151f Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 18:08:53 +0100
Subject: [PATCH 091/154] Set tags

---
 bricktracker/app.py                           |  2 +
 bricktracker/metadata_list.py                 |  3 +-
 bricktracker/record_list.py                   |  2 +
 bricktracker/reload.py                        |  5 ++
 bricktracker/set.py                           | 12 ++-
 bricktracker/set_list.py                      |  8 +-
 bricktracker/set_tag.py                       | 16 ++++
 bricktracker/set_tag_list.py                  | 17 ++++
 bricktracker/sql/migrations/0014.sql          | 19 +++++
 bricktracker/sql/schema/drop.sql              |  2 +
 bricktracker/sql/set/base/base.sql            |  3 +
 bricktracker/sql/set/base/full.sql            |  5 ++
 bricktracker/sql/set/delete/set.sql           |  3 +
 bricktracker/sql/set/metadata/tag/base.sql    |  6 ++
 bricktracker/sql/set/metadata/tag/delete.sql  |  9 ++
 bricktracker/sql/set/metadata/tag/insert.sql  | 14 ++++
 bricktracker/sql/set/metadata/tag/list.sql    |  1 +
 bricktracker/sql/set/metadata/tag/select.sql  |  5 ++
 .../sql/set/metadata/tag/update/field.sql     |  3 +
 .../sql/set/metadata/tag/update/state.sql     | 10 +++
 bricktracker/sql_counter.py                   |  1 +
 bricktracker/version.py                       |  2 +-
 bricktracker/views/add.py                     |  4 +
 bricktracker/views/admin/admin.py             | 13 ++-
 bricktracker/views/admin/tag.py               | 84 +++++++++++++++++++
 bricktracker/views/index.py                   |  3 +
 bricktracker/views/set.py                     | 19 ++++-
 static/scripts/socket/set.js                  | 16 ++++
 templates/add.html                            | 13 +++
 templates/admin.html                          |  3 +
 templates/admin/tag.html                      | 42 ++++++++++
 templates/admin/tag/delete.html               | 19 +++++
 templates/macro/badge.html                    | 13 ++-
 templates/set/card.html                       | 11 ++-
 templates/set/management.html                 | 50 +++++++----
 templates/sets.html                           | 16 +++-
 36 files changed, 424 insertions(+), 30 deletions(-)
 create mode 100644 bricktracker/set_tag.py
 create mode 100644 bricktracker/set_tag_list.py
 create mode 100644 bricktracker/sql/migrations/0014.sql
 create mode 100644 bricktracker/sql/set/metadata/tag/base.sql
 create mode 100644 bricktracker/sql/set/metadata/tag/delete.sql
 create mode 100644 bricktracker/sql/set/metadata/tag/insert.sql
 create mode 100644 bricktracker/sql/set/metadata/tag/list.sql
 create mode 100644 bricktracker/sql/set/metadata/tag/select.sql
 create mode 100644 bricktracker/sql/set/metadata/tag/update/field.sql
 create mode 100644 bricktracker/sql/set/metadata/tag/update/state.sql
 create mode 100644 bricktracker/views/admin/tag.py
 create mode 100644 templates/admin/tag.html
 create mode 100644 templates/admin/tag/delete.html

diff --git a/bricktracker/app.py b/bricktracker/app.py
index 15cb9a3d..240bc637 100644
--- a/bricktracker/app.py
+++ b/bricktracker/app.py
@@ -19,6 +19,7 @@ from bricktracker.views.admin.instructions import admin_instructions_page
 from bricktracker.views.admin.owner import admin_owner_page
 from bricktracker.views.admin.retired import admin_retired_page
 from bricktracker.views.admin.status import admin_status_page
+from bricktracker.views.admin.tag import admin_tag_page
 from bricktracker.views.admin.theme import admin_theme_page
 from bricktracker.views.error import error_404
 from bricktracker.views.index import index_page
@@ -85,6 +86,7 @@ def setup_app(app: Flask) -> None:
     app.register_blueprint(admin_retired_page)
     app.register_blueprint(admin_owner_page)
     app.register_blueprint(admin_status_page)
+    app.register_blueprint(admin_tag_page)
     app.register_blueprint(admin_theme_page)
 
     # An helper to make global variables available to the
diff --git a/bricktracker/metadata_list.py b/bricktracker/metadata_list.py
index bb2e337b..b0d42c3b 100644
--- a/bricktracker/metadata_list.py
+++ b/bricktracker/metadata_list.py
@@ -6,10 +6,11 @@ from .fields import BrickRecordFields
 from .record_list import BrickRecordList
 from .set_owner import BrickSetOwner
 from .set_status import BrickSetStatus
+from .set_tag import BrickSetTag
 
 logger = logging.getLogger(__name__)
 
-T = TypeVar('T', 'BrickSetStatus', 'BrickSetOwner')
+T = TypeVar('T', BrickSetStatus, BrickSetOwner, BrickSetTag)
 
 
 # Lego sets metadata list
diff --git a/bricktracker/record_list.py b/bricktracker/record_list.py
index 8927e717..3de9bf96 100644
--- a/bricktracker/record_list.py
+++ b/bricktracker/record_list.py
@@ -10,6 +10,7 @@ if TYPE_CHECKING:
     from .set import BrickSet
     from .set_owner import BrickSetOwner
     from .set_status import BrickSetStatus
+    from .set_tag import BrickSetTag
     from .wish import BrickWish
 
 T = TypeVar(
@@ -17,6 +18,7 @@ T = TypeVar(
     'BrickSet',
     'BrickSetOwner',
     'BrickSetStatus',
+    'BrickSetTag',
     'BrickPart',
     'BrickMinifigure',
     'BrickWish',
diff --git a/bricktracker/reload.py b/bricktracker/reload.py
index 73e9e241..16fca2f5 100644
--- a/bricktracker/reload.py
+++ b/bricktracker/reload.py
@@ -4,6 +4,8 @@ from .set_owner import BrickSetOwner
 from .set_owner_list import BrickSetOwnerList
 from .set_status import BrickSetStatus
 from .set_status_list import BrickSetStatusList
+from .set_tag import BrickSetTag
+from .set_tag_list import BrickSetTagList
 from .theme_list import BrickThemeList
 
 
@@ -20,6 +22,9 @@ def reload() -> None:
         # Reload the set statuses
         BrickSetStatusList(BrickSetStatus, force=True)
 
+        # Reload the set tags
+        BrickSetTagList(BrickSetTag, force=True)
+
         # Reload retired sets
         BrickRetiredList(force=True)
 
diff --git a/bricktracker/set.py b/bricktracker/set.py
index f4bf1a26..eb2bafb5 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -13,6 +13,8 @@ from .set_owner import BrickSetOwner
 from .set_owner_list import BrickSetOwnerList
 from .set_status import BrickSetStatus
 from .set_status_list import BrickSetStatusList
+from .set_tag import BrickSetTag
+from .set_tag_list import BrickSetTagList
 from .sql import BrickSQL
 if TYPE_CHECKING:
     from .socket import BrickSocket
@@ -77,6 +79,13 @@ class BrickSet(RebrickableSet):
                 owner = BrickSetOwnerList(BrickSetOwner).get(id)
                 owner.update_set_state(self, state=True)
 
+            # Save the tags
+            tags: list[str] = list(data.get('tags', []))
+
+            for id in tags:
+                tag = BrickSetTagList(BrickSetTag).get(id)
+                tag.update_set_state(self, state=True)
+
             # Commit the transaction to the database
             socket.auto_progress(
                 message='Set {set}: writing to the database'.format(
@@ -172,7 +181,8 @@ class BrickSet(RebrickableSet):
         # Load from database
         if not self.select(
             owners=BrickSetOwnerList(BrickSetOwner).as_columns(),
-            statuses=BrickSetStatusList(BrickSetStatus).as_columns(all=True)
+            statuses=BrickSetStatusList(BrickSetStatus).as_columns(all=True),
+            tags=BrickSetTagList(BrickSetTag).as_columns(),
         ):
             raise NotFoundException(
                 'Set with ID {id} was not found in the database'.format(
diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py
index e071dc63..54a3cb8c 100644
--- a/bricktracker/set_list.py
+++ b/bricktracker/set_list.py
@@ -7,6 +7,8 @@ from .set_owner import BrickSetOwner
 from .set_owner_list import BrickSetOwnerList
 from .set_status import BrickSetStatus
 from .set_status_list import BrickSetStatusList
+from .set_tag import BrickSetTag
+from .set_tag_list import BrickSetTagList
 from .set import BrickSet
 
 
@@ -41,7 +43,8 @@ class BrickSetList(BrickRecordList[BrickSet]):
         for record in self.select(
             order=self.order,
             owners=BrickSetOwnerList(BrickSetOwner).as_columns(),
-            statuses=BrickSetStatusList(BrickSetStatus).as_columns()
+            statuses=BrickSetStatusList(BrickSetStatus).as_columns(),
+            tags=BrickSetTagList(BrickSetTag).as_columns(),
         ):
             brickset = BrickSet(record=record)
 
@@ -78,7 +81,8 @@ class BrickSetList(BrickRecordList[BrickSet]):
             order=order,
             limit=limit,
             owners=BrickSetOwnerList(BrickSetOwner).as_columns(),
-            statuses=BrickSetStatusList(BrickSetStatus).as_columns()
+            statuses=BrickSetStatusList(BrickSetStatus).as_columns(),
+            tags=BrickSetTagList(BrickSetTag).as_columns(),
         ):
             brickset = BrickSet(record=record)
 
diff --git a/bricktracker/set_tag.py b/bricktracker/set_tag.py
new file mode 100644
index 00000000..6d81c18c
--- /dev/null
+++ b/bricktracker/set_tag.py
@@ -0,0 +1,16 @@
+from .metadata import BrickMetadata
+
+
+# Lego set tag metadata
+class BrickSetTag(BrickMetadata):
+    kind: str = 'tag'
+
+    # Set state endpoint
+    set_state_endpoint: str = 'set.update_tag'
+
+    # Queries
+    delete_query: str = 'set/metadata/tag/delete'
+    insert_query: str = 'set/metadata/tag/insert'
+    select_query: str = 'set/metadata/tag/select'
+    update_field_query: str = 'set/metadata/tag/update/field'
+    update_set_state_query: str = 'set/metadata/tag/update/state'
diff --git a/bricktracker/set_tag_list.py b/bricktracker/set_tag_list.py
new file mode 100644
index 00000000..92806f22
--- /dev/null
+++ b/bricktracker/set_tag_list.py
@@ -0,0 +1,17 @@
+import logging
+
+from .metadata_list import BrickMetadataList
+from .set_tag import BrickSetTag
+
+logger = logging.getLogger(__name__)
+
+
+# Lego sets tag list
+class BrickSetTagList(BrickMetadataList[BrickSetTag]):
+    kind: str = 'set tags'
+
+    # Database table
+    table: str = 'bricktracker_set_tags'
+
+    # Queries
+    select_query = 'set/metadata/tag/list'
diff --git a/bricktracker/sql/migrations/0014.sql b/bricktracker/sql/migrations/0014.sql
new file mode 100644
index 00000000..37c655ed
--- /dev/null
+++ b/bricktracker/sql/migrations/0014.sql
@@ -0,0 +1,19 @@
+-- description: Add set tags
+
+BEGIN TRANSACTION;
+
+-- Create a table to define each set tags: an id and a name
+CREATE TABLE "bricktracker_metadata_tags" (
+    "id" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    PRIMARY KEY("id")
+);
+
+-- Create a table for the set tags
+CREATE TABLE "bricktracker_set_tags" (
+    "id" TEXT NOT NULL,
+    PRIMARY KEY("id"),
+    FOREIGN KEY("id") REFERENCES "bricktracker_sets"("id")
+);
+
+COMMIT;
diff --git a/bricktracker/sql/schema/drop.sql b/bricktracker/sql/schema/drop.sql
index abc85229..1bab7d65 100644
--- a/bricktracker/sql/schema/drop.sql
+++ b/bricktracker/sql/schema/drop.sql
@@ -1,12 +1,14 @@
 BEGIN transaction;
 
 DROP TABLE IF EXISTS "bricktracker_metadata_statuses";
+DROP TABLE IF EXISTS "bricktracker_metadata_tags";
 DROP TABLE IF EXISTS "bricktracker_minifigures";
 DROP TABLE IF EXISTS "bricktracker_parts";
 DROP TABLE IF EXISTS "bricktracker_sets";
 DROP TABLE IF EXISTS "bricktracker_set_checkboxes";
 DROP TABLE IF EXISTS "bricktracker_set_statuses";
 DROP TABLE IF EXISTS "bricktracker_set_storages";
+DROP TABLE IF EXISTS "bricktracker_set_tags";
 DROP TABLE IF EXISTS "bricktracker_wishes";
 DROP TABLE IF EXISTS "inventory";
 DROP TABLE IF EXISTS "inventory_old";
diff --git a/bricktracker/sql/set/base/base.sql b/bricktracker/sql/set/base/base.sql
index 940dab9f..ffefe956 100644
--- a/bricktracker/sql/set/base/base.sql
+++ b/bricktracker/sql/set/base/base.sql
@@ -12,6 +12,9 @@ SELECT
     {% block owners %}
         {% if owners %}{{ owners }},{% endif %}
     {% endblock %}
+    {% block tags %}
+        {% if tags %}{{ tags }},{% endif %}
+    {% endblock %}
     {% block statuses %}
         {% if statuses %}{{ statuses }},{% endif %}
     {% endblock %}
diff --git a/bricktracker/sql/set/base/full.sql b/bricktracker/sql/set/base/full.sql
index 725b56dc..271f8909 100644
--- a/bricktracker/sql/set/base/full.sql
+++ b/bricktracker/sql/set/base/full.sql
@@ -23,6 +23,11 @@ LEFT JOIN "bricktracker_set_statuses"
 ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_statuses"."id"
 {% endif %}
 
+{% if tags %}
+LEFT JOIN "bricktracker_set_tags"
+ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_tags"."id"
+{% endif %}
+
 -- LEFT JOIN + SELECT to avoid messing the total
 LEFT JOIN (
     SELECT
diff --git a/bricktracker/sql/set/delete/set.sql b/bricktracker/sql/set/delete/set.sql
index 2db140de..4eca8456 100644
--- a/bricktracker/sql/set/delete/set.sql
+++ b/bricktracker/sql/set/delete/set.sql
@@ -12,6 +12,9 @@ WHERE "bricktracker_set_owners"."id" IS NOT DISTINCT FROM '{{ id }}';
 DELETE FROM "bricktracker_set_statuses"
 WHERE "bricktracker_set_statuses"."id" IS NOT DISTINCT FROM '{{ id }}';
 
+DELETE FROM "bricktracker_set_tags"
+WHERE "bricktracker_set_tags"."id" IS NOT DISTINCT FROM '{{ id }}';
+
 DELETE FROM "bricktracker_minifigures"
 WHERE "bricktracker_minifigures"."id" IS NOT DISTINCT FROM '{{ id }}';
 
diff --git a/bricktracker/sql/set/metadata/tag/base.sql b/bricktracker/sql/set/metadata/tag/base.sql
new file mode 100644
index 00000000..3ec57259
--- /dev/null
+++ b/bricktracker/sql/set/metadata/tag/base.sql
@@ -0,0 +1,6 @@
+SELECT
+    "bricktracker_metadata_tags"."id",
+    "bricktracker_metadata_tags"."name"
+FROM "bricktracker_metadata_tags"
+
+{% block where %}{% endblock %}
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/tag/delete.sql b/bricktracker/sql/set/metadata/tag/delete.sql
new file mode 100644
index 00000000..a80bb8f2
--- /dev/null
+++ b/bricktracker/sql/set/metadata/tag/delete.sql
@@ -0,0 +1,9 @@
+BEGIN TRANSACTION;
+
+ALTER TABLE "bricktracker_set_tags"
+DROP COLUMN "tag_{{ id }}";
+
+DELETE FROM "bricktracker_metadata_tags"
+WHERE "bricktracker_metadata_tags"."id" IS NOT DISTINCT FROM '{{ id }}';
+
+COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/tag/insert.sql b/bricktracker/sql/set/metadata/tag/insert.sql
new file mode 100644
index 00000000..7a62866b
--- /dev/null
+++ b/bricktracker/sql/set/metadata/tag/insert.sql
@@ -0,0 +1,14 @@
+BEGIN TRANSACTION;
+
+ALTER TABLE "bricktracker_set_tags"
+ADD COLUMN "tag_{{ id }}" BOOLEAN NOT NULL DEFAULT 0;
+
+INSERT INTO "bricktracker_metadata_tags" (
+    "id",
+    "name"
+) VALUES (
+    '{{ id }}',
+    '{{ name }}'
+);
+
+COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/tag/list.sql b/bricktracker/sql/set/metadata/tag/list.sql
new file mode 100644
index 00000000..fe44b5fc
--- /dev/null
+++ b/bricktracker/sql/set/metadata/tag/list.sql
@@ -0,0 +1 @@
+{% extends 'set/metadata/tag/base.sql' %}
diff --git a/bricktracker/sql/set/metadata/tag/select.sql b/bricktracker/sql/set/metadata/tag/select.sql
new file mode 100644
index 00000000..2d52076d
--- /dev/null
+++ b/bricktracker/sql/set/metadata/tag/select.sql
@@ -0,0 +1,5 @@
+{% extends 'set/metadata/tag/base.sql' %}
+
+{% block where %}
+WHERE "bricktracker_metadata_tags"."id" IS NOT DISTINCT FROM :id
+{% endblock %}
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/tag/update/field.sql b/bricktracker/sql/set/metadata/tag/update/field.sql
new file mode 100644
index 00000000..629a9e8a
--- /dev/null
+++ b/bricktracker/sql/set/metadata/tag/update/field.sql
@@ -0,0 +1,3 @@
+UPDATE "bricktracker_metadata_tags"
+SET "{{field}}" = :value
+WHERE "bricktracker_metadata_tags"."id" IS NOT DISTINCT FROM :id
diff --git a/bricktracker/sql/set/metadata/tag/update/state.sql b/bricktracker/sql/set/metadata/tag/update/state.sql
new file mode 100644
index 00000000..18de40a7
--- /dev/null
+++ b/bricktracker/sql/set/metadata/tag/update/state.sql
@@ -0,0 +1,10 @@
+INSERT INTO "bricktracker_set_tags" (
+    "id",
+    "{{name}}"
+) VALUES (
+    :set_id,
+    :state
+)
+ON CONFLICT("id")
+DO UPDATE SET "{{name}}" = :state
+WHERE "bricktracker_set_tags"."id" IS NOT DISTINCT FROM :set_id
diff --git a/bricktracker/sql_counter.py b/bricktracker/sql_counter.py
index 30c83d6d..74c18cc8 100644
--- a/bricktracker/sql_counter.py
+++ b/bricktracker/sql_counter.py
@@ -9,6 +9,7 @@ ALIASES: dict[str, Tuple[str, str]] = {
     'bricktracker_set_owners': ('Bricktracker set owners', 'checkbox-line'),  # noqa: E501
     'bricktracker_set_statuses': ('Bricktracker set statuses', 'user-line'),  # noqa: E501
     'bricktracker_set_storages': ('Bricktracker set storages', 'archive-2-line'),  # noqa: E501
+    'bricktracker_set_tags': ('Bricktracker set tags', 'price-tag-2-line'),  # noqa: E501
     'bricktracker_sets': ('Bricktracker sets', 'hashtag'),
     'bricktracker_wishes': ('Bricktracker wishes', 'gift-line'),
     'inventory': ('Parts', 'shapes-line'),
diff --git a/bricktracker/version.py b/bricktracker/version.py
index 996b4f61..767fad59 100644
--- a/bricktracker/version.py
+++ b/bricktracker/version.py
@@ -1,4 +1,4 @@
 from typing import Final
 
 __version__: Final[str] = '1.2.0'
-__database_version__: Final[int] = 13
+__database_version__: Final[int] = 14
diff --git a/bricktracker/views/add.py b/bricktracker/views/add.py
index 20607dcf..90729733 100644
--- a/bricktracker/views/add.py
+++ b/bricktracker/views/add.py
@@ -5,6 +5,8 @@ from ..configuration_list import BrickConfigurationList
 from .exceptions import exception_handler
 from ..set_owner import BrickSetOwner
 from ..set_owner_list import BrickSetOwnerList
+from ..set_tag import BrickSetTag
+from ..set_tag_list import BrickSetTagList
 from ..socket import MESSAGES
 
 add_page = Blueprint('add', __name__, url_prefix='/add')
@@ -20,6 +22,7 @@ def add() -> str:
     return render_template(
         'add.html',
         brickset_owners=BrickSetOwnerList(BrickSetOwner).list(),
+        brickset_tags=BrickSetTagList(BrickSetTag).list(),
         path=current_app.config['SOCKET_PATH'],
         namespace=current_app.config['SOCKET_NAMESPACE'],
         messages=MESSAGES
@@ -36,6 +39,7 @@ def bulk() -> str:
     return render_template(
         'add.html',
         brickset_owners=BrickSetOwnerList(BrickSetOwner).list(),
+        brickset_tags=BrickSetTagList(BrickSetTag).list(),
         path=current_app.config['SOCKET_PATH'],
         namespace=current_app.config['SOCKET_NAMESPACE'],
         messages=MESSAGES,
diff --git a/bricktracker/views/admin/admin.py b/bricktracker/views/admin/admin.py
index 36037d36..415cf487 100644
--- a/bricktracker/views/admin/admin.py
+++ b/bricktracker/views/admin/admin.py
@@ -12,6 +12,8 @@ from ...set_owner import BrickSetOwner
 from ...set_owner_list import BrickSetOwnerList
 from ...set_status import BrickSetStatus
 from ...set_status_list import BrickSetStatusList
+from ...set_tag import BrickSetTag
+from ...set_tag_list import BrickSetTagList
 from ...sql_counter import BrickCounter
 from ...sql import BrickSQL
 from ...theme_list import BrickThemeList
@@ -32,6 +34,7 @@ def admin() -> str:
     database_version: int = -1
     metadata_owners: list[BrickSetOwner] = []
     metadata_statuses: list[BrickSetStatus] = []
+    metadata_tags: list[BrickSetTag] = []
     nil_minifigure_name: str = ''
     nil_minifigure_url: str = ''
     nil_part_name: str = ''
@@ -46,6 +49,7 @@ def admin() -> str:
 
         metadata_owners = BrickSetOwnerList(BrickSetOwner).list()
         metadata_statuses = BrickSetStatusList(BrickSetStatus).list(all=True)
+        metadata_tags = BrickSetTagList(BrickSetTag).list()
     except Exception as e:
         database_exception = e
 
@@ -72,6 +76,7 @@ def admin() -> str:
     open_owner = request.args.get('open_owner', None)
     open_retired = request.args.get('open_retired', None)
     open_status = request.args.get('open_status', None)
+    open_tag = request.args.get('open_tag', None)
     open_theme = request.args.get('open_theme', None)
 
     open_database = (
@@ -81,6 +86,7 @@ def admin() -> str:
         open_owner is None and
         open_retired is None and
         open_status is None and
+        open_tag is None and
         open_theme is None
     )
 
@@ -95,20 +101,23 @@ def admin() -> str:
         instructions=BrickInstructionsList(),
         metadata_owners=metadata_owners,
         metadata_statuses=metadata_statuses,
+        metadata_tags=metadata_tags,
         nil_minifigure_name=nil_minifigure_name,
         nil_minifigure_url=nil_minifigure_url,
         nil_part_name=nil_part_name,
         nil_part_url=nil_part_url,
-        open_status=open_status,
         open_database=open_database,
         open_image=open_image,
         open_instructions=open_instructions,
         open_logout=open_logout,
         open_owner=open_owner,
         open_retired=open_retired,
+        open_status=open_status,
+        open_tag=open_tag,
         open_theme=open_theme,
         owner_error=request.args.get('owner_error'),
-        status_error=request.args.get('status_error'),
         retired=BrickRetiredList(),
+        status_error=request.args.get('status_error'),
+        tag_error=request.args.get('tag_error'),
         theme=BrickThemeList(),
     )
diff --git a/bricktracker/views/admin/tag.py b/bricktracker/views/admin/tag.py
new file mode 100644
index 00000000..d31bc49c
--- /dev/null
+++ b/bricktracker/views/admin/tag.py
@@ -0,0 +1,84 @@
+from flask import (
+    Blueprint,
+    redirect,
+    request,
+    render_template,
+    url_for,
+)
+from flask_login import login_required
+from werkzeug.wrappers.response import Response
+
+from ..exceptions import exception_handler
+from ...reload import reload
+from ...set_tag import BrickSetTag
+
+admin_tag_page = Blueprint(
+    'admin_tag',
+    __name__,
+    url_prefix='/admin/tag'
+)
+
+
+# Add a metadata tag
+@admin_tag_page.route('/add', methods=['POST'])
+@login_required
+@exception_handler(
+    __file__,
+    post_redirect='admin.admin',
+    error_name='tag_error',
+    open_tag=True
+)
+def add() -> Response:
+    BrickSetTag().from_form(request.form).insert()
+
+    reload()
+
+    return redirect(url_for('admin.admin', open_tag=True))
+
+
+# Delete the metadata tag
+@admin_tag_page.route('<id>/delete', methods=['GET'])
+@login_required
+@exception_handler(__file__)
+def delete(*, id: str) -> str:
+    return render_template(
+        'admin.html',
+        delete_tag=True,
+        tag=BrickSetTag().select_specific(id),
+        error=request.args.get('tag_error')
+    )
+
+
+# Actually delete the metadata tag
+@admin_tag_page.route('<id>/delete', methods=['POST'])
+@login_required
+@exception_handler(
+    __file__,
+    post_redirect='admin_tag.delete',
+    error_name='tag_error'
+)
+def do_delete(*, id: str) -> Response:
+    tag = BrickSetTag().select_specific(id)
+    tag.delete()
+
+    reload()
+
+    return redirect(url_for('admin.admin', open_tag=True))
+
+
+# Rename the metadata tag
+@admin_tag_page.route('<id>/rename', methods=['POST'])
+@login_required
+@exception_handler(
+    __file__,
+    post_redirect='admin.admin',
+    error_name='tag_error',
+    open_tag=True
+)
+def rename(*, id: str) -> Response:
+    tag = BrickSetTag().select_specific(id)
+    tag.from_form(request.form).rename()
+
+    reload()
+
+    return redirect(url_for('admin.admin', open_tag=True))
diff --git a/bricktracker/views/index.py b/bricktracker/views/index.py
index 3d8a55ec..1cbcd564 100644
--- a/bricktracker/views/index.py
+++ b/bricktracker/views/index.py
@@ -6,6 +6,8 @@ from ..set_owner import BrickSetOwner
 from ..set_owner_list import BrickSetOwnerList
 from ..set_status import BrickSetStatus
 from ..set_status_list import BrickSetStatusList
+from ..set_tag import BrickSetTag
+from ..set_tag_list import BrickSetTagList
 from ..set_list import BrickSetList
 
 index_page = Blueprint('index', __name__)
@@ -20,5 +22,6 @@ def index() -> str:
         brickset_collection=BrickSetList().last(),
         brickset_owners=BrickSetOwnerList(BrickSetOwner).list(),
         brickset_statuses=BrickSetStatusList(BrickSetStatus).list(),
+        brickset_tags=BrickSetTagList(BrickSetTag).list(),
         minifigure_collection=BrickMinifigureList().last(),
     )
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index fd922ec4..344b0e60 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -16,11 +16,13 @@ from .exceptions import exception_handler
 from ..minifigure import BrickMinifigure
 from ..part import BrickPart
 from ..set import BrickSet
+from ..set_list import BrickSetList
 from ..set_owner import BrickSetOwner
 from ..set_owner_list import BrickSetOwnerList
 from ..set_status import BrickSetStatus
 from ..set_status_list import BrickSetStatusList
-from ..set_list import BrickSetList
+from ..set_tag import BrickSetTag
+from ..set_tag_list import BrickSetTagList
 from ..socket import MESSAGES
 
 logger = logging.getLogger(__name__)
@@ -37,6 +39,7 @@ def list() -> str:
         collection=BrickSetList().all(),
         brickset_owners=BrickSetOwnerList(BrickSetOwner).list(),
         brickset_statuses=BrickSetStatusList(BrickSetStatus).list(),
+        brickset_tags=BrickSetTagList(BrickSetTag).list(),
     )
 
 
@@ -66,6 +69,19 @@ def update_status(*, id: str, metadata_id: str) -> Response:
     return jsonify({'value': state})
 
 
+# Change the state of a tag
+@set_page.route('/<id>/tag/<metadata_id>', methods=['POST'])
+@login_required
+@exception_handler(__file__, json=True)
+def update_tag(*, id: str, metadata_id: str) -> Response:
+    brickset = BrickSet().select_light(id)
+    tag = BrickSetTagList(BrickSetTag).get(metadata_id)
+
+    state = tag.update_set_state(brickset, json=request.json)
+
+    return jsonify({'value': state})
+
+
 # Ask for deletion of a set
 @set_page.route('/<id>/delete', methods=['GET'])
 @login_required
@@ -116,6 +132,7 @@ def details(*, id: str) -> str:
         open_instructions=request.args.get('open_instructions'),
         brickset_owners=BrickSetOwnerList(BrickSetOwner).list(),
         brickset_statuses=BrickSetStatusList(BrickSetStatus).list(all=True),
+        brickset_tags=BrickSetTagList(BrickSetTag).list(),
     )
 
 
diff --git a/static/scripts/socket/set.js b/static/scripts/socket/set.js
index 08e70dd8..a7e660b4 100644
--- a/static/scripts/socket/set.js
+++ b/static/scripts/socket/set.js
@@ -16,6 +16,7 @@ class BrickSetSocket extends BrickSocket {
         this.html_input = document.getElementById(`${id}-set`);
         this.html_no_confim = document.getElementById(`${id}-no-confirm`);
         this.html_owners = document.getElementById(`${id}-owners`);
+        this.html_tags = document.getElementById(`${id}-tags`);
 
         // Card elements
         this.html_card = document.getElementById(`${id}-card`);
@@ -150,11 +151,22 @@ class BrickSetSocket extends BrickSocket {
                 });
             }
 
+            // Grab the tags
+            const tags = [];
+            if (this.html_tags) {
+                this.html_tags.querySelectorAll('input').forEach(input => {
+                    if (input.checked) {
+                        tags.push(input.value);
+                    }
+                });
+            }
+
             this.spinner(true);
 
             this.socket.emit(this.messages.IMPORT_SET, {
                 set: (set !== undefined) ? set : this.html_input.value,
                 owners: owners,
+                tags: tags,
                 refresh: this.refresh
             });
         } else {
@@ -267,6 +279,10 @@ class BrickSetSocket extends BrickSocket {
             this.html_owners.querySelectorAll('input').forEach(input => input.disabled = !enabled);
         }
 
+        if (this.html_tags) {
+            this.html_tags.querySelectorAll('input').forEach(input => input.disabled = !enabled);
+        }
+
         if (this.html_card_confirm) {
             this.html_card_confirm.disabled = !enabled;
         }
diff --git a/templates/add.html b/templates/add.html
index 59c50299..3a0b7840 100644
--- a/templates/add.html
+++ b/templates/add.html
@@ -46,6 +46,19 @@
             {% endfor %}
             </div>
           {% endif %}
+          {% if brickset_tags | length %}
+            <h5 class="border-bottom mt-2">Tags</h5>
+            <div id="add-tags">
+            {% for tag in brickset_tags %}
+              {% with id=tag.as_dataset() %}
+                <div class="form-check">
+                  <input class="form-check-input" type="checkbox" value="{{ tag.fields.id }}" id="{{ id }}" autocomplete="off">
+                  <label class="form-check-label" for="{{ id }}">{{ tag.fields.name }}</label>
+                </div>
+              {% endwith %}
+            {% endfor %}
+            </div>
+          {% endif %}
           <hr>
           <div class="mb-3">
             <p>
diff --git a/templates/admin.html b/templates/admin.html
index 962730bc..064526d8 100644
--- a/templates/admin.html
+++ b/templates/admin.html
@@ -18,6 +18,8 @@
             {% include 'admin/owner/delete.html' %}
             {% elif delete_status %}
             {% include 'admin/status/delete.html' %}
+            {% elif delete_tag %}
+            {% include 'admin/tag/delete.html' %}
             {% elif drop_database %}
             {% include 'admin/database/drop.html' %}
             {% elif import_database %}
@@ -34,6 +36,7 @@
               {% include 'admin/retired.html' %}
               {% include 'admin/owner.html' %}
               {% include 'admin/status.html' %}
+              {% include 'admin/tag.html' %}
               {% include 'admin/database.html' %}
               {% include 'admin/configuration.html' %}
             {% endif %}
diff --git a/templates/admin/tag.html b/templates/admin/tag.html
new file mode 100644
index 00000000..7c67a56a
--- /dev/null
+++ b/templates/admin/tag.html
@@ -0,0 +1,42 @@
+{% import 'macro/accordion.html' as accordion %}
+
+{{ accordion.header('Set tags', 'tag', 'admin', expanded=open_tag, icon='price-tag-2-line', class='p-0') }}
+{% if tag_error %}<div class="alert alert-danger m-2" role="alert"><strong>Error:</strong> {{ tag_error }}.</div>{% endif %}
+<ul class="list-group list-group-flush">
+  {% if metadata_tags | length %}
+    {% for tag in metadata_tags %}
+      <li class="list-group-item">
+        <form action="{{ url_for('admin_tag.rename', id=tag.fields.id) }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
+          <div class="col-12 flex-grow-1">
+            <label class="visually-hidden" for="name-{{ tag.fields.id }}">Name</label>
+            <div class="input-group">
+              <div class="input-group-text">Name</div>
+              <input type="text" class="form-control" id="name-{{ tag.fields.id }}" name="name" value="{{ tag.fields.name }}">
+              <button type="submit" class="btn btn-primary"><i class="ri-edit-line"></i> Rename</button>
+            </div>
+          </div>
+          <div class="col-12">
+            <a href="{{ url_for('admin_tag.delete', id=tag.fields.id) }}" class="btn btn-danger" role="button"><i class="ri-delete-bin-2-line"></i> Delete</a>
+          </div>
+        </form>
+      </li>
+    {% endfor %}
+  {% else %}
+    <li class="list-group-item"><i class="ri-error-warning-line"></i> No tag found.</li>
+  {% endif %}
+  <li class="list-group-item">
+    <form action="{{ url_for('admin_tag.add') }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
+      <div class="col-12 flex-grow-1">
+        <label class="visually-hidden" for="name">Name</label>
+        <div class="input-group">
+          <div class="input-group-text">Name</div>
+          <input type="text" class="form-control" id="name" name="name" value="">
+        </div>
+      </div>
+      <div class="col-12">
+        <button type="submit" class="btn btn-primary"><i class="ri-add-circle-line"></i> Add</button>
+      </div>
+    </form>
+  </li>
+</ul>
+{{ accordion.footer() }}
diff --git a/templates/admin/tag/delete.html b/templates/admin/tag/delete.html
new file mode 100644
index 00000000..69dd3342
--- /dev/null
+++ b/templates/admin/tag/delete.html
@@ -0,0 +1,19 @@
+{% import 'macro/accordion.html' as accordion %}
+
+{{ accordion.header('Set tags danger zone', 'tag-danger', 'admin', expanded=true, danger=true, class='text-end') }}
+<form action="{{ url_for('admin_tag.do_delete', id=tag.fields.id) }}" method="post">
+  {% if tag_error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ tag_error }}.</div>{% endif %}
+  <div class="alert alert-danger text-center" role="alert">You are about to <strong>delete a set tag</strong>. This action is irreversible.</div>
+  <div class="row row-cols-lg-auto g-3 align-items-center">
+    <div class="col-12 flex-grow-1">
+      <div class="input-group">
+        <div class="input-group-text">Name</div>
+        <input type="text" class="form-control" value="{{ tag.fields.name }}" disabled>
+      </div>
+    </div>
+  </div>
+  <hr class="border-bottom">
+  <a class="btn btn-danger" href="{{ url_for('admin.admin', open_tag=true) }}" role="button"><i class="ri-arrow-left-long-line"></i> Back to the admin</a>
+  <button type="submit" class="btn btn-danger"><i class="ri-delete-bin-2-line"></i> Delete <strong>the set tag</strong></button>
+</form>
+{{ accordion.footer() }}
diff --git a/templates/macro/badge.html b/templates/macro/badge.html
index 2be95ca8..ca577693 100644
--- a/templates/macro/badge.html
+++ b/templates/macro/badge.html
@@ -51,7 +51,7 @@
 {% endmacro %}
 
 {% macro owner(item, owner, solo=false, last=false) %}
-  {% if last  %}
+  {% if last %}
     {% set tooltip=owner.fields.name %}
   {% else %}
     {% set text=owner.fields.name %}
@@ -72,8 +72,17 @@
   {{ badge(check=set, url=url, solo=solo, last=last, color='secondary', icon='hashtag', collapsible='Set:', text=set, alt='Set') }}
 {% endmacro %}
 
+{% macro tag(item, tag, solo=false, last=false) %}
+  {% if last %}
+    {% set tooltip=tag.fields.name %}
+  {% else %}
+    {% set text=tag.fields.name %}
+  {% endif %}
+  {{ badge(check=item.fields[tag.as_column()], solo=solo, last=last, color='light text-primary-emphasis bg-primary-subtle border border-primary-subtle', icon='price-tag-2-line', text=text, alt='Tag', tooltip=tooltip) }}
+{% endmacro %}
+
 {% macro theme(theme, solo=false, last=false) %}
-  {% if last  %}
+  {% if last %}
     {% set tooltip=theme %}
   {% else %}
     {% set text=theme %}
diff --git a/templates/set/card.html b/templates/set/card.html
index 9f2f83a7..bc302e36 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -12,7 +12,13 @@
     {% for owner in brickset_owners %}
       {% with checked=item.fields[owner.as_column()] %}
         data-{{ owner.as_dataset() }}="{{ checked }}"
-        {% if checked %} data-owner-{{ loop.index }}="{{ owner.fields.name | lower }}"{% endif %}
+        {% if checked %} data-search-owner-{{ loop.index }}="{{ owner.fields.name | lower }}"{% endif %}
+      {% endwith %}
+    {% endfor %}
+    {% for tag in brickset_tags %}
+      {% with checked=item.fields[tag.as_column()] %}
+        data-{{ tag.as_dataset() }}="{{ checked }}"
+        {% if checked %} data-search-tag-{{ loop.index }}="{{ tag.fields.name | lower }}"{% endif %}
       {% endwith %}
     {% endfor %}
   {% endif %}
@@ -21,6 +27,9 @@
   {{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.set) }}
   <div class="card-body border-bottom-0 {% if not solo %}p-1{% endif %}">
     {{ badge.theme(item.theme.name, solo=solo, last=last) }}
+    {% for tag in brickset_tags %}
+      {{ badge.tag(item, tag, solo=solo, last=last) }}
+    {% endfor %}
     {{ badge.year(item.fields.year, solo=solo, last=last) }}
     {{ badge.parts(item.fields.number_of_parts, solo=solo, last=last) }}
     {{ badge.total_minifigures(item.fields.total_minifigures, solo=solo, last=last) }}
diff --git a/templates/set/management.html b/templates/set/management.html
index 957db816..b1b20194 100644
--- a/templates/set/management.html
+++ b/templates/set/management.html
@@ -1,20 +1,34 @@
 {% if g.login.is_authenticated() %}
-{{ accordion.header('Owners', 'owner', 'set-details', icon='group-line', class='p-0') }}
-  <ul class="list-group list-group-flush">
-  {% if brickset_owners | length %}
-    {% for owner in brickset_owners %}
-      <li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(item, owner, delete=delete) }}</li>
-    {% endfor %}
-  {% else %}
-    <li class="list-group-item list-group-item-action"><i class="ri-error-warning-line"></i> No owner found.</li>
-    {% endif %}
-  </ul>
-  <div class="list-group list-group-flush border-top">
-    <a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_owner=true) }}"><i class="ri-settings-4-line"></i> Manage the set owners</a>
-  </div>
-{{ accordion.footer() }}
-{{ accordion.header('Management', 'management', 'set-details', expanded=true, icon='settings-4-line') }}
-  <h5 class="border-bottom">Data</h5>
-  <a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh the set data</a>
-{{ accordion.footer() }}
+  {{ accordion.header('Owners', 'owner', 'set-details', icon='group-line', class='p-0') }}
+    <ul class="list-group list-group-flush">
+    {% if brickset_owners | length %}
+      {% for owner in brickset_owners %}
+        <li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(item, owner, delete=delete) }}</li>
+      {% endfor %}
+    {% else %}
+      <li class="list-group-item list-group-item-action"><i class="ri-error-warning-line"></i> No owner found.</li>
+      {% endif %}
+    </ul>
+    <div class="list-group list-group-flush border-top">
+      <a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_owner=true) }}"><i class="ri-settings-4-line"></i> Manage the set owners</a>
+    </div>
+  {{ accordion.footer() }}
+  {{ accordion.header('Tags', 'tag', 'set-details', icon='price-tag-2-line', class='p-0') }}
+    <ul class="list-group list-group-flush">
+    {% if brickset_tags | length %}
+      {% for tag in brickset_tags %}
+        <li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(item, tag, delete=delete) }}</li>
+      {% endfor %}
+    {% else %}
+      <li class="list-group-item list-group-item-action"><i class="ri-error-warning-line"></i> No tag found.</li>
+      {% endif %}
+    </ul>
+    <div class="list-group list-group-flush border-top">
+      <a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_tag=true) }}"><i class="ri-settings-4-line"></i> Manage the set tags</a>
+    </div>
+  {{ accordion.footer() }}
+  {{ accordion.header('Management', 'management', 'set-details', expanded=true, icon='settings-4-line') }}
+    <h5 class="border-bottom">Data</h5>
+    <a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh the set data</a>
+  {{ accordion.footer() }}
 {% endif %}
diff --git a/templates/sets.html b/templates/sets.html
index b06c94dd..7541f1c9 100644
--- a/templates/sets.html
+++ b/templates/sets.html
@@ -10,7 +10,7 @@
       <label class="visually-hidden" for="grid-search">Search</label>
       <div class="input-group">
         <span class="input-group-text"><i class="ri-search-line"></i><span class="ms-1 d-none d-xl-inline"> Search</span></span>
-        <input id="grid-search" data-search-exact="name,number,parts,theme,year" data-search-list="owner" class="form-control form-control-sm" type="text" placeholder="Set, name, number of parts, theme, year, owner" value="">
+        <input id="grid-search" data-search-exact="name,number,parts,theme,year" data-search-list="searchOwner,searchTag" class="form-control form-control-sm" type="text" placeholder="Set, name, number of parts, theme, year, owner, tag" value="">
       </div>
     </div>
     <div class="col-12">
@@ -89,6 +89,20 @@
         </select>
       </div>
     </div>
+    <div class="col-12 flex-grow-1">
+      <label class="visually-hidden" for="grid-tag">Tag</label>
+      <div class="input-group">
+        <span class="input-group-text"><i class="ri-price-tag-2-line"></i><span class="ms-1 d-none d-xl-inline"> Tag</span></span>
+        <select id="grid-tag" class="form-select"
+          data-filter="metadata"
+          autocomplete="off">
+          <option value="" selected>All</option>
+          {% for tag in brickset_tags %}
+            <option value="{{ tag.as_dataset() }}">{{ tag.fields.name }}</option>
+          {% endfor %}
+        </select>
+      </div>
+    </div>
   </div>
   <div class="row" data-grid="true" id="grid">
   {% for item in collection %}

From 418a332f0396dd6c689057c0abdefc4c0d1b0a92 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 18:09:06 +0100
Subject: [PATCH 092/154] Add missing set owners SQL drop

---
 bricktracker/sql/schema/drop.sql | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/bricktracker/sql/schema/drop.sql b/bricktracker/sql/schema/drop.sql
index 1bab7d65..8c4cedb9 100644
--- a/bricktracker/sql/schema/drop.sql
+++ b/bricktracker/sql/schema/drop.sql
@@ -1,11 +1,13 @@
 BEGIN transaction;
 
+DROP TABLE IF EXISTS "bricktracker_metadata_owners";
 DROP TABLE IF EXISTS "bricktracker_metadata_statuses";
 DROP TABLE IF EXISTS "bricktracker_metadata_tags";
 DROP TABLE IF EXISTS "bricktracker_minifigures";
 DROP TABLE IF EXISTS "bricktracker_parts";
 DROP TABLE IF EXISTS "bricktracker_sets";
 DROP TABLE IF EXISTS "bricktracker_set_checkboxes";
+DROP TABLE IF EXISTS "bricktracker_set_owners";
 DROP TABLE IF EXISTS "bricktracker_set_statuses";
 DROP TABLE IF EXISTS "bricktracker_set_storages";
 DROP TABLE IF EXISTS "bricktracker_set_tags";

From 302eafe08c00c45319a6f039d7d8f7d2392b5d20 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 18:37:44 +0100
Subject: [PATCH 093/154] Fix broken set status

---
 bricktracker/set_status_list.py | 2 +-
 bricktracker/views/set.py       | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/bricktracker/set_status_list.py b/bricktracker/set_status_list.py
index b96f2139..dabd3b0a 100644
--- a/bricktracker/set_status_list.py
+++ b/bricktracker/set_status_list.py
@@ -7,7 +7,7 @@ logger = logging.getLogger(__name__)
 
 
 # Lego sets status list
-class BrickSetStatusList(BrickMetadataList):
+class BrickSetStatusList(BrickMetadataList[BrickSetStatus]):
     kind: str = 'set statuses'
 
     # Database table
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index 344b0e60..a6691e7d 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -64,7 +64,7 @@ def update_status(*, id: str, metadata_id: str) -> Response:
     brickset = BrickSet().select_light(id)
     status = BrickSetStatusList(BrickSetStatus).get(metadata_id)
 
-    state = status.update_set_state(brickset, request.json)
+    state = status.update_set_state(brickset, json=request.json)
 
     return jsonify({'value': state})
 

From 5ffea66de0e03a7cddbae133bb534db308048bec Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 18:38:03 +0100
Subject: [PATCH 094/154] Leaner card dataset

---
 static/scripts/grid/filter.js | 17 ++++++++++++++++-
 templates/set/card.html       | 18 +++++++++++++-----
 2 files changed, 29 insertions(+), 6 deletions(-)

diff --git a/static/scripts/grid/filter.js b/static/scripts/grid/filter.js
index f5b075f9..2825c53a 100644
--- a/static/scripts/grid/filter.js
+++ b/static/scripts/grid/filter.js
@@ -75,11 +75,13 @@ class BrickGridFilter {
                         if (select.value.startsWith("-")) {
                             options.filters.push({
                                 attribute: select.value.substring(1),
+                                bool: true,
                                 value: "0"
                             })
                         } else {
                             options.filters.push({
                                 attribute: select.value,
+                                bool: true,
                                 value: "1"
                             })
                         }
@@ -93,7 +95,20 @@ class BrickGridFilter {
         cards.forEach(current => {
             // Process all filters
             for (const filter of options.filters) {
-                if (current.getAttribute(`data-${filter.attribute}`) != filter.value) {
+                const attribute = current.getAttribute(`data-${filter.attribute}`);
+
+                // Bool check
+                // Attribute not equal value, or undefined and value is truthy
+                if (filter.bool) {
+                    if ((attribute != null && attribute != filter.value) || (attribute == null && filter.value == "1")) {
+                        current.parentElement.classList.add("d-none");
+                        return;
+                    }
+                }
+
+                // Value check
+                // Attribute not equal value, or attribute undefined
+                else if ((attribute != null && attribute != filter.value) || attribute == null) {
                     current.parentElement.classList.add("d-none");
                     return;
                 }
diff --git a/templates/set/card.html b/templates/set/card.html
index bc302e36..eb48c189 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -8,17 +8,25 @@
     data-index="{{ index }}" data-number="{{ item.fields.set }}" data-name="{{ item.fields.name | lower }}" data-parts="{{ item.fields.number_of_parts }}"
     data-year="{{ item.fields.year }}" data-theme="{{ item.theme.name | lower }}" data-minifigures="{{ item.fields.total_minifigures }}" data-has-minifigures="{{ (item.fields.total_minifigures > 0) | int }}"
     data-has-missing="{{ (item.fields.total_missing > 0) | int }}" data-has-missing-instructions="{{ (not (item.instructions | length)) | int }}" data-missing="{{ item.fields.total_missing }}"
-    {% for status in brickset_statuses %}data-{{ status.as_dataset() }}="{{ item.fields[status.as_column()] }}" {% endfor %}
+    {% for status in brickset_statuses %}
+      {% with checked=item.fields[status.as_column()] %}
+        {% if checked %}
+          data-{{ status.as_dataset() }}="{{ checked }}"
+        {% endif %}
+      {% endwith %}
+    {% endfor %}
     {% for owner in brickset_owners %}
       {% with checked=item.fields[owner.as_column()] %}
-        data-{{ owner.as_dataset() }}="{{ checked }}"
-        {% if checked %} data-search-owner-{{ loop.index }}="{{ owner.fields.name | lower }}"{% endif %}
+        {% if checked %}
+          data-{{ owner.as_dataset() }}="{{ checked }}" data-search-owner-{{ loop.index }}="{{ owner.fields.name | lower }}"
+        {% endif %}
       {% endwith %}
     {% endfor %}
     {% for tag in brickset_tags %}
       {% with checked=item.fields[tag.as_column()] %}
-        data-{{ tag.as_dataset() }}="{{ checked }}"
-        {% if checked %} data-search-tag-{{ loop.index }}="{{ tag.fields.name | lower }}"{% endif %}
+        {% if checked %}
+          data-{{ tag.as_dataset() }}="{{ checked }}" data-search-tag-{{ loop.index }}="{{ tag.fields.name | lower }}"
+        {% endif %}
       {% endwith %}
     {% endfor %}
   {% endif %}

From 271effd5d265a3b0afe19ba438b3c1b9fcdbfd38 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 20:46:36 +0100
Subject: [PATCH 095/154] Support for damaged parts

---
 .env.sample                                   |  5 ++-
 bricktracker/config.py                        |  2 +-
 bricktracker/minifigure_list.py               | 34 +++++++++------
 bricktracker/navbar.py                        |  2 +-
 bricktracker/part.py                          | 42 +++++++++++--------
 bricktracker/part_list.py                     |  8 ++--
 bricktracker/set_list.py                      | 37 +++++++++++++++-
 bricktracker/sql/minifigure/base/base.sql     |  3 ++
 bricktracker/sql/minifigure/list/all.sql      | 15 ++++---
 .../sql/minifigure/list/damaged_part.sql      | 28 +++++++++++++
 bricktracker/sql/minifigure/list/last.sql     |  4 ++
 .../sql/minifigure/select/generic.sql         | 13 ++++--
 bricktracker/sql/part/base/base.sql           |  3 ++
 bricktracker/sql/part/list/all.sql            |  4 ++
 .../sql/part/list/from_minifigure.sql         |  4 ++
 bricktracker/sql/part/list/from_print.sql     |  5 ++-
 .../part/list/{missing.sql => problem.sql}    |  5 +++
 bricktracker/sql/part/list/specific.sql       |  4 ++
 bricktracker/sql/part/select/generic.sql      |  4 ++
 bricktracker/sql/part/update/damaged.sql      |  7 ++++
 bricktracker/sql/set/base/base.sql            |  3 ++
 bricktracker/sql/set/base/full.sql            | 13 ++++--
 .../sql/set/list/damaged_minifigure.sql       | 11 +++++
 bricktracker/sql/set/list/damaged_part.sql    | 12 ++++++
 bricktracker/views/minifigure.py              |  1 +
 bricktracker/views/part.py                    | 18 +++++---
 bricktracker/views/set.py                     | 18 ++++----
 templates/macro/accordion.html                |  2 +-
 templates/macro/badge.html                    |  6 ++-
 templates/macro/table.html                    | 10 ++---
 templates/minifigure/card.html                |  4 +-
 templates/minifigure/table.html               |  3 +-
 templates/part/card.html                      |  7 +++-
 templates/part/table.html                     | 13 +++---
 templates/{missing.html => problem.html}      |  2 +-
 templates/set/card.html                       |  8 +++-
 templates/sets.html                           |  5 ++-
 37 files changed, 274 insertions(+), 91 deletions(-)
 create mode 100644 bricktracker/sql/minifigure/list/damaged_part.sql
 rename bricktracker/sql/part/list/{missing.sql => problem.sql} (86%)
 create mode 100644 bricktracker/sql/part/update/damaged.sql
 create mode 100644 bricktracker/sql/set/list/damaged_minifigure.sql
 create mode 100644 bricktracker/sql/set/list/damaged_part.sql
 rename templates/{missing.html => problem.html} (78%)

diff --git a/.env.sample b/.env.sample
index d6141e63..fb52132b 100644
--- a/.env.sample
+++ b/.env.sample
@@ -107,9 +107,10 @@
 # Default: false
 # BK_HIDE_ALL_SETS=true
 
-# Optional: Hide the 'Missing' entry from the menu. Does not disable the route.
+# Optional: Hide the 'Problems' entry from the menu. Does not disable the route.
 # Default: false
-# BK_HIDE_MISSING_PARTS=true
+# Legacy name: BK_HIDE_MISSING_PARTS
+# BK_HIDE_PROBLEMS_PARTS=true
 
 # Optional: Hide the 'Instructions' entry in a Set card
 # Default: false
diff --git a/bricktracker/config.py b/bricktracker/config.py
index cbb64a81..8ab193a5 100644
--- a/bricktracker/config.py
+++ b/bricktracker/config.py
@@ -29,7 +29,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
     {'n': 'HIDE_ALL_MINIFIGURES', 'c': bool},
     {'n': 'HIDE_ALL_PARTS', 'c': bool},
     {'n': 'HIDE_ALL_SETS', 'c': bool},
-    {'n': 'HIDE_MISSING_PARTS', 'c': bool},
+    {'n': 'HIDE_PROBLEMS_PARTS', 'e': 'BK_HIDE_MISSING_PARTS', 'c': bool},
     {'n': 'HIDE_SET_INSTRUCTIONS', 'c': bool},
     {'n': 'HIDE_WISHES', 'c': bool},
     {'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': '"rebrickable_minifigures"."name" ASC'},  # noqa: E501
diff --git a/bricktracker/minifigure_list.py b/bricktracker/minifigure_list.py
index 790018a7..24a4d2e1 100644
--- a/bricktracker/minifigure_list.py
+++ b/bricktracker/minifigure_list.py
@@ -21,10 +21,11 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
 
     # Queries
     all_query: str = 'minifigure/list/all'
+    damaged_part_query: str = 'minifigure/list/damaged_part'
     last_query: str = 'minifigure/list/last'
+    missing_part_query: str = 'minifigure/list/missing_part'
     select_query: str = 'minifigure/list/from_set'
     using_part_query: str = 'minifigure/list/using_part'
-    missing_part_query: str = 'minifigure/list/missing_part'
 
     def __init__(self, /):
         super().__init__()
@@ -47,6 +48,23 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
 
         return self
 
+    # Minifigures with a part damaged part
+    def damaged_part(self, part: str, color: int, /) -> Self:
+        # Save the parameters to the fields
+        self.fields.part = part
+        self.fields.color = color
+
+        # Load the minifigures from the database
+        for record in self.select(
+            override_query=self.damaged_part_query,
+            order=self.order
+        ):
+            minifigure = BrickMinifigure(record=record)
+
+            self.records.append(minifigure)
+
+        return self
+
     # Last added minifigure
     def last(self, /, *, limit: int = 6) -> Self:
         # Randomize
@@ -80,12 +98,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
         return self
 
     # Minifigures missing a part
-    def missing_part(
-        self,
-        part: str,
-        color: int,
-        /,
-    ) -> Self:
+    def missing_part(self, part: str, color: int, /) -> Self:
         # Save the parameters to the fields
         self.fields.part = part
         self.fields.color = color
@@ -102,12 +115,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
         return self
 
     # Minifigure using a part
-    def using_part(
-        self,
-        part: str,
-        color: int,
-        /,
-    ) -> Self:
+    def using_part(self, part: str, color: int, /) -> Self:
         # Save the parameters to the fields
         self.fields.part = part
         self.fields.color = color
diff --git a/bricktracker/navbar.py b/bricktracker/navbar.py
index 17853ebd..04b70533 100644
--- a/bricktracker/navbar.py
+++ b/bricktracker/navbar.py
@@ -11,7 +11,7 @@ NAVBAR: Final[list[dict[str, Any]]] = [
     {'e': 'set.list', 't': 'Sets', 'i': 'grid-line', 'f': 'HIDE_ALL_SETS'},  # noqa: E501
     {'e': 'add.add', 't': 'Add', 'i': 'add-circle-line', 'f': 'HIDE_ADD_SET'},  # noqa: E501
     {'e': 'part.list', 't': 'Parts', 'i': 'shapes-line', 'f': 'HIDE_ALL_PARTS'},  # noqa: E501
-    {'e': 'part.missing', 't': 'Missing', 'i': 'error-warning-line', 'f': 'HIDE_MISSING_PARTS'},  # noqa: E501
+    {'e': 'part.problem', 't': 'Problems', 'i': 'error-warning-line', 'f': 'HIDE_PROBLEMS_PARTS'},  # noqa: E501
     {'e': 'minifigure.list', 't': 'Minifigures', 'i': 'group-line', 'f': 'HIDE_ALL_MINIFIGURES'},  # noqa: E501
     {'e': 'instructions.list', 't': 'Instructions', 'i': 'file-line', 'f': 'HIDE_ALL_INSTRUCTIONS'},  # noqa: E501
     {'e': 'wish.list', 't': 'Wishlist', 'i': 'gift-line', 'f': 'HIDE_WISHES'},
diff --git a/bricktracker/part.py b/bricktracker/part.py
index 64d71df8..fa463bec 100644
--- a/bricktracker/part.py
+++ b/bricktracker/part.py
@@ -74,9 +74,12 @@ class BrickPart(RebrickablePart):
         return True
 
     # A identifier for HTML component
-    def html_id(self, /) -> str:
+    def html_id(self, prefix: str | None = None, /) -> str:
         components: list[str] = ['part']
 
+        if prefix is not None:
+            components.append(prefix)
+
         if self.fields.figure is not None:
             components.append(self.fields.figure)
 
@@ -144,36 +147,38 @@ class BrickPart(RebrickablePart):
 
         return self
 
-    # Update the missing part
-    def update_missing(self, json: Any | None, /) -> None:
-        missing: str | int = json.get('value', '')  # type: ignore
+    # Update a problematic part
+    def update_problem(self, problem: str, json: Any | None, /) -> int:
+        amount: str | int = json.get('value', '')  # type: ignore
 
         # We need a positive integer
         try:
-            if missing == '':
-                missing = 0
+            if amount == '':
+                amount = 0
 
-            missing = int(missing)
+            amount = int(amount)
 
-            if missing < 0:
-                missing = 0
+            if amount < 0:
+                amount = 0
         except Exception:
-            raise ErrorException('"{missing}" is not a valid integer'.format(
-                missing=missing
+            raise ErrorException('"{amount}" is not a valid integer'.format(
+                amount=amount
             ))
 
-        if missing < 0:
-            raise ErrorException('Cannot set a negative missing value')
+        if amount < 0:
+            raise ErrorException('Cannot set a negative amount')
 
-        self.fields.missing = missing
+        setattr(self.fields, problem, amount)
 
         BrickSQL().execute_and_commit(
-            'part/update/missing',
+            'part/update/{problem}'.format(problem=problem),
             parameters=self.sql_parameters()
         )
 
-    # Compute the url for missing part
-    def url_for_missing(self, /) -> str:
+        return amount
+
+    # Compute the url for problematic part
+    def url_for_problem(self, problem: str, /) -> str:
         # Different URL for a minifigure part
         if self.minifigure is not None:
             figure = self.minifigure.fields.figure
@@ -181,10 +186,11 @@ class BrickPart(RebrickablePart):
             figure = None
 
         return url_for(
-            'set.missing_part',
+            'set.problem_part',
             id=self.fields.id,
             figure=figure,
             part=self.fields.part,
             color=self.fields.color,
             spare=self.fields.spare,
+            problem=problem,
         )
diff --git a/bricktracker/part_list.py b/bricktracker/part_list.py
index 833ae61c..86ca34ad 100644
--- a/bricktracker/part_list.py
+++ b/bricktracker/part_list.py
@@ -25,7 +25,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
     all_query: str = 'part/list/all'
     last_query: str = 'part/list/last'
     minifigure_query: str = 'part/list/from_minifigure'
-    missing_query: str = 'part/list/missing'
+    problem_query: str = 'part/list/problem'
     print_query: str = 'part/list/from_print'
     select_query: str = 'part/list/specific'
 
@@ -138,10 +138,10 @@ class BrickPartList(BrickRecordList[BrickPart]):
 
         return self
 
-    # Load missing parts
-    def missing(self, /) -> Self:
+    # Load problematic parts
+    def problem(self, /) -> Self:
         for record in self.select(
-            override_query=self.missing_query,
+            override_query=self.problem_query,
             order=self.order
         ):
             part = BrickPart(record=record)
diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py
index 54a3cb8c..d538daa7 100644
--- a/bricktracker/set_list.py
+++ b/bricktracker/set_list.py
@@ -18,6 +18,8 @@ class BrickSetList(BrickRecordList[BrickSet]):
     order: str
 
     # Queries
+    damaged_minifigure_query: str = 'set/list/damaged_minifigure'
+    damaged_part_query: str = 'set/list/damaged_part'
     generic_query: str = 'set/list/generic'
     light_query: str = 'set/list/light'
     missing_minifigure_query: str = 'set/list/missing_minifigure'
@@ -57,6 +59,39 @@ class BrickSetList(BrickRecordList[BrickSet]):
 
         return self
 
+    # Sets with a minifigure part damaged
+    def damaged_minifigure(self, figure: str, /) -> Self:
+        # Save the parameters to the fields
+        self.fields.figure = figure
+
+        # Load the sets from the database
+        for record in self.select(
+            override_query=self.damaged_minifigure_query,
+            order=self.order
+        ):
+            brickset = BrickSet(record=record)
+
+            self.records.append(brickset)
+
+        return self
+
+    # Sets with a part damaged
+    def damaged_part(self, part: str, color: int, /) -> Self:
+        # Save the parameters to the fields
+        self.fields.part = part
+        self.fields.color = color
+
+        # Load the sets from the database
+        for record in self.select(
+            override_query=self.damaged_part_query,
+            order=self.order
+        ):
+            brickset = BrickSet(record=record)
+
+            self.records.append(brickset)
+
+        return self
+
     # A generic list of the different sets
     def generic(self, /) -> Self:
         for record in self.select(
@@ -90,7 +125,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
 
         return self
 
-    # Sets missing a minifigure
+    # Sets missing a minifigure part
     def missing_minifigure(self, figure: str, /) -> Self:
         # Save the parameters to the fields
         self.fields.figure = figure
diff --git a/bricktracker/sql/minifigure/base/base.sql b/bricktracker/sql/minifigure/base/base.sql
index dbfc4289..d651bf08 100644
--- a/bricktracker/sql/minifigure/base/base.sql
+++ b/bricktracker/sql/minifigure/base/base.sql
@@ -7,6 +7,9 @@ SELECT
     {% block total_missing %}
     NULL AS "total_missing", -- dummy for order: total_missing
     {% endblock %}
+    {% block total_damaged %}
+    NULL AS "total_damaged", -- dummy for order: total_damaged
+    {% endblock %}
     {% block total_quantity %}
     NULL AS "total_quantity", -- dummy for order: total_quantity
     {% endblock %}
diff --git a/bricktracker/sql/minifigure/list/all.sql b/bricktracker/sql/minifigure/list/all.sql
index e3ce2bda..d0bb6eb3 100644
--- a/bricktracker/sql/minifigure/list/all.sql
+++ b/bricktracker/sql/minifigure/list/all.sql
@@ -1,7 +1,11 @@
 {% extends 'minifigure/base/base.sql' %}
 
 {% block total_missing %}
-SUM(IFNULL("missing_join"."total", 0)) AS "total_missing",
+SUM(IFNULL("problem_join"."total_missing", 0)) AS "total_missing",
+{% endblock %}
+
+{% block total_damaged %}
+SUM(IFNULL("problem_join"."total_damaged", 0)) AS "total_damaged",
 {% endblock %}
 
 {% block total_quantity %}
@@ -18,15 +22,16 @@ LEFT JOIN (
     SELECT
         "bricktracker_parts"."id",
         "bricktracker_parts"."figure",
-        SUM("bricktracker_parts"."missing") AS total
+        SUM("bricktracker_parts"."missing") AS "total_missing",
+        SUM("bricktracker_parts"."damaged") AS "total_damaged"
     FROM "bricktracker_parts"
     WHERE "bricktracker_parts"."figure" IS NOT NULL
     GROUP BY
         "bricktracker_parts"."id",
         "bricktracker_parts"."figure"
-) "missing_join"
-ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "missing_join"."id"
-AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "missing_join"."figure"
+) "problem_join"
+ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "problem_join"."id"
+AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "problem_join"."figure"
 {% endblock %}
 
 {% block group %}
diff --git a/bricktracker/sql/minifigure/list/damaged_part.sql b/bricktracker/sql/minifigure/list/damaged_part.sql
new file mode 100644
index 00000000..5cd18db6
--- /dev/null
+++ b/bricktracker/sql/minifigure/list/damaged_part.sql
@@ -0,0 +1,28 @@
+{% extends 'minifigure/base/base.sql' %}
+
+{% block total_damaged %}
+SUM("bricktracker_parts"."damaged") AS "total_damaged",
+{% endblock %}
+
+{% block join %}
+LEFT JOIN "bricktracker_parts"
+ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_parts"."id"
+AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "bricktracker_parts"."figure"
+{% endblock %}
+
+{% block where %}
+WHERE "rebrickable_minifigures"."figure" IN (
+    SELECT "bricktracker_parts"."figure"
+    FROM "bricktracker_parts"
+    WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part
+    AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color
+    AND "bricktracker_parts"."figure" IS NOT NULL
+    AND "bricktracker_parts"."damaged" > 0
+    GROUP BY "bricktracker_parts"."figure"
+)
+{% endblock %}
+
+{% block group %}
+GROUP BY
+    "rebrickable_minifigures"."figure"
+{% endblock %}
diff --git a/bricktracker/sql/minifigure/list/last.sql b/bricktracker/sql/minifigure/list/last.sql
index 372610d4..ddae2125 100644
--- a/bricktracker/sql/minifigure/list/last.sql
+++ b/bricktracker/sql/minifigure/list/last.sql
@@ -4,6 +4,10 @@
 SUM("bricktracker_parts"."missing") AS "total_missing",
 {% endblock %}
 
+{% block total_damaged %}
+SUM("bricktracker_parts"."damaged") AS "total_damaged",
+{% endblock %}
+
 {% block join %}
 LEFT JOIN "bricktracker_parts"
 ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_parts"."id"
diff --git a/bricktracker/sql/minifigure/select/generic.sql b/bricktracker/sql/minifigure/select/generic.sql
index f5bacd7c..b48bfb71 100644
--- a/bricktracker/sql/minifigure/select/generic.sql
+++ b/bricktracker/sql/minifigure/select/generic.sql
@@ -1,7 +1,11 @@
 {% extends 'minifigure/base/base.sql' %}
 
 {% block total_missing %}
-IFNULL("missing_join"."total", 0) AS "total_missing",
+IFNULL("problem_join"."total_missing", 0) AS "total_missing",
+{% endblock %}
+
+{% block total_damaged %}
+IFNULL("problem_join"."total_damaged", 0) AS "total_damaged",
 {% endblock %}
 
 {% block total_quantity %}
@@ -17,12 +21,13 @@ COUNT(DISTINCT "bricktracker_minifigures"."id") AS "total_sets"
 LEFT JOIN (
     SELECT
         "bricktracker_parts"."figure",
-        SUM("bricktracker_parts"."missing") AS "total"
+        SUM("bricktracker_parts"."missing") AS "total_missing",
+        SUM("bricktracker_parts"."damaged") AS "total_damaged"
     FROM "bricktracker_parts"
     WHERE "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure
     GROUP BY "bricktracker_parts"."figure"
-) "missing_join"
-ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "missing_join"."figure"
+) "problem_join"
+ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "problem_join"."figure"
 {% endblock %}
 
 {% block where %}
diff --git a/bricktracker/sql/part/base/base.sql b/bricktracker/sql/part/base/base.sql
index 7849d4cc..24c1c567 100644
--- a/bricktracker/sql/part/base/base.sql
+++ b/bricktracker/sql/part/base/base.sql
@@ -23,6 +23,9 @@ SELECT
     {% block total_missing %}
     NULL AS "total_missing", -- dummy for order: total_missing
     {% endblock %}
+    {% block total_damaged %}
+    NULL AS "total_damaged", -- dummy for order: total_damaged
+    {% endblock %}
     {% block total_quantity %}
     NULL AS "total_quantity", -- dummy for order: total_quantity
     {% endblock %}
diff --git a/bricktracker/sql/part/list/all.sql b/bricktracker/sql/part/list/all.sql
index c1d0ed1f..77831a67 100644
--- a/bricktracker/sql/part/list/all.sql
+++ b/bricktracker/sql/part/list/all.sql
@@ -4,6 +4,10 @@
 SUM("bricktracker_parts"."missing") AS "total_missing",
 {% endblock %}
 
+{% block total_damaged %}
+SUM("bricktracker_parts"."damaged") AS "total_damaged",
+{% endblock %}
+
 {% block total_quantity %}
 SUM("bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity",
 {% endblock %}
diff --git a/bricktracker/sql/part/list/from_minifigure.sql b/bricktracker/sql/part/list/from_minifigure.sql
index c8409382..115b7917 100644
--- a/bricktracker/sql/part/list/from_minifigure.sql
+++ b/bricktracker/sql/part/list/from_minifigure.sql
@@ -5,6 +5,10 @@
 SUM("bricktracker_parts"."missing") AS "total_missing",
 {% endblock %}
 
+{% block total_damaged %}
+SUM("bricktracker_parts"."damaged") AS "total_damaged",
+{% endblock %}
+
 {% block where %}
 WHERE "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure
 {% endblock %}
diff --git a/bricktracker/sql/part/list/from_print.sql b/bricktracker/sql/part/list/from_print.sql
index f996864c..fe1198c0 100644
--- a/bricktracker/sql/part/list/from_print.sql
+++ b/bricktracker/sql/part/list/from_print.sql
@@ -1,8 +1,9 @@
 
 {% extends 'part/base/base.sql' %}
 
-{% block total_missing %}
-{% endblock %}
+{% block total_missing %}{% endblock %}
+
+{% block total_damaged %}{% endblock %}
 
 {% block where %}
 WHERE "rebrickable_parts"."print" IS NOT DISTINCT FROM :print
diff --git a/bricktracker/sql/part/list/missing.sql b/bricktracker/sql/part/list/problem.sql
similarity index 86%
rename from bricktracker/sql/part/list/missing.sql
rename to bricktracker/sql/part/list/problem.sql
index 9d3446e4..dbf411b9 100644
--- a/bricktracker/sql/part/list/missing.sql
+++ b/bricktracker/sql/part/list/problem.sql
@@ -4,6 +4,10 @@
 SUM("bricktracker_parts"."missing") AS "total_missing",
 {% endblock %}
 
+{% block total_damaged %}
+SUM("bricktracker_parts"."damaged") AS "total_damaged",
+{% endblock %}
+
 {% block total_sets %}
 COUNT("bricktracker_parts"."id")  - COUNT("bricktracker_parts"."figure") AS "total_sets",
 {% endblock %}
@@ -20,6 +24,7 @@ AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures
 
 {% block where %}
 WHERE "bricktracker_parts"."missing" > 0
+OR "bricktracker_parts"."damaged" > 0
 {% endblock %}
 
 {% block group %}
diff --git a/bricktracker/sql/part/list/specific.sql b/bricktracker/sql/part/list/specific.sql
index d3e291a3..7c62c68b 100644
--- a/bricktracker/sql/part/list/specific.sql
+++ b/bricktracker/sql/part/list/specific.sql
@@ -5,6 +5,10 @@
 IFNULL("bricktracker_parts"."missing", 0) AS "total_missing",
 {% endblock %}
 
+{% block total_damaged %}
+IFNULL("bricktracker_parts"."damaged", 0) AS "total_damaged",
+{% endblock %}
+
 {% block where %}
 WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM :id
 AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure
diff --git a/bricktracker/sql/part/select/generic.sql b/bricktracker/sql/part/select/generic.sql
index a1760d67..43a26da6 100644
--- a/bricktracker/sql/part/select/generic.sql
+++ b/bricktracker/sql/part/select/generic.sql
@@ -4,6 +4,10 @@
 SUM("bricktracker_parts"."missing") AS "total_missing",
 {% endblock %}
 
+{% block total_damaged %}
+SUM("bricktracker_parts"."damaged") AS "total_damaged",
+{% endblock %}
+
 {% block total_quantity %}
 SUM((NOT "bricktracker_parts"."spare") * "bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity",
 {% endblock %}
diff --git a/bricktracker/sql/part/update/damaged.sql b/bricktracker/sql/part/update/damaged.sql
new file mode 100644
index 00000000..d4bbabd4
--- /dev/null
+++ b/bricktracker/sql/part/update/damaged.sql
@@ -0,0 +1,7 @@
+UPDATE "bricktracker_parts"
+SET "damaged" = :damaged
+WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM :id
+AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure
+AND "bricktracker_parts"."part" IS NOT DISTINCT FROM :part
+AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color
+AND "bricktracker_parts"."spare" IS NOT DISTINCT FROM :spare
diff --git a/bricktracker/sql/set/base/base.sql b/bricktracker/sql/set/base/base.sql
index ffefe956..331b15e5 100644
--- a/bricktracker/sql/set/base/base.sql
+++ b/bricktracker/sql/set/base/base.sql
@@ -21,6 +21,9 @@ SELECT
     {% block total_missing %}
     NULL AS "total_missing", -- dummy for order: total_missing
     {% endblock %}
+    {% block total_damaged %}
+    NULL AS "total_damaged", -- dummy for order: total_damaged
+    {% endblock %}
     {% block total_quantity %}
     NULL AS "total_quantity", -- dummy for order: total_quantity
     {% endblock %}
diff --git a/bricktracker/sql/set/base/full.sql b/bricktracker/sql/set/base/full.sql
index 271f8909..92612df9 100644
--- a/bricktracker/sql/set/base/full.sql
+++ b/bricktracker/sql/set/base/full.sql
@@ -5,7 +5,11 @@
 {% endblock %}
 
 {% block total_missing %}
-IFNULL("missing_join"."total", 0) AS "total_missing",
+IFNULL("problem_join"."total_missing", 0) AS "total_missing",
+{% endblock %}
+
+{% block total_damaged %}
+IFNULL("problem_join"."total_damaged", 0) AS "total_damaged",
 {% endblock %}
 
 {% block total_quantity %}
@@ -32,12 +36,13 @@ ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_tags"."id"
 LEFT JOIN (
     SELECT
         "bricktracker_parts"."id",
-        SUM("bricktracker_parts"."missing") AS "total"
+        SUM("bricktracker_parts"."missing") AS "total_missing",
+        SUM("bricktracker_parts"."damaged") AS "total_damaged"
     FROM "bricktracker_parts"
     {% block where_missing %}{% endblock %}
     GROUP BY "bricktracker_parts"."id"
-) "missing_join"
-ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "missing_join"."id"
+) "problem_join"
+ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "problem_join"."id"
 
 -- LEFT JOIN + SELECT to avoid messing the total
 LEFT JOIN (
diff --git a/bricktracker/sql/set/list/damaged_minifigure.sql b/bricktracker/sql/set/list/damaged_minifigure.sql
new file mode 100644
index 00000000..51a615de
--- /dev/null
+++ b/bricktracker/sql/set/list/damaged_minifigure.sql
@@ -0,0 +1,11 @@
+{% extends 'set/base/full.sql' %}
+
+{% block where %}
+WHERE "bricktracker_sets"."id" IN (
+    SELECT "bricktracker_parts"."id"
+    FROM "bricktracker_parts"
+    WHERE "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure
+    AND "bricktracker_parts"."missing" > 0
+    GROUP BY "bricktracker_parts"."id"
+)
+{% endblock %}
diff --git a/bricktracker/sql/set/list/damaged_part.sql b/bricktracker/sql/set/list/damaged_part.sql
new file mode 100644
index 00000000..128931f3
--- /dev/null
+++ b/bricktracker/sql/set/list/damaged_part.sql
@@ -0,0 +1,12 @@
+{% extends 'set/base/full.sql' %}
+
+{% block where %}
+WHERE "bricktracker_sets"."id" IN (
+    SELECT "bricktracker_parts"."id"
+    FROM "bricktracker_parts"
+    WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part
+    AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color
+    AND "bricktracker_parts"."damaged" > 0
+    GROUP BY "bricktracker_parts"."id"
+)
+{% endblock %}
diff --git a/bricktracker/views/minifigure.py b/bricktracker/views/minifigure.py
index 60647fab..5d9cc85f 100644
--- a/bricktracker/views/minifigure.py
+++ b/bricktracker/views/minifigure.py
@@ -27,4 +27,5 @@ def details(*, figure: str) -> str:
         item=BrickMinifigure().select_generic(figure),
         using=BrickSetList().using_minifigure(figure),
         missing=BrickSetList().missing_minifigure(figure),
+        damaged=BrickSetList().damaged_minifigure(figure),
     )
diff --git a/bricktracker/views/part.py b/bricktracker/views/part.py
index dbcfe0dd..0bea4ab7 100644
--- a/bricktracker/views/part.py
+++ b/bricktracker/views/part.py
@@ -19,13 +19,13 @@ def list() -> str:
     )
 
 
-# Missing
-@part_page.route('/missing', methods=['GET'])
+# Problem
+@part_page.route('/problem', methods=['GET'])
 @exception_handler(__file__)
-def missing() -> str:
+def problem() -> str:
     return render_template(
-        'missing.html',
-        table_collection=BrickPartList().missing()
+        'problem.html',
+        table_collection=BrickPartList().problem()
     )
 
 
@@ -46,6 +46,10 @@ def details(*, part: str, color: int) -> str:
             part,
             color
         ),
+        sets_damaged=BrickSetList().damaged_part(
+            part,
+            color
+        ),
         minifigures_using=BrickMinifigureList().using_part(
             part,
             color
@@ -54,5 +58,9 @@ def details(*, part: str, color: int) -> str:
             part,
             color
         ),
+        minifigures_damaged=BrickMinifigureList().damaged_part(
+            part,
+            color
+        ),
         similar_prints=BrickPartList().from_print(brickpart)
     )
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index a6691e7d..add66d20 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -136,18 +136,19 @@ def details(*, id: str) -> str:
     )
 
 
-# Update the missing pieces of a part
-@set_page.route('/<id>/parts/<part>/<int:color>/<int:spare>/missing', defaults={'figure': None}, methods=['POST'])  # noqa: E501
-@set_page.route('/<id>/minifigures/<figure>/parts/<part>/<int:color>/<int:spare>/missing', methods=['POST'])  # noqa: E501
+# Update problematic pieces of a set
+@set_page.route('/<id>/parts/<part>/<int:color>/<int:spare>/<problem>', defaults={'figure': None}, methods=['POST'])  # noqa: E501
+@set_page.route('/<id>/minifigures/<figure>/parts/<part>/<int:color>/<int:spare>/<problem>', methods=['POST'])  # noqa: E501
 @login_required
 @exception_handler(__file__, json=True)
-def missing_part(
+def problem_part(
     *,
     id: str,
     figure: str | None,
     part: str,
     color: int,
     spare: int,
+    problem: str,
 ) -> Response:
     brickset = BrickSet().select_specific(id)
 
@@ -164,20 +165,21 @@ def missing_part(
         minifigure=brickminifigure,
     )
 
-    brickpart.update_missing(request.json)
+    amount = brickpart.update_problem(problem, request.json)
 
     # Info
-    logger.info('Set {set} ({id}): updated part ({part} color: {color}, spare: {spare}, minifigure: {figure}) missing count to {missing}'.format(  # noqa: E501
+    logger.info('Set {set} ({id}): updated part ({part} color: {color}, spare: {spare}, minifigure: {figure}) {problem} count to {amount}'.format(  # noqa: E501
         set=brickset.fields.set,
         id=brickset.fields.id,
         figure=figure,
         part=brickpart.fields.part,
         color=brickpart.fields.color,
         spare=brickpart.fields.spare,
-        missing=brickpart.fields.missing,
+        problem=problem,
+        amount=amount
     ))
 
-    return jsonify({'missing': brickpart.fields.missing})
+    return jsonify({problem: amount})
 
 
 # Refresh a set
diff --git a/templates/macro/accordion.html b/templates/macro/accordion.html
index 417ef474..8ae8a883 100644
--- a/templates/macro/accordion.html
+++ b/templates/macro/accordion.html
@@ -43,7 +43,7 @@
   {% endif %}
 {% endmacro %}
 
-{% macro table(table_collection, title, id, parent, target, quantity=none, icon=none, image=none, alt=none, details=none, no_missing=none, read_only=none) %}
+{% macro table(table_collection, title, id, parent, target, quantity=none, icon=none, image=none, alt=none, details=none, read_only=none) %}
   {% set size=table_collection | length %}
   {% if size %}
     {{ header(title, id, parent, quantity=quantity, icon=icon, class='p-0', image=image, alt=alt) }}
diff --git a/templates/macro/badge.html b/templates/macro/badge.html
index ca577693..a3ea6d7c 100644
--- a/templates/macro/badge.html
+++ b/templates/macro/badge.html
@@ -90,6 +90,10 @@
   {{ badge(check=theme, solo=solo, last=last, color='primary', icon='price-tag-3-line', text=text, alt='Theme', tooltip=tooltip) }}
 {% endmacro %}
 
+{% macro total_damaged(damaged, solo=false, last=false) %}
+  {{ badge(check=damaged, solo=solo, last=last, color='danger', icon='error-warning-line', collapsible='Damaged:', text=damaged, alt='Damaged') }}
+{% endmacro %}
+
 {% macro total_quantity(quantity, solo=false, last=false) %}
   {{ badge(check=quantity, solo=solo, last=last, color='success', icon='functions', collapsible='Quantity:', text=quantity, alt='Quantity') }}
 {% endmacro %}
@@ -99,7 +103,7 @@
 {% endmacro %}
 
 {% macro total_missing(missing, solo=false, last=false) %}
-  {{ badge(check=missing, solo=solo, last=last, color='danger', icon='error-warning-line', collapsible='Missing:', text=missing, alt='Missing') }}
+  {{ badge(check=missing, solo=solo, last=last, color='light text-danger-emphasis bg-danger-subtle border border-danger-subtle', icon='question-line', collapsible='Missing:', text=missing, alt='Missing') }}
 {% endmacro %}
 
 {% macro total_sets(sets, solo=false, last=false) %}
diff --git a/templates/macro/table.html b/templates/macro/table.html
index 91bb1c92..8db1ddaf 100644
--- a/templates/macro/table.html
+++ b/templates/macro/table.html
@@ -1,4 +1,4 @@
-{% macro header(color=false, quantity=false, missing=false, missing_parts=false, sets=false, minifigures=false) %}
+{% macro header(color=false, quantity=false, missing_parts=false, damaged_parts=false, sets=false, minifigures=false) %}
   <thead>
     <tr>
       <th data-table-no-sort="true" class="no-sort" scope="col"><i class="ri-image-line fw-normal"></i> Image</th>
@@ -9,12 +9,8 @@
       {% if quantity %}
         <th data-table-number="true" scope="col"><i class="ri-functions fw-normal"></i> Quantity</th>
       {% endif %}
-      {% if missing %}
-        <th data-table-number="true" scope="col"><i class="ri-error-warning-line fw-normal"></i> Missing</th>
-      {% endif %}
-      {% if missing_parts %}
-        <th data-table-number="true" scope="col"><i class="ri-error-warning-line fw-normal"></i> Missing parts</th>
-      {% endif %}
+      <th data-table-number="true" scope="col"><i class="ri-question-line fw-normal"></i> Missing{% if missing_parts %} parts{% endif %}</th>
+      <th data-table-number="true" scope="col"><i class="ri-error-warning-line fw-normal"></i> Damaged{% if damaged_parts %} parts{% endif %}</th>
       {% if sets %}
         <th data-table-number="true" scope="col"><i class="ri-hashtag fw-normal"></i> Sets</th>
       {% endif %}
diff --git a/templates/minifigure/card.html b/templates/minifigure/card.html
index 446c1647..80459e3a 100644
--- a/templates/minifigure/card.html
+++ b/templates/minifigure/card.html
@@ -13,6 +13,7 @@
     {{ badge.quantity(item.fields.total_quantity, solo=solo, last=last) }}
     {{ badge.total_sets(using | length, solo=solo, last=last) }}
     {{ badge.total_missing(item.fields.total_missing, solo=solo, last=last) }}
+    {{ badge.total_damaged(item.fields.total_damaged, solo=solo, last=last) }}
     {% if not last %}
       {{ badge.rebrickable(item, solo=solo, last=last) }}
     {% endif %}
@@ -21,7 +22,8 @@
     <div class="accordion accordion-flush" id="minifigure-details">
       {{ accordion.table(item.generic_parts(), 'Parts', item.fields.figure, 'minifigure-details', 'part/table.html', icon='shapes-line', alt=item.fields.figure, read_only=read_only)}}
       {{ accordion.cards(using, 'Sets using this minifigure', 'using-inventory', 'minifigure-details', 'set/card.html', icon='hashtag') }}
-      {{ accordion.cards(missing, 'Sets missing parts of this minifigure', 'missing-inventory', 'minifigure-details', 'set/card.html', icon='error-warning-line') }}
+      {{ accordion.cards(missing, 'Sets missing parts for this minifigure', 'missing-inventory', 'minifigure-details', 'set/card.html', icon='question-line') }}
+      {{ accordion.cards(damaged, 'Sets with damaged parts for this minifigure', 'damaged-inventory', 'minifigure-details', 'set/card.html', icon='error-warning-line') }}
     </div>
     <div class="card-footer"></div>
   {% endif %}
diff --git a/templates/minifigure/table.html b/templates/minifigure/table.html
index 66ece790..aeb0bb78 100644
--- a/templates/minifigure/table.html
+++ b/templates/minifigure/table.html
@@ -2,7 +2,7 @@
 
 <div class="table-responsive-sm">
   <table data-table="{% if all %}true{% endif %}" class="table table-striped align-middle" id="minifigures">
-    {{ table.header(quantity=true, missing_parts=true, sets=true) }}
+    {{ table.header(quantity=true, missing_parts=true, damaged_parts=true, sets=true) }}
     <tbody>
       {% for item in table_collection %}
       <tr>
@@ -15,6 +15,7 @@
         </td>
         <td>{{ item.fields.total_quantity }}</td>
         <td>{{ item.fields.total_missing }}</td>
+        <td>{{ item.fields.total_damaged }}</td>
         <td>{{ item.fields.total_sets }}</td>
       </tr>
       {% endfor %}
diff --git a/templates/part/card.html b/templates/part/card.html
index 4cb40314..8a0d7aae 100644
--- a/templates/part/card.html
+++ b/templates/part/card.html
@@ -15,6 +15,7 @@
     {{ badge.total_quantity(item.fields.total_quantity, solo=solo, last=last) }}
     {{ badge.total_spare(item.fields.total_spare, solo=solo, last=last) }}
     {{ badge.total_missing(item.fields.total_missing, solo=solo, last=last) }}
+    {{ badge.total_damaged(item.fields.total_damaged, solo=solo, last=last) }}
     {% if not last %}
       {{ badge.rebrickable(item, solo=solo, last=last) }}
       {{ badge.bricklink(item, solo=solo, last=last) }}
@@ -23,9 +24,11 @@
   {% if solo %}
     <div class="accordion accordion-flush border-top" id="part-details">
       {{ accordion.cards(sets_using, 'Sets using this part', 'sets-using-inventory', 'part-details', 'set/card.html', icon='hashtag') }}
-      {{ accordion.cards(sets_missing, 'Sets missing this part', 'sets-missing-inventory', 'part-details', 'set/card.html', icon='error-warning-line') }}
+      {{ accordion.cards(sets_missing, 'Sets missing this part', 'sets-missing-inventory', 'part-details', 'set/card.html', icon='question-line') }}
+      {{ accordion.cards(sets_damaged, 'Sets with this part damaged', 'sets-damaged-inventory', 'part-details', 'set/card.html', icon='error-warning-line') }}
       {{ accordion.cards(minifigures_using, 'Minifigures using this part', 'minifigures-using-inventory', 'part-details', 'minifigure/card.html', icon='group-line') }}
-      {{ accordion.cards(minifigures_missing, 'Minifigures missing this part', 'minifigures-missing-inventory', 'part-details', 'minifigure/card.html', icon='error-warning-line') }}
+      {{ accordion.cards(minifigures_missing, 'Minifigures missing this part', 'minifigures-missing-inventory', 'part-details', 'minifigure/card.html', icon='question-line') }}
+      {{ accordion.cards(minifigures_damaged, 'Minifigures with this part damaged', 'minifigures-damaged-inventory', 'part-details', 'minifigure/card.html', icon='error-warning-line') }}
       {{ accordion.cards(similar_prints, 'Prints using the same base', 'similar-prints', 'part-details', 'part/card.html', icon='paint-brush-line') }}
     </div>
     <div class="card-footer"></div>
diff --git a/templates/part/table.html b/templates/part/table.html
index cd16f9be..ac96af59 100644
--- a/templates/part/table.html
+++ b/templates/part/table.html
@@ -3,7 +3,7 @@
 
 <div class="table-responsive-sm">
   <table data-table="{% if all %}true{% endif %}" class="table table-striped align-middle {% if not all %}sortable mb-0{% endif %}" {% if all %}id="parts"{% endif %}>
-    {{ table.header(color=true, quantity=not no_quantity, missing=not no_missing, sets=all, minifigures=all) }}
+    {{ table.header(color=true, quantity=not no_quantity, sets=all, minifigures=all) }}
     <tbody>
       {% for item in table_collection %}
       <tr>
@@ -27,11 +27,12 @@
             <td>{% if quantity %}{{ item.fields.quantity * quantity }}{% else %}{{ item.fields.quantity }}{% endif %}</td>
           {% endif %}
         {% endif %}
-        {% if not no_missing %}
-          <td data-sort="{{ item.fields.total_missing }}" class="table-td-input">
-            {{ form.input('Missing', item.fields.id, item.html_id(), item.url_for_missing(), item.fields.total_missing, all=all, read_only=read_only) }}
-          </td>
-        {% endif %}
+        <td data-sort="{{ item.fields.total_missing }}" class="table-td-input">
+          {{ form.input('Missing', item.fields.id, item.html_id('missing'), item.url_for_problem('missing'), item.fields.total_missing, all=all, read_only=read_only) }}
+        </td>
+        <td data-sort="{{ item.fields.total_damaged }}" class="table-td-input">
+          {{ form.input('Damaged', item.fields.id, item.html_id('damaged'), item.url_for_problem('damaged'), item.fields.total_damaged, all=all, read_only=read_only) }}
+        </td>
         {% if all %}
           <td>{{ item.fields.total_sets }}</td>
           <td>{{ item.fields.total_minifigures }}</td>
diff --git a/templates/missing.html b/templates/problem.html
similarity index 78%
rename from templates/missing.html
rename to templates/problem.html
index d7c82a54..76f61f55 100644
--- a/templates/missing.html
+++ b/templates/problem.html
@@ -1,6 +1,6 @@
 {% extends 'base.html' %}
 
-{% block title %} - Missing parts{% endblock %}
+{% block title %} - Problematic parts{% endblock %}
 
 {% block main %}
 <div class="container-fluid px-0">
diff --git a/templates/set/card.html b/templates/set/card.html
index eb48c189..262c6409 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -6,8 +6,11 @@
 <div {% if not solo %}id="set-{{ item.fields.id }}"{% endif %} class="card mb-3 flex-fill {% if solo %}card-solo{% endif %}"
   {% if not solo %}
     data-index="{{ index }}" data-number="{{ item.fields.set }}" data-name="{{ item.fields.name | lower }}" data-parts="{{ item.fields.number_of_parts }}"
-    data-year="{{ item.fields.year }}" data-theme="{{ item.theme.name | lower }}" data-minifigures="{{ item.fields.total_minifigures }}" data-has-minifigures="{{ (item.fields.total_minifigures > 0) | int }}"
-    data-has-missing="{{ (item.fields.total_missing > 0) | int }}" data-has-missing-instructions="{{ (not (item.instructions | length)) | int }}" data-missing="{{ item.fields.total_missing }}"
+    data-year="{{ item.fields.year }}" data-theme="{{ item.theme.name | lower }}"
+    data-has-missing-instructions="{{ (not (item.instructions | length)) | int }}"
+    data-has-minifigures="{{ (item.fields.total_minifigures > 0) | int }}" data-minifigures="{{ item.fields.total_minifigures }}"
+    data-has-missing="{{ (item.fields.total_missing > 0) | int }}" data-missing="{{ item.fields.total_missing }}"
+    data-has-damaged="{{ (item.fields.total_damaged > 0) | int }}" data-damaged="{{ item.fields.total_damaged }}"
     {% for status in brickset_statuses %}
       {% with checked=item.fields[status.as_column()] %}
         {% if checked %}
@@ -42,6 +45,7 @@
     {{ badge.parts(item.fields.number_of_parts, solo=solo, last=last) }}
     {{ badge.total_minifigures(item.fields.total_minifigures, solo=solo, last=last) }}
     {{ badge.total_missing(item.fields.total_missing, solo=solo, last=last) }}
+    {{ badge.total_damaged(item.fields.total_damaged, solo=solo, last=last) }}
     {% for owner in brickset_owners %}
       {{ badge.owner(item, owner, solo=solo, last=last) }}
     {% endfor %}
diff --git a/templates/sets.html b/templates/sets.html
index 7541f1c9..dc1c10ef 100644
--- a/templates/sets.html
+++ b/templates/sets.html
@@ -29,7 +29,9 @@
         <button id="sort-parts" type="button" class="btn btn-outline-primary"
           data-sort-attribute="parts" data-sort-desc="true"><i class="ri-shapes-line"></i><span class="d-none d-xxl-inline"> Parts</span></button>
         <button id="sort-missing" type="button" class="btn btn-outline-primary"
-          data-sort-attribute="missing" data-sort-desc="true"><i class="ri-error-warning-line"></i><span class="d-none d-xxl-inline"> Missing</span></button>
+          data-sort-attribute="missing" data-sort-desc="true"><i class="ri-question-line"></i><span class="d-none d-xxl-inline"> Missing</span></button>
+        <button id="sort-damaged" type="button" class="btn btn-outline-primary"
+          data-sort-attribute="damaged" data-sort-desc="true"><i class="ri-error-warning-line"></i><span class="d-none d-xxl-inline"> Damaged</span></button>
         <button id="sort-clear" type="button" class="btn btn-outline-dark"
           data-sort-clear="true"><i class="ri-close-circle-line"></i><span class="d-none d-xxl-inline"> Clear</span></button>
       </div>
@@ -53,6 +55,7 @@
           <option value="" selected>All</option>
           <option value="-has-missing">Set is complete</option>
           <option value="has-missing">Set has missing pieces</option>
+          <option value="has-damaged">Set has damaged pieces</option>
           <option value="has-missing-instructions">Set has missing instructions</option>
           {% for status in brickset_statuses %}
             <option value="{{ status.as_dataset() }}">{{ status.fields.name }}</option>

From 2c06ca511eb7d385f387e375800e0fe919faaf74 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 20:49:55 +0100
Subject: [PATCH 096/154] Fix management always opened for sets

---
 templates/set/management.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/templates/set/management.html b/templates/set/management.html
index b1b20194..9b48448f 100644
--- a/templates/set/management.html
+++ b/templates/set/management.html
@@ -27,7 +27,7 @@
       <a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_tag=true) }}"><i class="ri-settings-4-line"></i> Manage the set tags</a>
     </div>
   {{ accordion.footer() }}
-  {{ accordion.header('Management', 'management', 'set-details', expanded=true, icon='settings-4-line') }}
+  {{ accordion.header('Management', 'management', 'set-details', icon='settings-4-line') }}
     <h5 class="border-bottom">Data</h5>
     <a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh the set data</a>
   {{ accordion.footer() }}

From b6c004c0457a9e04749056d01c9649ede01fc0b2 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 20:50:08 +0100
Subject: [PATCH 097/154] Remove unused html_id for sets

---
 bricktracker/set.py | 11 -----------
 1 file changed, 11 deletions(-)

diff --git a/bricktracker/set.py b/bricktracker/set.py
index eb2bafb5..000047d3 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -139,17 +139,6 @@ class BrickSet(RebrickableSet):
 
         return True
 
-    # A identifier for HTML component
-    def html_id(self, prefix: str | None = None, /) -> str:
-        components: list[str] = []
-
-        if prefix is not None:
-            components.append(prefix)
-
-        components.append(self.fields.id)
-
-        return '-'.join(components)
-
     # Minifigures
     def minifigures(self, /) -> BrickMinifigureList:
         return BrickMinifigureList().from_set(self)

From 1e2f9fb11ac4979e0df132d21f264755a3bd088b Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 20:51:34 +0100
Subject: [PATCH 098/154] Fix database counters layout

---
 templates/admin/database.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/templates/admin/database.html b/templates/admin/database.html
index 86e82c81..a10fbe34 100644
--- a/templates/admin/database.html
+++ b/templates/admin/database.html
@@ -20,7 +20,7 @@
 {% if database_counters %}
   <h5 class="border-bottom">Records</h5>
   <div class="row">
-    <ul class="list-group col-4">
+    <ul class="list-group col-4 mb-2">
       {% for counter in database_counters %}
         <li class="list-group-item d-flex justify-content-between align-items-start {% if counter.legacy %}list-group-item-dark{% endif %}">
           <span><i class="ri-{{ counter.icon }}"></i> {{ counter.name }}</span> <span class="badge {% if counter.legacy %}text-bg-light border{% else %}text-bg-primary{% endif %} rounded-pill ms-2">{{ counter.count }}</span>

From 9d6bc332cb5fab2d97caf5c92830c61fdde6acad Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 20:53:53 +0100
Subject: [PATCH 099/154] Add missing database counters

---
 bricktracker/sql_counter.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/bricktracker/sql_counter.py b/bricktracker/sql_counter.py
index 74c18cc8..7d8c8822 100644
--- a/bricktracker/sql_counter.py
+++ b/bricktracker/sql_counter.py
@@ -2,11 +2,14 @@ from typing import Tuple
 
 # Some table aliases to make it look cleaner (id: (name, icon))
 ALIASES: dict[str, Tuple[str, str]] = {
+    'bricktracker_metadata_owners': ('Bricktracker set owners metadata', 'user-line'),  # noqa: E501
     'bricktracker_metadata_statuses': ('Bricktracker set status metadata', 'checkbox-line'),  # noqa: E501
+    'bricktracker_metadata_tags': ('Bricktracker set tags metadata', 'price-tag-2-line'),  # noqa: E501
     'bricktracker_minifigures': ('Bricktracker minifigures', 'group-line'),
     'bricktracker_parts': ('Bricktracker parts', 'shapes-line'),
     'bricktracker_set_checkboxes': ('Bricktracker set checkboxes (legacy)', 'checkbox-line'),  # noqa: E501
     'bricktracker_set_owners': ('Bricktracker set owners', 'checkbox-line'),  # noqa: E501
+    'bricktracker_set_purchase_locations': ('Bricktracker set purchase locations', 'building-line'),  # noqa: E501
     'bricktracker_set_statuses': ('Bricktracker set statuses', 'user-line'),  # noqa: E501
     'bricktracker_set_storages': ('Bricktracker set storages', 'archive-2-line'),  # noqa: E501
     'bricktracker_set_tags': ('Bricktracker set tags', 'price-tag-2-line'),  # noqa: E501

From 690366794659458d15fb8afa224a141578b263bf Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Fri, 31 Jan 2025 20:56:10 +0100
Subject: [PATCH 100/154] Update changelog

---
 CHANGELOG.md | 43 +++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 43 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8bdc3b8d..6a7379e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,18 +2,38 @@
 
 ## Unreleased
 
+> **Warning**
+> "Missing" part has been renamed to "Problems" to accomodate for missing and damaged parts.
+> The associated environment variables have changed named (the old names are still valid)
+
 ## Code
 
+- Form
+    - Migrate missing input fields to BrickChanger
+
 - General cleanup
 
+- Metadata
+    - Underlying class to implement more metadata-like features
+
 - Minifigure
     - Deduplicate
 
+Parts
+    - Damaged parts
+
+- Sets
+    - Refresh data from Rebrickable
+    - Fix missing @login_required for set deletion
+    - Ownership
+    - Tags
+
 - Socket
     - Add decorator for rebrickable, authenticated and threaded socket actions
 
 - SQL
     - Allow for advanced migration scenarios through companion python files
+    - Add a bunch of the requested fields into the database for future implementation
 
 ### UI
 
@@ -22,10 +42,33 @@
 
 - Admin
     - Grey out legacy tables in the database view
+    - Checkboxes renamed to Set statuses
+
+- Cards
+    - Use macros for badge in the card header
+
+- Form
+    - Add a clear button for dynamic text inputs
+    - Add error message in a tooltip for dynamic inputs
+
+- Parts
+    - Use Rebrickable URL if stored (+ color code)
+    - Display color and transparency
+    - Display if print of another part
+    - Display prints using the same base
+    - Damaged parts
 
 - Sets
     - Add a flag to hide instructions in a set
+    - Make checkbox clickable on the whole width of the card
+    - Management
+        - Ownership
+        - Tags
+        - Refresh
 
+- Sets grid
+    - Collapsible controls depending on screen size
+    - Manually collapsible filters (with configuration variable for default state)
 
 ## 1.1.1: PDF Instructions Download
 

From eac9fc179345925ec795b9da782281e861aca777 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 09:52:33 +0100
Subject: [PATCH 101/154] Allow hiding the damaged and missing columns from the
 parts table

---
 .env.sample                | 10 +++++++++-
 CHANGELOG.md               |  8 +++++++-
 bricktracker/config.py     |  4 +++-
 bricktracker/navbar.py     |  2 +-
 templates/macro/table.html |  6 +++++-
 templates/part/table.html  | 16 ++++++++++------
 6 files changed, 35 insertions(+), 11 deletions(-)

diff --git a/.env.sample b/.env.sample
index fb52132b..0bfc3f02 100644
--- a/.env.sample
+++ b/.env.sample
@@ -110,12 +110,20 @@
 # Optional: Hide the 'Problems' entry from the menu. Does not disable the route.
 # Default: false
 # Legacy name: BK_HIDE_MISSING_PARTS
-# BK_HIDE_PROBLEMS_PARTS=true
+# BK_HIDE_ALL_PROBLEMS_PARTS=true
 
 # Optional: Hide the 'Instructions' entry in a Set card
 # Default: false
 # BK_HIDE_SET_INSTRUCTIONS=true
 
+# Optional: Hide the 'Damaged' column from the parts table.
+# Default: false
+# BK_HIDE_TABLE_DAMAGED_PARTS=true
+
+# Optional: Hide the 'Missing' column from the parts table.
+# Default: false
+# BK_HIDE_TABLE_MISSING_PARTS=true
+
 # Optional: Hide the 'Wishlist' entry from the menu. Does not disable the route.
 # Default: false
 # BK_HIDE_WISHES=true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6a7379e0..28a7f0f5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,13 @@
 > "Missing" part has been renamed to "Problems" to accomodate for missing and damaged parts.
 > The associated environment variables have changed named (the old names are still valid)
 
-## Code
+### Environment
+
+- Renamed: `BK_HIDE_MISSING_PARTS` -> `BK_HIDE_ALL_PROBLEMS_PARTS`
+- Added: `BK_HIDE_TABLE_MISSING_PARTS`, hide the Missing column in all tables
+- Added: `BK_HIDE_TABLE_DAMAGED_PARTS`, hide the Damaged column in all tables
+
+### Code
 
 - Form
     - Migrate missing input fields to BrickChanger
diff --git a/bricktracker/config.py b/bricktracker/config.py
index 8ab193a5..62e23a13 100644
--- a/bricktracker/config.py
+++ b/bricktracker/config.py
@@ -29,8 +29,10 @@ CONFIG: Final[list[dict[str, Any]]] = [
     {'n': 'HIDE_ALL_MINIFIGURES', 'c': bool},
     {'n': 'HIDE_ALL_PARTS', 'c': bool},
     {'n': 'HIDE_ALL_SETS', 'c': bool},
-    {'n': 'HIDE_PROBLEMS_PARTS', 'e': 'BK_HIDE_MISSING_PARTS', 'c': bool},
+    {'n': 'HIDE_ALL_PROBLEMS_PARTS', 'e': 'BK_HIDE_MISSING_PARTS', 'c': bool},
     {'n': 'HIDE_SET_INSTRUCTIONS', 'c': bool},
+    {'n': 'HIDE_TABLE_DAMAGED_PARTS', 'c': bool},
+    {'n': 'HIDE_TABLE_MISSING_PARTS', 'c': bool},
     {'n': 'HIDE_WISHES', 'c': bool},
     {'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': '"rebrickable_minifigures"."name" ASC'},  # noqa: E501
     {'n': 'MINIFIGURES_FOLDER', 'd': 'minifigs', 's': True},
diff --git a/bricktracker/navbar.py b/bricktracker/navbar.py
index 04b70533..30007dee 100644
--- a/bricktracker/navbar.py
+++ b/bricktracker/navbar.py
@@ -11,7 +11,7 @@ NAVBAR: Final[list[dict[str, Any]]] = [
     {'e': 'set.list', 't': 'Sets', 'i': 'grid-line', 'f': 'HIDE_ALL_SETS'},  # noqa: E501
     {'e': 'add.add', 't': 'Add', 'i': 'add-circle-line', 'f': 'HIDE_ADD_SET'},  # noqa: E501
     {'e': 'part.list', 't': 'Parts', 'i': 'shapes-line', 'f': 'HIDE_ALL_PARTS'},  # noqa: E501
-    {'e': 'part.problem', 't': 'Problems', 'i': 'error-warning-line', 'f': 'HIDE_PROBLEMS_PARTS'},  # noqa: E501
+    {'e': 'part.problem', 't': 'Problems', 'i': 'error-warning-line', 'f': 'HIDE_ALL_PROBLEMS_PARTS'},  # noqa: E501
     {'e': 'minifigure.list', 't': 'Minifigures', 'i': 'group-line', 'f': 'HIDE_ALL_MINIFIGURES'},  # noqa: E501
     {'e': 'instructions.list', 't': 'Instructions', 'i': 'file-line', 'f': 'HIDE_ALL_INSTRUCTIONS'},  # noqa: E501
     {'e': 'wish.list', 't': 'Wishlist', 'i': 'gift-line', 'f': 'HIDE_WISHES'},
diff --git a/templates/macro/table.html b/templates/macro/table.html
index 8db1ddaf..d31e1c2a 100644
--- a/templates/macro/table.html
+++ b/templates/macro/table.html
@@ -9,8 +9,12 @@
       {% if quantity %}
         <th data-table-number="true" scope="col"><i class="ri-functions fw-normal"></i> Quantity</th>
       {% endif %}
-      <th data-table-number="true" scope="col"><i class="ri-question-line fw-normal"></i> Missing{% if missing_parts %} parts{% endif %}</th>
+      {% if not config['HIDE_TABLE_MISSING_PARTS'] %}
+        <th data-table-number="true" scope="col"><i class="ri-question-line fw-normal"></i> Missing{% if missing_parts %} parts{% endif %}</th>
+      {% endif %}
+      {% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
       <th data-table-number="true" scope="col"><i class="ri-error-warning-line fw-normal"></i> Damaged{% if damaged_parts %} parts{% endif %}</th>
+      {% endif %}
       {% if sets %}
         <th data-table-number="true" scope="col"><i class="ri-hashtag fw-normal"></i> Sets</th>
       {% endif %}
diff --git a/templates/part/table.html b/templates/part/table.html
index ac96af59..d2f6569b 100644
--- a/templates/part/table.html
+++ b/templates/part/table.html
@@ -27,12 +27,16 @@
             <td>{% if quantity %}{{ item.fields.quantity * quantity }}{% else %}{{ item.fields.quantity }}{% endif %}</td>
           {% endif %}
         {% endif %}
-        <td data-sort="{{ item.fields.total_missing }}" class="table-td-input">
-          {{ form.input('Missing', item.fields.id, item.html_id('missing'), item.url_for_problem('missing'), item.fields.total_missing, all=all, read_only=read_only) }}
-        </td>
-        <td data-sort="{{ item.fields.total_damaged }}" class="table-td-input">
-          {{ form.input('Damaged', item.fields.id, item.html_id('damaged'), item.url_for_problem('damaged'), item.fields.total_damaged, all=all, read_only=read_only) }}
-        </td>
+        {% if not config['HIDE_TABLE_MISSING_PARTS'] %}
+          <td data-sort="{{ item.fields.total_missing }}" class="table-td-input">
+            {{ form.input('Missing', item.fields.id, item.html_id('missing'), item.url_for_problem('missing'), item.fields.total_missing, all=all, read_only=read_only) }}
+          </td>
+        {% endif %}
+        {% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
+          <td data-sort="{{ item.fields.total_damaged }}" class="table-td-input">
+            {{ form.input('Damaged', item.fields.id, item.html_id('damaged'), item.url_for_problem('damaged'), item.fields.total_damaged, all=all, read_only=read_only) }}
+          </td>
+        {% endif %}
         {% if all %}
           <td>{{ item.fields.total_sets }}</td>
           <td>{{ item.fields.total_minifigures }}</td>

From 34408a1bff0071e71b448fceeab54563ac39f751 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 10:10:06 +0100
Subject: [PATCH 102/154] Display same parts using a different color

---
 CHANGELOG.md                                  |  1 +
 bricktracker/part_list.py                     | 30 +++++++++++++++++++
 .../sql/part/list/with_different_color.sql    | 17 +++++++++++
 bricktracker/views/part.py                    |  3 +-
 templates/part/card.html                      |  1 +
 5 files changed, 51 insertions(+), 1 deletion(-)
 create mode 100644 bricktracker/sql/part/list/with_different_color.sql

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 28a7f0f5..54b0fa8c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -63,6 +63,7 @@ Parts
     - Display if print of another part
     - Display prints using the same base
     - Damaged parts
+    - Display same parts using a different color
 
 - Sets
     - Add a flag to hide instructions in a set
diff --git a/bricktracker/part_list.py b/bricktracker/part_list.py
index 86ca34ad..d0e71380 100644
--- a/bricktracker/part_list.py
+++ b/bricktracker/part_list.py
@@ -23,6 +23,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
 
     # Queries
     all_query: str = 'part/list/all'
+    different_color_query = 'part/list/with_different_color'
     last_query: str = 'part/list/last'
     minifigure_query: str = 'part/list/from_minifigure'
     problem_query: str = 'part/list/problem'
@@ -166,6 +167,35 @@ class BrickPartList(BrickRecordList[BrickPart]):
 
         return parameters
 
+    # Load generic parts with same base but different color
+    def with_different_color(
+        self,
+        brickpart: BrickPart,
+        /,
+    ) -> Self:
+        # Save the part
+        self.fields.part = brickpart.fields.part
+        self.fields.color = brickpart.fields.color
+
+        # Load the parts from the database
+        for record in self.select(
+            override_query=self.different_color_query,
+            order=self.order
+        ):
+            part = BrickPart(
+                record=record,
+            )
+
+            if (
+                current_app.config['SKIP_SPARE_PARTS'] and
+                part.fields.spare
+            ):
+                continue
+
+            self.records.append(part)
+
+        return self
+
     # Import the parts from Rebrickable
     @staticmethod
     def download(
diff --git a/bricktracker/sql/part/list/with_different_color.sql b/bricktracker/sql/part/list/with_different_color.sql
new file mode 100644
index 00000000..d75501db
--- /dev/null
+++ b/bricktracker/sql/part/list/with_different_color.sql
@@ -0,0 +1,17 @@
+
+{% extends 'part/base/base.sql' %}
+
+{% block total_missing %}{% endblock %}
+
+{% block total_damaged %}{% endblock %}
+
+{% block where %}
+WHERE "bricktracker_parts"."color" IS DISTINCT FROM :color
+AND "bricktracker_parts"."part" IS NOT DISTINCT FROM :part
+{% endblock %}
+
+{% block group %}
+GROUP BY
+    "bricktracker_parts"."part",
+    "bricktracker_parts"."color"
+{% endblock %}
diff --git a/bricktracker/views/part.py b/bricktracker/views/part.py
index 0bea4ab7..7cbc1c80 100644
--- a/bricktracker/views/part.py
+++ b/bricktracker/views/part.py
@@ -62,5 +62,6 @@ def details(*, part: str, color: int) -> str:
             part,
             color
         ),
-        similar_prints=BrickPartList().from_print(brickpart)
+        different_color=BrickPartList().with_different_color(brickpart),
+        similar_prints=BrickPartList().from_print(brickpart),
     )
diff --git a/templates/part/card.html b/templates/part/card.html
index 8a0d7aae..16f2103e 100644
--- a/templates/part/card.html
+++ b/templates/part/card.html
@@ -29,6 +29,7 @@
       {{ accordion.cards(minifigures_using, 'Minifigures using this part', 'minifigures-using-inventory', 'part-details', 'minifigure/card.html', icon='group-line') }}
       {{ accordion.cards(minifigures_missing, 'Minifigures missing this part', 'minifigures-missing-inventory', 'part-details', 'minifigure/card.html', icon='question-line') }}
       {{ accordion.cards(minifigures_damaged, 'Minifigures with this part damaged', 'minifigures-damaged-inventory', 'part-details', 'minifigure/card.html', icon='error-warning-line') }}
+      {{ accordion.cards(different_color, 'Same part with a different color', 'different-color', 'part-details', 'part/card.html', icon='palette-line') }}
       {{ accordion.cards(similar_prints, 'Prints using the same base', 'similar-prints', 'part-details', 'part/card.html', icon='paint-brush-line') }}
     </div>
     <div class="card-footer"></div>

From 4cf91a6edd7ba38ed9aa1abb421c608f3fe82c6e Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 10:35:42 +0100
Subject: [PATCH 103/154] Compute and display number of parts for minifigures

---
 CHANGELOG.md                                  |  4 +++
 bricktracker/minifigure.py                    |  6 ++--
 bricktracker/part_list.py                     |  8 +++++
 bricktracker/sql/migrations/0015.sql          | 32 +++++++++++++++++++
 bricktracker/sql/minifigure/base/base.sql     |  1 +
 .../sql/rebrickable/minifigure/insert.sql     |  9 ++++--
 bricktracker/version.py                       |  2 +-
 templates/macro/table.html                    |  5 ++-
 templates/minifigure/card.html                |  1 +
 templates/minifigure/table.html               |  3 +-
 10 files changed, 62 insertions(+), 9 deletions(-)
 create mode 100644 bricktracker/sql/migrations/0015.sql

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 54b0fa8c..40b4f173 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,7 @@
 
 - Minifigure
     - Deduplicate
+    - Compute number of parts
 
 Parts
     - Damaged parts
@@ -57,6 +58,9 @@ Parts
     - Add a clear button for dynamic text inputs
     - Add error message in a tooltip for dynamic inputs
 
+- Minifigure
+    - Display number of parts
+
 - Parts
     - Use Rebrickable URL if stored (+ color code)
     - Display color and transparency
diff --git a/bricktracker/minifigure.py b/bricktracker/minifigure.py
index 1ad6aa6a..c09589e9 100644
--- a/bricktracker/minifigure.py
+++ b/bricktracker/minifigure.py
@@ -37,9 +37,6 @@ class BrickMinifigure(RebrickableMinifigure):
                 # Insert into database
                 self.insert(commit=False)
 
-            # Insert the rebrickable set into database
-            self.insert_rebrickable()
-
             # Load the inventory
             if not BrickPartList.download(
                 socket,
@@ -49,6 +46,9 @@ class BrickMinifigure(RebrickableMinifigure):
             ):
                 return False
 
+            # Insert the rebrickable set into database (after counting parts)
+            self.insert_rebrickable()
+
         except Exception as e:
             socket.fail(
                 message='Error while importing minifigure {figure} from {set}: {error}'.format(  # noqa: E501
diff --git a/bricktracker/part_list.py b/bricktracker/part_list.py
index d0e71380..eb3f58d5 100644
--- a/bricktracker/part_list.py
+++ b/bricktracker/part_list.py
@@ -239,10 +239,18 @@ class BrickPartList(BrickRecordList[BrickPart]):
             ).list()
 
             # Process each part
+            number_of_parts: int = 0
             for part in inventory:
+                # Count the number of parts for minifigures
+                if minifigure is not None:
+                    number_of_parts += part.fields.quantity
+
                 if not part.download(socket, refresh=refresh):
                     return False
 
+            if minifigure is not None:
+                minifigure.fields.number_of_parts = number_of_parts
+
         except Exception as e:
             socket.fail(
                 message='Error while importing {kind} {identifier} parts list: {error}'.format(  # noqa: E501
diff --git a/bricktracker/sql/migrations/0015.sql b/bricktracker/sql/migrations/0015.sql
new file mode 100644
index 00000000..de2d6ecb
--- /dev/null
+++ b/bricktracker/sql/migrations/0015.sql
@@ -0,0 +1,32 @@
+-- description: Add number of parts for minifigures
+
+BEGIN TRANSACTION;
+
+-- Add the number_of_parts column to the minifigures
+ALTER TABLE "rebrickable_minifigures"
+ADD COLUMN "number_of_parts" INTEGER NOT NULL DEFAULT 0;
+
+-- Update the number of parts for each minifigure
+UPDATE "rebrickable_minifigures"
+SET "number_of_parts" = "parts_sum"."number_of_parts"
+FROM (
+    SELECT
+        "parts"."figure",
+        SUM("parts"."quantity") as "number_of_parts"
+    FROM (
+        SELECT
+            "bricktracker_parts"."figure",
+            "bricktracker_parts"."quantity"
+        FROM "bricktracker_parts"
+        WHERE "bricktracker_parts"."figure" IS NOT NULL
+        GROUP BY
+            "bricktracker_parts"."figure",
+            "bricktracker_parts"."part",
+            "bricktracker_parts"."color",
+            "bricktracker_parts"."spare"
+    ) "parts"
+    GROUP BY "parts"."figure"
+) "parts_sum"
+WHERE "rebrickable_minifigures"."figure" = "parts_sum"."figure";
+
+COMMIT;
diff --git a/bricktracker/sql/minifigure/base/base.sql b/bricktracker/sql/minifigure/base/base.sql
index d651bf08..a3a30a75 100644
--- a/bricktracker/sql/minifigure/base/base.sql
+++ b/bricktracker/sql/minifigure/base/base.sql
@@ -2,6 +2,7 @@ SELECT
     "bricktracker_minifigures"."quantity",
     "rebrickable_minifigures"."figure",
     "rebrickable_minifigures"."number",
+    "rebrickable_minifigures"."number_of_parts",
     "rebrickable_minifigures"."name",
     "rebrickable_minifigures"."image",
     {% block total_missing %}
diff --git a/bricktracker/sql/rebrickable/minifigure/insert.sql b/bricktracker/sql/rebrickable/minifigure/insert.sql
index 6c0ac8e8..3db1680a 100644
--- a/bricktracker/sql/rebrickable/minifigure/insert.sql
+++ b/bricktracker/sql/rebrickable/minifigure/insert.sql
@@ -2,16 +2,19 @@ INSERT OR IGNORE INTO "rebrickable_minifigures" (
     "figure",
     "number",
     "name",
-    "image"
+    "image",
+    "number_of_parts"
 ) VALUES (
     :figure,
     :number,
     :name,
-    :image
+    :image,
+    :number_of_parts
 )
 ON CONFLICT("figure")
 DO UPDATE SET
 "number" = :number,
 "name" = :name,
-"image" = :image
+"image" = :image,
+"number_of_parts" = :number_of_parts
 WHERE "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM :figure
diff --git a/bricktracker/version.py b/bricktracker/version.py
index 767fad59..4efb1e64 100644
--- a/bricktracker/version.py
+++ b/bricktracker/version.py
@@ -1,4 +1,4 @@
 from typing import Final
 
 __version__: Final[str] = '1.2.0'
-__database_version__: Final[int] = 14
+__database_version__: Final[int] = 15
diff --git a/templates/macro/table.html b/templates/macro/table.html
index d31e1c2a..ebf1ded6 100644
--- a/templates/macro/table.html
+++ b/templates/macro/table.html
@@ -1,4 +1,4 @@
-{% macro header(color=false, quantity=false, missing_parts=false, damaged_parts=false, sets=false, minifigures=false) %}
+{% macro header(color=false, parts=false, quantity=false, missing_parts=false, damaged_parts=false, sets=false, minifigures=false) %}
   <thead>
     <tr>
       <th data-table-no-sort="true" class="no-sort" scope="col"><i class="ri-image-line fw-normal"></i> Image</th>
@@ -6,6 +6,9 @@
       {% if color %}
         <th scope="col"><i class="ri-palette-line fw-normal"></i> Color</th>
       {% endif %}
+      {% if parts %}
+        <th data-table-number="true" scope="col"><i class="ri-shapes-line fw-normal"></i> Parts</th>
+      {% endif %}
       {% if quantity %}
         <th data-table-number="true" scope="col"><i class="ri-functions fw-normal"></i> Quantity</th>
       {% endif %}
diff --git a/templates/minifigure/card.html b/templates/minifigure/card.html
index 80459e3a..2cba9b4c 100644
--- a/templates/minifigure/card.html
+++ b/templates/minifigure/card.html
@@ -6,6 +6,7 @@
   {{ card.header(item, item.fields.name, solo=solo, identifier=item.fields.figure, icon='user-line') }}
   {{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.figure, medium=true) }}
   <div class="card-body border-bottom {% if not solo %}p-1{% endif %}">
+    {{ badge.parts(item.fields.number_of_parts, solo=solo, last=last) }}
     {% if last %}
       {{ badge.set(item.fields.set, solo=solo, last=last, id=item.fields.rebrickable_set_id) }}
       {{ badge.quantity(item.fields.quantity, solo=solo, last=last) }}
diff --git a/templates/minifigure/table.html b/templates/minifigure/table.html
index aeb0bb78..c91ab584 100644
--- a/templates/minifigure/table.html
+++ b/templates/minifigure/table.html
@@ -2,7 +2,7 @@
 
 <div class="table-responsive-sm">
   <table data-table="{% if all %}true{% endif %}" class="table table-striped align-middle" id="minifigures">
-    {{ table.header(quantity=true, missing_parts=true, damaged_parts=true, sets=true) }}
+    {{ table.header(parts=true, quantity=true, missing_parts=true, damaged_parts=true, sets=true) }}
     <tbody>
       {% for item in table_collection %}
       <tr>
@@ -13,6 +13,7 @@
             {{ table.rebrickable(item) }}
           {% endif %}
         </td>
+        <td>{{ item.fields.number_of_parts }}</td>
         <td>{{ item.fields.total_quantity }}</td>
         <td>{{ item.fields.total_missing }}</td>
         <td>{{ item.fields.total_damaged }}</td>

From 7453d97c81ec6c6bc67b6e89ffbabd866b2699c5 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 10:49:23 +0100
Subject: [PATCH 104/154] Wrap form metadata in accordion for legibility

---
 templates/add.html | 59 ++++++++++++++++++++++++++--------------------
 1 file changed, 33 insertions(+), 26 deletions(-)

diff --git a/templates/add.html b/templates/add.html
index 3a0b7840..9f33c059 100644
--- a/templates/add.html
+++ b/templates/add.html
@@ -1,3 +1,5 @@
+{% import 'macro/accordion.html' as accordion %}
+
 {% extends 'base.html' %}
 
 {% block title %} - {% if not bulk %}Add a set{% else %}Bulk add sets{% endif %}{% endblock %}
@@ -33,32 +35,37 @@
               Add without confirmation
             </label>
           </div>
-          {% if brickset_owners | length %}
-            <h5 class="border-bottom mt-2">Owners</h5>
-            <div id="add-owners">
-            {% for owner in brickset_owners %}
-              {% with id=owner.as_dataset() %}
-                <div class="form-check">
-                  <input class="form-check-input" type="checkbox" value="{{ owner.fields.id }}" id="{{ id }}" autocomplete="off">
-                  <label class="form-check-label" for="{{ id }}">{{ owner.fields.name }}</label>
-                </div>
-              {% endwith %}
-            {% endfor %}
-            </div>
-          {% endif %}
-          {% if brickset_tags | length %}
-            <h5 class="border-bottom mt-2">Tags</h5>
-            <div id="add-tags">
-            {% for tag in brickset_tags %}
-              {% with id=tag.as_dataset() %}
-                <div class="form-check">
-                  <input class="form-check-input" type="checkbox" value="{{ tag.fields.id }}" id="{{ id }}" autocomplete="off">
-                  <label class="form-check-label" for="{{ id }}">{{ tag.fields.name }}</label>
-                </div>
-              {% endwith %}
-            {% endfor %}
-            </div>
-          {% endif %}
+          <h6 class="border-bottom mt-2">Metadata</h6>
+          <div class="accordion accordion" id="metadata">
+            {% if brickset_owners | length %}
+              {{ accordion.header('Owners', 'owners', 'metadata', icon='user-line') }}
+              <div id="add-owners">
+              {% for owner in brickset_owners %}
+                {% with id=owner.as_dataset() %}
+                  <div class="form-check">
+                    <input class="form-check-input" type="checkbox" value="{{ owner.fields.id }}" id="{{ id }}" autocomplete="off">
+                    <label class="form-check-label" for="{{ id }}">{{ owner.fields.name }}</label>
+                  </div>
+                {% endwith %}
+              {% endfor %}
+              </div>
+              {{ accordion.footer() }}
+            {% endif %}
+            {% if brickset_tags | length %}
+              {{ accordion.header('Tags', 'tags', 'metadata', icon='price-tag-2-line') }}
+              <div id="add-tags">
+              {% for tag in brickset_tags %}
+                {% with id=tag.as_dataset() %}
+                  <div class="form-check">
+                    <input class="form-check-input" type="checkbox" value="{{ tag.fields.id }}" id="{{ id }}" autocomplete="off">
+                    <label class="form-check-label" for="{{ id }}">{{ tag.fields.name }}</label>
+                  </div>
+                {% endwith %}
+              {% endfor %}
+              </div>
+              {{ accordion.footer() }}
+            {% endif %}
+          </div>
           <hr>
           <div class="mb-3">
             <p>

From d0d1e53acc069967332d1e732124c9a3f5af88e6 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 10:56:32 +0100
Subject: [PATCH 105/154] Fix set storages and purchase locations to be normal
 metadata

---
 bricktracker/sql/migrations/0007.sql | 18 ++++++++++--------
 bricktracker/sql_counter.py          |  4 ++--
 2 files changed, 12 insertions(+), 10 deletions(-)

diff --git a/bricktracker/sql/migrations/0007.sql b/bricktracker/sql/migrations/0007.sql
index 89ef71f8..7d52d33a 100644
--- a/bricktracker/sql/migrations/0007.sql
+++ b/bricktracker/sql/migrations/0007.sql
@@ -7,16 +7,18 @@ BEGIN TRANSACTION;
 -- Rename sets table
 ALTER TABLE "bricktracker_sets" RENAME TO "bricktracker_sets_old";
 
--- Create a Bricktracker set storage table for later
-CREATE TABLE "bricktracker_set_storages" (
+-- Create a Bricktracker metadata storage table for later
+CREATE TABLE "bricktracker_metadata_storages" (
+    "id" TEXT NOT NULL,
     "name" TEXT NOT NULL,
-    PRIMARY KEY("name")
+    PRIMARY KEY("id")
 );
 
--- Create a Bricktracker set storage table for later
-CREATE TABLE "bricktracker_set_purchase_locations" (
+-- Create a Bricktracker metadata purchase location table for later
+CREATE TABLE "bricktracker_metadata_purchase_locations" (
+    "id" TEXT NOT NULL,
     "name" TEXT NOT NULL,
-    PRIMARY KEY("name")
+    PRIMARY KEY("id")
 );
 
 -- Re-Create a Bricktracker set table with the simplified name
@@ -30,8 +32,8 @@ CREATE TABLE "bricktracker_sets" (
     "purchase_price" REAL, -- Purchase price
     PRIMARY KEY("id"),
     FOREIGN KEY("set") REFERENCES "rebrickable_sets"("set"),
-    FOREIGN KEY("storage") REFERENCES "bricktracker_set_storages"("name"),
-    FOREIGN KEY("purchase_location") REFERENCES "bricktracker_set_purchase_locations"("name")
+    FOREIGN KEY("storage") REFERENCES "bricktracker_metadata_storages"("id"),
+    FOREIGN KEY("purchase_location") REFERENCES "bricktracker_metadata_purchase_locations"("id")
 );
 
 -- Insert existing sets into the new table
diff --git a/bricktracker/sql_counter.py b/bricktracker/sql_counter.py
index 7d8c8822..e5b92624 100644
--- a/bricktracker/sql_counter.py
+++ b/bricktracker/sql_counter.py
@@ -3,15 +3,15 @@ from typing import Tuple
 # Some table aliases to make it look cleaner (id: (name, icon))
 ALIASES: dict[str, Tuple[str, str]] = {
     'bricktracker_metadata_owners': ('Bricktracker set owners metadata', 'user-line'),  # noqa: E501
+    'bricktracker_metadata_purchase_locations': ('Bricktracker set purchase locations metadata', 'building-line'),  # noqa: E501
     'bricktracker_metadata_statuses': ('Bricktracker set status metadata', 'checkbox-line'),  # noqa: E501
+    'bricktracker_metadata_storages': ('Bricktracker set storages metadata', 'archive-2-line'),  # noqa: E501
     'bricktracker_metadata_tags': ('Bricktracker set tags metadata', 'price-tag-2-line'),  # noqa: E501
     'bricktracker_minifigures': ('Bricktracker minifigures', 'group-line'),
     'bricktracker_parts': ('Bricktracker parts', 'shapes-line'),
     'bricktracker_set_checkboxes': ('Bricktracker set checkboxes (legacy)', 'checkbox-line'),  # noqa: E501
     'bricktracker_set_owners': ('Bricktracker set owners', 'checkbox-line'),  # noqa: E501
-    'bricktracker_set_purchase_locations': ('Bricktracker set purchase locations', 'building-line'),  # noqa: E501
     'bricktracker_set_statuses': ('Bricktracker set statuses', 'user-line'),  # noqa: E501
-    'bricktracker_set_storages': ('Bricktracker set storages', 'archive-2-line'),  # noqa: E501
     'bricktracker_set_tags': ('Bricktracker set tags', 'price-tag-2-line'),  # noqa: E501
     'bricktracker_sets': ('Bricktracker sets', 'hashtag'),
     'bricktracker_wishes': ('Bricktracker wishes', 'gift-line'),

From 2b3793450379036bfac460d0cbbcb9030abec353 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 12:19:49 +0100
Subject: [PATCH 106/154] Make form.checkbox parent configurable

---
 templates/macro/form.html | 4 ++--
 templates/set/card.html   | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/templates/macro/form.html b/templates/macro/form.html
index dfd82112..b93ac174 100644
--- a/templates/macro/form.html
+++ b/templates/macro/form.html
@@ -1,9 +1,9 @@
-{% macro checkbox(item, metadata, delete=false) %}
+{% macro checkbox(item, metadata, parent=none, delete=false) %}
   {% if g.login.is_authenticated() %}
     {% set prefix=metadata.as_dataset() %}
     <input class="form-check-input" type="checkbox" id="{{ prefix }}-{{ item.fields.id }}" {% if item.fields[metadata.as_column()] %}checked{% endif %}
     {% if not delete %}
-      data-changer-id="{{ item.fields.id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ metadata.url_for_set_state(item.fields.id) }}" data-changer-parent="set"
+      data-changer-id="{{ item.fields.id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ metadata.url_for_set_state(item.fields.id) }}" {% if parent %}data-changer-parent="{{ parent }}"{% endif %}
     {% else %}
       disabled
     {% endif %}
diff --git a/templates/set/card.html b/templates/set/card.html
index 262c6409..73a90a5c 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -59,7 +59,7 @@
   {% if not tiny and brickset_statuses | length %}
     <ul class="list-group list-group-flush card-check border-bottom-0">
       {% for status in brickset_statuses %}
-        <li class="d-flex list-group-item {% if not solo %}p-1{% endif %} text-nowrap">{{ form.checkbox(item, status, delete=delete) }}</li>
+        <li class="d-flex list-group-item {% if not solo %}p-1{% endif %} text-nowrap">{{ form.checkbox(item, status, parent='set', delete=delete) }}</li>
       {% endfor %}
     </ul>
   {% endif %}

From 53d1603e3e30d01d40d5d77a7d8f5e97eb3b9f32 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 12:25:42 +0100
Subject: [PATCH 107/154] Simplify the instantiation of metadata list

---
 bricktracker/reload.py            |  9 +++------
 bricktracker/set.py               |  9 +++------
 bricktracker/set_list.py          | 15 ++++++---------
 bricktracker/set_owner_list.py    |  6 ++++++
 bricktracker/set_status_list.py   |  6 ++++++
 bricktracker/set_tag_list.py      |  6 ++++++
 bricktracker/views/add.py         | 10 ++++------
 bricktracker/views/admin/admin.py |  6 +++---
 bricktracker/views/index.py       |  9 +++------
 bricktracker/views/set.py         | 21 +++++++++------------
 10 files changed, 49 insertions(+), 48 deletions(-)

diff --git a/bricktracker/reload.py b/bricktracker/reload.py
index 16fca2f5..6673ab17 100644
--- a/bricktracker/reload.py
+++ b/bricktracker/reload.py
@@ -1,10 +1,7 @@
 from .instructions_list import BrickInstructionsList
 from .retired_list import BrickRetiredList
-from .set_owner import BrickSetOwner
 from .set_owner_list import BrickSetOwnerList
-from .set_status import BrickSetStatus
 from .set_status_list import BrickSetStatusList
-from .set_tag import BrickSetTag
 from .set_tag_list import BrickSetTagList
 from .theme_list import BrickThemeList
 
@@ -17,13 +14,13 @@ def reload() -> None:
         BrickInstructionsList(force=True)
 
         # Reload the set owners
-        BrickSetOwnerList(BrickSetOwner, force=True)
+        BrickSetOwnerList.new(force=True)
 
         # Reload the set statuses
-        BrickSetStatusList(BrickSetStatus, force=True)
+        BrickSetStatusList.new(force=True)
 
         # Reload the set tags
-        BrickSetTagList(BrickSetTag, force=True)
+        BrickSetTagList.new(force=True)
 
         # Reload retired sets
         BrickRetiredList(force=True)
diff --git a/bricktracker/set.py b/bricktracker/set.py
index 000047d3..09b22ecf 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -9,11 +9,8 @@ from .exceptions import NotFoundException
 from .minifigure_list import BrickMinifigureList
 from .part_list import BrickPartList
 from .rebrickable_set import RebrickableSet
-from .set_owner import BrickSetOwner
 from .set_owner_list import BrickSetOwnerList
-from .set_status import BrickSetStatus
 from .set_status_list import BrickSetStatusList
-from .set_tag import BrickSetTag
 from .set_tag_list import BrickSetTagList
 from .sql import BrickSQL
 if TYPE_CHECKING:
@@ -169,9 +166,9 @@ class BrickSet(RebrickableSet):
 
         # Load from database
         if not self.select(
-            owners=BrickSetOwnerList(BrickSetOwner).as_columns(),
-            statuses=BrickSetStatusList(BrickSetStatus).as_columns(all=True),
-            tags=BrickSetTagList(BrickSetTag).as_columns(),
+            owners=BrickSetOwnerList.new().as_columns(),
+            statuses=BrickSetStatusList.new().as_columns(all=True),
+            tags=BrickSetTagList.new().as_columns(),
         ):
             raise NotFoundException(
                 'Set with ID {id} was not found in the database'.format(
diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py
index d538daa7..28d8153c 100644
--- a/bricktracker/set_list.py
+++ b/bricktracker/set_list.py
@@ -3,11 +3,8 @@ from typing import Self
 from flask import current_app
 
 from .record_list import BrickRecordList
-from .set_owner import BrickSetOwner
 from .set_owner_list import BrickSetOwnerList
-from .set_status import BrickSetStatus
 from .set_status_list import BrickSetStatusList
-from .set_tag import BrickSetTag
 from .set_tag_list import BrickSetTagList
 from .set import BrickSet
 
@@ -44,9 +41,9 @@ class BrickSetList(BrickRecordList[BrickSet]):
         # Load the sets from the database
         for record in self.select(
             order=self.order,
-            owners=BrickSetOwnerList(BrickSetOwner).as_columns(),
-            statuses=BrickSetStatusList(BrickSetStatus).as_columns(),
-            tags=BrickSetTagList(BrickSetTag).as_columns(),
+            owners=BrickSetOwnerList.new().as_columns(),
+            statuses=BrickSetStatusList.new().as_columns(),
+            tags=BrickSetTagList.new().as_columns(),
         ):
             brickset = BrickSet(record=record)
 
@@ -115,9 +112,9 @@ class BrickSetList(BrickRecordList[BrickSet]):
         for record in self.select(
             order=order,
             limit=limit,
-            owners=BrickSetOwnerList(BrickSetOwner).as_columns(),
-            statuses=BrickSetStatusList(BrickSetStatus).as_columns(),
-            tags=BrickSetTagList(BrickSetTag).as_columns(),
+            owners=BrickSetOwnerList.new().as_columns(),
+            statuses=BrickSetStatusList.new().as_columns(),
+            tags=BrickSetTagList.new().as_columns(),
         ):
             brickset = BrickSet(record=record)
 
diff --git a/bricktracker/set_owner_list.py b/bricktracker/set_owner_list.py
index 13097490..7d3b8f5e 100644
--- a/bricktracker/set_owner_list.py
+++ b/bricktracker/set_owner_list.py
@@ -1,4 +1,5 @@
 import logging
+from typing import Self
 
 from .metadata_list import BrickMetadataList
 from .set_owner import BrickSetOwner
@@ -15,3 +16,8 @@ class BrickSetOwnerList(BrickMetadataList[BrickSetOwner]):
 
     # Queries
     select_query = 'set/metadata/owner/list'
+
+    # Instantiate the list with the proper class
+    @classmethod
+    def new(cls, /, *, force: bool = False) -> Self:
+        return cls(BrickSetOwner, force=force)
diff --git a/bricktracker/set_status_list.py b/bricktracker/set_status_list.py
index dabd3b0a..e238f62c 100644
--- a/bricktracker/set_status_list.py
+++ b/bricktracker/set_status_list.py
@@ -1,4 +1,5 @@
 import logging
+from typing import Self
 
 from .metadata_list import BrickMetadataList
 from .set_status import BrickSetStatus
@@ -24,3 +25,8 @@ class BrickSetStatusList(BrickMetadataList[BrickSetStatus]):
             in self.records
             if all or record.fields.displayed_on_grid
         ]
+
+    # Instantiate the list with the proper class
+    @classmethod
+    def new(cls, /, *, force: bool = False) -> Self:
+        return cls(BrickSetStatus, force=force)
diff --git a/bricktracker/set_tag_list.py b/bricktracker/set_tag_list.py
index 92806f22..822ac3bf 100644
--- a/bricktracker/set_tag_list.py
+++ b/bricktracker/set_tag_list.py
@@ -1,4 +1,5 @@
 import logging
+from typing import Self
 
 from .metadata_list import BrickMetadataList
 from .set_tag import BrickSetTag
@@ -15,3 +16,8 @@ class BrickSetTagList(BrickMetadataList[BrickSetTag]):
 
     # Queries
     select_query = 'set/metadata/tag/list'
+
+    # Instantiate the list with the proper class
+    @classmethod
+    def new(cls, /, *, force: bool = False) -> Self:
+        return cls(BrickSetTag, force=force)
diff --git a/bricktracker/views/add.py b/bricktracker/views/add.py
index 90729733..e77e7dd6 100644
--- a/bricktracker/views/add.py
+++ b/bricktracker/views/add.py
@@ -3,9 +3,7 @@ from flask_login import login_required
 
 from ..configuration_list import BrickConfigurationList
 from .exceptions import exception_handler
-from ..set_owner import BrickSetOwner
 from ..set_owner_list import BrickSetOwnerList
-from ..set_tag import BrickSetTag
 from ..set_tag_list import BrickSetTagList
 from ..socket import MESSAGES
 
@@ -21,8 +19,8 @@ def add() -> str:
 
     return render_template(
         'add.html',
-        brickset_owners=BrickSetOwnerList(BrickSetOwner).list(),
-        brickset_tags=BrickSetTagList(BrickSetTag).list(),
+        brickset_owners=BrickSetOwnerList.new().list(),
+        brickset_tags=BrickSetTagList.new().list(),
         path=current_app.config['SOCKET_PATH'],
         namespace=current_app.config['SOCKET_NAMESPACE'],
         messages=MESSAGES
@@ -38,8 +36,8 @@ def bulk() -> str:
 
     return render_template(
         'add.html',
-        brickset_owners=BrickSetOwnerList(BrickSetOwner).list(),
-        brickset_tags=BrickSetTagList(BrickSetTag).list(),
+        brickset_owners=BrickSetOwnerList.new().list(),
+        brickset_tags=BrickSetTagList.new().list(),
         path=current_app.config['SOCKET_PATH'],
         namespace=current_app.config['SOCKET_NAMESPACE'],
         messages=MESSAGES,
diff --git a/bricktracker/views/admin/admin.py b/bricktracker/views/admin/admin.py
index 415cf487..c716b567 100644
--- a/bricktracker/views/admin/admin.py
+++ b/bricktracker/views/admin/admin.py
@@ -47,9 +47,9 @@ def admin() -> str:
         database_version = database.version
         database_counters = BrickSQL().count_records()
 
-        metadata_owners = BrickSetOwnerList(BrickSetOwner).list()
-        metadata_statuses = BrickSetStatusList(BrickSetStatus).list(all=True)
-        metadata_tags = BrickSetTagList(BrickSetTag).list()
+        metadata_owners = BrickSetOwnerList.new().list()
+        metadata_statuses = BrickSetStatusList.new().list(all=True)
+        metadata_tags = BrickSetTagList.new().list()
     except Exception as e:
         database_exception = e
 
diff --git a/bricktracker/views/index.py b/bricktracker/views/index.py
index 1cbcd564..0a25e072 100644
--- a/bricktracker/views/index.py
+++ b/bricktracker/views/index.py
@@ -2,11 +2,8 @@ from flask import Blueprint, render_template
 
 from .exceptions import exception_handler
 from ..minifigure_list import BrickMinifigureList
-from ..set_owner import BrickSetOwner
 from ..set_owner_list import BrickSetOwnerList
-from ..set_status import BrickSetStatus
 from ..set_status_list import BrickSetStatusList
-from ..set_tag import BrickSetTag
 from ..set_tag_list import BrickSetTagList
 from ..set_list import BrickSetList
 
@@ -20,8 +17,8 @@ def index() -> str:
     return render_template(
         'index.html',
         brickset_collection=BrickSetList().last(),
-        brickset_owners=BrickSetOwnerList(BrickSetOwner).list(),
-        brickset_statuses=BrickSetStatusList(BrickSetStatus).list(),
-        brickset_tags=BrickSetTagList(BrickSetTag).list(),
+        brickset_owners=BrickSetOwnerList.new().list(),
+        brickset_statuses=BrickSetStatusList.new().list(),
+        brickset_tags=BrickSetTagList.new().list(),
         minifigure_collection=BrickMinifigureList().last(),
     )
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index add66d20..6d86bb1d 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -17,11 +17,8 @@ from ..minifigure import BrickMinifigure
 from ..part import BrickPart
 from ..set import BrickSet
 from ..set_list import BrickSetList
-from ..set_owner import BrickSetOwner
 from ..set_owner_list import BrickSetOwnerList
-from ..set_status import BrickSetStatus
 from ..set_status_list import BrickSetStatusList
-from ..set_tag import BrickSetTag
 from ..set_tag_list import BrickSetTagList
 from ..socket import MESSAGES
 
@@ -37,9 +34,9 @@ def list() -> str:
     return render_template(
         'sets.html',
         collection=BrickSetList().all(),
-        brickset_owners=BrickSetOwnerList(BrickSetOwner).list(),
-        brickset_statuses=BrickSetStatusList(BrickSetStatus).list(),
-        brickset_tags=BrickSetTagList(BrickSetTag).list(),
+        brickset_owners=BrickSetOwnerList.new().list(),
+        brickset_statuses=BrickSetStatusList.new().list(),
+        brickset_tags=BrickSetTagList.new().list(),
     )
 
 
@@ -49,7 +46,7 @@ def list() -> str:
 @exception_handler(__file__, json=True)
 def update_owner(*, id: str, metadata_id: str) -> Response:
     brickset = BrickSet().select_light(id)
-    owner = BrickSetOwnerList(BrickSetOwner).get(metadata_id)
+    owner = BrickSetOwnerList.new().get(metadata_id)
 
     state = owner.update_set_state(brickset, json=request.json)
 
@@ -62,7 +59,7 @@ def update_owner(*, id: str, metadata_id: str) -> Response:
 @exception_handler(__file__, json=True)
 def update_status(*, id: str, metadata_id: str) -> Response:
     brickset = BrickSet().select_light(id)
-    status = BrickSetStatusList(BrickSetStatus).get(metadata_id)
+    status = BrickSetStatusList.new().get(metadata_id)
 
     state = status.update_set_state(brickset, json=request.json)
 
@@ -75,7 +72,7 @@ def update_status(*, id: str, metadata_id: str) -> Response:
 @exception_handler(__file__, json=True)
 def update_tag(*, id: str, metadata_id: str) -> Response:
     brickset = BrickSet().select_light(id)
-    tag = BrickSetTagList(BrickSetTag).get(metadata_id)
+    tag = BrickSetTagList.new().get(metadata_id)
 
     state = tag.update_set_state(brickset, json=request.json)
 
@@ -130,9 +127,9 @@ def details(*, id: str) -> str:
         'set.html',
         item=BrickSet().select_specific(id),
         open_instructions=request.args.get('open_instructions'),
-        brickset_owners=BrickSetOwnerList(BrickSetOwner).list(),
-        brickset_statuses=BrickSetStatusList(BrickSetStatus).list(all=True),
-        brickset_tags=BrickSetTagList(BrickSetTag).list(),
+        brickset_owners=BrickSetOwnerList.new().list(),
+        brickset_statuses=BrickSetStatusList.new().list(all=True),
+        brickset_tags=BrickSetTagList.new().list(),
     )
 
 

From 8ea6a3d003e36acf21541ee9410747dd3548b9b4 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 16:03:21 +0100
Subject: [PATCH 108/154] Remove useless format()

---
 bricktracker/metadata.py | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/bricktracker/metadata.py b/bricktracker/metadata.py
index 4b7c54ea..f43eaf59 100644
--- a/bricktracker/metadata.py
+++ b/bricktracker/metadata.py
@@ -49,9 +49,7 @@ class BrickMetadata(BrickRecord):
 
     # HTML dataset name
     def as_dataset(self, /) -> str:
-        return '{id}'.format(
-            id=self.as_column().replace('_', '-')
-        )
+        return self.as_column().replace('_', '-')
 
     # Delete from database
     def delete(self, /) -> None:

From b87ff162c1404cb30790d791e9907fa87448d870 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 16:06:56 +0100
Subject: [PATCH 109/154] Center not found message for metadata

---
 templates/admin/owner.html    | 2 +-
 templates/admin/status.html   | 2 +-
 templates/admin/tag.html      | 2 +-
 templates/set/management.html | 4 ++--
 4 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/templates/admin/owner.html b/templates/admin/owner.html
index 7447a6da..8c7c2619 100644
--- a/templates/admin/owner.html
+++ b/templates/admin/owner.html
@@ -22,7 +22,7 @@
       </li>
     {% endfor %}
   {% else %}
-    <li class="list-group-item"><i class="ri-error-warning-line"></i> No owner found.</li>
+    <li class="list-group-item text-center"><i class="ri-error-warning-line"></i> No owner found.</li>
   {% endif %}
   <li class="list-group-item">
     <form action="{{ url_for('admin_owner.add') }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
diff --git a/templates/admin/status.html b/templates/admin/status.html
index a73462ad..609bc68d 100644
--- a/templates/admin/status.html
+++ b/templates/admin/status.html
@@ -33,7 +33,7 @@
       </li>
     {% endfor %}
   {% else %}
-    <li class="list-group-item"><i class="ri-error-warning-line"></i> No status found.</li>
+    <li class="list-group-item text-center"><i class="ri-error-warning-line"></i> No status found.</li>
   {% endif %}
   <li class="list-group-item">
     <form action="{{ url_for('admin_status.add') }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
diff --git a/templates/admin/tag.html b/templates/admin/tag.html
index 7c67a56a..c3ca1f61 100644
--- a/templates/admin/tag.html
+++ b/templates/admin/tag.html
@@ -22,7 +22,7 @@
       </li>
     {% endfor %}
   {% else %}
-    <li class="list-group-item"><i class="ri-error-warning-line"></i> No tag found.</li>
+    <li class="list-group-item text-center"><i class="ri-error-warning-line"></i> No tag found.</li>
   {% endif %}
   <li class="list-group-item">
     <form action="{{ url_for('admin_tag.add') }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
diff --git a/templates/set/management.html b/templates/set/management.html
index 9b48448f..f80830bd 100644
--- a/templates/set/management.html
+++ b/templates/set/management.html
@@ -6,7 +6,7 @@
         <li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(item, owner, delete=delete) }}</li>
       {% endfor %}
     {% else %}
-      <li class="list-group-item list-group-item-action"><i class="ri-error-warning-line"></i> No owner found.</li>
+      <li class="list-group-item list-group-item-action text-center"><i class="ri-error-warning-line"></i> No owner found.</li>
       {% endif %}
     </ul>
     <div class="list-group list-group-flush border-top">
@@ -20,7 +20,7 @@
         <li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(item, tag, delete=delete) }}</li>
       {% endfor %}
     {% else %}
-      <li class="list-group-item list-group-item-action"><i class="ri-error-warning-line"></i> No tag found.</li>
+      <li class="list-group-item list-group-item-action text-center"><i class="ri-error-warning-line"></i> No tag found.</li>
       {% endif %}
     </ul>
     <div class="list-group list-group-flush border-top">

From 187afdc2cf2e0bf4c0de114db28f54c199e7ee28 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 16:08:11 +0100
Subject: [PATCH 110/154] Add support for select to BrickChanger

---
 static/scripts/changer.js | 51 +++++++++++++++++++++++++++++----------
 1 file changed, 38 insertions(+), 13 deletions(-)

diff --git a/static/scripts/changer.js b/static/scripts/changer.js
index 89f012da..4db9cd7a 100644
--- a/static/scripts/changer.js
+++ b/static/scripts/changer.js
@@ -7,7 +7,7 @@ class BrickChanger {
         this.html_clear = document.getElementById(`clear-${prefix}-${id}`);
         this.html_status = document.getElementById(`status-${prefix}-${id}`);
         this.html_status_tooltip = undefined;
-        this.html_type = this.html_element.getAttribute("type");
+        this.html_type = undefined;
         this.url = url;
 
         if (parent) {
@@ -16,12 +16,29 @@ class BrickChanger {
         }
 
         // Register an event depending on the type
-        if (this.html_type == "checkbox") {
-            var listener = "change";
-        } else if(this.html_type == "text") {
-            var listener = "change";
-        } else {
-            throw Error("Unsupported input type for BrickChanger");
+        let listener = undefined;
+        switch (this.html_element.tagName) {
+            case "INPUT":
+                this.html_type = this.html_element.getAttribute("type");
+
+                switch (this.html_type) {
+                    case "checkbox":
+                    case "text":
+                        listener = "change";
+                    break;
+
+                    default:
+                        throw Error(`Unsupported input type for BrickChanger: ${this.html_type}`);
+                }
+            break;
+
+            case "SELECT":
+                this.html_type = "select";
+                listener = "change";
+            break;
+
+            default:
+                throw Error(`Unsupported HTML tag type for BrickChanger: ${this.html_element.tagName}`);
         }
 
         this.html_element.addEventListener(listener, ((changer) => (e) => {
@@ -90,12 +107,20 @@ class BrickChanger {
             this.status_unknown();
 
             // Grab the value depending on the type
-            if (this.html_type == "checkbox") {
-                var value = this.html_element.checked;
-            } else if(this.html_type == "text") {
-                var value = this.html_element.value;
-            } else {
-                throw Error("Unsupported input type for BrickChanger");
+            let value = undefined;
+
+            switch(this.html_type) {
+                case "checkbox":
+                    value = this.html_element.checked;
+                break;
+
+                case "text":
+                case "select":
+                    value = this.html_element.value;
+                break;
+
+                default:
+                    throw Error("Unsupported input type for BrickChanger");
             }
 
             const response = await fetch(this.url, {

From ec7fab2a7a506dbafd56cc46cb69494af96c7d1a Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 16:35:09 +0100
Subject: [PATCH 111/154] Scroll confirm and progress to view when adding a set
 through the socket

---
 static/scripts/socket/set.js | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/static/scripts/socket/set.js b/static/scripts/socket/set.js
index a7e660b4..d561261e 100644
--- a/static/scripts/socket/set.js
+++ b/static/scripts/socket/set.js
@@ -163,6 +163,10 @@ class BrickSetSocket extends BrickSocket {
 
             this.spinner(true);
 
+            if (this.html_progress_bar) {
+                this.html_progress_bar.scrollIntoView();
+            }
+
             this.socket.emit(this.messages.IMPORT_SET, {
                 set: (set !== undefined) ? set : this.html_input.value,
                 owners: owners,
@@ -240,6 +244,8 @@ class BrickSetSocket extends BrickSocket {
                         })(this, data["set"]);
 
                         this.html_card_confirm.addEventListener("click", this.confirm_listener);
+
+                        this.html_card_confirm.scrollIntoView();
                     }
                 }
             }

From 9aff7e622d6ebd2b79f4ae3544bf8110129b7755 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 16:46:45 +0100
Subject: [PATCH 112/154] Set storage

---
 CHANGELOG.md                                  |   2 +
 bricktracker/app.py                           |   2 +
 bricktracker/metadata.py                      |  62 +++++++-
 bricktracker/metadata_list.py                 | 135 +++++++++++++-----
 bricktracker/record_list.py                   |   6 +-
 bricktracker/reload.py                        |   4 +
 bricktracker/set.py                           |  42 +++---
 bricktracker/set_list.py                      |  12 +-
 bricktracker/set_storage.py                   |  13 ++
 bricktracker/set_storage_list.py              |  23 +++
 bricktracker/sql/set/base/base.sql            |   1 +
 bricktracker/sql/set/insert.sql               |   6 +-
 .../sql/set/metadata/storage/base.sql         |   6 +
 .../sql/set/metadata/storage/delete.sql       |   6 +
 .../sql/set/metadata/storage/insert.sql       |  11 ++
 .../sql/set/metadata/storage/list.sql         |   1 +
 .../sql/set/metadata/storage/select.sql       |   5 +
 .../sql/set/metadata/storage/update/field.sql |   3 +
 .../sql/set/metadata/storage/update/state.sql |   3 +
 bricktracker/views/add.py                     |  11 +-
 bricktracker/views/admin/admin.py             |  15 +-
 bricktracker/views/admin/storage.py           |  84 +++++++++++
 bricktracker/views/index.py                   |   8 +-
 bricktracker/views/set.py                     |  37 +++--
 static/scripts/socket/set.js                  |  12 ++
 templates/add.html                            |  13 ++
 templates/admin.html                          |   3 +
 templates/admin/storage.html                  |  42 ++++++
 templates/admin/storage/delete.html           |  19 +++
 templates/macro/badge.html                    |  12 ++
 templates/macro/form.html                     |  24 ++++
 templates/set/card.html                       |   3 +-
 32 files changed, 538 insertions(+), 88 deletions(-)
 create mode 100644 bricktracker/set_storage.py
 create mode 100644 bricktracker/set_storage_list.py
 create mode 100644 bricktracker/sql/set/metadata/storage/base.sql
 create mode 100644 bricktracker/sql/set/metadata/storage/delete.sql
 create mode 100644 bricktracker/sql/set/metadata/storage/insert.sql
 create mode 100644 bricktracker/sql/set/metadata/storage/list.sql
 create mode 100644 bricktracker/sql/set/metadata/storage/select.sql
 create mode 100644 bricktracker/sql/set/metadata/storage/update/field.sql
 create mode 100644 bricktracker/sql/set/metadata/storage/update/state.sql
 create mode 100644 bricktracker/views/admin/storage.py
 create mode 100644 templates/admin/storage.html
 create mode 100644 templates/admin/storage/delete.html

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 40b4f173..8a1c34d7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,6 +34,7 @@ Parts
     - Fix missing @login_required for set deletion
     - Ownership
     - Tags
+    - Storage
 
 - Socket
     - Add decorator for rebrickable, authenticated and threaded socket actions
@@ -76,6 +77,7 @@ Parts
         - Ownership
         - Tags
         - Refresh
+        - Storage
 
 - Sets grid
     - Collapsible controls depending on screen size
diff --git a/bricktracker/app.py b/bricktracker/app.py
index 240bc637..4b6f0d4f 100644
--- a/bricktracker/app.py
+++ b/bricktracker/app.py
@@ -19,6 +19,7 @@ from bricktracker.views.admin.instructions import admin_instructions_page
 from bricktracker.views.admin.owner import admin_owner_page
 from bricktracker.views.admin.retired import admin_retired_page
 from bricktracker.views.admin.status import admin_status_page
+from bricktracker.views.admin.storage import admin_storage_page
 from bricktracker.views.admin.tag import admin_tag_page
 from bricktracker.views.admin.theme import admin_theme_page
 from bricktracker.views.error import error_404
@@ -86,6 +87,7 @@ def setup_app(app: Flask) -> None:
     app.register_blueprint(admin_retired_page)
     app.register_blueprint(admin_owner_page)
     app.register_blueprint(admin_status_page)
+    app.register_blueprint(admin_storage_page)
     app.register_blueprint(admin_tag_page)
     app.register_blueprint(admin_theme_page)
 
diff --git a/bricktracker/metadata.py b/bricktracker/metadata.py
index f43eaf59..07545f92 100644
--- a/bricktracker/metadata.py
+++ b/bricktracker/metadata.py
@@ -36,6 +36,9 @@ class BrickMetadata(BrickRecord):
     ):
         super().__init__()
 
+        # Defined an empty ID
+        self.fields.id = None
+
         # Ingest the record if it has one
         if record is not None:
             self.ingest(record)
@@ -129,8 +132,8 @@ class BrickMetadata(BrickRecord):
         json: Any | None = None,
         value: Any | None = None
     ) -> Any:
-        if value is None:
-            value = json.get('value', None)  # type: ignore
+        if value is None and json is not None:
+            value = json.get('value', None)
 
         if value is None:
             raise ErrorException('"{field}" of a {kind} cannot be set to an empty value'.format(  # noqa: E501
@@ -180,16 +183,15 @@ class BrickMetadata(BrickRecord):
         /,
         *,
         json: Any | None = None,
-        state: bool | None = None,
+        state: Any | None = None
     ) -> Any:
-        if state is None:
-            state = json.get('value', False)  # type: ignore
+        if state is None and json is not None:
+            state = json.get('value', False)
 
         parameters = self.sql_parameters()
         parameters['set_id'] = brickset.fields.id
         parameters['state'] = state
 
-        # Update the status
         rows, _ = BrickSQL().execute_and_commit(
             self.update_set_state_query,
             parameters=parameters,
@@ -205,7 +207,53 @@ class BrickMetadata(BrickRecord):
             ))
 
         # Info
-        logger.info('{kind} "{name}" state change to "{state}" for set {set} ({id})'.format(  # noqa: E501
+        logger.info('{kind} "{name}" state changed to "{state}" for set {set} ({id})'.format(  # noqa: E501
+            kind=self.kind,
+            name=self.fields.name,
+            state=state,
+            set=brickset.fields.set,
+            id=brickset.fields.id,
+        ))
+
+        return state
+
+    # Update the selected value of this metadata item for a set
+    def update_set_value(
+        self,
+        brickset: 'BrickSet',
+        /,
+        *,
+        json: Any | None = None,
+        state: Any | None = None,
+    ) -> Any:
+        if state is None and json is not None:
+            state = json.get('value', '')
+
+        if state == '':
+            state = None
+
+        parameters = self.sql_parameters()
+        parameters['set_id'] = brickset.fields.id
+        parameters['state'] = state
+
+        rows, _ = BrickSQL().execute_and_commit(
+            self.update_set_state_query,
+            parameters=parameters,
+        )
+
+        # Update the status
+        if state is None and not hasattr(self.fields, 'name'):
+            self.fields.name = 'None'
+
+        if rows != 1:
+            raise DatabaseException('Could not update the {kind} value for set {set} ({id})'.format(  # noqa: E501
+                kind=self.kind,
+                set=brickset.fields.set,
+                id=brickset.fields.id,
+            ))
+
+        # Info
+        logger.info('{kind} value changed to "{name}" ({state}) for set {set} ({id})'.format(  # noqa: E501
             kind=self.kind,
             name=self.fields.name,
             state=state,
diff --git a/bricktracker/metadata_list.py b/bricktracker/metadata_list.py
index b0d42c3b..5dfa73c3 100644
--- a/bricktracker/metadata_list.py
+++ b/bricktracker/metadata_list.py
@@ -1,16 +1,19 @@
 import logging
-from typing import Type, TypeVar
+from typing import List, overload, Self, Type, TypeVar
+
+from flask import url_for
 
 from .exceptions import NotFoundException
 from .fields import BrickRecordFields
 from .record_list import BrickRecordList
 from .set_owner import BrickSetOwner
 from .set_status import BrickSetStatus
+from .set_storage import BrickSetStorage
 from .set_tag import BrickSetTag
 
 logger = logging.getLogger(__name__)
 
-T = TypeVar('T', BrickSetStatus, BrickSetOwner, BrickSetTag)
+T = TypeVar('T', BrickSetOwner, BrickSetStatus, BrickSetStorage, BrickSetTag)
 
 
 # Lego sets metadata list
@@ -25,55 +28,119 @@ class BrickMetadataList(BrickRecordList[T]):
     # Queries
     select_query: str
 
-    def __init__(self, model: Type[T], /, *, force: bool = False):
-        # Load statuses only if there is none already loaded
-        records = getattr(self, 'records', None)
+    # Set state endpoint
+    set_state_endpoint: str
 
-        if records is None or force:
-            # Don't use super()__init__ as it would mask class variables
-            self.fields = BrickRecordFields()
+    def __init__(
+        self,
+        model: Type[T],
+        /,
+        *,
+        force: bool = False,
+        records: list[T] | None = None
+    ):
+        self.model = model
 
-            logger.info('Loading {kind} list'.format(
-                kind=self.kind
-            ))
+        # Records override (masking the class variables with instance ones)
+        if records is not None:
+            self.records = []
+            self.mapping = {}
 
-            self.__class__.records = []
-            self.__class__.mapping = {}
+            for metadata in records:
+                self.records.append(metadata)
+                self.mapping[metadata.fields.id] = metadata
+        else:
+            # Load metadata only if there is none already loaded
+            records = getattr(self, 'records', None)
 
-            # Load the statuses from the database
-            for record in self.select():
-                status = model(record=record)
+            if records is None or force:
+                # Don't use super()__init__ as it would mask class variables
+                self.fields = BrickRecordFields()
 
-                self.__class__.records.append(status)
-                self.__class__.mapping[status.fields.id] = status
+                logger.info('Loading {kind} list'.format(
+                    kind=self.kind
+                ))
 
-    # Return the items as columns for a select
-    def as_columns(self, /, **kwargs) -> str:
-        return ', '.join([
-            '"{table}"."{column}"'.format(
-                table=self.table,
-                column=record.as_column(),
-            )
-            for record
-            in self.filter(**kwargs)
-        ])
+                self.__class__.records = []
+                self.__class__.mapping = {}
+
+                # Load the metadata from the database
+                for record in self.select():
+                    metadata = model(record=record)
+
+                    self.__class__.records.append(metadata)
+                    self.__class__.mapping[metadata.fields.id] = metadata
+
+    # HTML prefix name
+    def as_prefix(self, /) -> str:
+        return self.kind.replace(' ', '-')
 
     # Filter the list of records (this one does nothing)
     def filter(self) -> list[T]:
         return self.records
 
+    # Return the items as columns for a select
+    @classmethod
+    def as_columns(cls, /, **kwargs) -> str:
+        new = cls.new()
+
+        return ', '.join([
+            '"{table}"."{column}"'.format(
+                table=cls.table,
+                column=record.as_column(),
+            )
+            for record
+            in new.filter(**kwargs)
+        ])
+
     # Grab a specific status
-    def get(self, id: str, /) -> T:
-        if id not in self.mapping:
+    @classmethod
+    def get(cls, id: str, /, *, allow_none: bool = False) -> T:
+        new = cls.new()
+
+        if allow_none and id == '':
+            return new.model()
+
+        if id not in new.mapping:
             raise NotFoundException(
                 '{kind} with ID {id} was not found in the database'.format(
-                    kind=self.kind.capitalize(),
+                    kind=new.kind.capitalize(),
                     id=id,
                 ),
             )
 
-        return self.mapping[id]
+        return new.mapping[id]
 
     # Get the list of statuses depending on the context
-    def list(self, /, **kwargs) -> list[T]:
-        return self.filter(**kwargs)
+    @overload
+    @classmethod
+    def list(cls, /, **kwargs) -> List[T]: ...
+
+    @overload
+    @classmethod
+    def list(cls, /, as_class: bool = False, **kwargs) -> Self: ...
+
+    @classmethod
+    def list(cls, /, as_class: bool = False, **kwargs) -> List[T] | Self:
+        new = cls.new()
+        list = new.filter(**kwargs)
+
+        if as_class:
+            print(list)
+            # Return a copy of the metadata list with overriden records
+            return cls(new.model, records=list)
+        else:
+            return list
+
+    # Instantiate the list with the proper class
+    @classmethod
+    def new(cls, /, *, force: bool = False) -> Self:
+        raise Exception('new() is not implemented for BrickMetadataList')
+
+    # URL to change the selected state of this metadata item for a set
+    @classmethod
+    def url_for_set_state(cls, id: str, /) -> str:
+        return url_for(
+            cls.set_state_endpoint,
+            id=id,
+        )
diff --git a/bricktracker/record_list.py b/bricktracker/record_list.py
index 3de9bf96..23da29bc 100644
--- a/bricktracker/record_list.py
+++ b/bricktracker/record_list.py
@@ -10,17 +10,19 @@ if TYPE_CHECKING:
     from .set import BrickSet
     from .set_owner import BrickSetOwner
     from .set_status import BrickSetStatus
+    from .set_storage import BrickSetStorage
     from .set_tag import BrickSetTag
     from .wish import BrickWish
 
 T = TypeVar(
     'T',
+    'BrickMinifigure',
+    'BrickPart',
     'BrickSet',
     'BrickSetOwner',
     'BrickSetStatus',
+    'BrickSetStorage',
     'BrickSetTag',
-    'BrickPart',
-    'BrickMinifigure',
     'BrickWish',
     'RebrickableSet'
 )
diff --git a/bricktracker/reload.py b/bricktracker/reload.py
index 6673ab17..b2247ea4 100644
--- a/bricktracker/reload.py
+++ b/bricktracker/reload.py
@@ -2,6 +2,7 @@ from .instructions_list import BrickInstructionsList
 from .retired_list import BrickRetiredList
 from .set_owner_list import BrickSetOwnerList
 from .set_status_list import BrickSetStatusList
+from .set_storage_list import BrickSetStorageList
 from .set_tag_list import BrickSetTagList
 from .theme_list import BrickThemeList
 
@@ -19,6 +20,9 @@ def reload() -> None:
         # Reload the set statuses
         BrickSetStatusList.new(force=True)
 
+        # Reload the set storages
+        BrickSetStorageList.new(force=True)
+
         # Reload the set tags
         BrickSetTagList.new(force=True)
 
diff --git a/bricktracker/set.py b/bricktracker/set.py
index 09b22ecf..90b2679b 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -11,6 +11,7 @@ from .part_list import BrickPartList
 from .rebrickable_set import RebrickableSet
 from .set_owner_list import BrickSetOwnerList
 from .set_status_list import BrickSetStatusList
+from .set_storage_list import BrickSetStorageList
 from .set_tag_list import BrickSetTagList
 from .sql import BrickSQL
 if TYPE_CHECKING:
@@ -55,9 +56,30 @@ class BrickSet(RebrickableSet):
             self.fields.id = str(uuid4())
 
             if not refresh:
+                # Save the storage
+                storage = BrickSetStorageList.get(
+                    data.get('storage', ''),
+                    allow_none=True
+                )
+                self.fields.storage = storage.fields.id
+
                 # Insert into database
                 self.insert(commit=False)
 
+                # Save the owners
+                owners: list[str] = list(data.get('owners', []))
+
+                for id in owners:
+                    owner = BrickSetOwnerList.get(id)
+                    owner.update_set_state(self, state=True)
+
+                # Save the tags
+                tags: list[str] = list(data.get('tags', []))
+
+                for id in tags:
+                    tag = BrickSetTagList.get(id)
+                    tag.update_set_state(self, state=True)
+
             # Insert the rebrickable set into database
             self.insert_rebrickable()
 
@@ -69,20 +91,6 @@ class BrickSet(RebrickableSet):
             if not BrickMinifigureList.download(socket, self, refresh=refresh):
                 return False
 
-            # Save the owners
-            owners: list[str] = list(data.get('owners', []))
-
-            for id in owners:
-                owner = BrickSetOwnerList(BrickSetOwner).get(id)
-                owner.update_set_state(self, state=True)
-
-            # Save the tags
-            tags: list[str] = list(data.get('tags', []))
-
-            for id in tags:
-                tag = BrickSetTagList(BrickSetTag).get(id)
-                tag.update_set_state(self, state=True)
-
             # Commit the transaction to the database
             socket.auto_progress(
                 message='Set {set}: writing to the database'.format(
@@ -166,9 +174,9 @@ class BrickSet(RebrickableSet):
 
         # Load from database
         if not self.select(
-            owners=BrickSetOwnerList.new().as_columns(),
-            statuses=BrickSetStatusList.new().as_columns(all=True),
-            tags=BrickSetTagList.new().as_columns(),
+            owners=BrickSetOwnerList.as_columns(),
+            statuses=BrickSetStatusList.as_columns(all=True),
+            tags=BrickSetTagList.as_columns(),
         ):
             raise NotFoundException(
                 'Set with ID {id} was not found in the database'.format(
diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py
index 28d8153c..e25594ca 100644
--- a/bricktracker/set_list.py
+++ b/bricktracker/set_list.py
@@ -41,9 +41,9 @@ class BrickSetList(BrickRecordList[BrickSet]):
         # Load the sets from the database
         for record in self.select(
             order=self.order,
-            owners=BrickSetOwnerList.new().as_columns(),
-            statuses=BrickSetStatusList.new().as_columns(),
-            tags=BrickSetTagList.new().as_columns(),
+            owners=BrickSetOwnerList.as_columns(),
+            statuses=BrickSetStatusList.as_columns(),
+            tags=BrickSetTagList.as_columns(),
         ):
             brickset = BrickSet(record=record)
 
@@ -112,9 +112,9 @@ class BrickSetList(BrickRecordList[BrickSet]):
         for record in self.select(
             order=order,
             limit=limit,
-            owners=BrickSetOwnerList.new().as_columns(),
-            statuses=BrickSetStatusList.new().as_columns(),
-            tags=BrickSetTagList.new().as_columns(),
+            owners=BrickSetOwnerList.as_columns(),
+            statuses=BrickSetStatusList.as_columns(),
+            tags=BrickSetTagList.as_columns(),
         ):
             brickset = BrickSet(record=record)
 
diff --git a/bricktracker/set_storage.py b/bricktracker/set_storage.py
new file mode 100644
index 00000000..0a54262f
--- /dev/null
+++ b/bricktracker/set_storage.py
@@ -0,0 +1,13 @@
+from .metadata import BrickMetadata
+
+
+# Lego set storage metadata
+class BrickSetStorage(BrickMetadata):
+    kind: str = 'storage'
+
+    # Queries
+    delete_query: str = 'set/metadata/storage/delete'
+    insert_query: str = 'set/metadata/storage/insert'
+    select_query: str = 'set/metadata/storage/select'
+    update_field_query: str = 'set/metadata/storage/update/field'
+    update_set_state_query: str = 'set/metadata/storage/update/state'
diff --git a/bricktracker/set_storage_list.py b/bricktracker/set_storage_list.py
new file mode 100644
index 00000000..72efde70
--- /dev/null
+++ b/bricktracker/set_storage_list.py
@@ -0,0 +1,23 @@
+import logging
+from typing import Self
+
+from .metadata_list import BrickMetadataList
+from .set_storage import BrickSetStorage
+
+logger = logging.getLogger(__name__)
+
+
+# Lego sets storage list
+class BrickSetStorageList(BrickMetadataList[BrickSetStorage]):
+    kind: str = 'set storages'
+
+    # Queries
+    select_query = 'set/metadata/storage/list'
+
+    # Set state endpoint
+    set_state_endpoint: str = 'set.update_storage'
+
+    # Instantiate the list with the proper class
+    @classmethod
+    def new(cls, /, *, force: bool = False) -> Self:
+        return cls(BrickSetStorage, force=force)
diff --git a/bricktracker/sql/set/base/base.sql b/bricktracker/sql/set/base/base.sql
index 331b15e5..fbc86e0d 100644
--- a/bricktracker/sql/set/base/base.sql
+++ b/bricktracker/sql/set/base/base.sql
@@ -1,5 +1,6 @@
 SELECT
     {% block id %}{% endblock %}
+    "bricktracker_sets"."storage",
     "rebrickable_sets"."set",
     "rebrickable_sets"."number",
     "rebrickable_sets"."version",
diff --git a/bricktracker/sql/set/insert.sql b/bricktracker/sql/set/insert.sql
index 7dd6dec8..9a46f88b 100644
--- a/bricktracker/sql/set/insert.sql
+++ b/bricktracker/sql/set/insert.sql
@@ -1,7 +1,9 @@
 INSERT OR IGNORE INTO "bricktracker_sets" (
     "id",
-    "set"
+    "set",
+    "storage"
 ) VALUES (
     :id,
-    :set
+    :set,
+    :storage
 )
diff --git a/bricktracker/sql/set/metadata/storage/base.sql b/bricktracker/sql/set/metadata/storage/base.sql
new file mode 100644
index 00000000..2417aa69
--- /dev/null
+++ b/bricktracker/sql/set/metadata/storage/base.sql
@@ -0,0 +1,6 @@
+SELECT
+    "bricktracker_metadata_storages"."id",
+    "bricktracker_metadata_storages"."name"
+FROM "bricktracker_metadata_storages"
+
+{% block where %}{% endblock %}
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/storage/delete.sql b/bricktracker/sql/set/metadata/storage/delete.sql
new file mode 100644
index 00000000..c50b348e
--- /dev/null
+++ b/bricktracker/sql/set/metadata/storage/delete.sql
@@ -0,0 +1,6 @@
+BEGIN TRANSACTION;
+
+DELETE FROM "bricktracker_metadata_storages"
+WHERE "bricktracker_metadata_statuses"."id" IS NOT DISTINCT FROM '{{ id }}';
+
+COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/storage/insert.sql b/bricktracker/sql/set/metadata/storage/insert.sql
new file mode 100644
index 00000000..262c6515
--- /dev/null
+++ b/bricktracker/sql/set/metadata/storage/insert.sql
@@ -0,0 +1,11 @@
+BEGIN TRANSACTION;
+
+INSERT INTO "bricktracker_metadata_storages" (
+    "id",
+    "name"
+) VALUES (
+    '{{ id }}',
+    '{{ name }}'
+);
+
+COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/storage/list.sql b/bricktracker/sql/set/metadata/storage/list.sql
new file mode 100644
index 00000000..87ac7a48
--- /dev/null
+++ b/bricktracker/sql/set/metadata/storage/list.sql
@@ -0,0 +1 @@
+{% extends 'set/metadata/storage/base.sql' %}
diff --git a/bricktracker/sql/set/metadata/storage/select.sql b/bricktracker/sql/set/metadata/storage/select.sql
new file mode 100644
index 00000000..b37a7e83
--- /dev/null
+++ b/bricktracker/sql/set/metadata/storage/select.sql
@@ -0,0 +1,5 @@
+{% extends 'set/metadata/storage/base.sql' %}
+
+{% block where %}
+WHERE "bricktracker_metadata_storages"."id" IS NOT DISTINCT FROM :id
+{% endblock %}
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/storage/update/field.sql b/bricktracker/sql/set/metadata/storage/update/field.sql
new file mode 100644
index 00000000..d27d27c5
--- /dev/null
+++ b/bricktracker/sql/set/metadata/storage/update/field.sql
@@ -0,0 +1,3 @@
+UPDATE "bricktracker_metadata_storages"
+SET "{{field}}" = :value
+WHERE "bricktracker_metadata_storages"."id" IS NOT DISTINCT FROM :id
diff --git a/bricktracker/sql/set/metadata/storage/update/state.sql b/bricktracker/sql/set/metadata/storage/update/state.sql
new file mode 100644
index 00000000..7cc40d6f
--- /dev/null
+++ b/bricktracker/sql/set/metadata/storage/update/state.sql
@@ -0,0 +1,3 @@
+UPDATE "bricktracker_sets"
+SET "storage" = :state
+WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM :set_id
diff --git a/bricktracker/views/add.py b/bricktracker/views/add.py
index e77e7dd6..fb11efe4 100644
--- a/bricktracker/views/add.py
+++ b/bricktracker/views/add.py
@@ -4,6 +4,7 @@ from flask_login import login_required
 from ..configuration_list import BrickConfigurationList
 from .exceptions import exception_handler
 from ..set_owner_list import BrickSetOwnerList
+from ..set_storage_list import BrickSetStorageList
 from ..set_tag_list import BrickSetTagList
 from ..socket import MESSAGES
 
@@ -19,8 +20,9 @@ def add() -> str:
 
     return render_template(
         'add.html',
-        brickset_owners=BrickSetOwnerList.new().list(),
-        brickset_tags=BrickSetTagList.new().list(),
+        brickset_owners=BrickSetOwnerList.list(),
+        brickset_storages=BrickSetStorageList.list(),
+        brickset_tags=BrickSetTagList.list(),
         path=current_app.config['SOCKET_PATH'],
         namespace=current_app.config['SOCKET_NAMESPACE'],
         messages=MESSAGES
@@ -36,8 +38,9 @@ def bulk() -> str:
 
     return render_template(
         'add.html',
-        brickset_owners=BrickSetOwnerList.new().list(),
-        brickset_tags=BrickSetTagList.new().list(),
+        brickset_owners=BrickSetOwnerList.list(),
+        brickset_storages=BrickSetStorageList.list(),
+        brickset_tags=BrickSetTagList.list(),
         path=current_app.config['SOCKET_PATH'],
         namespace=current_app.config['SOCKET_NAMESPACE'],
         messages=MESSAGES,
diff --git a/bricktracker/views/admin/admin.py b/bricktracker/views/admin/admin.py
index c716b567..584a359f 100644
--- a/bricktracker/views/admin/admin.py
+++ b/bricktracker/views/admin/admin.py
@@ -10,6 +10,8 @@ from ...rebrickable_image import RebrickableImage
 from ...retired_list import BrickRetiredList
 from ...set_owner import BrickSetOwner
 from ...set_owner_list import BrickSetOwnerList
+from ...set_storage import BrickSetStorage
+from ...set_storage_list import BrickSetStorageList
 from ...set_status import BrickSetStatus
 from ...set_status_list import BrickSetStatusList
 from ...set_tag import BrickSetTag
@@ -34,6 +36,7 @@ def admin() -> str:
     database_version: int = -1
     metadata_owners: list[BrickSetOwner] = []
     metadata_statuses: list[BrickSetStatus] = []
+    metadata_storages: list[BrickSetStorage] = []
     metadata_tags: list[BrickSetTag] = []
     nil_minifigure_name: str = ''
     nil_minifigure_url: str = ''
@@ -47,9 +50,10 @@ def admin() -> str:
         database_version = database.version
         database_counters = BrickSQL().count_records()
 
-        metadata_owners = BrickSetOwnerList.new().list()
-        metadata_statuses = BrickSetStatusList.new().list(all=True)
-        metadata_tags = BrickSetTagList.new().list()
+        metadata_owners = BrickSetOwnerList.list()
+        metadata_statuses = BrickSetStatusList.list(all=True)
+        metadata_storages = BrickSetStorageList.list()
+        metadata_tags = BrickSetTagList.list()
     except Exception as e:
         database_exception = e
 
@@ -76,6 +80,7 @@ def admin() -> str:
     open_owner = request.args.get('open_owner', None)
     open_retired = request.args.get('open_retired', None)
     open_status = request.args.get('open_status', None)
+    open_storage = request.args.get('open_storage', None)
     open_tag = request.args.get('open_tag', None)
     open_theme = request.args.get('open_theme', None)
 
@@ -86,6 +91,7 @@ def admin() -> str:
         open_owner is None and
         open_retired is None and
         open_status is None and
+        open_storage is None and
         open_tag is None and
         open_theme is None
     )
@@ -101,6 +107,7 @@ def admin() -> str:
         instructions=BrickInstructionsList(),
         metadata_owners=metadata_owners,
         metadata_statuses=metadata_statuses,
+        metadata_storages=metadata_storages,
         metadata_tags=metadata_tags,
         nil_minifigure_name=nil_minifigure_name,
         nil_minifigure_url=nil_minifigure_url,
@@ -113,11 +120,13 @@ def admin() -> str:
         open_owner=open_owner,
         open_retired=open_retired,
         open_status=open_status,
+        open_storage=open_storage,
         open_tag=open_tag,
         open_theme=open_theme,
         owner_error=request.args.get('owner_error'),
         retired=BrickRetiredList(),
         status_error=request.args.get('status_error'),
+        storage_error=request.args.get('storage_error'),
         tag_error=request.args.get('tag_error'),
         theme=BrickThemeList(),
     )
diff --git a/bricktracker/views/admin/storage.py b/bricktracker/views/admin/storage.py
new file mode 100644
index 00000000..7c686bfa
--- /dev/null
+++ b/bricktracker/views/admin/storage.py
@@ -0,0 +1,84 @@
+from flask import (
+    Blueprint,
+    redirect,
+    request,
+    render_template,
+    url_for,
+)
+from flask_login import login_required
+from werkzeug.wrappers.response import Response
+
+from ..exceptions import exception_handler
+from ...reload import reload
+from ...set_storage import BrickSetStorage
+
+admin_storage_page = Blueprint(
+    'admin_storage',
+    __name__,
+    url_prefix='/admin/storage'
+)
+
+
+# Add a metadata storage
+@admin_storage_page.route('/add', methods=['POST'])
+@login_required
+@exception_handler(
+    __file__,
+    post_redirect='admin.admin',
+    error_name='storage_error',
+    open_storage=True
+)
+def add() -> Response:
+    BrickSetStorage().from_form(request.form).insert()
+
+    reload()
+
+    return redirect(url_for('admin.admin', open_storage=True))
+
+
+# Delete the metadata storage
+@admin_storage_page.route('<id>/delete', methods=['GET'])
+@login_required
+@exception_handler(__file__)
+def delete(*, id: str) -> str:
+    return render_template(
+        'admin.html',
+        delete_storage=True,
+        storage=BrickSetStorage().select_specific(id),
+        error=request.args.get('storage_error')
+    )
+
+
+# Actually delete the metadata storage
+@admin_storage_page.route('<id>/delete', methods=['POST'])
+@login_required
+@exception_handler(
+    __file__,
+    post_redirect='admin_storage.delete',
+    error_name='storage_error'
+)
+def do_delete(*, id: str) -> Response:
+    storage = BrickSetStorage().select_specific(id)
+    storage.delete()
+
+    reload()
+
+    return redirect(url_for('admin.admin', open_storage=True))
+
+
+# Rename the metadata storage
+@admin_storage_page.route('<id>/rename', methods=['POST'])
+@login_required
+@exception_handler(
+    __file__,
+    post_redirect='admin.admin',
+    error_name='storage_error',
+    open_storage=True
+)
+def rename(*, id: str) -> Response:
+    storage = BrickSetStorage().select_specific(id)
+    storage.from_form(request.form).rename()
+
+    reload()
+
+    return redirect(url_for('admin.admin', open_storage=True))
diff --git a/bricktracker/views/index.py b/bricktracker/views/index.py
index 0a25e072..b64775bf 100644
--- a/bricktracker/views/index.py
+++ b/bricktracker/views/index.py
@@ -4,6 +4,7 @@ from .exceptions import exception_handler
 from ..minifigure_list import BrickMinifigureList
 from ..set_owner_list import BrickSetOwnerList
 from ..set_status_list import BrickSetStatusList
+from ..set_storage_list import BrickSetStorageList
 from ..set_tag_list import BrickSetTagList
 from ..set_list import BrickSetList
 
@@ -17,8 +18,9 @@ def index() -> str:
     return render_template(
         'index.html',
         brickset_collection=BrickSetList().last(),
-        brickset_owners=BrickSetOwnerList.new().list(),
-        brickset_statuses=BrickSetStatusList.new().list(),
-        brickset_tags=BrickSetTagList.new().list(),
+        brickset_owners=BrickSetOwnerList.list(),
+        brickset_statuses=BrickSetStatusList.list(),
+        brickset_storages=BrickSetStorageList.list(as_class=True),
+        brickset_tags=BrickSetTagList.list(),
         minifigure_collection=BrickMinifigureList().last(),
     )
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index 6d86bb1d..8983cf94 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -19,6 +19,7 @@ from ..set import BrickSet
 from ..set_list import BrickSetList
 from ..set_owner_list import BrickSetOwnerList
 from ..set_status_list import BrickSetStatusList
+from ..set_storage_list import BrickSetStorageList
 from ..set_tag_list import BrickSetTagList
 from ..socket import MESSAGES
 
@@ -34,9 +35,10 @@ def list() -> str:
     return render_template(
         'sets.html',
         collection=BrickSetList().all(),
-        brickset_owners=BrickSetOwnerList.new().list(),
-        brickset_statuses=BrickSetStatusList.new().list(),
-        brickset_tags=BrickSetTagList.new().list(),
+        brickset_owners=BrickSetOwnerList.list(),
+        brickset_statuses=BrickSetStatusList.list(),
+        brickset_storages=BrickSetStorageList.list(as_class=True),
+        brickset_tags=BrickSetTagList.list(),
     )
 
 
@@ -46,7 +48,7 @@ def list() -> str:
 @exception_handler(__file__, json=True)
 def update_owner(*, id: str, metadata_id: str) -> Response:
     brickset = BrickSet().select_light(id)
-    owner = BrickSetOwnerList.new().get(metadata_id)
+    owner = BrickSetOwnerList.get(metadata_id)
 
     state = owner.update_set_state(brickset, json=request.json)
 
@@ -59,20 +61,36 @@ def update_owner(*, id: str, metadata_id: str) -> Response:
 @exception_handler(__file__, json=True)
 def update_status(*, id: str, metadata_id: str) -> Response:
     brickset = BrickSet().select_light(id)
-    status = BrickSetStatusList.new().get(metadata_id)
+    status = BrickSetStatusList.get(metadata_id)
 
     state = status.update_set_state(brickset, json=request.json)
 
     return jsonify({'value': state})
 
 
+# Change the state of a storage
+@set_page.route('/<id>/storage', methods=['POST'])
+@login_required
+@exception_handler(__file__, json=True)
+def update_storage(*, id: str) -> Response:
+    brickset = BrickSet().select_light(id)
+    storage = BrickSetStorageList.get(
+        request.json.get('value', ''),  # type: ignore
+        allow_none=True
+    )
+
+    state = storage.update_set_value(brickset, state=storage.fields.id)
+
+    return jsonify({'value': state})
+
+
 # Change the state of a tag
 @set_page.route('/<id>/tag/<metadata_id>', methods=['POST'])
 @login_required
 @exception_handler(__file__, json=True)
 def update_tag(*, id: str, metadata_id: str) -> Response:
     brickset = BrickSet().select_light(id)
-    tag = BrickSetTagList.new().get(metadata_id)
+    tag = BrickSetTagList.get(metadata_id)
 
     state = tag.update_set_state(brickset, json=request.json)
 
@@ -127,9 +145,10 @@ def details(*, id: str) -> str:
         'set.html',
         item=BrickSet().select_specific(id),
         open_instructions=request.args.get('open_instructions'),
-        brickset_owners=BrickSetOwnerList.new().list(),
-        brickset_statuses=BrickSetStatusList.new().list(all=True),
-        brickset_tags=BrickSetTagList.new().list(),
+        brickset_owners=BrickSetOwnerList.list(),
+        brickset_statuses=BrickSetStatusList.list(all=True),
+        brickset_storages=BrickSetStorageList.list(as_class=True),
+        brickset_tags=BrickSetTagList.list(),
     )
 
 
diff --git a/static/scripts/socket/set.js b/static/scripts/socket/set.js
index d561261e..311eac50 100644
--- a/static/scripts/socket/set.js
+++ b/static/scripts/socket/set.js
@@ -16,6 +16,7 @@ class BrickSetSocket extends BrickSocket {
         this.html_input = document.getElementById(`${id}-set`);
         this.html_no_confim = document.getElementById(`${id}-no-confirm`);
         this.html_owners = document.getElementById(`${id}-owners`);
+        this.html_storage = document.getElementById(`${id}-storage`);
         this.html_tags = document.getElementById(`${id}-tags`);
 
         // Card elements
@@ -151,6 +152,12 @@ class BrickSetSocket extends BrickSocket {
                 });
             }
 
+            // Grab the storage
+            let storage = null;
+            if (this.html_storage) {
+                storage = this.html_storage.value;
+            }
+
             // Grab the tags
             const tags = [];
             if (this.html_tags) {
@@ -170,6 +177,7 @@ class BrickSetSocket extends BrickSocket {
             this.socket.emit(this.messages.IMPORT_SET, {
                 set: (set !== undefined) ? set : this.html_input.value,
                 owners: owners,
+                storage: storage,
                 tags: tags,
                 refresh: this.refresh
             });
@@ -285,6 +293,10 @@ class BrickSetSocket extends BrickSocket {
             this.html_owners.querySelectorAll('input').forEach(input => input.disabled = !enabled);
         }
 
+        if (this.html_storage) {
+            this.html_storage.disabled = !enabled;
+        }
+
         if (this.html_tags) {
             this.html_tags.querySelectorAll('input').forEach(input => input.disabled = !enabled);
         }
diff --git a/templates/add.html b/templates/add.html
index 9f33c059..9a0deebf 100644
--- a/templates/add.html
+++ b/templates/add.html
@@ -51,6 +51,19 @@
               </div>
               {{ accordion.footer() }}
             {% endif %}
+            {% if brickset_storages | length %}
+              {{ accordion.header('Storage', 'storage', 'metadata', icon='archive-2-line') }}
+              <label class="visually-hidden" for="storage">{{ name }}</label>
+              <div class="input-group">
+                <select id="add-storage" class="form-select" autocomplete="off">
+                  <option value="" selected><i>None</i></option>
+                  {% for storage in brickset_storages %}
+                    <option value="{{ storage.fields.id }}">{{ storage.fields.name }}</option>
+                  {% endfor %}
+                </select>
+              </div>
+              {{ accordion.footer() }}
+            {% endif %}
             {% if brickset_tags | length %}
               {{ accordion.header('Tags', 'tags', 'metadata', icon='price-tag-2-line') }}
               <div id="add-tags">
diff --git a/templates/admin.html b/templates/admin.html
index 064526d8..3582c7b6 100644
--- a/templates/admin.html
+++ b/templates/admin.html
@@ -18,6 +18,8 @@
             {% include 'admin/owner/delete.html' %}
             {% elif delete_status %}
             {% include 'admin/status/delete.html' %}
+            {% elif delete_storage %}
+            {% include 'admin/storage/delete.html' %}
             {% elif delete_tag %}
             {% include 'admin/tag/delete.html' %}
             {% elif drop_database %}
@@ -36,6 +38,7 @@
               {% include 'admin/retired.html' %}
               {% include 'admin/owner.html' %}
               {% include 'admin/status.html' %}
+              {% include 'admin/storage.html' %}
               {% include 'admin/tag.html' %}
               {% include 'admin/database.html' %}
               {% include 'admin/configuration.html' %}
diff --git a/templates/admin/storage.html b/templates/admin/storage.html
new file mode 100644
index 00000000..1f317e6c
--- /dev/null
+++ b/templates/admin/storage.html
@@ -0,0 +1,42 @@
+{% import 'macro/accordion.html' as accordion %}
+
+{{ accordion.header('Set storages', 'storage', 'admin', expanded=open_storage, icon='archive-2-line', class='p-0') }}
+{% if storage_error %}<div class="alert alert-danger m-2" role="alert"><strong>Error:</strong> {{ storage_error }}.</div>{% endif %}
+<ul class="list-group list-group-flush">
+  {% if metadata_storages | length %}
+    {% for storage in metadata_storages %}
+      <li class="list-group-item">
+        <form action="{{ url_for('admin_storage.rename', id=storage.fields.id) }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
+          <div class="col-12 flex-grow-1">
+            <label class="visually-hidden" for="name-{{ storage.fields.id }}">Name</label>
+            <div class="input-group">
+              <div class="input-group-text">Name</div>
+              <input type="text" class="form-control" id="name-{{ storage.fields.id }}" name="name" value="{{ storage.fields.name }}">
+              <button type="submit" class="btn btn-primary"><i class="ri-edit-line"></i> Rename</button>
+            </div>
+          </div>
+          <div class="col-12">
+            <a href="{{ url_for('admin_storage.delete', id=storage.fields.id) }}" class="btn btn-danger" role="button"><i class="ri-delete-bin-2-line"></i> Delete</a>
+          </div>
+        </form>
+      </li>
+    {% endfor %}
+  {% else %}
+    <li class="list-group-item text-center"><i class="ri-error-warning-line"></i> No storage found.</li>
+  {% endif %}
+  <li class="list-group-item">
+    <form action="{{ url_for('admin_storage.add') }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
+      <div class="col-12 flex-grow-1">
+        <label class="visually-hidden" for="name">Name</label>
+        <div class="input-group">
+          <div class="input-group-text">Name</div>
+          <input type="text" class="form-control" id="name" name="name" value="">
+        </div>
+      </div>
+      <div class="col-12">
+        <button type="submit" class="btn btn-primary"><i class="ri-add-circle-line"></i> Add</button>
+      </div>
+    </form>
+  </li>
+</ul>
+{{ accordion.footer() }}
diff --git a/templates/admin/storage/delete.html b/templates/admin/storage/delete.html
new file mode 100644
index 00000000..b3eb990c
--- /dev/null
+++ b/templates/admin/storage/delete.html
@@ -0,0 +1,19 @@
+{% import 'macro/accordion.html' as accordion %}
+
+{{ accordion.header('Set storages danger zone', 'storage-danger', 'admin', expanded=true, danger=true, class='text-end') }}
+<form action="{{ url_for('admin_storage.do_delete', id=storage.fields.id) }}" method="post">
+  {% if storage_error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ storage_error }}.</div>{% endif %}
+  <div class="alert alert-danger text-center" role="alert">You are about to <strong>delete a set storage</strong>. This action is irreversible.</div>
+  <div class="row row-cols-lg-auto g-3 align-items-center">
+    <div class="col-12 flex-grow-1">
+      <div class="input-group">
+        <div class="input-group-text">Name</div>
+        <input type="text" class="form-control" value="{{ storage.fields.name }}" disabled>
+      </div>
+    </div>
+  </div>
+  <hr class="border-bottom">
+  <a class="btn btn-danger" href="{{ url_for('admin.admin', open_storage=true) }}" role="button"><i class="ri-arrow-left-long-line"></i> Back to the admin</a>
+  <button type="submit" class="btn btn-danger"><i class="ri-delete-bin-2-line"></i> Delete <strong>the set storage</strong></button>
+</form>
+{{ accordion.footer() }}
diff --git a/templates/macro/badge.html b/templates/macro/badge.html
index a3ea6d7c..dfc93840 100644
--- a/templates/macro/badge.html
+++ b/templates/macro/badge.html
@@ -72,6 +72,18 @@
   {{ badge(check=set, url=url, solo=solo, last=last, color='secondary', icon='hashtag', collapsible='Set:', text=set, alt='Set') }}
 {% endmacro %}
 
+{% macro storage(item, storages, solo=false, last=false) %}
+  {% if item.fields.storage in storages.mapping %}
+    {% set storage = storages.mapping[item.fields.storage] %}
+    {% if last %}
+      {% set tooltip=storage.fields.name %}
+    {% else %}
+      {% set text=storage.fields.name %}
+    {% endif %}
+    {{ badge(check=storage, solo=solo, last=last, color='light text-warning-emphasis bg-warning-subtle border border-warning-subtle', icon='archive-2-line', text=text, alt='Storage', tooltip=tooltip) }}
+  {% endif %}
+{% endmacro %}
+
 {% macro tag(item, tag, solo=false, last=false) %}
   {% if last %}
     {% set tooltip=tag.fields.name %}
diff --git a/templates/macro/form.html b/templates/macro/form.html
index b93ac174..d3d35128 100644
--- a/templates/macro/form.html
+++ b/templates/macro/form.html
@@ -39,3 +39,27 @@
     </div>
   {% endif %}
 {% endmacro %}
+
+{% macro select(name, item, field, metadata_list, nullable=true, icon=none, delete=false) %}
+  {% if g.login.is_authenticated() %}
+    {% set prefix=metadata_list.as_prefix() %}
+    <label class="visually-hidden" for="{{ prefix }}-{{ item.fields.id }}">{{ name }}</label>
+    <div class="input-group">
+      {% if icon %}<span class="input-group-text"><i class="ri-{{ icon }}"></i></span>{% endif %}
+      <select id="{{ prefix }}-{{ item.fields.id }}" class="form-select"
+        {% if not delete %}
+          data-changer-id="{{ item.fields.id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ metadata_list.url_for_set_state(item.fields.id) }}"
+        {% else %}
+          disabled
+        {% endif %}
+        autocomplete="off">
+        {% if nullable %}<option value="" {% if item.fields[field] is none %}selected{% endif %}><i>None</i></option>{% endif %}
+        {% for metadata in metadata_list %}
+          <option value="{{ metadata.fields.id }}" {% if metadata.fields.id == item.fields[field] %}selected{% endif %}>{{ metadata.fields.name }}</option>
+        {% endfor %}
+      </select>
+      <span id="status-{{ prefix }}-{{ item.fields.id }}" class="input-group-text ri-save-line"></span>
+      <button id="clear-{{ prefix }}-{{ item.fields.id }}" type="button" class="btn btn-sm btn-light btn-outline-danger border"><i class="ri-eraser-line"></i></button>
+    </div>
+  {% endif %}
+{% endmacro %}
diff --git a/templates/set/card.html b/templates/set/card.html
index 73a90a5c..a3270526 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -48,7 +48,8 @@
     {{ badge.total_damaged(item.fields.total_damaged, solo=solo, last=last) }}
     {% for owner in brickset_owners %}
       {{ badge.owner(item, owner, solo=solo, last=last) }}
-    {% endfor %}
+      {% endfor %}
+    {{ badge.storage(item, brickset_storages, solo=solo, last=last) }}
     {% if not last %}
       {% if not solo %}
         {{ badge.instructions(item, solo=solo, last=last) }}

From 103c3c3017785c2eb69fd04c7f78b1102bacaddd Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 16:47:09 +0100
Subject: [PATCH 113/154] Additional socket debug messages

---
 bricktracker/socket.py | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/bricktracker/socket.py b/bricktracker/socket.py
index 7aedaf26..99cd6258 100644
--- a/bricktracker/socket.py
+++ b/bricktracker/socket.py
@@ -109,10 +109,20 @@ class BrickSocket(object):
         @self.socket.on(MESSAGES['IMPORT_SET'], namespace=self.namespace)
         @rebrickable_socket(self)
         def import_set(data: dict[str, Any], /) -> None:
+            logger.debug('Socket: IMPORT_SET={data} (from: {fr})'.format(
+                data=data,
+                fr=request.sid,  # type: ignore
+            ))
+
             BrickSet().download(self, data)
 
         @self.socket.on(MESSAGES['LOAD_SET'], namespace=self.namespace)
         def load_set(data: dict[str, Any], /) -> None:
+            logger.debug('Socket: LOAD_SET={data} (from: {fr})'.format(
+                data=data,
+                fr=request.sid,  # type: ignore
+            ))
+
             BrickSet().load(self, data)
 
     # Update the progress auto-incrementing

From 714e84ea09fdde7902172421336a5391366f0beb Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 16:47:26 +0100
Subject: [PATCH 114/154] Missed set storage management

---
 templates/set/management.html | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/templates/set/management.html b/templates/set/management.html
index f80830bd..54b2e879 100644
--- a/templates/set/management.html
+++ b/templates/set/management.html
@@ -13,6 +13,15 @@
       <a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_owner=true) }}"><i class="ri-settings-4-line"></i> Manage the set owners</a>
     </div>
   {{ accordion.footer() }}
+  {{ accordion.header('Storage', 'storage', 'set-details', icon='archive-2-line') }}
+    {% if brickset_storages | length %}
+      {{ form.select('Storage', item, 'storage', brickset_storages, delete=delete) }}
+    {% else %}
+      <p class="text-center"><i class="ri-error-warning-line"></i> No storage found.</p>
+    {% endif %}
+    <hr>
+    <a href="{{ url_for('admin.admin', open_tag=true) }}" class="btn btn-primary" role="button"><i class="ri-settings-4-line"></i> Manage the set storages</a>
+  {{ accordion.footer() }}
   {{ accordion.header('Tags', 'tag', 'set-details', icon='price-tag-2-line', class='p-0') }}
     <ul class="list-group list-group-flush">
     {% if brickset_tags | length %}

From 56c926a8ef65d1ef5cfdb96daa450b031f2639e6 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 16:47:35 +0100
Subject: [PATCH 115/154] Cosmetics

---
 templates/macro/form.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/templates/macro/form.html b/templates/macro/form.html
index d3d35128..6fb38902 100644
--- a/templates/macro/form.html
+++ b/templates/macro/form.html
@@ -9,7 +9,7 @@
     {% endif %}
       autocomplete="off">
     <label class="form-check-label flex-grow-1 ms-1" for="{{ prefix }}-{{ item.fields.id }}">
-      {{ metadata.fields.name }} <i id="status-{{ prefix }}-{{ item.fields.id }}" class="mb-1"></i>
+      {{ metadata.fields.name }} <i id="status-{{ prefix }}-{{ item.fields.id }}"></i>
     </label>
   {% else %}
     <input class="form-check-input text-reset" type="checkbox" {% if item.fields[metadata.as_column()] %}checked{% endif %} disabled>

From ac2d2a0b5d9bf0c52f0660754e10aa25fc1949d0 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 17:09:59 +0100
Subject: [PATCH 116/154] Storage filterable and searchable on the Grid

---
 templates/set/card.html |  5 +++++
 templates/sets.html     | 19 ++++++++++++++++++-
 2 files changed, 23 insertions(+), 1 deletion(-)

diff --git a/templates/set/card.html b/templates/set/card.html
index a3270526..3585acc4 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -11,6 +11,11 @@
     data-has-minifigures="{{ (item.fields.total_minifigures > 0) | int }}" data-minifigures="{{ item.fields.total_minifigures }}"
     data-has-missing="{{ (item.fields.total_missing > 0) | int }}" data-missing="{{ item.fields.total_missing }}"
     data-has-damaged="{{ (item.fields.total_damaged > 0) | int }}" data-damaged="{{ item.fields.total_damaged }}"
+    data-has-storage="{{ item.fields.storage is not none | int }}"
+    {% if item.fields.storage is not none %}
+      data-storage="{{ item.fields.storage }}"
+      {% if item.fields.storage in brickset_storages.mapping %}data-search-storage="{{ brickset_storages.mapping[item.fields.storage].fields.name | lower }}"{% endif %}
+    {% endif %}
     {% for status in brickset_statuses %}
       {% with checked=item.fields[status.as_column()] %}
         {% if checked %}
diff --git a/templates/sets.html b/templates/sets.html
index dc1c10ef..dc0a5574 100644
--- a/templates/sets.html
+++ b/templates/sets.html
@@ -10,7 +10,7 @@
       <label class="visually-hidden" for="grid-search">Search</label>
       <div class="input-group">
         <span class="input-group-text"><i class="ri-search-line"></i><span class="ms-1 d-none d-xl-inline"> Search</span></span>
-        <input id="grid-search" data-search-exact="name,number,parts,theme,year" data-search-list="searchOwner,searchTag" class="form-control form-control-sm" type="text" placeholder="Set, name, number of parts, theme, year, owner, tag" value="">
+        <input id="grid-search" data-search-exact="name,number,parts,searchStorage,theme,year" data-search-list="searchOwner,searchTag" class="form-control form-control-sm" type="text" placeholder="Set, name, number of parts, theme, year, owner, storage, tag" value="">
       </div>
     </div>
     <div class="col-12">
@@ -57,6 +57,7 @@
           <option value="has-missing">Set has missing pieces</option>
           <option value="has-damaged">Set has damaged pieces</option>
           <option value="has-missing-instructions">Set has missing instructions</option>
+          {% if brickset_storage | length %}<option value="has-storage">Is in storage</option>{% endif %}
           {% for status in brickset_statuses %}
             <option value="{{ status.as_dataset() }}">{{ status.fields.name }}</option>
             <option value="-{{ status.as_dataset() }}">NOT: {{ status.fields.name }}</option>
@@ -92,6 +93,22 @@
         </select>
       </div>
     </div>
+    {% if brickset_storages | length %}
+      <div class="col-12 flex-grow-1">
+        <label class="visually-hidden" for="grid-owner">Storage</label>
+        <div class="input-group">
+          <span class="input-group-text"><i class="ri-archive-2-line"></i><span class="ms-1 d-none d-xl-inline"> Storage</span></span>
+          <select id="grid-storage" class="form-select"
+            data-filter="value" data-filter-attribute="storage"
+            autocomplete="off">
+            <option value="" selected>All</option>
+            {% for storage in brickset_storages %}
+              <option value="{{ storage.fields.id }}">{{ storage.fields.name }}</option>
+            {% endfor %}
+          </select>
+        </div>
+      </div>
+    {% endif %}
     <div class="col-12 flex-grow-1">
       <label class="visually-hidden" for="grid-tag">Tag</label>
       <div class="input-group">

From d45070eb74c57e6365dffb46f3e08526f9855166 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 17:10:13 +0100
Subject: [PATCH 117/154] Display metadata filters only if they have values

---
 templates/sets.html | 56 ++++++++++++++++++++++++---------------------
 1 file changed, 30 insertions(+), 26 deletions(-)

diff --git a/templates/sets.html b/templates/sets.html
index dc0a5574..943f7941 100644
--- a/templates/sets.html
+++ b/templates/sets.html
@@ -79,20 +79,22 @@
         </select>
       </div>
     </div>
-    <div class="col-12 flex-grow-1">
-      <label class="visually-hidden" for="grid-owner">Owner</label>
-      <div class="input-group">
-        <span class="input-group-text"><i class="ri-user-line"></i><span class="ms-1 d-none d-xl-inline"> Owner</span></span>
-        <select id="grid-owner" class="form-select"
-          data-filter="metadata"
-          autocomplete="off">
-          <option value="" selected>All</option>
-          {% for owner in brickset_owners %}
-            <option value="{{ owner.as_dataset() }}">{{ owner.fields.name }}</option>
-          {% endfor %}
-        </select>
+    {% if brickset_owners | length %}
+      <div class="col-12 flex-grow-1">
+        <label class="visually-hidden" for="grid-owner">Owner</label>
+        <div class="input-group">
+          <span class="input-group-text"><i class="ri-user-line"></i><span class="ms-1 d-none d-xl-inline"> Owner</span></span>
+          <select id="grid-owner" class="form-select"
+            data-filter="metadata"
+            autocomplete="off">
+            <option value="" selected>All</option>
+            {% for owner in brickset_owners %}
+              <option value="{{ owner.as_dataset() }}">{{ owner.fields.name }}</option>
+            {% endfor %}
+          </select>
+        </div>
       </div>
-    </div>
+    {% endif %}
     {% if brickset_storages | length %}
       <div class="col-12 flex-grow-1">
         <label class="visually-hidden" for="grid-owner">Storage</label>
@@ -109,20 +111,22 @@
         </div>
       </div>
     {% endif %}
-    <div class="col-12 flex-grow-1">
-      <label class="visually-hidden" for="grid-tag">Tag</label>
-      <div class="input-group">
-        <span class="input-group-text"><i class="ri-price-tag-2-line"></i><span class="ms-1 d-none d-xl-inline"> Tag</span></span>
-        <select id="grid-tag" class="form-select"
-          data-filter="metadata"
-          autocomplete="off">
-          <option value="" selected>All</option>
-          {% for tag in brickset_tags %}
-            <option value="{{ tag.as_dataset() }}">{{ tag.fields.name }}</option>
-          {% endfor %}
-        </select>
+    {% if brickset_tags | length %}
+      <div class="col-12 flex-grow-1">
+        <label class="visually-hidden" for="grid-tag">Tag</label>
+        <div class="input-group">
+          <span class="input-group-text"><i class="ri-price-tag-2-line"></i><span class="ms-1 d-none d-xl-inline"> Tag</span></span>
+          <select id="grid-tag" class="form-select"
+            data-filter="metadata"
+            autocomplete="off">
+            <option value="" selected>All</option>
+            {% for tag in brickset_tags %}
+              <option value="{{ tag.as_dataset() }}">{{ tag.fields.name }}</option>
+            {% endfor %}
+          </select>
+        </div>
       </div>
-    </div>
+    {% endif %}
   </div>
   <div class="row" data-grid="true" id="grid">
   {% for item in collection %}

From 561720343b746ffc6bbdd87b45b2f663b41df1a9 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 17:12:03 +0100
Subject: [PATCH 118/154] Remove year from tiny cards

---
 templates/set/card.html | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/templates/set/card.html b/templates/set/card.html
index 3585acc4..126c9b38 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -46,7 +46,9 @@
     {% for tag in brickset_tags %}
       {{ badge.tag(item, tag, solo=solo, last=last) }}
     {% endfor %}
-    {{ badge.year(item.fields.year, solo=solo, last=last) }}
+    {% if not last %}
+      {{ badge.year(item.fields.year, solo=solo, last=last) }}
+    {% endif %}
     {{ badge.parts(item.fields.number_of_parts, solo=solo, last=last) }}
     {{ badge.total_minifigures(item.fields.total_minifigures, solo=solo, last=last) }}
     {{ badge.total_missing(item.fields.total_missing, solo=solo, last=last) }}

From d8046ac1744c6075d467f8ec24f090ee44daa708 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 17:38:39 +0100
Subject: [PATCH 119/154] Add missing metadata for set loaded from minifigures
 or parts

---
 bricktracker/set_list.py         | 20 ++++++++++++++++----
 bricktracker/views/minifigure.py |  6 ++++++
 bricktracker/views/part.py       |  6 ++++++
 templates/macro/badge.html       |  2 +-
 templates/minifigure/card.html   |  2 +-
 templates/part/card.html         |  2 +-
 6 files changed, 31 insertions(+), 7 deletions(-)

diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py
index e25594ca..ffc436d2 100644
--- a/bricktracker/set_list.py
+++ b/bricktracker/set_list.py
@@ -130,7 +130,10 @@ class BrickSetList(BrickRecordList[BrickSet]):
         # Load the sets from the database
         for record in self.select(
             override_query=self.missing_minifigure_query,
-            order=self.order
+            order=self.order,
+            owners=BrickSetOwnerList.as_columns(),
+            statuses=BrickSetStatusList.as_columns(),
+            tags=BrickSetTagList.as_columns(),
         ):
             brickset = BrickSet(record=record)
 
@@ -147,7 +150,10 @@ class BrickSetList(BrickRecordList[BrickSet]):
         # Load the sets from the database
         for record in self.select(
             override_query=self.missing_part_query,
-            order=self.order
+            order=self.order,
+            owners=BrickSetOwnerList.as_columns(),
+            statuses=BrickSetStatusList.as_columns(),
+            tags=BrickSetTagList.as_columns(),
         ):
             brickset = BrickSet(record=record)
 
@@ -163,7 +169,10 @@ class BrickSetList(BrickRecordList[BrickSet]):
         # Load the sets from the database
         for record in self.select(
             override_query=self.using_minifigure_query,
-            order=self.order
+            order=self.order,
+            owners=BrickSetOwnerList.as_columns(),
+            statuses=BrickSetStatusList.as_columns(),
+            tags=BrickSetTagList.as_columns(),
         ):
             brickset = BrickSet(record=record)
 
@@ -180,7 +189,10 @@ class BrickSetList(BrickRecordList[BrickSet]):
         # Load the sets from the database
         for record in self.select(
             override_query=self.using_part_query,
-            order=self.order
+            order=self.order,
+            owners=BrickSetOwnerList.as_columns(),
+            statuses=BrickSetStatusList.as_columns(),
+            tags=BrickSetTagList.as_columns(),
         ):
             brickset = BrickSet(record=record)
 
diff --git a/bricktracker/views/minifigure.py b/bricktracker/views/minifigure.py
index 5d9cc85f..99587287 100644
--- a/bricktracker/views/minifigure.py
+++ b/bricktracker/views/minifigure.py
@@ -3,7 +3,10 @@ from flask import Blueprint, render_template
 from .exceptions import exception_handler
 from ..minifigure import BrickMinifigure
 from ..minifigure_list import BrickMinifigureList
+from ..set_owner_list import BrickSetOwnerList
 from ..set_list import BrickSetList
+from ..set_storage_list import BrickSetStorageList
+from ..set_tag_list import BrickSetTagList
 
 minifigure_page = Blueprint('minifigure', __name__, url_prefix='/minifigures')
 
@@ -28,4 +31,7 @@ def details(*, figure: str) -> str:
         using=BrickSetList().using_minifigure(figure),
         missing=BrickSetList().missing_minifigure(figure),
         damaged=BrickSetList().damaged_minifigure(figure),
+        brickset_owners=BrickSetOwnerList.list(),
+        brickset_storages=BrickSetStorageList.list(as_class=True),
+        brickset_tags=BrickSetTagList.list(),
     )
diff --git a/bricktracker/views/part.py b/bricktracker/views/part.py
index 7cbc1c80..b2a9eedd 100644
--- a/bricktracker/views/part.py
+++ b/bricktracker/views/part.py
@@ -4,7 +4,10 @@ from .exceptions import exception_handler
 from ..minifigure_list import BrickMinifigureList
 from ..part import BrickPart
 from ..part_list import BrickPartList
+from ..set_owner_list import BrickSetOwnerList
 from ..set_list import BrickSetList
+from ..set_storage_list import BrickSetStorageList
+from ..set_tag_list import BrickSetTagList
 
 part_page = Blueprint('part', __name__, url_prefix='/parts')
 
@@ -64,4 +67,7 @@ def details(*, part: str, color: int) -> str:
         ),
         different_color=BrickPartList().with_different_color(brickpart),
         similar_prints=BrickPartList().from_print(brickpart),
+        brickset_owners=BrickSetOwnerList.list(),
+        brickset_storages=BrickSetStorageList.list(as_class=True),
+        brickset_tags=BrickSetTagList.list(),
     )
diff --git a/templates/macro/badge.html b/templates/macro/badge.html
index dfc93840..bd683f40 100644
--- a/templates/macro/badge.html
+++ b/templates/macro/badge.html
@@ -73,7 +73,7 @@
 {% endmacro %}
 
 {% macro storage(item, storages, solo=false, last=false) %}
-  {% if item.fields.storage in storages.mapping %}
+  {% if storages and item.fields.storage in storages.mapping %}
     {% set storage = storages.mapping[item.fields.storage] %}
     {% if last %}
       {% set tooltip=storage.fields.name %}
diff --git a/templates/minifigure/card.html b/templates/minifigure/card.html
index 2cba9b4c..812c2cd5 100644
--- a/templates/minifigure/card.html
+++ b/templates/minifigure/card.html
@@ -1,4 +1,4 @@
-{% import 'macro/accordion.html' as accordion %}
+{% import 'macro/accordion.html' as accordion with context %}
 {% import 'macro/badge.html' as badge %}
 {% import 'macro/card.html' as card %}
 
diff --git a/templates/part/card.html b/templates/part/card.html
index 16f2103e..a83a2f7f 100644
--- a/templates/part/card.html
+++ b/templates/part/card.html
@@ -1,4 +1,4 @@
-{% import 'macro/accordion.html' as accordion %}
+{% import 'macro/accordion.html' as accordion with context %}
 {% import 'macro/badge.html' as badge %}
 {% import 'macro/card.html' as card %}
 

From 38e664b733b730fbe5ae90ca4dae18fc5de1c629 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 17:38:54 +0100
Subject: [PATCH 120/154] Don't load card dataset for tiny cards

---
 templates/set/card.html | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/templates/set/card.html b/templates/set/card.html
index 126c9b38..f47a7ca3 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -4,7 +4,7 @@
 {% import 'macro/form.html' as form %}
 
 <div {% if not solo %}id="set-{{ item.fields.id }}"{% endif %} class="card mb-3 flex-fill {% if solo %}card-solo{% endif %}"
-  {% if not solo %}
+  {% if not solo and not tiny %}
     data-index="{{ index }}" data-number="{{ item.fields.set }}" data-name="{{ item.fields.name | lower }}" data-parts="{{ item.fields.number_of_parts }}"
     data-year="{{ item.fields.year }}" data-theme="{{ item.theme.name | lower }}"
     data-has-missing-instructions="{{ (not (item.instructions | length)) | int }}"
@@ -55,7 +55,7 @@
     {{ badge.total_damaged(item.fields.total_damaged, solo=solo, last=last) }}
     {% for owner in brickset_owners %}
       {{ badge.owner(item, owner, solo=solo, last=last) }}
-      {% endfor %}
+    {% endfor %}
     {{ badge.storage(item, brickset_storages, solo=solo, last=last) }}
     {% if not last %}
       {% if not solo %}

From 9b55fd5e33234abc9de9d5a811d81a46f8499096 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 18:07:03 +0100
Subject: [PATCH 121/154] Fix storage status filters

---
 templates/sets.html | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/templates/sets.html b/templates/sets.html
index 943f7941..236c3785 100644
--- a/templates/sets.html
+++ b/templates/sets.html
@@ -57,7 +57,10 @@
           <option value="has-missing">Set has missing pieces</option>
           <option value="has-damaged">Set has damaged pieces</option>
           <option value="has-missing-instructions">Set has missing instructions</option>
-          {% if brickset_storage | length %}<option value="has-storage">Is in storage</option>{% endif %}
+          {% if brickset_storages | length %}
+            <option value="has-storage">Is in storage</option>
+            <option value="-has-storage">Is NOT in storage</option>
+          {% endif %}
           {% for status in brickset_statuses %}
             <option value="{{ status.as_dataset() }}">{{ status.fields.name }}</option>
             <option value="-{{ status.as_dataset() }}">NOT: {{ status.fields.name }}</option>

From 0e485ddb71af1a118f6dbf9dbec3f43dbd0f0b22 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 18:07:56 +0100
Subject: [PATCH 122/154] Collapsible sort on the grid

---
 .env.sample            |  4 ++++
 CHANGELOG.md           |  3 +++
 bricktracker/config.py |  1 +
 templates/sets.html    | 25 +++++++++++++++++--------
 4 files changed, 25 insertions(+), 8 deletions(-)

diff --git a/.env.sample b/.env.sample
index 0bfc3f02..b93e8c6b 100644
--- a/.env.sample
+++ b/.env.sample
@@ -239,6 +239,10 @@
 # Default: false
 # BK_SHOW_GRID_FILTERS=true
 
+# Optional: Make the grid sort displayed by default, rather than collapsed
+# Default: false
+# BK_SHOW_GRID_SORT=true
+
 # Optional: Skip saving or displaying spare parts
 # Default: false
 # BK_SKIP_SPARE_PARTS=true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8a1c34d7..5ed15af7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,8 @@
 - Renamed: `BK_HIDE_MISSING_PARTS` -> `BK_HIDE_ALL_PROBLEMS_PARTS`
 - Added: `BK_HIDE_TABLE_MISSING_PARTS`, hide the Missing column in all tables
 - Added: `BK_HIDE_TABLE_DAMAGED_PARTS`, hide the Damaged column in all tables
+- Added: `BK_SHOW_GRID_SORT`, show the sort options on the grid by default
+- Added: `BK_SHOW_GRID_FILTERS`, show the filter options on the grid by default
 
 ### Code
 
@@ -82,6 +84,7 @@ Parts
 - Sets grid
     - Collapsible controls depending on screen size
     - Manually collapsible filters (with configuration variable for default state)
+    - Manually collapsible sort (with configuration variable for default state)
 
 ## 1.1.1: PDF Instructions Download
 
diff --git a/bricktracker/config.py b/bricktracker/config.py
index 62e23a13..cd7ef74a 100644
--- a/bricktracker/config.py
+++ b/bricktracker/config.py
@@ -55,6 +55,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
     {'n': 'SETS_DEFAULT_ORDER', 'd': '"rebrickable_sets"."number" DESC, "rebrickable_sets"."version" ASC'},  # noqa: E501
     {'n': 'SETS_FOLDER', 'd': 'sets', 's': True},
     {'n': 'SHOW_GRID_FILTERS', 'c': bool},
+    {'n': 'SHOW_GRID_SORT', 'c': bool},
     {'n': 'SKIP_SPARE_PARTS', 'c': bool},
     {'n': 'SOCKET_NAMESPACE', 'd': 'bricksocket'},
     {'n': 'SOCKET_PATH', 'd': '/bricksocket/'},
diff --git a/templates/sets.html b/templates/sets.html
index 236c3785..60101b54 100644
--- a/templates/sets.html
+++ b/templates/sets.html
@@ -14,7 +14,23 @@
       </div>
     </div>
     <div class="col-12">
-      <div id="grid-sort" class="input-group">
+      <div class="input-group">
+        <button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#grid-sort" aria-expanded="{% if config['SHOW_GRID_SORT'] %}true{% else %}false{% endif %}" aria-controls="grid-sort">
+          <i class="ri-sort-asc"></i> Sort
+        </button>
+      </div>
+    </div>
+    <div class="col-12">
+      <div class="input-group">
+        <button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#grid-filter" aria-expanded="{% if config['SHOW_GRID_FILTERS'] %}true{% else %}false{% endif %}" aria-controls="grid-filter">
+          <i class="ri-filter-line"></i> Filters
+        </button>
+      </div>
+    </div>
+  </div>
+  <div id="grid-sort" class="collapse {% if config['SHOW_GRID_SORT'] %}show{% endif %} row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
+    <div class="col-12 flex-grow-1">
+      <div class="input-group">
         <span class="input-group-text"><i class="ri-sort-asc"></i><span class="ms-1 d-none d-xxl-inline"> Sort</span></span>
         <button id="sort-number" type="button" class="btn btn-outline-primary"
           data-sort-attribute="number" data-sort-natural="true"><i class="ri-hashtag"></i><span class="d-none d-xxl-inline"> Set</span></button>
@@ -36,13 +52,6 @@
           data-sort-clear="true"><i class="ri-close-circle-line"></i><span class="d-none d-xxl-inline"> Clear</span></button>
       </div>
     </div>
-    <div class="col-12">
-      <div class="input-group">
-        <button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#grid-filter" aria-expanded="{% if config['SHOW_GRID_FILTERS'] %}true{% else %}false{% endif %}" aria-controls="grid-filter">
-          <i class="ri-filter-line"></i> Filters
-        </button>
-      </div>
-    </div>
   </div>
   <div id="grid-filter" class="collapse {% if config['SHOW_GRID_FILTERS'] %}show{% endif %} row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
     <div class="col-12 flex-grow-1">

From 8ad525926a2214b0d9e613701aa99e15943f183d Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 18:12:31 +0100
Subject: [PATCH 123/154] Fix metadata storage deletion

---
 bricktracker/sql/set/metadata/storage/delete.sql | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/bricktracker/sql/set/metadata/storage/delete.sql b/bricktracker/sql/set/metadata/storage/delete.sql
index c50b348e..e1c5d226 100644
--- a/bricktracker/sql/set/metadata/storage/delete.sql
+++ b/bricktracker/sql/set/metadata/storage/delete.sql
@@ -1,6 +1,10 @@
 BEGIN TRANSACTION;
 
 DELETE FROM "bricktracker_metadata_storages"
-WHERE "bricktracker_metadata_statuses"."id" IS NOT DISTINCT FROM '{{ id }}';
+WHERE "bricktracker_metadata_storages"."id" IS NOT DISTINCT FROM '{{ id }}';
+
+UPDATE "bricktracker_sets"
+SET "storage" = NULL
+WHERE "bricktracker_sets"."storage" IS NOT DISTINCT FROM '{{ id }}';
 
 COMMIT;
\ No newline at end of file

From 8e40b1fd7e6e501bd5e73e39d4dbfd4739cf339a Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 22:20:43 +0100
Subject: [PATCH 124/154] Simplify BrickRecord based lists to deduplicate code

---
 bricktracker/minifigure_list.py |  74 +++++++++----------
 bricktracker/part_list.py       | 114 ++++++++++++-----------------
 bricktracker/set_list.py        | 122 ++++++++++----------------------
 3 files changed, 117 insertions(+), 193 deletions(-)

diff --git a/bricktracker/minifigure_list.py b/bricktracker/minifigure_list.py
index 24a4d2e1..fa735629 100644
--- a/bricktracker/minifigure_list.py
+++ b/bricktracker/minifigure_list.py
@@ -38,13 +38,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
 
     # Load all minifigures
     def all(self, /) -> Self:
-        for record in self.select(
-            override_query=self.all_query,
-            order=self.order
-        ):
-            minifigure = BrickMinifigure(record=record)
-
-            self.records.append(minifigure)
+        self.list(override_query=self.all_query)
 
         return self
 
@@ -55,13 +49,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
         self.fields.color = color
 
         # Load the minifigures from the database
-        for record in self.select(
-            override_query=self.damaged_part_query,
-            order=self.order
-        ):
-            minifigure = BrickMinifigure(record=record)
-
-            self.records.append(minifigure)
+        self.list(override_query=self.damaged_part_query)
 
         return self
 
@@ -73,27 +61,45 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
         else:
             order = '"bricktracker_minifigures"."rowid" DESC'
 
-        for record in self.select(
-            override_query=self.last_query,
-            order=order,
-            limit=limit
-        ):
-            minifigure = BrickMinifigure(record=record)
-
-            self.records.append(minifigure)
+        self.list(override_query=self.last_query, order=order, limit=limit)
 
         return self
 
+    # Base minifigure list
+    def list(
+        self,
+        /,
+        *,
+        override_query: str | None = None,
+        order: str | None = None,
+        limit: int | None = None,
+        **context: Any,
+    ) -> None:
+        if order is None:
+            order = self.order
+
+        if hasattr(self, 'brickset'):
+            brickset = self.brickset
+        else:
+            brickset = None
+
+        # Load the sets from the database
+        for record in super().select(
+            override_query=override_query,
+            order=order,
+            limit=limit,
+        ):
+            minifigure = BrickMinifigure(brickset=brickset, record=record)
+
+            self.records.append(minifigure)
+
     # Load minifigures from a brickset
     def from_set(self, brickset: 'BrickSet', /) -> Self:
         # Save the brickset
         self.brickset = brickset
 
         # Load the minifigures from the database
-        for record in self.select(order=self.order):
-            minifigure = BrickMinifigure(brickset=self.brickset, record=record)
-
-            self.records.append(minifigure)
+        self.list()
 
         return self
 
@@ -104,13 +110,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
         self.fields.color = color
 
         # Load the minifigures from the database
-        for record in self.select(
-            override_query=self.missing_part_query,
-            order=self.order
-        ):
-            minifigure = BrickMinifigure(record=record)
-
-            self.records.append(minifigure)
+        self.list(override_query=self.missing_part_query)
 
         return self
 
@@ -121,13 +121,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
         self.fields.color = color
 
         # Load the minifigures from the database
-        for record in self.select(
-            override_query=self.using_part_query,
-            order=self.order
-        ):
-            minifigure = BrickMinifigure(record=record)
-
-            self.records.append(minifigure)
+        self.list(override_query=self.using_part_query)
 
         return self
 
diff --git a/bricktracker/part_list.py b/bricktracker/part_list.py
index eb3f58d5..a12ef89e 100644
--- a/bricktracker/part_list.py
+++ b/bricktracker/part_list.py
@@ -42,16 +42,50 @@ class BrickPartList(BrickRecordList[BrickPart]):
 
     # Load all parts
     def all(self, /) -> Self:
-        for record in self.select(
-            override_query=self.all_query,
-            order=self.order
-        ):
-            part = BrickPart(record=record)
-
-            self.records.append(part)
+        self.list(override_query=self.all_query)
 
         return self
 
+    # Base part list
+    def list(
+        self,
+        /,
+        *,
+        override_query: str | None = None,
+        order: str | None = None,
+        limit: int | None = None,
+        **context: Any,
+    ) -> None:
+        if order is None:
+            order = self.order
+
+        if hasattr(self, 'brickset'):
+            brickset = self.brickset
+        else:
+            brickset = None
+
+        if hasattr(self, 'minifigure'):
+            minifigure = self.minifigure
+        else:
+            minifigure = None
+
+        # Load the sets from the database
+        for record in super().select(
+            override_query=override_query,
+            order=order,
+            limit=limit,
+        ):
+            part = BrickPart(
+                brickset=brickset,
+                minifigure=minifigure,
+                record=record,
+            )
+
+            if current_app.config['SKIP_SPARE_PARTS'] and part.fields.spare:
+                continue
+
+            self.records.append(part)
+
     # List specific parts from a brickset or minifigure
     def list_specific(
         self,
@@ -65,17 +99,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
         self.minifigure = minifigure
 
         # Load the parts from the database
-        for record in self.select(order=self.order):
-            part = BrickPart(
-                brickset=self.brickset,
-                minifigure=minifigure,
-                record=record,
-            )
-
-            if current_app.config['SKIP_SPARE_PARTS'] and part.fields.spare:
-                continue
-
-            self.records.append(part)
+        self.list()
 
         return self
 
@@ -89,19 +113,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
         self.minifigure = minifigure
 
         # Load the parts from the database
-        for record in self.select(
-            override_query=self.minifigure_query,
-            order=self.order
-        ):
-            part = BrickPart(
-                minifigure=minifigure,
-                record=record,
-            )
-
-            if current_app.config['SKIP_SPARE_PARTS'] and part.fields.spare:
-                continue
-
-            self.records.append(part)
+        self.list(override_query=self.minifigure_query)
 
         return self
 
@@ -121,33 +133,13 @@ class BrickPartList(BrickRecordList[BrickPart]):
         self.fields.color = brickpart.fields.color
 
         # Load the parts from the database
-        for record in self.select(
-            override_query=self.print_query,
-            order=self.order
-        ):
-            part = BrickPart(
-                record=record,
-            )
-
-            if (
-                current_app.config['SKIP_SPARE_PARTS'] and
-                part.fields.spare
-            ):
-                continue
-
-            self.records.append(part)
+        self.list(override_query=self.print_query)
 
         return self
 
     # Load problematic parts
     def problem(self, /) -> Self:
-        for record in self.select(
-            override_query=self.problem_query,
-            order=self.order
-        ):
-            part = BrickPart(record=record)
-
-            self.records.append(part)
+        self.list(override_query=self.problem_query)
 
         return self
 
@@ -178,21 +170,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
         self.fields.color = brickpart.fields.color
 
         # Load the parts from the database
-        for record in self.select(
-            override_query=self.different_color_query,
-            order=self.order
-        ):
-            part = BrickPart(
-                record=record,
-            )
-
-            if (
-                current_app.config['SKIP_SPARE_PARTS'] and
-                part.fields.spare
-            ):
-                continue
-
-            self.records.append(part)
+        self.list(override_query=self.different_color_query)
 
         return self
 
diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py
index ffc436d2..b12f9717 100644
--- a/bricktracker/set_list.py
+++ b/bricktracker/set_list.py
@@ -1,4 +1,4 @@
-from typing import Self
+from typing import Any, Self
 
 from flask import current_app
 
@@ -36,23 +36,8 @@ class BrickSetList(BrickRecordList[BrickSet]):
 
     # All the sets
     def all(self, /) -> Self:
-        themes = set()
-
         # Load the sets from the database
-        for record in self.select(
-            order=self.order,
-            owners=BrickSetOwnerList.as_columns(),
-            statuses=BrickSetStatusList.as_columns(),
-            tags=BrickSetTagList.as_columns(),
-        ):
-            brickset = BrickSet(record=record)
-
-            self.records.append(brickset)
-            themes.add(brickset.theme.name)
-
-        # Convert the set into a list and sort it
-        self.themes = list(themes)
-        self.themes.sort()
+        self.list(do_theme=True)
 
         return self
 
@@ -62,13 +47,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
         self.fields.figure = figure
 
         # Load the sets from the database
-        for record in self.select(
-            override_query=self.damaged_minifigure_query,
-            order=self.order
-        ):
-            brickset = BrickSet(record=record)
-
-            self.records.append(brickset)
+        self.list(override_query=self.damaged_minifigure_query)
 
         return self
 
@@ -79,25 +58,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
         self.fields.color = color
 
         # Load the sets from the database
-        for record in self.select(
-            override_query=self.damaged_part_query,
-            order=self.order
-        ):
-            brickset = BrickSet(record=record)
-
-            self.records.append(brickset)
-
-        return self
-
-    # A generic list of the different sets
-    def generic(self, /) -> Self:
-        for record in self.select(
-            override_query=self.generic_query,
-            order=self.order
-        ):
-            brickset = BrickSet(record=record)
-
-            self.records.append(brickset)
+        self.list(override_query=self.damaged_part_query)
 
         return self
 
@@ -109,7 +70,29 @@ class BrickSetList(BrickRecordList[BrickSet]):
         else:
             order = '"bricktracker_sets"."rowid" DESC'
 
-        for record in self.select(
+        self.list(order=order, limit=limit)
+
+        return self
+
+    # Base set list
+    def list(
+        self,
+        /,
+        *,
+        override_query: str | None = None,
+        order: str | None = None,
+        limit: int | None = None,
+        do_theme: bool = False,
+        **context: Any,
+    ) -> None:
+        themes = set()
+
+        if order is None:
+            order = self.order
+
+        # Load the sets from the database
+        for record in super().select(
+            override_query=override_query,
             order=order,
             limit=limit,
             owners=BrickSetOwnerList.as_columns(),
@@ -119,8 +102,13 @@ class BrickSetList(BrickRecordList[BrickSet]):
             brickset = BrickSet(record=record)
 
             self.records.append(brickset)
+            if do_theme:
+                themes.add(brickset.theme.name)
 
-        return self
+        # Convert the set into a list and sort it
+        if do_theme:
+            self.themes = list(themes)
+            self.themes.sort()
 
     # Sets missing a minifigure part
     def missing_minifigure(self, figure: str, /) -> Self:
@@ -128,16 +116,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
         self.fields.figure = figure
 
         # Load the sets from the database
-        for record in self.select(
-            override_query=self.missing_minifigure_query,
-            order=self.order,
-            owners=BrickSetOwnerList.as_columns(),
-            statuses=BrickSetStatusList.as_columns(),
-            tags=BrickSetTagList.as_columns(),
-        ):
-            brickset = BrickSet(record=record)
-
-            self.records.append(brickset)
+        self.list(override_query=self.missing_minifigure_query)
 
         return self
 
@@ -148,16 +127,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
         self.fields.color = color
 
         # Load the sets from the database
-        for record in self.select(
-            override_query=self.missing_part_query,
-            order=self.order,
-            owners=BrickSetOwnerList.as_columns(),
-            statuses=BrickSetStatusList.as_columns(),
-            tags=BrickSetTagList.as_columns(),
-        ):
-            brickset = BrickSet(record=record)
-
-            self.records.append(brickset)
+        self.list(override_query=self.missing_part_query)
 
         return self
 
@@ -167,16 +137,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
         self.fields.figure = figure
 
         # Load the sets from the database
-        for record in self.select(
-            override_query=self.using_minifigure_query,
-            order=self.order,
-            owners=BrickSetOwnerList.as_columns(),
-            statuses=BrickSetStatusList.as_columns(),
-            tags=BrickSetTagList.as_columns(),
-        ):
-            brickset = BrickSet(record=record)
-
-            self.records.append(brickset)
+        self.list(override_query=self.using_minifigure_query)
 
         return self
 
@@ -187,15 +148,6 @@ class BrickSetList(BrickRecordList[BrickSet]):
         self.fields.color = color
 
         # Load the sets from the database
-        for record in self.select(
-            override_query=self.using_part_query,
-            order=self.order,
-            owners=BrickSetOwnerList.as_columns(),
-            statuses=BrickSetStatusList.as_columns(),
-            tags=BrickSetTagList.as_columns(),
-        ):
-            brickset = BrickSet(record=record)
-
-            self.records.append(brickset)
+        self.list(override_query=self.using_part_query)
 
         return self

From 9a9b5af7f4adabfbbc7068de825d2ef95dddf3b2 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 22:21:26 +0100
Subject: [PATCH 125/154] Restore RebrickablePart __init__ definition

---
 bricktracker/part.py | 16 ++++++++++++++--
 1 file changed, 14 insertions(+), 2 deletions(-)

diff --git a/bricktracker/part.py b/bricktracker/part.py
index fa463bec..12eab28c 100644
--- a/bricktracker/part.py
+++ b/bricktracker/part.py
@@ -1,4 +1,5 @@
 import logging
+from sqlite3 import Row
 from typing import Any, Self, TYPE_CHECKING
 import traceback
 
@@ -25,8 +26,19 @@ class BrickPart(RebrickablePart):
     generic_query: str = 'part/select/generic'
     select_query: str = 'part/select/specific'
 
-    def __init__(self, /, **kwargs):
-        super().__init__(**kwargs)
+    def __init__(
+        self,
+        /,
+        *,
+        brickset: 'BrickSet | None' = None,
+        minifigure: 'BrickMinifigure | None' = None,
+        record: Row | dict[str, Any] | None = None
+    ):
+        super().__init__(
+            brickset=brickset,
+            minifigure=minifigure,
+            record=record
+        )
 
         if self.minifigure is not None:
             self.identifier = self.minifigure.fields.figure

From 76ccb20dfacb58d52ecab76221adecb2fbda7059 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 22:28:36 +0100
Subject: [PATCH 126/154] Add a little border at the left of accordion to
 separate sections

---
 templates/macro/accordion.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/templates/macro/accordion.html b/templates/macro/accordion.html
index 8ae8a883..1fd91f3c 100644
--- a/templates/macro/accordion.html
+++ b/templates/macro/accordion.html
@@ -16,7 +16,7 @@
         {% endif %}
       </button>
     </h2>
-    <div id="{{ id }}" class="accordion-collapse collapse {% if expanded %}show{% endif %}" {% if not config['INDEPENDENT_ACCORDIONS'] %}data-bs-parent="#{{ parent }}"{% endif %}>
+    <div id="{{ id }}" class="accordion-collapse collapse border-start border-5 rounded-0 {% if expanded %}show{% endif %}" {% if not config['INDEPENDENT_ACCORDIONS'] %}data-bs-parent="#{{ parent }}"{% endif %}>
       <div class="accordion-body {% if class %}{{ class }}{% endif %}">
 {% endmacro %}
 

From f9e9edd50662b7bd59c537fe1002e12693f353e6 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 22:46:34 +0100
Subject: [PATCH 127/154] Remove debug print

---
 bricktracker/metadata_list.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/bricktracker/metadata_list.py b/bricktracker/metadata_list.py
index 5dfa73c3..68238e9d 100644
--- a/bricktracker/metadata_list.py
+++ b/bricktracker/metadata_list.py
@@ -126,7 +126,6 @@ class BrickMetadataList(BrickRecordList[T]):
         list = new.filter(**kwargs)
 
         if as_class:
-            print(list)
             # Return a copy of the metadata list with overriden records
             return cls(new.model, records=list)
         else:

From 4e3ae491874d9d072f69c1abb78862f8063c9df0 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 23:45:35 +0100
Subject: [PATCH 128/154] Set storage details

---
 .env.sample                                   | 16 +++++++--
 CHANGELOG.md                                  | 11 +++++-
 bricktracker/app.py                           |  2 ++
 bricktracker/config.py                        |  2 ++
 bricktracker/metadata_list.py                 | 10 ++++--
 bricktracker/navbar.py                        |  1 +
 bricktracker/set.py                           | 12 ++++---
 bricktracker/set_list.py                      | 12 +++++++
 bricktracker/set_storage.py                   |  9 +++++
 bricktracker/set_storage_list.py              | 17 +++++++++
 bricktracker/sql/set/list/using_storage.sql   |  5 +++
 bricktracker/sql/set/metadata/storage/all.sql | 14 ++++++++
 .../sql/set/metadata/storage/base.sql         | 15 ++++++--
 bricktracker/views/storage.py                 | 36 +++++++++++++++++++
 templates/macro/accordion.html                |  4 +--
 templates/macro/badge.html                    |  2 +-
 templates/macro/table.html                    | 10 +++---
 templates/storage.html                        | 15 ++++++++
 templates/storage/card.html                   | 16 +++++++++
 templates/storage/table.html                  | 16 +++++++++
 templates/storages.html                       | 11 ++++++
 21 files changed, 217 insertions(+), 19 deletions(-)
 create mode 100644 bricktracker/sql/set/list/using_storage.sql
 create mode 100644 bricktracker/sql/set/metadata/storage/all.sql
 create mode 100644 bricktracker/views/storage.py
 create mode 100644 templates/storage.html
 create mode 100644 templates/storage/card.html
 create mode 100644 templates/storage/table.html
 create mode 100644 templates/storages.html

diff --git a/.env.sample b/.env.sample
index b93e8c6b..d14491e5 100644
--- a/.env.sample
+++ b/.env.sample
@@ -91,6 +91,11 @@
 # Default: false
 # BK_HIDE_ADMIN=true
 
+# Optional: Hide the 'Problems' entry from the menu. Does not disable the route.
+# Default: false
+# Legacy name: BK_HIDE_MISSING_PARTS
+# BK_HIDE_ALL_PROBLEMS_PARTS=true
+
 # Optional: Hide the 'Instructions' entry from the menu. Does not disable the route.
 # Default: false
 # BK_HIDE_ALL_INSTRUCTIONS=true
@@ -107,10 +112,9 @@
 # Default: false
 # BK_HIDE_ALL_SETS=true
 
-# Optional: Hide the 'Problems' entry from the menu. Does not disable the route.
+# Optional: Hide the 'Storages' entry from the menu. Does not disable the route.
 # Default: false
-# Legacy name: BK_HIDE_MISSING_PARTS
-# BK_HIDE_ALL_PROBLEMS_PARTS=true
+# BK_HIDE_ALL_STORAGES=true
 
 # Optional: Hide the 'Instructions' entry in a Set card
 # Default: false
@@ -255,6 +259,12 @@
 # Default: /bricksocket/
 # BK_SOCKET_PATH=custompath
 
+# Optional: Change the default order of storages. By default ordered by insertion order.
+# Useful column names for this option are:
+# - "bricktracker_metadata_storages"."name" ASC: storage name
+# Default: "bricktracker_metadata_storages"."name" ASC
+# BK_MINIFIGURES_DEFAULT_ORDER="bricktracker_metadata_storages"."name" ASC
+
 # Optional: URL to the themes.csv.gz on Rebrickable
 # Default: https://cdn.rebrickable.com/media/downloads/themes.csv.gz
 # BK_THEMES_FILE_URL=
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ed15af7..b50f3427 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,8 @@
 - Added: `BK_HIDE_TABLE_DAMAGED_PARTS`, hide the Damaged column in all tables
 - Added: `BK_SHOW_GRID_SORT`, show the sort options on the grid by default
 - Added: `BK_SHOW_GRID_FILTERS`, show the filter options on the grid by default
+- Added: `BK_HIDE_ALL_STORAGES`, hide the "Storages" menu entry
+- Added: `BK_MINIFIGURES_DEFAULT_ORDER`, ordering of storages
 
 ### Code
 
@@ -28,7 +30,7 @@
     - Deduplicate
     - Compute number of parts
 
-Parts
+- Parts
     - Damaged parts
 
 - Sets
@@ -38,6 +40,9 @@ Parts
     - Tags
     - Storage
 
+- Storage
+    - Storage content and list
+
 - Socket
     - Add decorator for rebrickable, authenticated and threaded socket actions
 
@@ -86,6 +91,10 @@ Parts
     - Manually collapsible filters (with configuration variable for default state)
     - Manually collapsible sort (with configuration variable for default state)
 
+- Storage
+    - Storage list
+    - Storage content
+
 ## 1.1.1: PDF Instructions Download
 
 ### Instructions
diff --git a/bricktracker/app.py b/bricktracker/app.py
index 4b6f0d4f..af005d96 100644
--- a/bricktracker/app.py
+++ b/bricktracker/app.py
@@ -29,6 +29,7 @@ from bricktracker.views.login import login_page
 from bricktracker.views.minifigure import minifigure_page
 from bricktracker.views.part import part_page
 from bricktracker.views.set import set_page
+from bricktracker.views.storage import storage_page
 from bricktracker.views.wish import wish_page
 
 
@@ -77,6 +78,7 @@ def setup_app(app: Flask) -> None:
     app.register_blueprint(minifigure_page)
     app.register_blueprint(part_page)
     app.register_blueprint(set_page)
+    app.register_blueprint(storage_page)
     app.register_blueprint(wish_page)
 
     # Register admin routes
diff --git a/bricktracker/config.py b/bricktracker/config.py
index cd7ef74a..5b9788fb 100644
--- a/bricktracker/config.py
+++ b/bricktracker/config.py
@@ -29,6 +29,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
     {'n': 'HIDE_ALL_MINIFIGURES', 'c': bool},
     {'n': 'HIDE_ALL_PARTS', 'c': bool},
     {'n': 'HIDE_ALL_SETS', 'c': bool},
+    {'n': 'HIDE_ALL_STORAGES', 'c': bool},
     {'n': 'HIDE_ALL_PROBLEMS_PARTS', 'e': 'BK_HIDE_MISSING_PARTS', 'c': bool},
     {'n': 'HIDE_SET_INSTRUCTIONS', 'c': bool},
     {'n': 'HIDE_TABLE_DAMAGED_PARTS', 'c': bool},
@@ -59,6 +60,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
     {'n': 'SKIP_SPARE_PARTS', 'c': bool},
     {'n': 'SOCKET_NAMESPACE', 'd': 'bricksocket'},
     {'n': 'SOCKET_PATH', 'd': '/bricksocket/'},
+    {'n': 'STORAGE_DEFAULT_ORDER', 'd': '"bricktracker_metadata_storages"."name" ASC'},  # noqa: E501
     {'n': 'THEMES_FILE_URL', 'd': 'https://cdn.rebrickable.com/media/downloads/themes.csv.gz'},  # noqa: E501
     {'n': 'THEMES_PATH', 'd': './themes.csv'},
     {'n': 'TIMEZONE', 'd': 'Etc/UTC'},
diff --git a/bricktracker/metadata_list.py b/bricktracker/metadata_list.py
index 68238e9d..60bfb5f1 100644
--- a/bricktracker/metadata_list.py
+++ b/bricktracker/metadata_list.py
@@ -43,8 +43,7 @@ class BrickMetadataList(BrickRecordList[T]):
 
         # Records override (masking the class variables with instance ones)
         if records is not None:
-            self.records = []
-            self.mapping = {}
+            self.override()
 
             for metadata in records:
                 self.records.append(metadata)
@@ -79,6 +78,13 @@ class BrickMetadataList(BrickRecordList[T]):
     def filter(self) -> list[T]:
         return self.records
 
+    # Add a layer of override data
+    def override(self) -> None:
+        self.fields = BrickRecordFields()
+
+        self.records = []
+        self.mapping = {}
+
     # Return the items as columns for a select
     @classmethod
     def as_columns(cls, /, **kwargs) -> str:
diff --git a/bricktracker/navbar.py b/bricktracker/navbar.py
index 30007dee..20a2b292 100644
--- a/bricktracker/navbar.py
+++ b/bricktracker/navbar.py
@@ -14,6 +14,7 @@ NAVBAR: Final[list[dict[str, Any]]] = [
     {'e': 'part.problem', 't': 'Problems', 'i': 'error-warning-line', 'f': 'HIDE_ALL_PROBLEMS_PARTS'},  # noqa: E501
     {'e': 'minifigure.list', 't': 'Minifigures', 'i': 'group-line', 'f': 'HIDE_ALL_MINIFIGURES'},  # noqa: E501
     {'e': 'instructions.list', 't': 'Instructions', 'i': 'file-line', 'f': 'HIDE_ALL_INSTRUCTIONS'},  # noqa: E501
+    {'e': 'storage.list', 't': 'Storages', 'i': 'archive-2-line', 'f': 'HIDE_ALL_STORAGES'},  # noqa: E501
     {'e': 'wish.list', 't': 'Wishlist', 'i': 'gift-line', 'f': 'HIDE_WISHES'},
     {'e': 'admin.admin', 't': 'Admin', 'i': 'settings-4-line', 'f': 'HIDE_ADMIN'},  # noqa: E501
 ]
diff --git a/bricktracker/set.py b/bricktracker/set.py
index 90b2679b..6368d40c 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -214,7 +214,11 @@ class BrickSet(RebrickableSet):
 
     # Compute the url for the refresh button
     def url_for_refresh(self, /) -> str:
-        return url_for(
-            'set.refresh',
-            id=self.fields.id,
-        )
+        return url_for('set.refresh', id=self.fields.id)
+
+    # Compute the url for the set storage
+    def url_for_storage(self, /) -> str:
+        if self.fields.storage is not None:
+            return url_for('storage.details', id=self.fields.storage)
+        else:
+            return ''
diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py
index b12f9717..6c3b9283 100644
--- a/bricktracker/set_list.py
+++ b/bricktracker/set_list.py
@@ -5,6 +5,7 @@ from flask import current_app
 from .record_list import BrickRecordList
 from .set_owner_list import BrickSetOwnerList
 from .set_status_list import BrickSetStatusList
+from .set_storage import BrickSetStorage
 from .set_tag_list import BrickSetTagList
 from .set import BrickSet
 
@@ -24,6 +25,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
     select_query: str = 'set/list/all'
     using_minifigure_query: str = 'set/list/using_minifigure'
     using_part_query: str = 'set/list/using_part'
+    using_storage_query: str = 'set/list/using_storage'
 
     def __init__(self, /):
         super().__init__()
@@ -151,3 +153,13 @@ class BrickSetList(BrickRecordList[BrickSet]):
         self.list(override_query=self.using_part_query)
 
         return self
+
+    # Sets using a storage
+    def using_storage(self, storage: BrickSetStorage, /) -> Self:
+        # Save the parameters to the fields
+        self.fields.storage = storage.fields.id
+
+        # Load the sets from the database
+        self.list(override_query=self.using_storage_query)
+
+        return self
diff --git a/bricktracker/set_storage.py b/bricktracker/set_storage.py
index 0a54262f..30c559c9 100644
--- a/bricktracker/set_storage.py
+++ b/bricktracker/set_storage.py
@@ -1,5 +1,7 @@
 from .metadata import BrickMetadata
 
+from flask import url_for
+
 
 # Lego set storage metadata
 class BrickSetStorage(BrickMetadata):
@@ -11,3 +13,10 @@ class BrickSetStorage(BrickMetadata):
     select_query: str = 'set/metadata/storage/select'
     update_field_query: str = 'set/metadata/storage/update/field'
     update_set_state_query: str = 'set/metadata/storage/update/state'
+
+    # Self url
+    def url(self, /) -> str:
+        return url_for(
+            'storage.details',
+            id=self.fields.id,
+        )
diff --git a/bricktracker/set_storage_list.py b/bricktracker/set_storage_list.py
index 72efde70..8453f366 100644
--- a/bricktracker/set_storage_list.py
+++ b/bricktracker/set_storage_list.py
@@ -1,6 +1,8 @@
 import logging
 from typing import Self
 
+from flask import current_app
+
 from .metadata_list import BrickMetadataList
 from .set_storage import BrickSetStorage
 
@@ -13,10 +15,25 @@ class BrickSetStorageList(BrickMetadataList[BrickSetStorage]):
 
     # Queries
     select_query = 'set/metadata/storage/list'
+    all_query = 'set/metadata/storage/all'
 
     # Set state endpoint
     set_state_endpoint: str = 'set.update_storage'
 
+    # Load all storages
+    @classmethod
+    def all(cls, /) -> Self:
+        new = cls.new()
+        new.override()
+
+        for record in new.select(
+            override_query=cls.all_query,
+            order=current_app.config['STORAGE_DEFAULT_ORDER']
+        ):
+            new.records.append(new.model(record=record))
+
+        return new
+
     # Instantiate the list with the proper class
     @classmethod
     def new(cls, /, *, force: bool = False) -> Self:
diff --git a/bricktracker/sql/set/list/using_storage.sql b/bricktracker/sql/set/list/using_storage.sql
new file mode 100644
index 00000000..0dc0f14a
--- /dev/null
+++ b/bricktracker/sql/set/list/using_storage.sql
@@ -0,0 +1,5 @@
+{% extends 'set/base/full.sql' %}
+
+{% block where %}
+WHERE "bricktracker_sets"."storage" IS NOT DISTINCT FROM :storage
+{% endblock %}
diff --git a/bricktracker/sql/set/metadata/storage/all.sql b/bricktracker/sql/set/metadata/storage/all.sql
new file mode 100644
index 00000000..0bd8b8eb
--- /dev/null
+++ b/bricktracker/sql/set/metadata/storage/all.sql
@@ -0,0 +1,14 @@
+{% extends 'set/metadata/storage/base.sql' %}
+
+{% block total_sets %}
+IFNULL(COUNT("bricktracker_sets"."id"), 0) AS "total_sets"
+{% endblock %}
+
+{% block join %}
+LEFT JOIN "bricktracker_sets"
+ON "bricktracker_metadata_storages"."id" IS NOT DISTINCT FROM "bricktracker_sets"."storage"
+{% endblock %}
+
+{% block group %}
+GROUP BY "bricktracker_metadata_storages"."id"
+{% endblock %}
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/storage/base.sql b/bricktracker/sql/set/metadata/storage/base.sql
index 2417aa69..bf616ed9 100644
--- a/bricktracker/sql/set/metadata/storage/base.sql
+++ b/bricktracker/sql/set/metadata/storage/base.sql
@@ -1,6 +1,17 @@
 SELECT
     "bricktracker_metadata_storages"."id",
-    "bricktracker_metadata_storages"."name"
+    "bricktracker_metadata_storages"."name",
+    {% block total_sets %}
+    NULL as "total_sets" -- dummy for order: total_sets
+    {% endblock %}
 FROM "bricktracker_metadata_storages"
 
-{% block where %}{% endblock %}
\ No newline at end of file
+{% block join %}{% endblock %}
+
+{% block where %}{% endblock %}
+
+{% block group %}{% endblock %}
+
+{% if order %}
+ORDER BY {{ order }}
+{% endif %}
diff --git a/bricktracker/views/storage.py b/bricktracker/views/storage.py
new file mode 100644
index 00000000..e41e97a4
--- /dev/null
+++ b/bricktracker/views/storage.py
@@ -0,0 +1,36 @@
+from flask import Blueprint, render_template
+
+from .exceptions import exception_handler
+from ..set_owner_list import BrickSetOwnerList
+from ..set_list import BrickSetList
+from ..set_storage import BrickSetStorage
+from ..set_storage_list import BrickSetStorageList
+from ..set_tag_list import BrickSetTagList
+
+storage_page = Blueprint('storage', __name__, url_prefix='/storages')
+
+
+# Index
+@storage_page.route('/', methods=['GET'])
+@exception_handler(__file__)
+def list() -> str:
+    return render_template(
+        'storages.html',
+        table_collection=BrickSetStorageList.all(),
+    )
+
+
+# Storage details
+@storage_page.route('/<id>/details')
+@exception_handler(__file__)
+def details(*, id: str) -> str:
+    storage = BrickSetStorage().select_specific(id)
+
+    return render_template(
+        'storage.html',
+        item=storage,
+        sets=BrickSetList().using_storage(storage),
+        brickset_owners=BrickSetOwnerList.list(),
+        brickset_storages=BrickSetStorageList.list(as_class=True),
+        brickset_tags=BrickSetTagList.list(),
+    )
diff --git a/templates/macro/accordion.html b/templates/macro/accordion.html
index 1fd91f3c..c5c89545 100644
--- a/templates/macro/accordion.html
+++ b/templates/macro/accordion.html
@@ -26,10 +26,10 @@
   </div>
 {% endmacro %}
 
-{% macro cards(card_collection, title, id, parent, target, icon=none) %}
+{% macro cards(card_collection, title, id, parent, target, expanded=false, icon=none) %}
   {% set size=card_collection | length %}
   {% if size %}
-    {{ header(title, id, parent, icon=icon) }}
+    {{ header(title, id, parent, expanded=expanded, icon=icon) }}
       <div class="row">
         {% for item in card_collection %}
           <div class="col-md-6 col-xl-3 d-flex align-items-stretch">
diff --git a/templates/macro/badge.html b/templates/macro/badge.html
index bd683f40..e1f9071a 100644
--- a/templates/macro/badge.html
+++ b/templates/macro/badge.html
@@ -80,7 +80,7 @@
     {% else %}
       {% set text=storage.fields.name %}
     {% endif %}
-    {{ badge(check=storage, solo=solo, last=last, color='light text-warning-emphasis bg-warning-subtle border border-warning-subtle', icon='archive-2-line', text=text, alt='Storage', tooltip=tooltip) }}
+    {{ badge(url=item.url_for_storage(), solo=solo, last=last, color='light text-warning-emphasis bg-warning-subtle border border-warning-subtle', icon='archive-2-line', text=text, alt='Storage', tooltip=tooltip) }}
   {% endif %}
 {% endmacro %}
 
diff --git a/templates/macro/table.html b/templates/macro/table.html
index ebf1ded6..0638380f 100644
--- a/templates/macro/table.html
+++ b/templates/macro/table.html
@@ -1,7 +1,9 @@
-{% macro header(color=false, parts=false, quantity=false, missing_parts=false, damaged_parts=false, sets=false, minifigures=false) %}
+{% macro header(image=true, color=false, parts=false, quantity=false, missing=true, missing_parts=false, damaged=true, damaged_parts=false, sets=false, minifigures=false) %}
   <thead>
     <tr>
-      <th data-table-no-sort="true" class="no-sort" scope="col"><i class="ri-image-line fw-normal"></i> Image</th>
+      {% if image %}
+        <th data-table-no-sort="true" class="no-sort" scope="col"><i class="ri-image-line fw-normal"></i> Image</th>
+      {% endif %}
       <th scope="col"><i class="ri-pencil-line fw-normal"></i> Name</th>
       {% if color %}
         <th scope="col"><i class="ri-palette-line fw-normal"></i> Color</th>
@@ -12,10 +14,10 @@
       {% if quantity %}
         <th data-table-number="true" scope="col"><i class="ri-functions fw-normal"></i> Quantity</th>
       {% endif %}
-      {% if not config['HIDE_TABLE_MISSING_PARTS'] %}
+      {% if missing and not config['HIDE_TABLE_MISSING_PARTS'] %}
         <th data-table-number="true" scope="col"><i class="ri-question-line fw-normal"></i> Missing{% if missing_parts %} parts{% endif %}</th>
       {% endif %}
-      {% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
+      {% if damaged and not config['HIDE_TABLE_DAMAGED_PARTS'] %}
       <th data-table-number="true" scope="col"><i class="ri-error-warning-line fw-normal"></i> Damaged{% if damaged_parts %} parts{% endif %}</th>
       {% endif %}
       {% if sets %}
diff --git a/templates/storage.html b/templates/storage.html
new file mode 100644
index 00000000..76e95497
--- /dev/null
+++ b/templates/storage.html
@@ -0,0 +1,15 @@
+{% extends 'base.html' %}
+
+{% block title %} - Storage {{ item.fields.name}}{% endblock %}
+
+{% block main %}
+<div class="container">
+  <div class="row">
+    <div class="col-12">
+      {% with solo=true %}
+        {% include 'storage/card.html' %}
+      {% endwith %}
+    </div>
+  </div>
+</div>
+{% endblock %}
diff --git a/templates/storage/card.html b/templates/storage/card.html
new file mode 100644
index 00000000..cf29de3f
--- /dev/null
+++ b/templates/storage/card.html
@@ -0,0 +1,16 @@
+{% import 'macro/accordion.html' as accordion with context %}
+{% import 'macro/badge.html' as badge %}
+{% import 'macro/card.html' as card %}
+
+<div class="card mb-3 flex-fill {% if solo %}card-solo{% endif %}">
+  {{ card.header(item, item.fields.name, solo=solo, icon='archive-2-line') }}
+  <div class="card-body border-bottom-0 {% if not solo %}p-1{% endif %}">
+    {{ badge.total_sets(sets | length, solo=solo, last=last) }}
+  </div>
+  {% if solo %}
+    <div class="accordion accordion-flush border-top" id="storage-details">
+      {{ accordion.cards(sets, 'Sets', 'sets-stored', 'storage-details', 'set/card.html', expanded=true, icon='hashtag') }}
+    </div>
+    <div class="card-footer"></div>
+  {% endif %}
+</div>
diff --git a/templates/storage/table.html b/templates/storage/table.html
new file mode 100644
index 00000000..680c75c8
--- /dev/null
+++ b/templates/storage/table.html
@@ -0,0 +1,16 @@
+{% import 'macro/form.html' as form %}
+{% import 'macro/table.html' as table %}
+
+<div class="table-responsive-sm">
+  <table data-table="true" class="table table-striped align-middle" id="storage">
+    {{ table.header(image=false, missing=false, damaged=false, sets=true) }}
+    <tbody>
+      {% for item in table_collection %}
+      <tr>
+        <td data-sort="{{ item.fields.name }}"><a class="text-reset" href="{{ item.url() }}">{{ item.fields.name }}</a></td>
+        <td>{{ item.fields.total_sets }}</td>
+      </tr>
+      {% endfor %}
+    </tbody>
+  </table>
+</div>
diff --git a/templates/storages.html b/templates/storages.html
new file mode 100644
index 00000000..14ac3548
--- /dev/null
+++ b/templates/storages.html
@@ -0,0 +1,11 @@
+{% extends 'base.html' %}
+
+{% block title %} - All storages{% endblock %}
+
+{% block main %}
+<div class="container-fluid px-0">
+  {% with all=true %}
+    {% include 'storage/table.html' %}
+  {% endwith %}
+</div>
+{% endblock %}

From 48e4b59344bfa630d7a993fad8e72705a121b462 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 23:46:05 +0100
Subject: [PATCH 129/154] Make sure COUNT() does not return NULL

---
 bricktracker/sql/minifigure/list/all.sql       | 2 +-
 bricktracker/sql/minifigure/select/generic.sql | 2 +-
 bricktracker/sql/part/list/all.sql             | 2 +-
 bricktracker/sql/part/list/problem.sql         | 2 +-
 4 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/bricktracker/sql/minifigure/list/all.sql b/bricktracker/sql/minifigure/list/all.sql
index d0bb6eb3..904e818e 100644
--- a/bricktracker/sql/minifigure/list/all.sql
+++ b/bricktracker/sql/minifigure/list/all.sql
@@ -13,7 +13,7 @@ SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_quantity",
 {% endblock %}
 
 {% block total_sets %}
-COUNT("bricktracker_minifigures"."id") AS "total_sets"
+IFNULL(COUNT("bricktracker_minifigures"."id"), 0) AS "total_sets"
 {% endblock %}
 
 {% block join %}
diff --git a/bricktracker/sql/minifigure/select/generic.sql b/bricktracker/sql/minifigure/select/generic.sql
index b48bfb71..6301550b 100644
--- a/bricktracker/sql/minifigure/select/generic.sql
+++ b/bricktracker/sql/minifigure/select/generic.sql
@@ -13,7 +13,7 @@ SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_quantity",
 {% endblock %}
 
 {% block total_sets %}
-COUNT(DISTINCT "bricktracker_minifigures"."id") AS "total_sets"
+IFNULL(COUNT(DISTINCT "bricktracker_minifigures"."id"), 0) AS "total_sets"
 {% endblock %}
 
 {% block join %}
diff --git a/bricktracker/sql/part/list/all.sql b/bricktracker/sql/part/list/all.sql
index 77831a67..8bb8dcba 100644
--- a/bricktracker/sql/part/list/all.sql
+++ b/bricktracker/sql/part/list/all.sql
@@ -13,7 +13,7 @@ SUM("bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantit
 {% endblock %}
 
 {% block total_sets %}
-COUNT(DISTINCT "bricktracker_parts"."id") AS "total_sets",
+IFNULL(COUNT(DISTINCT "bricktracker_parts"."id"), 0) AS "total_sets",
 {% endblock %}
 
 {% block total_minifigures %}
diff --git a/bricktracker/sql/part/list/problem.sql b/bricktracker/sql/part/list/problem.sql
index dbf411b9..068b8d86 100644
--- a/bricktracker/sql/part/list/problem.sql
+++ b/bricktracker/sql/part/list/problem.sql
@@ -9,7 +9,7 @@ SUM("bricktracker_parts"."damaged") AS "total_damaged",
 {% endblock %}
 
 {% block total_sets %}
-COUNT("bricktracker_parts"."id")  - COUNT("bricktracker_parts"."figure") AS "total_sets",
+IFNULL(COUNT("bricktracker_parts"."id"), 0)  - IFNULL(COUNT("bricktracker_parts"."figure"), 0) AS "total_sets",
 {% endblock %}
 
 {% block total_minifigures %}

From bd8c52941ad642d196e1c51d8d8c632d0b759a7c Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 08:47:38 +0100
Subject: [PATCH 130/154] Move grid filters and sort to their own files (plus
 cosmetics)

---
 templates/set/filter.html |  87 ++++++++++++++++++++++++++++
 templates/set/sort.html   |  25 ++++++++
 templates/sets.html       | 116 +-------------------------------------
 3 files changed, 115 insertions(+), 113 deletions(-)
 create mode 100644 templates/set/filter.html
 create mode 100644 templates/set/sort.html

diff --git a/templates/set/filter.html b/templates/set/filter.html
new file mode 100644
index 00000000..65c33433
--- /dev/null
+++ b/templates/set/filter.html
@@ -0,0 +1,87 @@
+<div id="grid-filter" class="collapse {% if config['SHOW_GRID_FILTERS'] %}show{% endif %} row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
+  <div class="col-12 flex-grow-1">
+    <label class="visually-hidden" for="grid-status">Status</label>
+    <div class="input-group">
+      <span class="input-group-text"><i class="ri-checkbox-line"></i><span class="ms-1 d-none d-md-inline"> Status</span></span>
+      <select id="grid-status" class="form-select"
+        data-filter="metadata"
+        autocomplete="off">
+        <option value="" selected>All</option>
+        <option value="-has-missing">Set is complete</option>
+        <option value="has-missing">Set has missing pieces</option>
+        <option value="has-damaged">Set has damaged pieces</option>
+        <option value="has-missing-instructions">Set has missing instructions</option>
+        {% if brickset_storages | length %}
+          <option value="has-storage">Is in storage</option>
+          <option value="-has-storage">Is NOT in storage</option>
+        {% endif %}
+        {% for status in brickset_statuses %}
+          <option value="{{ status.as_dataset() }}">{{ status.fields.name }}</option>
+          <option value="-{{ status.as_dataset() }}">NOT: {{ status.fields.name }}</option>
+        {% endfor %}
+      </select>
+    </div>
+  </div>
+  <div class="col-12 flex-grow-1">
+    <label class="visually-hidden" for="grid-theme">Theme</label>
+    <div class="input-group">
+      <span class="input-group-text"><i class="ri-price-tag-3-line"></i><span class="ms-1 d-none d-md-inline"> Theme</span></span>
+      <select id="grid-theme" class="form-select"
+        data-filter="value" data-filter-attribute="theme"
+        autocomplete="off">
+        <option value="" selected>All</option>
+        {% for theme in collection.themes %}
+        <option value="{{ theme | lower }}">{{ theme }}</option>
+        {% endfor %}
+      </select>
+    </div>
+  </div>
+  {% if brickset_owners | length %}
+    <div class="col-12 flex-grow-1">
+      <label class="visually-hidden" for="grid-owner">Owner</label>
+      <div class="input-group">
+        <span class="input-group-text"><i class="ri-user-line"></i><span class="ms-1 d-none d-md-inline"> Owner</span></span>
+        <select id="grid-owner" class="form-select"
+          data-filter="metadata"
+          autocomplete="off">
+          <option value="" selected>All</option>
+          {% for owner in brickset_owners %}
+            <option value="{{ owner.as_dataset() }}">{{ owner.fields.name }}</option>
+          {% endfor %}
+        </select>
+      </div>
+    </div>
+  {% endif %}
+  {% if brickset_storages | length %}
+    <div class="col-12 flex-grow-1">
+      <label class="visually-hidden" for="grid-owner">Storage</label>
+      <div class="input-group">
+        <span class="input-group-text"><i class="ri-archive-2-line"></i><span class="ms-1 d-none d-md-inline"> Storage</span></span>
+        <select id="grid-storage" class="form-select"
+          data-filter="value" data-filter-attribute="storage"
+          autocomplete="off">
+          <option value="" selected>All</option>
+          {% for storage in brickset_storages %}
+            <option value="{{ storage.fields.id }}">{{ storage.fields.name }}</option>
+          {% endfor %}
+        </select>
+      </div>
+    </div>
+  {% endif %}
+  {% if brickset_tags | length %}
+    <div class="col-12 flex-grow-1">
+      <label class="visually-hidden" for="grid-tag">Tag</label>
+      <div class="input-group">
+        <span class="input-group-text"><i class="ri-price-tag-2-line"></i><span class="ms-1 d-none d-md-inline"> Tag</span></span>
+        <select id="grid-tag" class="form-select"
+          data-filter="metadata"
+          autocomplete="off">
+          <option value="" selected>All</option>
+          {% for tag in brickset_tags %}
+            <option value="{{ tag.as_dataset() }}">{{ tag.fields.name }}</option>
+          {% endfor %}
+        </select>
+      </div>
+    </div>
+  {% endif %}
+</div>
diff --git a/templates/set/sort.html b/templates/set/sort.html
new file mode 100644
index 00000000..09f31f05
--- /dev/null
+++ b/templates/set/sort.html
@@ -0,0 +1,25 @@
+<div id="grid-sort" class="collapse {% if config['SHOW_GRID_SORT'] %}show{% endif %} row row-cols-lg-auto g-1 justify-content-center align-items-center">
+  <div class="col-12 flex-grow-1">
+    <div class="input-group">
+      <span class="input-group-text mb-2"><i class="ri-sort-asc"></i><span class="ms-1 d-none d-md-inline"> Sort</span></span>
+      <button id="sort-number" type="button" class="btn btn-outline-primary mb-2"
+        data-sort-attribute="number" data-sort-natural="true"><i class="ri-hashtag"></i><span class="d-none d-md-inline"> Set</span></button>
+      <button id="sort-name" type="button" class="btn btn-outline-primary mb-2"
+        data-sort-attribute="name"><i class="ri-pencil-line"></i><span class="d-none d-md-inline"> Name</span></button>
+      <button id="sort-theme" type="button" class="btn btn-outline-primary mb-2"
+        data-sort-attribute="theme"><i class="ri-price-tag-3-line"></i><span class="d-none d-md-inline"> Theme</span></button>
+      <button id="sort-year" type="button" class="btn btn-outline-primary mb-2"
+        data-sort-attribute="year"><i class="ri-calendar-line"></i><span class="d-none d-md-inline"> Year</span></button>
+      <button id="sort-minifigure" type="button" class="btn btn-outline-primary mb-2"
+        data-sort-attribute="minifigures" data-sort-desc="true"><i class="ri-group-line"></i><span class="d-none d-xl-inline"> Figures</span></button>
+      <button id="sort-parts" type="button" class="btn btn-outline-primary mb-2"
+        data-sort-attribute="parts" data-sort-desc="true"><i class="ri-shapes-line"></i><span class="d-none d-xl-inline"> Parts</span></button>
+      <button id="sort-missing" type="button" class="btn btn-outline-primary mb-2"
+        data-sort-attribute="missing" data-sort-desc="true"><i class="ri-question-line"></i><span class="d-none d-xl-inline"> Missing</span></button>
+      <button id="sort-damaged" type="button" class="btn btn-outline-primary mb-2"
+        data-sort-attribute="damaged" data-sort-desc="true"><i class="ri-error-warning-line"></i><span class="d-none d-xl-inline"> Damaged</span></button>
+      <button id="sort-clear" type="button" class="btn btn-outline-dark mb-2"
+        data-sort-clear="true"><i class="ri-close-circle-line"></i><span class="d-none d-xl-inline"> Clear</span></button>
+    </div>
+  </div>
+</div>
diff --git a/templates/sets.html b/templates/sets.html
index 60101b54..5af93c93 100644
--- a/templates/sets.html
+++ b/templates/sets.html
@@ -9,7 +9,7 @@
     <div class="col-12 flex-grow-1">
       <label class="visually-hidden" for="grid-search">Search</label>
       <div class="input-group">
-        <span class="input-group-text"><i class="ri-search-line"></i><span class="ms-1 d-none d-xl-inline"> Search</span></span>
+        <span class="input-group-text"><i class="ri-search-line"></i><span class="ms-1 d-none d-md-inline"> Search</span></span>
         <input id="grid-search" data-search-exact="name,number,parts,searchStorage,theme,year" data-search-list="searchOwner,searchTag" class="form-control form-control-sm" type="text" placeholder="Set, name, number of parts, theme, year, owner, storage, tag" value="">
       </div>
     </div>
@@ -28,118 +28,8 @@
       </div>
     </div>
   </div>
-  <div id="grid-sort" class="collapse {% if config['SHOW_GRID_SORT'] %}show{% endif %} row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
-    <div class="col-12 flex-grow-1">
-      <div class="input-group">
-        <span class="input-group-text"><i class="ri-sort-asc"></i><span class="ms-1 d-none d-xxl-inline"> Sort</span></span>
-        <button id="sort-number" type="button" class="btn btn-outline-primary"
-          data-sort-attribute="number" data-sort-natural="true"><i class="ri-hashtag"></i><span class="d-none d-xxl-inline"> Set</span></button>
-        <button id="sort-name" type="button" class="btn btn-outline-primary"
-          data-sort-attribute="name"><i class="ri-pencil-line"></i><span class="d-none d-xxl-inline"> Name</span></button>
-        <button id="sort-theme" type="button" class="btn btn-outline-primary"
-          data-sort-attribute="theme"><i class="ri-price-tag-3-line"></i><span class="d-none d-xxl-inline"> Theme</span></button>
-        <button id="sort-year" type="button" class="btn btn-outline-primary"
-          data-sort-attribute="year"><i class="ri-calendar-line"></i><span class="d-none d-xxl-inline"> Year</span></button>
-        <button id="sort-minifigure" type="button" class="btn btn-outline-primary"
-          data-sort-attribute="minifigures" data-sort-desc="true"><i class="ri-group-line"></i><span class="d-none d-xxl-inline"> Figures</span></button>
-        <button id="sort-parts" type="button" class="btn btn-outline-primary"
-          data-sort-attribute="parts" data-sort-desc="true"><i class="ri-shapes-line"></i><span class="d-none d-xxl-inline"> Parts</span></button>
-        <button id="sort-missing" type="button" class="btn btn-outline-primary"
-          data-sort-attribute="missing" data-sort-desc="true"><i class="ri-question-line"></i><span class="d-none d-xxl-inline"> Missing</span></button>
-        <button id="sort-damaged" type="button" class="btn btn-outline-primary"
-          data-sort-attribute="damaged" data-sort-desc="true"><i class="ri-error-warning-line"></i><span class="d-none d-xxl-inline"> Damaged</span></button>
-        <button id="sort-clear" type="button" class="btn btn-outline-dark"
-          data-sort-clear="true"><i class="ri-close-circle-line"></i><span class="d-none d-xxl-inline"> Clear</span></button>
-      </div>
-    </div>
-  </div>
-  <div id="grid-filter" class="collapse {% if config['SHOW_GRID_FILTERS'] %}show{% endif %} row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
-    <div class="col-12 flex-grow-1">
-      <label class="visually-hidden" for="grid-status">Status</label>
-      <div class="input-group">
-        <span class="input-group-text"><i class="ri-checkbox-line"></i><span class="ms-1 d-none d-xl-inline"> Status</span></span>
-        <select id="grid-status" class="form-select"
-          data-filter="metadata"
-          autocomplete="off">
-          <option value="" selected>All</option>
-          <option value="-has-missing">Set is complete</option>
-          <option value="has-missing">Set has missing pieces</option>
-          <option value="has-damaged">Set has damaged pieces</option>
-          <option value="has-missing-instructions">Set has missing instructions</option>
-          {% if brickset_storages | length %}
-            <option value="has-storage">Is in storage</option>
-            <option value="-has-storage">Is NOT in storage</option>
-          {% endif %}
-          {% for status in brickset_statuses %}
-            <option value="{{ status.as_dataset() }}">{{ status.fields.name }}</option>
-            <option value="-{{ status.as_dataset() }}">NOT: {{ status.fields.name }}</option>
-          {% endfor %}
-        </select>
-      </div>
-    </div>
-    <div class="col-12 flex-grow-1">
-      <label class="visually-hidden" for="grid-theme">Theme</label>
-      <div class="input-group">
-        <span class="input-group-text"><i class="ri-price-tag-3-line"></i><span class="ms-1 d-none d-xl-inline"> Theme</span></span>
-        <select id="grid-theme" class="form-select"
-          data-filter="value" data-filter-attribute="theme"
-          autocomplete="off">
-          <option value="" selected>All</option>
-          {% for theme in collection.themes %}
-          <option value="{{ theme | lower }}">{{ theme }}</option>
-          {% endfor %}
-        </select>
-      </div>
-    </div>
-    {% if brickset_owners | length %}
-      <div class="col-12 flex-grow-1">
-        <label class="visually-hidden" for="grid-owner">Owner</label>
-        <div class="input-group">
-          <span class="input-group-text"><i class="ri-user-line"></i><span class="ms-1 d-none d-xl-inline"> Owner</span></span>
-          <select id="grid-owner" class="form-select"
-            data-filter="metadata"
-            autocomplete="off">
-            <option value="" selected>All</option>
-            {% for owner in brickset_owners %}
-              <option value="{{ owner.as_dataset() }}">{{ owner.fields.name }}</option>
-            {% endfor %}
-          </select>
-        </div>
-      </div>
-    {% endif %}
-    {% if brickset_storages | length %}
-      <div class="col-12 flex-grow-1">
-        <label class="visually-hidden" for="grid-owner">Storage</label>
-        <div class="input-group">
-          <span class="input-group-text"><i class="ri-archive-2-line"></i><span class="ms-1 d-none d-xl-inline"> Storage</span></span>
-          <select id="grid-storage" class="form-select"
-            data-filter="value" data-filter-attribute="storage"
-            autocomplete="off">
-            <option value="" selected>All</option>
-            {% for storage in brickset_storages %}
-              <option value="{{ storage.fields.id }}">{{ storage.fields.name }}</option>
-            {% endfor %}
-          </select>
-        </div>
-      </div>
-    {% endif %}
-    {% if brickset_tags | length %}
-      <div class="col-12 flex-grow-1">
-        <label class="visually-hidden" for="grid-tag">Tag</label>
-        <div class="input-group">
-          <span class="input-group-text"><i class="ri-price-tag-2-line"></i><span class="ms-1 d-none d-xl-inline"> Tag</span></span>
-          <select id="grid-tag" class="form-select"
-            data-filter="metadata"
-            autocomplete="off">
-            <option value="" selected>All</option>
-            {% for tag in brickset_tags %}
-              <option value="{{ tag.as_dataset() }}">{{ tag.fields.name }}</option>
-            {% endfor %}
-          </select>
-        </div>
-      </div>
-    {% endif %}
-  </div>
+  {% include 'set/sort.html' %}
+  {% include 'set/filter.html' %}
   <div class="row" data-grid="true" id="grid">
   {% for item in collection %}
     <div class="col-md-6 col-xl-3 d-flex align-items-stretch">

From b0c7cd7da57abe03c68ba3116e4a083b830b8f8c Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 09:32:57 +0100
Subject: [PATCH 131/154] Enforce hidden features in the card and grid
 filters/sort

---
 templates/macro/badge.html |  8 ++++++--
 templates/set/card.html    | 12 +++++++++---
 templates/set/filter.html  | 16 ++++++++++++----
 templates/set/sort.html    |  4 ++++
 4 files changed, 31 insertions(+), 9 deletions(-)

diff --git a/templates/macro/badge.html b/templates/macro/badge.html
index e1f9071a..ea2be584 100644
--- a/templates/macro/badge.html
+++ b/templates/macro/badge.html
@@ -103,7 +103,9 @@
 {% endmacro %}
 
 {% macro total_damaged(damaged, solo=false, last=false) %}
-  {{ badge(check=damaged, solo=solo, last=last, color='danger', icon='error-warning-line', collapsible='Damaged:', text=damaged, alt='Damaged') }}
+  {% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
+    {{ badge(check=damaged, solo=solo, last=last, color='danger', icon='error-warning-line', collapsible='Damaged:', text=damaged, alt='Damaged') }}
+  {% endif %}
 {% endmacro %}
 
 {% macro total_quantity(quantity, solo=false, last=false) %}
@@ -115,7 +117,9 @@
 {% endmacro %}
 
 {% macro total_missing(missing, solo=false, last=false) %}
-  {{ badge(check=missing, solo=solo, last=last, color='light text-danger-emphasis bg-danger-subtle border border-danger-subtle', icon='question-line', collapsible='Missing:', text=missing, alt='Missing') }}
+  {% if not config['HIDE_TABLE_MISSING_PARTS'] %}
+    {{ badge(check=missing, solo=solo, last=last, color='light text-danger-emphasis bg-danger-subtle border border-danger-subtle', icon='question-line', collapsible='Missing:', text=missing, alt='Missing') }}
+  {% endif %}
 {% endmacro %}
 
 {% macro total_sets(sets, solo=false, last=false) %}
diff --git a/templates/set/card.html b/templates/set/card.html
index f47a7ca3..20642066 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -7,10 +7,16 @@
   {% if not solo and not tiny %}
     data-index="{{ index }}" data-number="{{ item.fields.set }}" data-name="{{ item.fields.name | lower }}" data-parts="{{ item.fields.number_of_parts }}"
     data-year="{{ item.fields.year }}" data-theme="{{ item.theme.name | lower }}"
-    data-has-missing-instructions="{{ (not (item.instructions | length)) | int }}"
+    {% if not config['HIDE_SET_INSTRUCTIONS'] %}
+      data-has-missing-instructions="{{ (item.instructions | length == 0) | int }}"
+    {% endif %}
     data-has-minifigures="{{ (item.fields.total_minifigures > 0) | int }}" data-minifigures="{{ item.fields.total_minifigures }}"
-    data-has-missing="{{ (item.fields.total_missing > 0) | int }}" data-missing="{{ item.fields.total_missing }}"
-    data-has-damaged="{{ (item.fields.total_damaged > 0) | int }}" data-damaged="{{ item.fields.total_damaged }}"
+    {% if not config['HIDE_TABLE_MISSING_PARTS'] %}
+      data-has-missing="{{ (item.fields.total_missing > 0) | int }}" data-missing="{{ item.fields.total_missing }}"
+    {% endif %}
+    {% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
+      data-has-damaged="{{ (item.fields.total_damaged > 0) | int }}" data-damaged="{{ item.fields.total_damaged }}"
+    {% endif %}
     data-has-storage="{{ item.fields.storage is not none | int }}"
     {% if item.fields.storage is not none %}
       data-storage="{{ item.fields.storage }}"
diff --git a/templates/set/filter.html b/templates/set/filter.html
index 65c33433..8f3b4109 100644
--- a/templates/set/filter.html
+++ b/templates/set/filter.html
@@ -7,10 +7,18 @@
         data-filter="metadata"
         autocomplete="off">
         <option value="" selected>All</option>
-        <option value="-has-missing">Set is complete</option>
-        <option value="has-missing">Set has missing pieces</option>
-        <option value="has-damaged">Set has damaged pieces</option>
-        <option value="has-missing-instructions">Set has missing instructions</option>
+        {% if not config['HIDE_TABLE_MISSING_PARTS'] %}
+          <option value="has-missing">Has missing pieces</option>
+          <option value="-has-missing">Has NO missing pieces</option>
+        {% endif %}
+        {% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
+          <option value="has-damaged">Has damaged pieces</option>
+          <option value="-has-damaged">Has NO damaged pieces</option>
+        {% endif %}
+        {% if not config['HIDE_SET_INSTRUCTIONS'] %}
+          <option value="-has-missing-instructions">Has instructions</option>
+          <option value="has-missing-instructions">Is MISSING instructions</option>
+        {% endif %}
         {% if brickset_storages | length %}
           <option value="has-storage">Is in storage</option>
           <option value="-has-storage">Is NOT in storage</option>
diff --git a/templates/set/sort.html b/templates/set/sort.html
index 09f31f05..3315a1df 100644
--- a/templates/set/sort.html
+++ b/templates/set/sort.html
@@ -14,10 +14,14 @@
         data-sort-attribute="minifigures" data-sort-desc="true"><i class="ri-group-line"></i><span class="d-none d-xl-inline"> Figures</span></button>
       <button id="sort-parts" type="button" class="btn btn-outline-primary mb-2"
         data-sort-attribute="parts" data-sort-desc="true"><i class="ri-shapes-line"></i><span class="d-none d-xl-inline"> Parts</span></button>
+      {% if not config['HIDE_TABLE_MISSING_PARTS'] %}
       <button id="sort-missing" type="button" class="btn btn-outline-primary mb-2"
         data-sort-attribute="missing" data-sort-desc="true"><i class="ri-question-line"></i><span class="d-none d-xl-inline"> Missing</span></button>
+      {% endif %}
+      {% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
       <button id="sort-damaged" type="button" class="btn btn-outline-primary mb-2"
         data-sort-attribute="damaged" data-sort-desc="true"><i class="ri-error-warning-line"></i><span class="d-none d-xl-inline"> Damaged</span></button>
+      {% endif %}
       <button id="sort-clear" type="button" class="btn btn-outline-dark mb-2"
         data-sort-clear="true"><i class="ri-close-circle-line"></i><span class="d-none d-xl-inline"> Clear</span></button>
     </div>

From 82b744334f577f14ab11dbfea9e9b45a80b73492 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 10:08:25 +0100
Subject: [PATCH 132/154] Add helper to produce the set metadata lists

---
 bricktracker/set_list.py         | 24 +++++++++++++++++++++++-
 bricktracker/views/add.py        | 16 +++++-----------
 bricktracker/views/index.py      |  9 ++-------
 bricktracker/views/minifigure.py |  9 ++-------
 bricktracker/views/part.py       |  9 ++-------
 bricktracker/views/set.py        | 10 +++-------
 bricktracker/views/storage.py    |  8 ++------
 7 files changed, 39 insertions(+), 46 deletions(-)

diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py
index 6c3b9283..deaf269b 100644
--- a/bricktracker/set_list.py
+++ b/bricktracker/set_list.py
@@ -1,11 +1,14 @@
-from typing import Any, Self
+from typing import Any, Self, Union
 
 from flask import current_app
 
 from .record_list import BrickRecordList
+from .set_owner import BrickSetOwner
 from .set_owner_list import BrickSetOwnerList
 from .set_status_list import BrickSetStatusList
 from .set_storage import BrickSetStorage
+from .set_storage_list import BrickSetStorageList
+from .set_tag import BrickSetTag
 from .set_tag_list import BrickSetTagList
 from .set import BrickSet
 
@@ -163,3 +166,22 @@ class BrickSetList(BrickRecordList[BrickSet]):
         self.list(override_query=self.using_storage_query)
 
         return self
+
+
+# Helper to build the metadata lists
+def set_metadata_lists(
+    as_class: bool = False
+) -> dict[
+    str,
+    Union[
+        list[BrickSetOwner],
+        list[BrickSetStorage],
+        BrickSetStorageList,
+        list[BrickSetTag]
+    ]
+]:
+    return {
+        'brickset_owners': BrickSetOwnerList.list(),
+        'brickset_storages': BrickSetStorageList.list(as_class=as_class),
+        'brickset_tags': BrickSetTagList.list(),
+    }
diff --git a/bricktracker/views/add.py b/bricktracker/views/add.py
index fb11efe4..db4671e8 100644
--- a/bricktracker/views/add.py
+++ b/bricktracker/views/add.py
@@ -3,9 +3,7 @@ from flask_login import login_required
 
 from ..configuration_list import BrickConfigurationList
 from .exceptions import exception_handler
-from ..set_owner_list import BrickSetOwnerList
-from ..set_storage_list import BrickSetStorageList
-from ..set_tag_list import BrickSetTagList
+from ..set_list import set_metadata_lists
 from ..socket import MESSAGES
 
 add_page = Blueprint('add', __name__, url_prefix='/add')
@@ -20,12 +18,10 @@ def add() -> str:
 
     return render_template(
         'add.html',
-        brickset_owners=BrickSetOwnerList.list(),
-        brickset_storages=BrickSetStorageList.list(),
-        brickset_tags=BrickSetTagList.list(),
         path=current_app.config['SOCKET_PATH'],
         namespace=current_app.config['SOCKET_NAMESPACE'],
-        messages=MESSAGES
+        messages=MESSAGES,
+        **set_metadata_lists()
     )
 
 
@@ -38,11 +34,9 @@ def bulk() -> str:
 
     return render_template(
         'add.html',
-        brickset_owners=BrickSetOwnerList.list(),
-        brickset_storages=BrickSetStorageList.list(),
-        brickset_tags=BrickSetTagList.list(),
         path=current_app.config['SOCKET_PATH'],
         namespace=current_app.config['SOCKET_NAMESPACE'],
         messages=MESSAGES,
-        bulk=True
+        bulk=True,
+        **set_metadata_lists()
     )
diff --git a/bricktracker/views/index.py b/bricktracker/views/index.py
index b64775bf..1bf7c22e 100644
--- a/bricktracker/views/index.py
+++ b/bricktracker/views/index.py
@@ -2,11 +2,8 @@ from flask import Blueprint, render_template
 
 from .exceptions import exception_handler
 from ..minifigure_list import BrickMinifigureList
-from ..set_owner_list import BrickSetOwnerList
 from ..set_status_list import BrickSetStatusList
-from ..set_storage_list import BrickSetStorageList
-from ..set_tag_list import BrickSetTagList
-from ..set_list import BrickSetList
+from ..set_list import BrickSetList, set_metadata_lists
 
 index_page = Blueprint('index', __name__)
 
@@ -18,9 +15,7 @@ def index() -> str:
     return render_template(
         'index.html',
         brickset_collection=BrickSetList().last(),
-        brickset_owners=BrickSetOwnerList.list(),
         brickset_statuses=BrickSetStatusList.list(),
-        brickset_storages=BrickSetStorageList.list(as_class=True),
-        brickset_tags=BrickSetTagList.list(),
         minifigure_collection=BrickMinifigureList().last(),
+        **set_metadata_lists(as_class=True)
     )
diff --git a/bricktracker/views/minifigure.py b/bricktracker/views/minifigure.py
index 99587287..7123e4a7 100644
--- a/bricktracker/views/minifigure.py
+++ b/bricktracker/views/minifigure.py
@@ -3,10 +3,7 @@ from flask import Blueprint, render_template
 from .exceptions import exception_handler
 from ..minifigure import BrickMinifigure
 from ..minifigure_list import BrickMinifigureList
-from ..set_owner_list import BrickSetOwnerList
-from ..set_list import BrickSetList
-from ..set_storage_list import BrickSetStorageList
-from ..set_tag_list import BrickSetTagList
+from ..set_list import BrickSetList, set_metadata_lists
 
 minifigure_page = Blueprint('minifigure', __name__, url_prefix='/minifigures')
 
@@ -31,7 +28,5 @@ def details(*, figure: str) -> str:
         using=BrickSetList().using_minifigure(figure),
         missing=BrickSetList().missing_minifigure(figure),
         damaged=BrickSetList().damaged_minifigure(figure),
-        brickset_owners=BrickSetOwnerList.list(),
-        brickset_storages=BrickSetStorageList.list(as_class=True),
-        brickset_tags=BrickSetTagList.list(),
+        **set_metadata_lists(as_class=True)
     )
diff --git a/bricktracker/views/part.py b/bricktracker/views/part.py
index b2a9eedd..fc800c4e 100644
--- a/bricktracker/views/part.py
+++ b/bricktracker/views/part.py
@@ -4,10 +4,7 @@ from .exceptions import exception_handler
 from ..minifigure_list import BrickMinifigureList
 from ..part import BrickPart
 from ..part_list import BrickPartList
-from ..set_owner_list import BrickSetOwnerList
-from ..set_list import BrickSetList
-from ..set_storage_list import BrickSetStorageList
-from ..set_tag_list import BrickSetTagList
+from ..set_list import BrickSetList, set_metadata_lists
 
 part_page = Blueprint('part', __name__, url_prefix='/parts')
 
@@ -67,7 +64,5 @@ def details(*, part: str, color: int) -> str:
         ),
         different_color=BrickPartList().with_different_color(brickpart),
         similar_prints=BrickPartList().from_print(brickpart),
-        brickset_owners=BrickSetOwnerList.list(),
-        brickset_storages=BrickSetStorageList.list(as_class=True),
-        brickset_tags=BrickSetTagList.list(),
+        **set_metadata_lists(as_class=True)
     )
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index 8983cf94..de57cd30 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -16,7 +16,7 @@ from .exceptions import exception_handler
 from ..minifigure import BrickMinifigure
 from ..part import BrickPart
 from ..set import BrickSet
-from ..set_list import BrickSetList
+from ..set_list import BrickSetList, set_metadata_lists
 from ..set_owner_list import BrickSetOwnerList
 from ..set_status_list import BrickSetStatusList
 from ..set_storage_list import BrickSetStorageList
@@ -35,10 +35,8 @@ def list() -> str:
     return render_template(
         'sets.html',
         collection=BrickSetList().all(),
-        brickset_owners=BrickSetOwnerList.list(),
         brickset_statuses=BrickSetStatusList.list(),
-        brickset_storages=BrickSetStorageList.list(as_class=True),
-        brickset_tags=BrickSetTagList.list(),
+        **set_metadata_lists(as_class=True)
     )
 
 
@@ -145,10 +143,8 @@ def details(*, id: str) -> str:
         'set.html',
         item=BrickSet().select_specific(id),
         open_instructions=request.args.get('open_instructions'),
-        brickset_owners=BrickSetOwnerList.list(),
         brickset_statuses=BrickSetStatusList.list(all=True),
-        brickset_storages=BrickSetStorageList.list(as_class=True),
-        brickset_tags=BrickSetTagList.list(),
+        **set_metadata_lists(as_class=True)
     )
 
 
diff --git a/bricktracker/views/storage.py b/bricktracker/views/storage.py
index e41e97a4..7d5ba3f0 100644
--- a/bricktracker/views/storage.py
+++ b/bricktracker/views/storage.py
@@ -1,11 +1,9 @@
 from flask import Blueprint, render_template
 
 from .exceptions import exception_handler
-from ..set_owner_list import BrickSetOwnerList
-from ..set_list import BrickSetList
+from ..set_list import BrickSetList, set_metadata_lists
 from ..set_storage import BrickSetStorage
 from ..set_storage_list import BrickSetStorageList
-from ..set_tag_list import BrickSetTagList
 
 storage_page = Blueprint('storage', __name__, url_prefix='/storages')
 
@@ -30,7 +28,5 @@ def details(*, id: str) -> str:
         'storage.html',
         item=storage,
         sets=BrickSetList().using_storage(storage),
-        brickset_owners=BrickSetOwnerList.list(),
-        brickset_storages=BrickSetStorageList.list(as_class=True),
-        brickset_tags=BrickSetTagList.list(),
+        **set_metadata_lists(as_class=True)
     )

From 7ce029029ddbb510fdbede3313371d3fb4c4d1df Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 10:37:43 +0100
Subject: [PATCH 133/154] Properly separate setting state and value for
 metadata

---
 bricktracker/metadata.py                      | 23 ++++++++++---------
 bricktracker/set_storage.py                   |  2 +-
 .../storage/update/{state.sql => value.sql}   |  2 +-
 bricktracker/views/set.py                     |  4 ++--
 4 files changed, 16 insertions(+), 15 deletions(-)
 rename bricktracker/sql/set/metadata/storage/update/{state.sql => value.sql} (79%)

diff --git a/bricktracker/metadata.py b/bricktracker/metadata.py
index 07545f92..0b2f61cd 100644
--- a/bricktracker/metadata.py
+++ b/bricktracker/metadata.py
@@ -27,6 +27,7 @@ class BrickMetadata(BrickRecord):
     select_query: str
     update_field_query: str
     update_set_state_query: str
+    update_set_value_query: str
 
     def __init__(
         self,
@@ -224,25 +225,25 @@ class BrickMetadata(BrickRecord):
         /,
         *,
         json: Any | None = None,
-        state: Any | None = None,
+        value: Any | None = None,
     ) -> Any:
-        if state is None and json is not None:
-            state = json.get('value', '')
+        if value is None and json is not None:
+            value = json.get('value', '')
 
-        if state == '':
-            state = None
+        if value == '':
+            value = None
 
         parameters = self.sql_parameters()
         parameters['set_id'] = brickset.fields.id
-        parameters['state'] = state
+        parameters['value'] = value
 
         rows, _ = BrickSQL().execute_and_commit(
-            self.update_set_state_query,
+            self.update_set_value_query,
             parameters=parameters,
         )
 
         # Update the status
-        if state is None and not hasattr(self.fields, 'name'):
+        if value is None and not hasattr(self.fields, 'name'):
             self.fields.name = 'None'
 
         if rows != 1:
@@ -253,12 +254,12 @@ class BrickMetadata(BrickRecord):
             ))
 
         # Info
-        logger.info('{kind} value changed to "{name}" ({state}) for set {set} ({id})'.format(  # noqa: E501
+        logger.info('{kind} value changed to "{name}" ({value}) for set {set} ({id})'.format(  # noqa: E501
             kind=self.kind,
             name=self.fields.name,
-            state=state,
+            value=value,
             set=brickset.fields.set,
             id=brickset.fields.id,
         ))
 
-        return state
+        return value
diff --git a/bricktracker/set_storage.py b/bricktracker/set_storage.py
index 30c559c9..08c2429d 100644
--- a/bricktracker/set_storage.py
+++ b/bricktracker/set_storage.py
@@ -12,7 +12,7 @@ class BrickSetStorage(BrickMetadata):
     insert_query: str = 'set/metadata/storage/insert'
     select_query: str = 'set/metadata/storage/select'
     update_field_query: str = 'set/metadata/storage/update/field'
-    update_set_state_query: str = 'set/metadata/storage/update/state'
+    update_set_value_query: str = 'set/metadata/storage/update/value'
 
     # Self url
     def url(self, /) -> str:
diff --git a/bricktracker/sql/set/metadata/storage/update/state.sql b/bricktracker/sql/set/metadata/storage/update/value.sql
similarity index 79%
rename from bricktracker/sql/set/metadata/storage/update/state.sql
rename to bricktracker/sql/set/metadata/storage/update/value.sql
index 7cc40d6f..b758f08c 100644
--- a/bricktracker/sql/set/metadata/storage/update/state.sql
+++ b/bricktracker/sql/set/metadata/storage/update/value.sql
@@ -1,3 +1,3 @@
 UPDATE "bricktracker_sets"
-SET "storage" = :state
+SET "storage" = :value
 WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM :set_id
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index de57cd30..66a2a506 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -77,9 +77,9 @@ def update_storage(*, id: str) -> Response:
         allow_none=True
     )
 
-    state = storage.update_set_value(brickset, state=storage.fields.id)
+    value = storage.update_set_value(brickset, value=storage.fields.id)
 
-    return jsonify({'value': state})
+    return jsonify({'value': value})
 
 
 # Change the state of a tag

From 3d660c594be621a1b3b24ed5a8310b39bdde7b7f Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 10:47:22 +0100
Subject: [PATCH 134/154] Make instructions failsafe in the admin

---
 bricktracker/views/admin/admin.py |  5 +++-
 templates/admin/instructions.html | 40 ++++++++++++++++---------------
 2 files changed, 25 insertions(+), 20 deletions(-)

diff --git a/bricktracker/views/admin/admin.py b/bricktracker/views/admin/admin.py
index 584a359f..bb6a5e60 100644
--- a/bricktracker/views/admin/admin.py
+++ b/bricktracker/views/admin/admin.py
@@ -34,6 +34,7 @@ def admin() -> str:
     database_exception: Exception | None = None
     database_upgrade_needed: bool = False
     database_version: int = -1
+    instructions: BrickInstructionsList | None = None
     metadata_owners: list[BrickSetOwner] = []
     metadata_statuses: list[BrickSetStatus] = []
     metadata_storages: list[BrickSetStorage] = []
@@ -50,6 +51,8 @@ def admin() -> str:
         database_version = database.version
         database_counters = BrickSQL().count_records()
 
+        instructions = BrickInstructionsList()
+
         metadata_owners = BrickSetOwnerList.list()
         metadata_statuses = BrickSetStatusList.list(all=True)
         metadata_storages = BrickSetStorageList.list()
@@ -104,7 +107,7 @@ def admin() -> str:
         database_exception=database_exception,
         database_upgrade_needed=database_upgrade_needed,
         database_version=database_version,
-        instructions=BrickInstructionsList(),
+        instructions=instructions,
         metadata_owners=metadata_owners,
         metadata_statuses=metadata_statuses,
         metadata_storages=metadata_storages,
diff --git a/templates/admin/instructions.html b/templates/admin/instructions.html
index 99fbe5e7..997348cd 100644
--- a/templates/admin/instructions.html
+++ b/templates/admin/instructions.html
@@ -6,25 +6,27 @@
   The instructions files folder is: <code>{{ config['INSTRUCTIONS_FOLDER'] }}</code>. <br>
   Allowed file formats for instructions are the following: <code>{{ ', '.join(config['INSTRUCTIONS_ALLOWED_EXTENSIONS']) }}</code>.
 </p>
-<h5 class="border-bottom">Counters</h5>
-<p>
-  <div class="d-flex justify-content-start">
-    <ul class="list-group">
-      <li class="list-group-item d-flex justify-content-between align-items-start">
-        <span><i class="ri-hashtag"></i> Sets</span> <span class="badge text-bg-primary rounded-pill ms-2">{{ instructions.sets | length }}</span>
-      </li>
-      <li class="list-group-item d-flex justify-content-between align-items-start">
-        <span><i class="ri-file-line"></i> Instructions for sets</span> <span class="badge text-bg-primary rounded-pill ms-2">{{ instructions.sets_total }}</span>
-      </li>
-      <li class="list-group-item d-flex justify-content-between align-items-start">
-        <span><i class="ri-question-line"></i> Unknown</span> <span class="badge text-bg-primary rounded-pill ms-2">{{ instructions.unknown_total }}</span>
-      </li>
-      <li class="list-group-item d-flex justify-content-between align-items-start">
-        <span><i class="ri-prohibited-line"></i> Rejected files</span> <span class="badge text-bg-primary rounded-pill ms-2">{{ instructions.rejected_total }}</span>
-      </li>
-    </ul>
-  </div>
-</p>
+{% if instructions %}
+  <h5 class="border-bottom">Counters</h5>
+  <p>
+    <div class="d-flex justify-content-start">
+      <ul class="list-group">
+        <li class="list-group-item d-flex justify-content-between align-items-start">
+          <span><i class="ri-hashtag"></i> Sets</span> <span class="badge text-bg-primary rounded-pill ms-2">{{ instructions.sets | length }}</span>
+        </li>
+        <li class="list-group-item d-flex justify-content-between align-items-start">
+          <span><i class="ri-file-line"></i> Instructions for sets</span> <span class="badge text-bg-primary rounded-pill ms-2">{{ instructions.sets_total }}</span>
+        </li>
+        <li class="list-group-item d-flex justify-content-between align-items-start">
+          <span><i class="ri-question-line"></i> Unknown</span> <span class="badge text-bg-primary rounded-pill ms-2">{{ instructions.unknown_total }}</span>
+        </li>
+        <li class="list-group-item d-flex justify-content-between align-items-start">
+          <span><i class="ri-prohibited-line"></i> Rejected files</span> <span class="badge text-bg-primary rounded-pill ms-2">{{ instructions.rejected_total }}</span>
+        </li>
+      </ul>
+    </div>
+  </p>
+{% endif %}
 <h5 class="border-bottom">Refresh</h5>
 <p>
   <a href="{{ url_for('admin_instructions.refresh') }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh the instructions cache</a>

From 584389e2051557392b55ad00358adb7f1a203e34 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 10:55:59 +0100
Subject: [PATCH 135/154] Typo

---
 .env.sample | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.env.sample b/.env.sample
index d14491e5..44d52fec 100644
--- a/.env.sample
+++ b/.env.sample
@@ -263,7 +263,7 @@
 # Useful column names for this option are:
 # - "bricktracker_metadata_storages"."name" ASC: storage name
 # Default: "bricktracker_metadata_storages"."name" ASC
-# BK_MINIFIGURES_DEFAULT_ORDER="bricktracker_metadata_storages"."name" ASC
+# BK_STORAGE_DEFAULT_ORDER="bricktracker_metadata_storages"."name" ASC
 
 # Optional: URL to the themes.csv.gz on Rebrickable
 # Default: https://cdn.rebrickable.com/media/downloads/themes.csv.gz

From 16e4c28516346ad3f1246417f56d289abe8bec56 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 12:34:19 +0100
Subject: [PATCH 136/154] Continue separation of state and value

---
 bricktracker/metadata_list.py    | 11 ++++++++++-
 bricktracker/set_storage_list.py |  4 ++--
 bricktracker/views/set.py        |  2 +-
 templates/macro/form.html        |  2 +-
 4 files changed, 14 insertions(+), 5 deletions(-)

diff --git a/bricktracker/metadata_list.py b/bricktracker/metadata_list.py
index 60bfb5f1..3b98a132 100644
--- a/bricktracker/metadata_list.py
+++ b/bricktracker/metadata_list.py
@@ -28,8 +28,9 @@ class BrickMetadataList(BrickRecordList[T]):
     # Queries
     select_query: str
 
-    # Set state endpoint
+    # Set endpoints
     set_state_endpoint: str
+    set_value_endpoint: str
 
     def __init__(
         self,
@@ -149,3 +150,11 @@ class BrickMetadataList(BrickRecordList[T]):
             cls.set_state_endpoint,
             id=id,
         )
+
+    # URL to change the selected value of this metadata item for a set
+    @classmethod
+    def url_for_set_value(cls, id: str, /) -> str:
+        return url_for(
+            cls.set_value_endpoint,
+            id=id,
+        )
diff --git a/bricktracker/set_storage_list.py b/bricktracker/set_storage_list.py
index 8453f366..77be7164 100644
--- a/bricktracker/set_storage_list.py
+++ b/bricktracker/set_storage_list.py
@@ -17,8 +17,8 @@ class BrickSetStorageList(BrickMetadataList[BrickSetStorage]):
     select_query = 'set/metadata/storage/list'
     all_query = 'set/metadata/storage/all'
 
-    # Set state endpoint
-    set_state_endpoint: str = 'set.update_storage'
+    # Set value endpoint
+    set_value_endpoint: str = 'set.update_storage'
 
     # Load all storages
     @classmethod
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index 66a2a506..7f397da7 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -66,7 +66,7 @@ def update_status(*, id: str, metadata_id: str) -> Response:
     return jsonify({'value': state})
 
 
-# Change the state of a storage
+# Change the value of storage
 @set_page.route('/<id>/storage', methods=['POST'])
 @login_required
 @exception_handler(__file__, json=True)
diff --git a/templates/macro/form.html b/templates/macro/form.html
index 6fb38902..45ec2b5d 100644
--- a/templates/macro/form.html
+++ b/templates/macro/form.html
@@ -48,7 +48,7 @@
       {% if icon %}<span class="input-group-text"><i class="ri-{{ icon }}"></i></span>{% endif %}
       <select id="{{ prefix }}-{{ item.fields.id }}" class="form-select"
         {% if not delete %}
-          data-changer-id="{{ item.fields.id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ metadata_list.url_for_set_state(item.fields.id) }}"
+          data-changer-id="{{ item.fields.id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ metadata_list.url_for_set_value(item.fields.id) }}"
         {% else %}
           disabled
         {% endif %}

From 7f684c5f02c15c57bf3f878ef5d1d423bf54110b Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 12:34:46 +0100
Subject: [PATCH 137/154] Fix improper open flag

---
 templates/set/management.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/templates/set/management.html b/templates/set/management.html
index 54b2e879..26959d3a 100644
--- a/templates/set/management.html
+++ b/templates/set/management.html
@@ -20,7 +20,7 @@
       <p class="text-center"><i class="ri-error-warning-line"></i> No storage found.</p>
     {% endif %}
     <hr>
-    <a href="{{ url_for('admin.admin', open_tag=true) }}" class="btn btn-primary" role="button"><i class="ri-settings-4-line"></i> Manage the set storages</a>
+      <a href="{{ url_for('admin.admin', open_storage=true) }}" class="btn btn-primary" role="button"><i class="ri-settings-4-line"></i> Manage the set storages</a>
   {{ accordion.footer() }}
   {{ accordion.header('Tags', 'tag', 'set-details', icon='price-tag-2-line', class='p-0') }}
     <ul class="list-group list-group-flush">

From e7bfa66512dafa07ae1e246d6afb149e60036cd2 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 12:40:49 +0100
Subject: [PATCH 138/154] Put set metadata in nested accordion to reduce
 footprint

---
 bricktracker/views/admin/admin.py | 13 +++++++++----
 templates/admin.html              |  4 ++++
 templates/admin/owner.html        |  2 +-
 templates/admin/status.html       |  2 +-
 templates/admin/storage.html      |  2 +-
 templates/admin/tag.html          |  2 +-
 templates/set/management.html     | 11 ++++++-----
 7 files changed, 23 insertions(+), 13 deletions(-)

diff --git a/bricktracker/views/admin/admin.py b/bricktracker/views/admin/admin.py
index bb6a5e60..08cdafcb 100644
--- a/bricktracker/views/admin/admin.py
+++ b/bricktracker/views/admin/admin.py
@@ -87,15 +87,19 @@ def admin() -> str:
     open_tag = request.args.get('open_tag', None)
     open_theme = request.args.get('open_theme', None)
 
+    open_metadata = (
+        open_owner or
+        open_status or
+        open_storage or
+        open_tag
+    )
+
     open_database = (
         open_image is None and
         open_instructions is None and
         open_logout is None and
-        open_owner is None and
+        not open_metadata and
         open_retired is None and
-        open_status is None and
-        open_storage is None and
-        open_tag is None and
         open_theme is None
     )
 
@@ -120,6 +124,7 @@ def admin() -> str:
         open_image=open_image,
         open_instructions=open_instructions,
         open_logout=open_logout,
+        open_metadata=open_metadata,
         open_owner=open_owner,
         open_retired=open_retired,
         open_status=open_status,
diff --git a/templates/admin.html b/templates/admin.html
index 3582c7b6..22a7e1a7 100644
--- a/templates/admin.html
+++ b/templates/admin.html
@@ -1,3 +1,5 @@
+{% import 'macro/accordion.html' as accordion %}
+
 {% extends 'base.html' %}
 
 {% block title %} - Administration{% endblock %}
@@ -36,10 +38,12 @@
               {% endif %}
               {% include 'admin/theme.html' %}
               {% include 'admin/retired.html' %}
+              {{ accordion.header('Set metadata', 'metadata', 'admin', expanded=open_metadata, icon='profile-line', class='p-0') }}
               {% include 'admin/owner.html' %}
               {% include 'admin/status.html' %}
               {% include 'admin/storage.html' %}
               {% include 'admin/tag.html' %}
+              {{ accordion.footer() }}
               {% include 'admin/database.html' %}
               {% include 'admin/configuration.html' %}
             {% endif %}
diff --git a/templates/admin/owner.html b/templates/admin/owner.html
index 8c7c2619..351eaa51 100644
--- a/templates/admin/owner.html
+++ b/templates/admin/owner.html
@@ -1,6 +1,6 @@
 {% import 'macro/accordion.html' as accordion %}
 
-{{ accordion.header('Set owners', 'owner', 'admin', expanded=open_owner, icon='user-line', class='p-0') }}
+{{ accordion.header('Set owners', 'owner', 'metadata', expanded=open_owner, icon='user-line', class='p-0') }}
 {% if owner_error %}<div class="alert alert-danger m-2" role="alert"><strong>Error:</strong> {{ owner_error }}.</div>{% endif %}
 <ul class="list-group list-group-flush">
   {% if metadata_owners | length %}
diff --git a/templates/admin/status.html b/templates/admin/status.html
index 609bc68d..0377a4c3 100644
--- a/templates/admin/status.html
+++ b/templates/admin/status.html
@@ -1,6 +1,6 @@
 {% import 'macro/accordion.html' as accordion %}
 
-{{ accordion.header('Set statuses', 'status', 'admin', expanded=open_status, icon='checkbox-line', class='p-0') }}
+{{ accordion.header('Set statuses', 'status', 'metadata', expanded=open_status, icon='checkbox-line', class='p-0') }}
 {% if status_error %}<div class="alert alert-danger m-2" role="alert"><strong>Error:</strong> {{ status_error }}.</div>{% endif %}
 <ul class="list-group list-group-flush">
   {% if metadata_statuses | length %}
diff --git a/templates/admin/storage.html b/templates/admin/storage.html
index 1f317e6c..ddd3e4b4 100644
--- a/templates/admin/storage.html
+++ b/templates/admin/storage.html
@@ -1,6 +1,6 @@
 {% import 'macro/accordion.html' as accordion %}
 
-{{ accordion.header('Set storages', 'storage', 'admin', expanded=open_storage, icon='archive-2-line', class='p-0') }}
+{{ accordion.header('Set storages', 'storage', 'metadata', expanded=open_storage, icon='archive-2-line', class='p-0') }}
 {% if storage_error %}<div class="alert alert-danger m-2" role="alert"><strong>Error:</strong> {{ storage_error }}.</div>{% endif %}
 <ul class="list-group list-group-flush">
   {% if metadata_storages | length %}
diff --git a/templates/admin/tag.html b/templates/admin/tag.html
index c3ca1f61..30bfa711 100644
--- a/templates/admin/tag.html
+++ b/templates/admin/tag.html
@@ -1,6 +1,6 @@
 {% import 'macro/accordion.html' as accordion %}
 
-{{ accordion.header('Set tags', 'tag', 'admin', expanded=open_tag, icon='price-tag-2-line', class='p-0') }}
+{{ accordion.header('Set tags', 'tag', 'metadata', expanded=open_tag, icon='price-tag-2-line', class='p-0') }}
 {% if tag_error %}<div class="alert alert-danger m-2" role="alert"><strong>Error:</strong> {{ tag_error }}.</div>{% endif %}
 <ul class="list-group list-group-flush">
   {% if metadata_tags | length %}
diff --git a/templates/set/management.html b/templates/set/management.html
index 26959d3a..1a8c2b7b 100644
--- a/templates/set/management.html
+++ b/templates/set/management.html
@@ -1,5 +1,6 @@
 {% if g.login.is_authenticated() %}
-  {{ accordion.header('Owners', 'owner', 'set-details', icon='group-line', class='p-0') }}
+  {{ accordion.header('Management', 'set-management', 'set-details', icon='settings-4-line', class='p-0') }}
+    {{ accordion.header('Owners', 'owner', 'set-management', icon='group-line', class='p-0') }}
     <ul class="list-group list-group-flush">
     {% if brickset_owners | length %}
       {% for owner in brickset_owners %}
@@ -13,7 +14,7 @@
       <a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_owner=true) }}"><i class="ri-settings-4-line"></i> Manage the set owners</a>
     </div>
   {{ accordion.footer() }}
-  {{ accordion.header('Storage', 'storage', 'set-details', icon='archive-2-line') }}
+    {{ accordion.header('Storage', 'storage', 'set-management', icon='archive-2-line') }}
     {% if brickset_storages | length %}
       {{ form.select('Storage', item, 'storage', brickset_storages, delete=delete) }}
     {% else %}
@@ -22,7 +23,7 @@
     <hr>
       <a href="{{ url_for('admin.admin', open_storage=true) }}" class="btn btn-primary" role="button"><i class="ri-settings-4-line"></i> Manage the set storages</a>
   {{ accordion.footer() }}
-  {{ accordion.header('Tags', 'tag', 'set-details', icon='price-tag-2-line', class='p-0') }}
+    {{ accordion.header('Tags', 'tag', 'set-management', icon='price-tag-2-line', class='p-0') }}
     <ul class="list-group list-group-flush">
     {% if brickset_tags | length %}
       {% for tag in brickset_tags %}
@@ -36,8 +37,8 @@
       <a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_tag=true) }}"><i class="ri-settings-4-line"></i> Manage the set tags</a>
     </div>
   {{ accordion.footer() }}
-  {{ accordion.header('Management', 'management', 'set-details', icon='settings-4-line') }}
-    <h5 class="border-bottom">Data</h5>
+    {{ accordion.header('Data', 'data', 'set-management', icon='database-2-line') }}
     <a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh the set data</a>
+    {{ accordion.footer() }}
   {{ accordion.footer() }}
 {% endif %}

From 195f18f141a4ff5dd6c3d10d7109b22f2b9c7f26 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 12:52:18 +0100
Subject: [PATCH 139/154] Set purchase location

---
 .env.sample                                   |  6 ++
 CHANGELOG.md                                  |  5 +-
 bricktracker/app.py                           |  2 +
 bricktracker/config.py                        |  1 +
 bricktracker/metadata.py                      |  6 +-
 bricktracker/metadata_list.py                 | 10 ++-
 bricktracker/record_list.py                   |  2 +
 bricktracker/reload.py                        |  4 +
 bricktracker/set.py                           |  8 ++
 bricktracker/set_list.py                      |  5 ++
 bricktracker/set_purchase_location.py         | 13 +++
 bricktracker/set_purchase_location_list.py    | 42 ++++++++++
 bricktracker/sql/set/base/base.sql            |  1 +
 bricktracker/sql/set/insert.sql               |  6 +-
 .../set/metadata/purchase_location/base.sql   |  6 ++
 .../set/metadata/purchase_location/delete.sql | 10 +++
 .../set/metadata/purchase_location/insert.sql | 11 +++
 .../set/metadata/purchase_location/list.sql   |  1 +
 .../set/metadata/purchase_location/select.sql |  5 ++
 .../purchase_location/update/field.sql        |  3 +
 .../purchase_location/update/value.sql        |  3 +
 bricktracker/views/admin/admin.py             |  9 ++
 bricktracker/views/admin/purchase_location.py | 84 +++++++++++++++++++
 bricktracker/views/set.py                     | 20 +++++
 static/scripts/socket/set.js                  | 12 +++
 templates/add.html                            | 15 +++-
 templates/admin.html                          | 11 ++-
 templates/admin/purchase_location.html        | 42 ++++++++++
 templates/admin/purchase_location/delete.html | 19 +++++
 templates/macro/badge.html                    | 12 +++
 templates/set/card.html                       |  6 ++
 templates/set/filter.html                     | 16 ++++
 templates/set/management.html                 | 77 +++++++++--------
 templates/sets.html                           |  2 +-
 34 files changed, 427 insertions(+), 48 deletions(-)
 create mode 100644 bricktracker/set_purchase_location.py
 create mode 100644 bricktracker/set_purchase_location_list.py
 create mode 100644 bricktracker/sql/set/metadata/purchase_location/base.sql
 create mode 100644 bricktracker/sql/set/metadata/purchase_location/delete.sql
 create mode 100644 bricktracker/sql/set/metadata/purchase_location/insert.sql
 create mode 100644 bricktracker/sql/set/metadata/purchase_location/list.sql
 create mode 100644 bricktracker/sql/set/metadata/purchase_location/select.sql
 create mode 100644 bricktracker/sql/set/metadata/purchase_location/update/field.sql
 create mode 100644 bricktracker/sql/set/metadata/purchase_location/update/value.sql
 create mode 100644 bricktracker/views/admin/purchase_location.py
 create mode 100644 templates/admin/purchase_location.html
 create mode 100644 templates/admin/purchase_location/delete.html

diff --git a/.env.sample b/.env.sample
index 44d52fec..8f129392 100644
--- a/.env.sample
+++ b/.env.sample
@@ -168,6 +168,12 @@
 # Default: 3333
 # BK_PORT=3333
 
+# Optional: Change the default order of purchase locations. By default ordered by insertion order.
+# Useful column names for this option are:
+# - "bricktracker_metadata_purchase_locations"."name" ASC: storage name
+# Default: "bricktracker_metadata_purchase_locations"."name" ASC
+# BK_PURCHASE_LOCATION_DEFAULT_ORDER="bricktracker_metadata_purchase_locations"."name" ASC
+
 # Optional: Shuffle the lists on the front page.
 # Default: false
 # Legacy name: RANDOM
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b50f3427..efcdb16d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,7 +14,8 @@
 - Added: `BK_SHOW_GRID_SORT`, show the sort options on the grid by default
 - Added: `BK_SHOW_GRID_FILTERS`, show the filter options on the grid by default
 - Added: `BK_HIDE_ALL_STORAGES`, hide the "Storages" menu entry
-- Added: `BK_MINIFIGURES_DEFAULT_ORDER`, ordering of storages
+- Added: `BK_STORAGE_DEFAULT_ORDER`, ordering of storages
+- Added: `BK_PURCHASE_LOCATION_DEFAULT_ORDER`, ordering of purchase locations
 
 ### Code
 
@@ -39,6 +40,7 @@
     - Ownership
     - Tags
     - Storage
+    - Purchase location
 
 - Storage
     - Storage content and list
@@ -85,6 +87,7 @@
         - Tags
         - Refresh
         - Storage
+        - Purchase location
 
 - Sets grid
     - Collapsible controls depending on screen size
diff --git a/bricktracker/app.py b/bricktracker/app.py
index af005d96..f0afe429 100644
--- a/bricktracker/app.py
+++ b/bricktracker/app.py
@@ -17,6 +17,7 @@ from bricktracker.views.admin.database import admin_database_page
 from bricktracker.views.admin.image import admin_image_page
 from bricktracker.views.admin.instructions import admin_instructions_page
 from bricktracker.views.admin.owner import admin_owner_page
+from bricktracker.views.admin.purchase_location import admin_purchase_location_page  # noqa: E501
 from bricktracker.views.admin.retired import admin_retired_page
 from bricktracker.views.admin.status import admin_status_page
 from bricktracker.views.admin.storage import admin_storage_page
@@ -88,6 +89,7 @@ def setup_app(app: Flask) -> None:
     app.register_blueprint(admin_instructions_page)
     app.register_blueprint(admin_retired_page)
     app.register_blueprint(admin_owner_page)
+    app.register_blueprint(admin_purchase_location_page)
     app.register_blueprint(admin_status_page)
     app.register_blueprint(admin_storage_page)
     app.register_blueprint(admin_tag_page)
diff --git a/bricktracker/config.py b/bricktracker/config.py
index 5b9788fb..729049a9 100644
--- a/bricktracker/config.py
+++ b/bricktracker/config.py
@@ -41,6 +41,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
     {'n': 'PARTS_DEFAULT_ORDER', 'd': '"rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "bricktracker_parts"."spare" ASC'},  # noqa: E501
     {'n': 'PARTS_FOLDER', 'd': 'parts', 's': True},
     {'n': 'PORT', 'd': 3333, 'c': int},
+    {'n': 'PURCHASE_LOCATION_DEFAULT_ORDER', 'd': '"bricktracker_metadata_purchase_locations"."name" ASC'},  # noqa: E501
     {'n': 'RANDOM', 'e': 'RANDOM', 'c': bool},
     {'n': 'REBRICKABLE_API_KEY', 'e': 'REBRICKABLE_API_KEY', 'd': ''},
     {'n': 'REBRICKABLE_IMAGE_NIL', 'd': 'https://rebrickable.com/static/img/nil.png'},  # noqa: E501
diff --git a/bricktracker/metadata.py b/bricktracker/metadata.py
index 0b2f61cd..88d26235 100644
--- a/bricktracker/metadata.py
+++ b/bricktracker/metadata.py
@@ -48,7 +48,7 @@ class BrickMetadata(BrickRecord):
     def as_column(self, /) -> str:
         return '{kind}_{id}'.format(
             id=self.fields.id,
-            kind=self.kind.lower()
+            kind=self.kind.lower().replace(' ', '-')
         )
 
     # HTML dataset name
@@ -90,8 +90,6 @@ class BrickMetadata(BrickRecord):
 
     # Rename the entry
     def rename(self, /) -> None:
-        self.safe()
-
         self.update_field('name', value=self.fields.name)
 
     # Make the name "safe"
@@ -159,7 +157,7 @@ class BrickMetadata(BrickRecord):
         )
 
         if rows != 1:
-            raise DatabaseException('Could not update the field "{field}" for {kind} {name} ({id})'.format(  # noqa: E501
+            raise DatabaseException('Could not update the field "{field}" for {kind} "{name}" ({id})'.format(  # noqa: E501
                 field=field,
                 kind=self.kind,
                 name=self.fields.name,
diff --git a/bricktracker/metadata_list.py b/bricktracker/metadata_list.py
index 3b98a132..71245483 100644
--- a/bricktracker/metadata_list.py
+++ b/bricktracker/metadata_list.py
@@ -7,13 +7,21 @@ from .exceptions import NotFoundException
 from .fields import BrickRecordFields
 from .record_list import BrickRecordList
 from .set_owner import BrickSetOwner
+from .set_purchase_location import BrickSetPurchaseLocation
 from .set_status import BrickSetStatus
 from .set_storage import BrickSetStorage
 from .set_tag import BrickSetTag
 
 logger = logging.getLogger(__name__)
 
-T = TypeVar('T', BrickSetOwner, BrickSetStatus, BrickSetStorage, BrickSetTag)
+T = TypeVar(
+    'T',
+    BrickSetOwner,
+    BrickSetPurchaseLocation,
+    BrickSetStatus,
+    BrickSetStorage,
+    BrickSetTag
+)
 
 
 # Lego sets metadata list
diff --git a/bricktracker/record_list.py b/bricktracker/record_list.py
index 23da29bc..18dcb10c 100644
--- a/bricktracker/record_list.py
+++ b/bricktracker/record_list.py
@@ -9,6 +9,7 @@ if TYPE_CHECKING:
     from .rebrickable_set import RebrickableSet
     from .set import BrickSet
     from .set_owner import BrickSetOwner
+    from .set_purchase_location import BrickSetPurchaseLocation
     from .set_status import BrickSetStatus
     from .set_storage import BrickSetStorage
     from .set_tag import BrickSetTag
@@ -20,6 +21,7 @@ T = TypeVar(
     'BrickPart',
     'BrickSet',
     'BrickSetOwner',
+    'BrickSetPurchaseLocation',
     'BrickSetStatus',
     'BrickSetStorage',
     'BrickSetTag',
diff --git a/bricktracker/reload.py b/bricktracker/reload.py
index b2247ea4..38929f68 100644
--- a/bricktracker/reload.py
+++ b/bricktracker/reload.py
@@ -1,6 +1,7 @@
 from .instructions_list import BrickInstructionsList
 from .retired_list import BrickRetiredList
 from .set_owner_list import BrickSetOwnerList
+from .set_purchase_location_list import BrickSetPurchaseLocationList
 from .set_status_list import BrickSetStatusList
 from .set_storage_list import BrickSetStorageList
 from .set_tag_list import BrickSetTagList
@@ -17,6 +18,9 @@ def reload() -> None:
         # Reload the set owners
         BrickSetOwnerList.new(force=True)
 
+        # Reload the set purchase locations
+        BrickSetPurchaseLocationList.new(force=True)
+
         # Reload the set statuses
         BrickSetStatusList.new(force=True)
 
diff --git a/bricktracker/set.py b/bricktracker/set.py
index 6368d40c..fb972092 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -10,6 +10,7 @@ from .minifigure_list import BrickMinifigureList
 from .part_list import BrickPartList
 from .rebrickable_set import RebrickableSet
 from .set_owner_list import BrickSetOwnerList
+from .set_purchase_location_list import BrickSetPurchaseLocationList
 from .set_status_list import BrickSetStatusList
 from .set_storage_list import BrickSetStorageList
 from .set_tag_list import BrickSetTagList
@@ -63,6 +64,13 @@ class BrickSet(RebrickableSet):
                 )
                 self.fields.storage = storage.fields.id
 
+                # Save the purchase location
+                purchase_location = BrickSetPurchaseLocationList.get(
+                    data.get('purchase_location', ''),
+                    allow_none=True
+                )
+                self.fields.purchase_location = purchase_location.fields.id
+
                 # Insert into database
                 self.insert(commit=False)
 
diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py
index deaf269b..a8f5faa4 100644
--- a/bricktracker/set_list.py
+++ b/bricktracker/set_list.py
@@ -5,6 +5,8 @@ from flask import current_app
 from .record_list import BrickRecordList
 from .set_owner import BrickSetOwner
 from .set_owner_list import BrickSetOwnerList
+from .set_purchase_location import BrickSetPurchaseLocation
+from .set_purchase_location_list import BrickSetPurchaseLocationList
 from .set_status_list import BrickSetStatusList
 from .set_storage import BrickSetStorage
 from .set_storage_list import BrickSetStorageList
@@ -175,6 +177,8 @@ def set_metadata_lists(
     str,
     Union[
         list[BrickSetOwner],
+        list[BrickSetPurchaseLocation],
+        BrickSetPurchaseLocation,
         list[BrickSetStorage],
         BrickSetStorageList,
         list[BrickSetTag]
@@ -182,6 +186,7 @@ def set_metadata_lists(
 ]:
     return {
         'brickset_owners': BrickSetOwnerList.list(),
+        'brickset_purchase_locations': BrickSetPurchaseLocationList.list(as_class=as_class),  # noqa: E501
         'brickset_storages': BrickSetStorageList.list(as_class=as_class),
         'brickset_tags': BrickSetTagList.list(),
     }
diff --git a/bricktracker/set_purchase_location.py b/bricktracker/set_purchase_location.py
new file mode 100644
index 00000000..801ccf82
--- /dev/null
+++ b/bricktracker/set_purchase_location.py
@@ -0,0 +1,13 @@
+from .metadata import BrickMetadata
+
+
+# Lego set purchase location metadata
+class BrickSetPurchaseLocation(BrickMetadata):
+    kind: str = 'purchase location'
+
+    # Queries
+    delete_query: str = 'set/metadata/purchase_location/delete'
+    insert_query: str = 'set/metadata/purchase_location/insert'
+    select_query: str = 'set/metadata/purchase_location/select'
+    update_field_query: str = 'set/metadata/purchase_location/update/field'
+    update_set_value_query: str = 'set/metadata/purchase_location/update/value'
diff --git a/bricktracker/set_purchase_location_list.py b/bricktracker/set_purchase_location_list.py
new file mode 100644
index 00000000..3ffae4bc
--- /dev/null
+++ b/bricktracker/set_purchase_location_list.py
@@ -0,0 +1,42 @@
+import logging
+from typing import Self
+
+from flask import current_app
+
+from .metadata_list import BrickMetadataList
+from .set_purchase_location import BrickSetPurchaseLocation
+
+logger = logging.getLogger(__name__)
+
+
+# Lego sets purchase location list
+class BrickSetPurchaseLocationList(
+    BrickMetadataList[BrickSetPurchaseLocation]
+):
+    kind: str = 'set purchase locations'
+
+    # Queries
+    select_query = 'set/metadata/purchase_location/list'
+    all_query = 'set/metadata/purchase_location/all'
+
+    # Set value endpoint
+    set_value_endpoint: str = 'set.update_purchase_location'
+
+    # Load all purchase locations
+    @classmethod
+    def all(cls, /) -> Self:
+        new = cls.new()
+        new.override()
+
+        for record in new.select(
+            override_query=cls.all_query,
+            order=current_app.config['PURCHASE_LOCATION_DEFAULT_ORDER']
+        ):
+            new.records.append(new.model(record=record))
+
+        return new
+
+    # Instantiate the list with the proper class
+    @classmethod
+    def new(cls, /, *, force: bool = False) -> Self:
+        return cls(BrickSetPurchaseLocation, force=force)
diff --git a/bricktracker/sql/set/base/base.sql b/bricktracker/sql/set/base/base.sql
index fbc86e0d..333868d0 100644
--- a/bricktracker/sql/set/base/base.sql
+++ b/bricktracker/sql/set/base/base.sql
@@ -1,6 +1,7 @@
 SELECT
     {% block id %}{% endblock %}
     "bricktracker_sets"."storage",
+    "bricktracker_sets"."purchase_location",
     "rebrickable_sets"."set",
     "rebrickable_sets"."number",
     "rebrickable_sets"."version",
diff --git a/bricktracker/sql/set/insert.sql b/bricktracker/sql/set/insert.sql
index 9a46f88b..bc933c33 100644
--- a/bricktracker/sql/set/insert.sql
+++ b/bricktracker/sql/set/insert.sql
@@ -1,9 +1,11 @@
 INSERT OR IGNORE INTO "bricktracker_sets" (
     "id",
     "set",
-    "storage"
+    "storage",
+    "purchase_location"
 ) VALUES (
     :id,
     :set,
-    :storage
+    :storage,
+    :purchase_location
 )
diff --git a/bricktracker/sql/set/metadata/purchase_location/base.sql b/bricktracker/sql/set/metadata/purchase_location/base.sql
new file mode 100644
index 00000000..8ac33ca1
--- /dev/null
+++ b/bricktracker/sql/set/metadata/purchase_location/base.sql
@@ -0,0 +1,6 @@
+SELECT
+    "bricktracker_metadata_purchase_locations"."id",
+    "bricktracker_metadata_purchase_locations"."name"
+FROM "bricktracker_metadata_purchase_locations"
+
+{% block where %}{% endblock %}
diff --git a/bricktracker/sql/set/metadata/purchase_location/delete.sql b/bricktracker/sql/set/metadata/purchase_location/delete.sql
new file mode 100644
index 00000000..489dfd07
--- /dev/null
+++ b/bricktracker/sql/set/metadata/purchase_location/delete.sql
@@ -0,0 +1,10 @@
+BEGIN TRANSACTION;
+
+DELETE FROM "bricktracker_metadata_purchase_locations"
+WHERE "bricktracker_metadata_purchase_locations"."id" IS NOT DISTINCT FROM '{{ id }}';
+
+UPDATE "bricktracker_sets"
+SET "purchase_location" = NULL
+WHERE "bricktracker_sets"."purchase_location" IS NOT DISTINCT FROM '{{ id }}';
+
+COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/purchase_location/insert.sql b/bricktracker/sql/set/metadata/purchase_location/insert.sql
new file mode 100644
index 00000000..22fc5879
--- /dev/null
+++ b/bricktracker/sql/set/metadata/purchase_location/insert.sql
@@ -0,0 +1,11 @@
+BEGIN TRANSACTION;
+
+INSERT INTO "bricktracker_metadata_purchase_locations" (
+    "id",
+    "name"
+) VALUES (
+    '{{ id }}',
+    '{{ name }}'
+);
+
+COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/purchase_location/list.sql b/bricktracker/sql/set/metadata/purchase_location/list.sql
new file mode 100644
index 00000000..2a0813b5
--- /dev/null
+++ b/bricktracker/sql/set/metadata/purchase_location/list.sql
@@ -0,0 +1 @@
+{% extends 'set/metadata/purchase_location/base.sql' %}
diff --git a/bricktracker/sql/set/metadata/purchase_location/select.sql b/bricktracker/sql/set/metadata/purchase_location/select.sql
new file mode 100644
index 00000000..a9e6161f
--- /dev/null
+++ b/bricktracker/sql/set/metadata/purchase_location/select.sql
@@ -0,0 +1,5 @@
+{% extends 'set/metadata/purchase_location/base.sql' %}
+
+{% block where %}
+WHERE "bricktracker_metadata_purchase_locations"."id" IS NOT DISTINCT FROM :id
+{% endblock %}
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/purchase_location/update/field.sql b/bricktracker/sql/set/metadata/purchase_location/update/field.sql
new file mode 100644
index 00000000..323d98d1
--- /dev/null
+++ b/bricktracker/sql/set/metadata/purchase_location/update/field.sql
@@ -0,0 +1,3 @@
+UPDATE "bricktracker_metadata_purchase_locations"
+SET "{{field}}" = :value
+WHERE "bricktracker_metadata_purchase_locations"."id" IS NOT DISTINCT FROM :id
diff --git a/bricktracker/sql/set/metadata/purchase_location/update/value.sql b/bricktracker/sql/set/metadata/purchase_location/update/value.sql
new file mode 100644
index 00000000..d27469e1
--- /dev/null
+++ b/bricktracker/sql/set/metadata/purchase_location/update/value.sql
@@ -0,0 +1,3 @@
+UPDATE "bricktracker_sets"
+SET "purchase_location" = :value
+WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM :set_id
diff --git a/bricktracker/views/admin/admin.py b/bricktracker/views/admin/admin.py
index 08cdafcb..749b3df2 100644
--- a/bricktracker/views/admin/admin.py
+++ b/bricktracker/views/admin/admin.py
@@ -10,6 +10,8 @@ from ...rebrickable_image import RebrickableImage
 from ...retired_list import BrickRetiredList
 from ...set_owner import BrickSetOwner
 from ...set_owner_list import BrickSetOwnerList
+from ...set_purchase_location import BrickSetPurchaseLocation
+from ...set_purchase_location_list import BrickSetPurchaseLocationList
 from ...set_storage import BrickSetStorage
 from ...set_storage_list import BrickSetStorageList
 from ...set_status import BrickSetStatus
@@ -36,6 +38,7 @@ def admin() -> str:
     database_version: int = -1
     instructions: BrickInstructionsList | None = None
     metadata_owners: list[BrickSetOwner] = []
+    metadata_purchase_locations: list[BrickSetPurchaseLocation] = []
     metadata_statuses: list[BrickSetStatus] = []
     metadata_storages: list[BrickSetStorage] = []
     metadata_tags: list[BrickSetTag] = []
@@ -54,6 +57,7 @@ def admin() -> str:
         instructions = BrickInstructionsList()
 
         metadata_owners = BrickSetOwnerList.list()
+        metadata_purchase_locations = BrickSetPurchaseLocationList.list()
         metadata_statuses = BrickSetStatusList.list(all=True)
         metadata_storages = BrickSetStorageList.list()
         metadata_tags = BrickSetTagList.list()
@@ -81,6 +85,7 @@ def admin() -> str:
     open_instructions = request.args.get('open_instructions', None)
     open_logout = request.args.get('open_logout', None)
     open_owner = request.args.get('open_owner', None)
+    open_purchase_location = request.args.get('open_purchase_location', None)
     open_retired = request.args.get('open_retired', None)
     open_status = request.args.get('open_status', None)
     open_storage = request.args.get('open_storage', None)
@@ -89,6 +94,7 @@ def admin() -> str:
 
     open_metadata = (
         open_owner or
+        open_purchase_location or
         open_status or
         open_storage or
         open_tag
@@ -113,6 +119,7 @@ def admin() -> str:
         database_version=database_version,
         instructions=instructions,
         metadata_owners=metadata_owners,
+        metadata_purchase_locations=metadata_purchase_locations,
         metadata_statuses=metadata_statuses,
         metadata_storages=metadata_storages,
         metadata_tags=metadata_tags,
@@ -126,12 +133,14 @@ def admin() -> str:
         open_logout=open_logout,
         open_metadata=open_metadata,
         open_owner=open_owner,
+        open_purchase_location=open_purchase_location,
         open_retired=open_retired,
         open_status=open_status,
         open_storage=open_storage,
         open_tag=open_tag,
         open_theme=open_theme,
         owner_error=request.args.get('owner_error'),
+        purchase_location_error=request.args.get('purchase_location_error'),
         retired=BrickRetiredList(),
         status_error=request.args.get('status_error'),
         storage_error=request.args.get('storage_error'),
diff --git a/bricktracker/views/admin/purchase_location.py b/bricktracker/views/admin/purchase_location.py
new file mode 100644
index 00000000..48e7b7d5
--- /dev/null
+++ b/bricktracker/views/admin/purchase_location.py
@@ -0,0 +1,84 @@
+from flask import (
+    Blueprint,
+    redirect,
+    request,
+    render_template,
+    url_for,
+)
+from flask_login import login_required
+from werkzeug.wrappers.response import Response
+
+from ..exceptions import exception_handler
+from ...reload import reload
+from ...set_purchase_location import BrickSetPurchaseLocation
+
+admin_purchase_location_page = Blueprint(
+    'admin_purchase_location',
+    __name__,
+    url_prefix='/admin/purchase_location'
+)
+
+
+# Add a metadata purchase location
+@admin_purchase_location_page.route('/add', methods=['POST'])
+@login_required
+@exception_handler(
+    __file__,
+    post_redirect='admin.admin',
+    error_name='purchase_location_error',
+    open_purchase_location=True
+)
+def add() -> Response:
+    BrickSetPurchaseLocation().from_form(request.form).insert()
+
+    reload()
+
+    return redirect(url_for('admin.admin', open_purchase_location=True))
+
+
+# Delete the metadata purchase location
+@admin_purchase_location_page.route('<id>/delete', methods=['GET'])
+@login_required
+@exception_handler(__file__)
+def delete(*, id: str) -> str:
+    return render_template(
+        'admin.html',
+        delete_purchase_location=True,
+        purchase_location=BrickSetPurchaseLocation().select_specific(id),
+        error=request.args.get('purchase_location_error')
+    )
+
+
+# Actually delete the metadata purchase location
+@admin_purchase_location_page.route('<id>/delete', methods=['POST'])
+@login_required
+@exception_handler(
+    __file__,
+    post_redirect='admin_purchase_location.delete',
+    error_name='purchase_location_error'
+)
+def do_delete(*, id: str) -> Response:
+    purchase_location = BrickSetPurchaseLocation().select_specific(id)
+    purchase_location.delete()
+
+    reload()
+
+    return redirect(url_for('admin.admin', open_purchase_location=True))
+
+
+# Rename the metadata purchase location
+@admin_purchase_location_page.route('<id>/rename', methods=['POST'])
+@login_required
+@exception_handler(
+    __file__,
+    post_redirect='admin.admin',
+    error_name='purchase_location_error',
+    open_purchase_location=True
+)
+def rename(*, id: str) -> Response:
+    purchase_location = BrickSetPurchaseLocation().select_specific(id)
+    purchase_location.from_form(request.form).rename()
+
+    reload()
+
+    return redirect(url_for('admin.admin', open_purchase_location=True))
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index 7f397da7..0777f4e9 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -18,6 +18,7 @@ from ..part import BrickPart
 from ..set import BrickSet
 from ..set_list import BrickSetList, set_metadata_lists
 from ..set_owner_list import BrickSetOwnerList
+from ..set_purchase_location_list import BrickSetPurchaseLocationList
 from ..set_status_list import BrickSetStatusList
 from ..set_storage_list import BrickSetStorageList
 from ..set_tag_list import BrickSetTagList
@@ -40,6 +41,25 @@ def list() -> str:
     )
 
 
+# Change the value of purchase location
+@set_page.route('/<id>/purchase_location', methods=['POST'])
+@login_required
+@exception_handler(__file__, json=True)
+def update_purchase_location(*, id: str) -> Response:
+    brickset = BrickSet().select_light(id)
+    purchase_location = BrickSetPurchaseLocationList.get(
+        request.json.get('value', ''),  # type: ignore
+        allow_none=True
+    )
+
+    value = purchase_location.update_set_value(
+        brickset,
+        value=purchase_location.fields.id
+    )
+
+    return jsonify({'value': value})
+
+
 # Change the state of a owner
 @set_page.route('/<id>/owner/<metadata_id>', methods=['POST'])
 @login_required
diff --git a/static/scripts/socket/set.js b/static/scripts/socket/set.js
index 311eac50..8a4e5bba 100644
--- a/static/scripts/socket/set.js
+++ b/static/scripts/socket/set.js
@@ -16,6 +16,7 @@ class BrickSetSocket extends BrickSocket {
         this.html_input = document.getElementById(`${id}-set`);
         this.html_no_confim = document.getElementById(`${id}-no-confirm`);
         this.html_owners = document.getElementById(`${id}-owners`);
+        this.html_purchase_location = document.getElementById(`${id}-purchase-location`);
         this.html_storage = document.getElementById(`${id}-storage`);
         this.html_tags = document.getElementById(`${id}-tags`);
 
@@ -152,6 +153,12 @@ class BrickSetSocket extends BrickSocket {
                 });
             }
 
+            // Grab the purchase location
+            let purchase_location = null;
+            if (this.html_purchase_location) {
+                purchase_location = this.html_purchase_location.value;
+            }
+
             // Grab the storage
             let storage = null;
             if (this.html_storage) {
@@ -177,6 +184,7 @@ class BrickSetSocket extends BrickSocket {
             this.socket.emit(this.messages.IMPORT_SET, {
                 set: (set !== undefined) ? set : this.html_input.value,
                 owners: owners,
+                purchase_location: purchase_location,
                 storage: storage,
                 tags: tags,
                 refresh: this.refresh
@@ -293,6 +301,10 @@ class BrickSetSocket extends BrickSocket {
             this.html_owners.querySelectorAll('input').forEach(input => input.disabled = !enabled);
         }
 
+        if (this.html_purchase_location) {
+            this.html_purchase_location.disabled = !enabled;
+        }
+
         if (this.html_storage) {
             this.html_storage.disabled = !enabled;
         }
diff --git a/templates/add.html b/templates/add.html
index 9a0deebf..d9a94629 100644
--- a/templates/add.html
+++ b/templates/add.html
@@ -51,9 +51,22 @@
               </div>
               {{ accordion.footer() }}
             {% endif %}
+            {% if brickset_purchase_locations | length %}
+              {{ accordion.header('Purchase location', 'purchase-location', 'metadata', icon='building-line') }}
+              <label class="visually-hidden" for="add-purchase-location">{{ name }}</label>
+              <div class="input-group">
+                <select id="add-purchase-location" class="form-select" autocomplete="off">
+                  <option value="" selected><i>None</i></option>
+                  {% for purchase_location in brickset_purchase_locations %}
+                    <option value="{{ purchase_location.fields.id }}">{{ purchase_location.fields.name }}</option>
+                  {% endfor %}
+                </select>
+              </div>
+              {{ accordion.footer() }}
+            {% endif %}
             {% if brickset_storages | length %}
               {{ accordion.header('Storage', 'storage', 'metadata', icon='archive-2-line') }}
-              <label class="visually-hidden" for="storage">{{ name }}</label>
+              <label class="visually-hidden" for="add-storage">{{ name }}</label>
               <div class="input-group">
                 <select id="add-storage" class="form-select" autocomplete="off">
                   <option value="" selected><i>None</i></option>
diff --git a/templates/admin.html b/templates/admin.html
index 22a7e1a7..87dbdb4a 100644
--- a/templates/admin.html
+++ b/templates/admin.html
@@ -18,6 +18,8 @@
             {% include 'admin/database/delete.html' %}
             {% elif delete_owner %}
             {% include 'admin/owner/delete.html' %}
+            {% elif delete_purchase_location %}
+            {% include 'admin/purchase_location/delete.html' %}
             {% elif delete_status %}
             {% include 'admin/status/delete.html' %}
             {% elif delete_storage %}
@@ -39,10 +41,11 @@
               {% include 'admin/theme.html' %}
               {% include 'admin/retired.html' %}
               {{ accordion.header('Set metadata', 'metadata', 'admin', expanded=open_metadata, icon='profile-line', class='p-0') }}
-              {% include 'admin/owner.html' %}
-              {% include 'admin/status.html' %}
-              {% include 'admin/storage.html' %}
-              {% include 'admin/tag.html' %}
+                {% include 'admin/owner.html' %}
+                {% include 'admin/purchase_location.html' %}
+                {% include 'admin/status.html' %}
+                {% include 'admin/storage.html' %}
+                {% include 'admin/tag.html' %}
               {{ accordion.footer() }}
               {% include 'admin/database.html' %}
               {% include 'admin/configuration.html' %}
diff --git a/templates/admin/purchase_location.html b/templates/admin/purchase_location.html
new file mode 100644
index 00000000..12675768
--- /dev/null
+++ b/templates/admin/purchase_location.html
@@ -0,0 +1,42 @@
+{% import 'macro/accordion.html' as accordion %}
+
+{{ accordion.header('Set purchase locations', 'purchase-location', 'metadata', expanded=open_purchase_location, icon='building-line', class='p-0') }}
+{% if purchase_location_error %}<div class="alert alert-danger m-2" role="alert"><strong>Error:</strong> {{ purchase_location_error }}.</div>{% endif %}
+<ul class="list-group list-group-flush">
+  {% if metadata_purchase_locations | length %}
+    {% for purchase_location in metadata_purchase_locations %}
+      <li class="list-group-item">
+        <form action="{{ url_for('admin_purchase_location.rename', id=purchase_location.fields.id) }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
+          <div class="col-12 flex-grow-1">
+            <label class="visually-hidden" for="name-{{ purchase_location.fields.id }}">Name</label>
+            <div class="input-group">
+              <div class="input-group-text">Name</div>
+              <input type="text" class="form-control" id="name-{{ purchase_location.fields.id }}" name="name" value="{{ purchase_location.fields.name }}">
+              <button type="submit" class="btn btn-primary"><i class="ri-edit-line"></i> Rename</button>
+            </div>
+          </div>
+          <div class="col-12">
+            <a href="{{ url_for('admin_purchase_location.delete', id=purchase_location.fields.id) }}" class="btn btn-danger" role="button"><i class="ri-delete-bin-2-line"></i> Delete</a>
+          </div>
+        </form>
+      </li>
+    {% endfor %}
+  {% else %}
+    <li class="list-group-item text-center"><i class="ri-error-warning-line"></i> No purchase location found.</li>
+  {% endif %}
+  <li class="list-group-item">
+    <form action="{{ url_for('admin_purchase_location.add') }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
+      <div class="col-12 flex-grow-1">
+        <label class="visually-hidden" for="name">Name</label>
+        <div class="input-group">
+          <div class="input-group-text">Name</div>
+          <input type="text" class="form-control" id="name" name="name" value="">
+        </div>
+      </div>
+      <div class="col-12">
+        <button type="submit" class="btn btn-primary"><i class="ri-add-circle-line"></i> Add</button>
+      </div>
+    </form>
+  </li>
+</ul>
+{{ accordion.footer() }}
diff --git a/templates/admin/purchase_location/delete.html b/templates/admin/purchase_location/delete.html
new file mode 100644
index 00000000..c53d8c22
--- /dev/null
+++ b/templates/admin/purchase_location/delete.html
@@ -0,0 +1,19 @@
+{% import 'macro/accordion.html' as accordion %}
+
+{{ accordion.header('Set purchase locations danger zone', 'purchase-location-danger', 'admin', expanded=true, danger=true, class='text-end') }}
+<form action="{{ url_for('admin_purchase_location.do_delete', id=purchase_location.fields.id) }}" method="post">
+  {% if purchase_location_error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ purchase_location_error }}.</div>{% endif %}
+  <div class="alert alert-danger text-center" role="alert">You are about to <strong>delete a set purchase location</strong>. This action is irreversible.</div>
+  <div class="row row-cols-lg-auto g-3 align-items-center">
+    <div class="col-12 flex-grow-1">
+      <div class="input-group">
+        <div class="input-group-text">Name</div>
+        <input type="text" class="form-control" value="{{ purchase_location.fields.name }}" disabled>
+      </div>
+    </div>
+  </div>
+  <hr class="border-bottom">
+  <a class="btn btn-danger" href="{{ url_for('admin.admin', open_purchase_location=true) }}" role="button"><i class="ri-arrow-left-long-line"></i> Back to the admin</a>
+  <button type="submit" class="btn btn-danger"><i class="ri-delete-bin-2-line"></i> Delete <strong>the set purchase location</strong></button>
+</form>
+{{ accordion.footer() }}
diff --git a/templates/macro/badge.html b/templates/macro/badge.html
index ea2be584..f1c9f89f 100644
--- a/templates/macro/badge.html
+++ b/templates/macro/badge.html
@@ -65,6 +65,18 @@
   {% endif %}
 {% endmacro %}
 
+{% macro purchase_location(item, purchase_locations, solo=false, last=false) %}
+  {% if purchase_locations and item.fields.purchase_location in purchase_locations.mapping %}
+    {% set purchase_location = purchase_locations.mapping[item.fields.purchase_location] %}
+    {% if last %}
+      {% set tooltip=purchase_location.fields.name %}
+    {% else %}
+      {% set text=purchase_location.fields.name %}
+    {% endif %}
+    {{ badge(check=purchase_location, solo=solo, last=last, color='light border', icon='building-line', text=text, tooltip=tooltip) }}
+  {% endif %}
+{% endmacro %}
+
 {% macro set(set, solo=false, last=false, url=None, id=None) %}
   {% if id %}
       {% set url=url_for('set.details', id=id) %}
diff --git a/templates/set/card.html b/templates/set/card.html
index 20642066..7c2525d1 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -17,6 +17,11 @@
     {% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
       data-has-damaged="{{ (item.fields.total_damaged > 0) | int }}" data-damaged="{{ item.fields.total_damaged }}"
     {% endif %}
+    data-has-purchase-location="{{ item.fields.purchase_location is not none | int }}"
+    {% if item.fields.purchase_location is not none %}
+      data-purchase-location="{{ item.fields.purchase_location }}"
+      {% if item.fields.purchase_location in brickset_purchase_locations.mapping %}data-search-purchase-location="{{ brickset_purchase_locations.mapping[item.fields.purchase_location].fields.name | lower }}"{% endif %}
+    {% endif %}
     data-has-storage="{{ item.fields.storage is not none | int }}"
     {% if item.fields.storage is not none %}
       data-storage="{{ item.fields.storage }}"
@@ -63,6 +68,7 @@
       {{ badge.owner(item, owner, solo=solo, last=last) }}
     {% endfor %}
     {{ badge.storage(item, brickset_storages, solo=solo, last=last) }}
+    {{ badge.purchase_location(item, brickset_purchase_locations, solo=solo, last=last) }}
     {% if not last %}
       {% if not solo %}
         {{ badge.instructions(item, solo=solo, last=last) }}
diff --git a/templates/set/filter.html b/templates/set/filter.html
index 8f3b4109..24454c25 100644
--- a/templates/set/filter.html
+++ b/templates/set/filter.html
@@ -60,6 +60,22 @@
       </div>
     </div>
   {% endif %}
+  {% if brickset_purchase_locations | length %}
+    <div class="col-12 flex-grow-1">
+      <label class="visually-hidden" for="grid-owner">Purchase location</label>
+      <div class="input-group">
+        <span class="input-group-text"><i class="ri-building-line"></i><span class="ms-1 d-none d-md-inline"> Purchase location</span></span>
+        <select id="grid-purchase-location" class="form-select"
+          data-filter="value" data-filter-attribute="purchase-location"
+          autocomplete="off">
+          <option value="" selected>All</option>
+          {% for purchase_location in brickset_purchase_locations %}
+            <option value="{{ purchase_location.fields.id }}">{{ purchase_location.fields.name }}</option>
+          {% endfor %}
+        </select>
+      </div>
+    </div>
+  {% endif %}
   {% if brickset_storages | length %}
     <div class="col-12 flex-grow-1">
       <label class="visually-hidden" for="grid-owner">Storage</label>
diff --git a/templates/set/management.html b/templates/set/management.html
index 1a8c2b7b..8d675f9a 100644
--- a/templates/set/management.html
+++ b/templates/set/management.html
@@ -1,44 +1,53 @@
 {% if g.login.is_authenticated() %}
   {{ accordion.header('Management', 'set-management', 'set-details', icon='settings-4-line', class='p-0') }}
     {{ accordion.header('Owners', 'owner', 'set-management', icon='group-line', class='p-0') }}
-    <ul class="list-group list-group-flush">
-    {% if brickset_owners | length %}
-      {% for owner in brickset_owners %}
-        <li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(item, owner, delete=delete) }}</li>
-      {% endfor %}
-    {% else %}
-      <li class="list-group-item list-group-item-action text-center"><i class="ri-error-warning-line"></i> No owner found.</li>
+      <ul class="list-group list-group-flush">
+      {% if brickset_owners | length %}
+        {% for owner in brickset_owners %}
+          <li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(item, owner, delete=delete) }}</li>
+        {% endfor %}
+      {% else %}
+        <li class="list-group-item list-group-item-action text-center"><i class="ri-error-warning-line"></i> No owner found.</li>
+        {% endif %}
+      </ul>
+      <div class="list-group list-group-flush border-top">
+        <a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_owner=true) }}"><i class="ri-settings-4-line"></i> Manage the set owners</a>
+      </div>
+    {{ accordion.footer() }}
+    {{ accordion.header('Purchase location', 'purchase-location', 'set-management', icon='building-line') }}
+      {% if brickset_purchase_locations | length %}
+        {{ form.select('Purchase location', item, 'purchase_location', brickset_purchase_locations, delete=delete) }}
+      {% else %}
+        <p class="text-center"><i class="ri-error-warning-line"></i> No purchase location found.</p>
       {% endif %}
-    </ul>
-    <div class="list-group list-group-flush border-top">
-      <a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_owner=true) }}"><i class="ri-settings-4-line"></i> Manage the set owners</a>
-    </div>
-  {{ accordion.footer() }}
+      <hr>
+      <a href="{{ url_for('admin.admin', open_purchase_location=true) }}" class="btn btn-primary" role="button"><i class="ri-settings-4-line"></i> Manage the set purchase locations</a>
+    {{ accordion.footer() }}
     {{ accordion.header('Storage', 'storage', 'set-management', icon='archive-2-line') }}
-    {% if brickset_storages | length %}
-      {{ form.select('Storage', item, 'storage', brickset_storages, delete=delete) }}
-    {% else %}
-      <p class="text-center"><i class="ri-error-warning-line"></i> No storage found.</p>
-    {% endif %}
-    <hr>
-      <a href="{{ url_for('admin.admin', open_storage=true) }}" class="btn btn-primary" role="button"><i class="ri-settings-4-line"></i> Manage the set storages</a>
-  {{ accordion.footer() }}
-    {{ accordion.header('Tags', 'tag', 'set-management', icon='price-tag-2-line', class='p-0') }}
-    <ul class="list-group list-group-flush">
-    {% if brickset_tags | length %}
-      {% for tag in brickset_tags %}
-        <li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(item, tag, delete=delete) }}</li>
-      {% endfor %}
-    {% else %}
-      <li class="list-group-item list-group-item-action text-center"><i class="ri-error-warning-line"></i> No tag found.</li>
+      {% if brickset_storages | length %}
+        {{ form.select('Storage', item, 'storage', brickset_storages, delete=delete) }}
+      {% else %}
+        <p class="text-center"><i class="ri-error-warning-line"></i> No storage found.</p>
       {% endif %}
-    </ul>
-    <div class="list-group list-group-flush border-top">
-      <a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_tag=true) }}"><i class="ri-settings-4-line"></i> Manage the set tags</a>
-    </div>
-  {{ accordion.footer() }}
+      <hr>
+      <a href="{{ url_for('admin.admin', open_storage=true) }}" class="btn btn-primary" role="button"><i class="ri-settings-4-line"></i> Manage the set storages</a>
+    {{ accordion.footer() }}
+    {{ accordion.header('Tags', 'tag', 'set-management', icon='price-tag-2-line', class='p-0') }}
+      <ul class="list-group list-group-flush">
+      {% if brickset_tags | length %}
+        {% for tag in brickset_tags %}
+          <li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(item, tag, delete=delete) }}</li>
+        {% endfor %}
+      {% else %}
+        <li class="list-group-item list-group-item-action text-center"><i class="ri-error-warning-line"></i> No tag found.</li>
+        {% endif %}
+      </ul>
+      <div class="list-group list-group-flush border-top">
+        <a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_tag=true) }}"><i class="ri-settings-4-line"></i> Manage the set tags</a>
+      </div>
+    {{ accordion.footer() }}
     {{ accordion.header('Data', 'data', 'set-management', icon='database-2-line') }}
-    <a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh the set data</a>
+      <a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh the set data</a>
     {{ accordion.footer() }}
   {{ accordion.footer() }}
 {% endif %}
diff --git a/templates/sets.html b/templates/sets.html
index 5af93c93..4832160c 100644
--- a/templates/sets.html
+++ b/templates/sets.html
@@ -10,7 +10,7 @@
       <label class="visually-hidden" for="grid-search">Search</label>
       <div class="input-group">
         <span class="input-group-text"><i class="ri-search-line"></i><span class="ms-1 d-none d-md-inline"> Search</span></span>
-        <input id="grid-search" data-search-exact="name,number,parts,searchStorage,theme,year" data-search-list="searchOwner,searchTag" class="form-control form-control-sm" type="text" placeholder="Set, name, number of parts, theme, year, owner, storage, tag" value="">
+        <input id="grid-search" data-search-exact="name,number,parts,searchPurchaseLocation,searchStorage,theme,year" data-search-list="searchOwner,searchTag" class="form-control form-control-sm" type="text" placeholder="Set, name, number of parts, theme, year, owner, purchase location, storage, tag" value="">
       </div>
     </div>
     <div class="col-12">

From f0cec23da9566f044bd8435d5baeebe36b4901db Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 17:03:39 +0100
Subject: [PATCH 140/154] Set purchase date and price

---
 .env.sample                                   |   9 ++
 CHANGELOG.md                                  |   6 +-
 bricktracker/config.py                        |   2 +
 bricktracker/set.py                           | 111 +++++++++++++++++-
 bricktracker/sql/migrations/0007.sql          |   2 +-
 bricktracker/sql/set/base/base.sql            |   2 +
 bricktracker/sql/set/update/purchase_date.sql |   3 +
 .../sql/set/update/purchase_price.sql         |   3 +
 bricktracker/views/set.py                     |  24 ++++
 docs/development.md                           |   1 +
 static/scripts/changer.js                     |  17 ++-
 templates/base.html                           |   2 +
 templates/macro/badge.html                    |  20 +++-
 templates/macro/form.html                     |   7 +-
 templates/set/card.html                       |   6 +-
 templates/set/management.html                 |  25 ++--
 templates/set/sort.html                       |   4 +
 17 files changed, 228 insertions(+), 16 deletions(-)
 create mode 100644 bricktracker/sql/set/update/purchase_date.sql
 create mode 100644 bricktracker/sql/set/update/purchase_price.sql

diff --git a/.env.sample b/.env.sample
index 8f129392..f4d6a48b 100644
--- a/.env.sample
+++ b/.env.sample
@@ -168,6 +168,15 @@
 # Default: 3333
 # BK_PORT=3333
 
+# Optional: Format of the timestamp for purchase dates
+# Check https://docs.python.org/3/library/time.html#time.strftime for format details
+# Default: %d/%m/%Y
+# BK_PURCHASE_DATE_FORMAT=%m/%d/%Y
+
+# Optional: Currency to display for purchase prices.
+# Default: €
+# BK_PURCHASE_CURRENCY=£
+
 # Optional: Change the default order of purchase locations. By default ordered by insertion order.
 # Useful column names for this option are:
 # - "bricktracker_metadata_purchase_locations"."name" ASC: storage name
diff --git a/CHANGELOG.md b/CHANGELOG.md
index efcdb16d..2bbb26cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,8 @@
 - Added: `BK_HIDE_ALL_STORAGES`, hide the "Storages" menu entry
 - Added: `BK_STORAGE_DEFAULT_ORDER`, ordering of storages
 - Added: `BK_PURCHASE_LOCATION_DEFAULT_ORDER`, ordering of purchase locations
+- Added: `BK_PURCHASE_CURRENCY`, currency to display for purchase prices
+- Added: `BK_PURCHASE_DATE_FORMAT`, date format for purchase dates
 
 ### Code
 
@@ -40,7 +42,7 @@
     - Ownership
     - Tags
     - Storage
-    - Purchase location
+    - Purchase location, date, price
 
 - Storage
     - Storage content and list
@@ -87,7 +89,7 @@
         - Tags
         - Refresh
         - Storage
-        - Purchase location
+        - Purchase location, date, price
 
 - Sets grid
     - Collapsible controls depending on screen size
diff --git a/bricktracker/config.py b/bricktracker/config.py
index 729049a9..5452f938 100644
--- a/bricktracker/config.py
+++ b/bricktracker/config.py
@@ -41,6 +41,8 @@ CONFIG: Final[list[dict[str, Any]]] = [
     {'n': 'PARTS_DEFAULT_ORDER', 'd': '"rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "bricktracker_parts"."spare" ASC'},  # noqa: E501
     {'n': 'PARTS_FOLDER', 'd': 'parts', 's': True},
     {'n': 'PORT', 'd': 3333, 'c': int},
+    {'n': 'PURCHASE_DATE_FORMAT', 'd': '%d/%m/%Y'},
+    {'n': 'PURCHASE_CURRENCY', 'd': '€'},
     {'n': 'PURCHASE_LOCATION_DEFAULT_ORDER', 'd': '"bricktracker_metadata_purchase_locations"."name" ASC'},  # noqa: E501
     {'n': 'RANDOM', 'e': 'RANDOM', 'c': bool},
     {'n': 'REBRICKABLE_API_KEY', 'e': 'REBRICKABLE_API_KEY', 'd': ''},
diff --git a/bricktracker/set.py b/bricktracker/set.py
index fb972092..c397b130 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -1,3 +1,4 @@
+from datetime import datetime
 import logging
 import traceback
 from typing import Any, Self, TYPE_CHECKING
@@ -5,7 +6,7 @@ from uuid import uuid4
 
 from flask import current_app, url_for
 
-from .exceptions import NotFoundException
+from .exceptions import NotFoundException, DatabaseException, ErrorException
 from .minifigure_list import BrickMinifigureList
 from .part_list import BrickPartList
 from .rebrickable_set import RebrickableSet
@@ -27,6 +28,8 @@ class BrickSet(RebrickableSet):
     select_query: str = 'set/select/full'
     light_query: str = 'set/select/light'
     insert_query: str = 'set/insert'
+    update_purchase_date_query: str = 'set/update/purchase_date'
+    update_purchase_price_query: str = 'set/update/purchase_price'
 
     # Delete a set
     def delete(self, /) -> None:
@@ -152,6 +155,30 @@ class BrickSet(RebrickableSet):
 
         return True
 
+    # Purchase date
+    def purchase_date(self, /, *, standard: bool = False) -> str:
+        if self.fields.purchase_date is not None:
+            time = datetime.fromtimestamp(self.fields.purchase_date)
+
+            if standard:
+                return time.strftime('%Y/%m/%d')
+            else:
+                return time.strftime(
+                    current_app.config['PURCHASE_DATE_FORMAT']
+                )
+        else:
+            return ''
+
+    # Purchase price with currency
+    def purchase_price(self, /) -> str:
+        if self.fields.purchase_price is not None:
+            return '{price}{currency}'.format(
+                price=self.fields.purchase_price,
+                currency=current_app.config['PURCHASE_CURRENCY']
+            )
+        else:
+            return ''
+
     # Minifigures
     def minifigures(self, /) -> BrickMinifigureList:
         return BrickMinifigureList().from_set(self)
@@ -194,6 +221,80 @@ class BrickSet(RebrickableSet):
 
         return self
 
+    # Update the purchase date
+    def update_purchase_date(self, json: Any | None, /) -> Any:
+        value = json.get('value', None)  # type: ignore
+
+        try:
+            if value == '':
+                value = None
+
+            if value is not None:
+                value = datetime.strptime(value, '%Y/%m/%d').timestamp()
+        except Exception:
+            raise ErrorException('{value} is not a date'.format(
+                value=value,
+            ))
+
+        self.fields.purchase_date = value
+
+        rows, _ = BrickSQL().execute_and_commit(
+            self.update_purchase_date_query,
+            parameters=self.sql_parameters()
+        )
+
+        if rows != 1:
+            raise DatabaseException('Could not update the purchase date for set {set} ({id})'.format(  # noqa: E501
+                set=self.fields.set,
+                id=self.fields.id,
+            ))
+
+        # Info
+        logger.info('Purchase date changed to "{value}" for set {set} ({id})'.format(  # noqa: E501
+            value=value,
+            set=self.fields.set,
+            id=self.fields.id,
+        ))
+
+        return value
+
+    # Update the purchase price
+    def update_purchase_price(self, json: Any | None, /) -> Any:
+        value = json.get('value', None)  # type: ignore
+
+        try:
+            if value == '':
+                value = None
+
+            if value is not None:
+                value = float(value)
+        except Exception:
+            raise ErrorException('{value} is not a number or empty'.format(
+                value=value,
+            ))
+
+        self.fields.purchase_price = value
+
+        rows, _ = BrickSQL().execute_and_commit(
+            self.update_purchase_price_query,
+            parameters=self.sql_parameters()
+        )
+
+        if rows != 1:
+            raise DatabaseException('Could not update the purchase price for set {set} ({id})'.format(  # noqa: E501
+                set=self.fields.set,
+                id=self.fields.id,
+            ))
+
+        # Info
+        logger.info('Purchase price changed to "{value}" for set {set} ({id})'.format(  # noqa: E501
+            value=value,
+            set=self.fields.set,
+            id=self.fields.id,
+        ))
+
+        return value
+
     # Self url
     def url(self, /) -> str:
         return url_for('set.details', id=self.fields.id)
@@ -230,3 +331,11 @@ class BrickSet(RebrickableSet):
             return url_for('storage.details', id=self.fields.storage)
         else:
             return ''
+
+    # Update purchase date url
+    def url_for_purchase_date(self, /) -> str:
+        return url_for('set.update_purchase_date', id=self.fields.id)
+
+    # Update purchase price url
+    def url_for_purchase_price(self, /) -> str:
+        return url_for('set.update_purchase_price', id=self.fields.id)
diff --git a/bricktracker/sql/migrations/0007.sql b/bricktracker/sql/migrations/0007.sql
index 7d52d33a..720be769 100644
--- a/bricktracker/sql/migrations/0007.sql
+++ b/bricktracker/sql/migrations/0007.sql
@@ -27,7 +27,7 @@ CREATE TABLE "bricktracker_sets" (
     "set" TEXT NOT NULL,
     "description" TEXT,
     "storage" TEXT, -- Storage bin location
-    "purchase_date" INTEGER, -- Purchase data
+    "purchase_date" REAL, -- Purchase data
     "purchase_location" TEXT, -- Purchase location
     "purchase_price" REAL, -- Purchase price
     PRIMARY KEY("id"),
diff --git a/bricktracker/sql/set/base/base.sql b/bricktracker/sql/set/base/base.sql
index 333868d0..02ef7711 100644
--- a/bricktracker/sql/set/base/base.sql
+++ b/bricktracker/sql/set/base/base.sql
@@ -1,7 +1,9 @@
 SELECT
     {% block id %}{% endblock %}
     "bricktracker_sets"."storage",
+    "bricktracker_sets"."purchase_date",
     "bricktracker_sets"."purchase_location",
+    "bricktracker_sets"."purchase_price",
     "rebrickable_sets"."set",
     "rebrickable_sets"."number",
     "rebrickable_sets"."version",
diff --git a/bricktracker/sql/set/update/purchase_date.sql b/bricktracker/sql/set/update/purchase_date.sql
new file mode 100644
index 00000000..cb8516ee
--- /dev/null
+++ b/bricktracker/sql/set/update/purchase_date.sql
@@ -0,0 +1,3 @@
+UPDATE "bricktracker_sets"
+SET "purchase_date" = :purchase_date
+WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM :id
diff --git a/bricktracker/sql/set/update/purchase_price.sql b/bricktracker/sql/set/update/purchase_price.sql
new file mode 100644
index 00000000..f742a8fe
--- /dev/null
+++ b/bricktracker/sql/set/update/purchase_price.sql
@@ -0,0 +1,3 @@
+UPDATE "bricktracker_sets"
+SET "purchase_price" = :purchase_price
+WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM :id
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index 0777f4e9..f1493b8d 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -41,6 +41,18 @@ def list() -> str:
     )
 
 
+# Change the value of purchase date
+@set_page.route('/<id>/purchase_date', methods=['POST'])
+@login_required
+@exception_handler(__file__, json=True)
+def update_purchase_date(*, id: str) -> Response:
+    brickset = BrickSet().select_light(id)
+
+    value = brickset.update_purchase_date(request.json)
+
+    return jsonify({'value': value})
+
+
 # Change the value of purchase location
 @set_page.route('/<id>/purchase_location', methods=['POST'])
 @login_required
@@ -60,6 +72,18 @@ def update_purchase_location(*, id: str) -> Response:
     return jsonify({'value': value})
 
 
+# Change the value of purchase price
+@set_page.route('/<id>/purchase_price', methods=['POST'])
+@login_required
+@exception_handler(__file__, json=True)
+def update_purchase_price(*, id: str) -> Response:
+    brickset = BrickSet().select_light(id)
+
+    value = brickset.update_purchase_price(request.json)
+
+    return jsonify({'value': value})
+
+
 # Change the state of a owner
 @set_page.route('/<id>/owner/<metadata_id>', methods=['POST'])
 @login_required
diff --git a/docs/development.md b/docs/development.md
index 8657590c..dd3beee6 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -21,6 +21,7 @@ It also uses the following libraries and frameworks:
 - `tinysort` (https://github.com/Sjeiti/TinySort)
 - `sortable` (https://github.com/tofsjonas/sortable)
 - `simple-datatables` (https://github.com/fiduswriter/simple-datatables)
+- `vanillajs-datepicker` (https://github.com/mymth/vanillajs-datepicker)
 
 The BrickTracker brick logo is part of the Small n' Flat Icons set designed by [Arnaud Chesne](https://iconduck.com/designers/arnaud-chesne).
 
diff --git a/static/scripts/changer.js b/static/scripts/changer.js
index 4db9cd7a..ffa41ace 100644
--- a/static/scripts/changer.js
+++ b/static/scripts/changer.js
@@ -1,5 +1,6 @@
 // Generic state changer with visual feedback
-// Tooltips require boostrap.Tooltip
+// Tooltips requires boostrap.Tooltip
+// Date requires vanillajs-datepicker
 class BrickChanger {
     constructor(prefix, id, url, parent = undefined) {
         this.prefix = prefix
@@ -51,6 +52,20 @@ class BrickChanger {
                 changer.change();
             })(this));
         }
+
+        // Date picker
+        this.picker = undefined;
+        if (this.html_element.dataset.changerDate == "true") {
+            this.picker = new Datepicker(this.html_element, {
+                buttonClass: 'btn',
+                format: 'yyyy/mm/dd',
+            });
+
+            // Picker fires a custom "changeDate" event
+            this.html_element.addEventListener("changeDate", ((changer) => (e) => {
+                changer.change();
+            })(this));
+        }
     }
 
     // Clean the status
diff --git a/templates/base.html b/templates/base.html
index a82a4eaa..658ef370 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -9,6 +9,7 @@
     <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/baguettebox.js/1.12.0/baguetteBox.css" integrity="sha512-VZ783G3QIpxXpg7tWpzHn+XhjsOCIxFYoSWmyipKCB41OYaB9i4brxAWuY1c8gGCSqKo7uvckzPJhYcdBZQ9gg==" crossorigin="anonymous" referrerpolicy="no-referrer">
     <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/simple-datatables@9.2.1/dist/style.min.css">
     <link href="https://cdn.jsdelivr.net/npm/remixicon@4.6.0/fonts/remixicon.css" rel="stylesheet">
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vanillajs-datepicker@1.3.4/dist/css/datepicker-bs5.min.css">
     <link href="{{ url_for('static', filename='styles.css') }}" rel="stylesheet">
     <link rel="icon" type="image/png" sizes="48x48" href="{{ url_for('static', filename='brick.png') }}">
   </head>
@@ -78,6 +79,7 @@
     <script src="https://cdnjs.cloudflare.com/ajax/libs/baguettebox.js/1.12.0/baguetteBox.min.js" integrity="sha512-HzIuiABxntLbBS8ClRa7drXZI3cqvkAZ5DD0JCAkmRwUtykSGqzA9uItHivDhRUYnW3MMyY5xqk7qVUHOEMbMA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
     <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.8.1/socket.io.min.js" integrity="sha512-8ExARjWWkIllMlNzVg7JKq9RKWPlJABQUNq6YvAjE/HobctjH/NA+bSiDMDvouBVjp4Wwnf1VP1OEv7Zgjtuxw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
     <script src="https://cdn.jsdelivr.net/npm/simple-datatables@9.2.1/dist/umd/simple-datatables.min.js"></script>
+    <script src="https://cdn.jsdelivr.net/npm/vanillajs-datepicker@1.3.4/dist/js/datepicker-full.min.js"></script>
     <!-- BrickTracker scripts -->
     <script src="{{ url_for('static', filename='scripts/changer.js') }}"></script>
     <script src="{{ url_for('static', filename='scripts/grid/filter.js') }}"></script>
diff --git a/templates/macro/badge.html b/templates/macro/badge.html
index f1c9f89f..ee68a69d 100644
--- a/templates/macro/badge.html
+++ b/templates/macro/badge.html
@@ -65,6 +65,15 @@
   {% endif %}
 {% endmacro %}
 
+{% macro purchase_date(date, solo=false, last=false) %}
+  {% if last %}
+    {% set tooltip=date %}
+  {% else %}
+    {% set text=date %}
+  {% endif %}
+  {{ badge(check=date, solo=solo, last=last, color='light border', icon='calendar-line', text=text, tooltip=tooltip, collapsible='Date:') }}
+{% endmacro %}
+
 {% macro purchase_location(item, purchase_locations, solo=false, last=false) %}
   {% if purchase_locations and item.fields.purchase_location in purchase_locations.mapping %}
     {% set purchase_location = purchase_locations.mapping[item.fields.purchase_location] %}
@@ -73,10 +82,19 @@
     {% else %}
       {% set text=purchase_location.fields.name %}
     {% endif %}
-    {{ badge(check=purchase_location, solo=solo, last=last, color='light border', icon='building-line', text=text, tooltip=tooltip) }}
+    {{ badge(check=purchase_location, solo=solo, last=last, color='light border', icon='building-line', text=text, tooltip=tooltip, collapsible='Location:') }}
   {% endif %}
 {% endmacro %}
 
+{% macro purchase_price(price, solo=false, last=false) %}
+  {% if last %}
+    {% set tooltip=price %}
+  {% else %}
+    {% set text=price %}
+  {% endif %}
+  {{ badge(check=price, solo=solo, last=last, color='light border', icon='wallet-3-line', text=text, tooltip=tooltip, collapsible='Price:') }}
+{% endmacro %}
+
 {% macro set(set, solo=false, last=false, url=None, id=None) %}
   {% if id %}
       {% set url=url_for('set.details', id=id) %}
diff --git a/templates/macro/form.html b/templates/macro/form.html
index 45ec2b5d..9564f350 100644
--- a/templates/macro/form.html
+++ b/templates/macro/form.html
@@ -17,19 +17,22 @@
   {% endif %}
 {% endmacro %}
 
-{% macro input(name, id, prefix, url, value, all=none, read_only=none) %}
+{% macro input(name, id, prefix, url, value, all=none, read_only=none, icon=none, suffix=none, date=false) %}
   {% if all or read_only %}
     {{ value }}
   {% else %}
     <label class="visually-hidden" for="{{ prefix }}-{{ id }}">{{ name }}</label>
     <div class="input-group">
+      {% if icon %}<span class="input-group-text"><i class="ri-{{ icon }} me-1"></i><span class="ms-1 d-none d-md-inline"> {{ name }}</span></span>{% endif %}
       <input class="form-control form-control-sm flex-shrink-1" type="text" id="{{ prefix }}-{{ id }}" value="{% if value %}{{ value }}{% endif %}"
       {% if g.login.is_authenticated() %}
         data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}"
+        {% if date %}data-changer-date="true"{% endif %}
       {% else %}
         disabled
       {% endif %}
         autocomplete="off">
+      {% if suffix %}<span class="input-group-text d-none d-md-inline">{{ suffix }}</span>{% endif %}
       {% if g.login.is_authenticated() %}
         <span id="status-{{ prefix }}-{{ id }}" class="input-group-text ri-save-line"></span>
         <button id="clear-{{ prefix }}-{{ id }}" type="button" class="btn btn-sm btn-light btn-outline-danger border"><i class="ri-eraser-line"></i></button>
@@ -45,7 +48,7 @@
     {% set prefix=metadata_list.as_prefix() %}
     <label class="visually-hidden" for="{{ prefix }}-{{ item.fields.id }}">{{ name }}</label>
     <div class="input-group">
-      {% if icon %}<span class="input-group-text"><i class="ri-{{ icon }}"></i></span>{% endif %}
+      {% if icon %}<span class="input-group-text"><i class="ri-{{ icon }} me-1"></i><span class="ms-1 d-none d-md-inline"> {{ name }}</span></span>{% endif %}
       <select id="{{ prefix }}-{{ item.fields.id }}" class="form-select"
         {% if not delete %}
           data-changer-id="{{ item.fields.id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ metadata_list.url_for_set_value(item.fields.id) }}"
diff --git a/templates/set/card.html b/templates/set/card.html
index 7c2525d1..924bcd75 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -17,6 +17,8 @@
     {% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
       data-has-damaged="{{ (item.fields.total_damaged > 0) | int }}" data-damaged="{{ item.fields.total_damaged }}"
     {% endif %}
+    {% if item.fields.purchase_date is not none %}data-purchase-date="{{ item.fields.purchase_date }}"{% endif %}
+    {% if item.fields.purchase_price is not none %}data-purchase-price="{{ item.fields.purchase_price }}"{% endif %}
     data-has-purchase-location="{{ item.fields.purchase_location is not none | int }}"
     {% if item.fields.purchase_location is not none %}
       data-purchase-location="{{ item.fields.purchase_location }}"
@@ -68,8 +70,10 @@
       {{ badge.owner(item, owner, solo=solo, last=last) }}
     {% endfor %}
     {{ badge.storage(item, brickset_storages, solo=solo, last=last) }}
-    {{ badge.purchase_location(item, brickset_purchase_locations, solo=solo, last=last) }}
     {% if not last %}
+      {{ badge.purchase_date(item.purchase_date(), solo=solo, last=last) }}
+      {{ badge.purchase_location(item, brickset_purchase_locations, solo=solo, last=last) }}
+      {{ badge.purchase_price(item.purchase_price(), solo=solo, last=last) }}
       {% if not solo %}
         {{ badge.instructions(item, solo=solo, last=last) }}
       {% endif %}
diff --git a/templates/set/management.html b/templates/set/management.html
index 8d675f9a..7ed92aff 100644
--- a/templates/set/management.html
+++ b/templates/set/management.html
@@ -1,5 +1,5 @@
 {% if g.login.is_authenticated() %}
-  {{ accordion.header('Management', 'set-management', 'set-details', icon='settings-4-line', class='p-0') }}
+  {{ accordion.header('Management', 'set-management', 'set-details', icon='settings-4-line', class='p-0', expanded=true) }}
     {{ accordion.header('Owners', 'owner', 'set-management', icon='group-line', class='p-0') }}
       <ul class="list-group list-group-flush">
       {% if brickset_owners | length %}
@@ -14,12 +14,23 @@
         <a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_owner=true) }}"><i class="ri-settings-4-line"></i> Manage the set owners</a>
       </div>
     {{ accordion.footer() }}
-    {{ accordion.header('Purchase location', 'purchase-location', 'set-management', icon='building-line') }}
-      {% if brickset_purchase_locations | length %}
-        {{ form.select('Purchase location', item, 'purchase_location', brickset_purchase_locations, delete=delete) }}
-      {% else %}
-        <p class="text-center"><i class="ri-error-warning-line"></i> No purchase location found.</p>
-      {% endif %}
+    {{ accordion.header('Purchase', 'purchase', 'set-management', icon='wallet-3-line', expanded=true) }}
+      <div class="alert alert-info" role="alert">The expected date format here is <code>yyyy/mm/dd</code> (year/month/day), but you can configured how it is displayed in the set card with the <code>PURCHASE_DATE_FORMAT</code> variable.</div>
+      <div class="row row-cols-lg-auto g-1 justify-content-start align-items-center pb-2">
+        <div class="col-12">
+          {{ form.input('Date', item.fields.id, 'date', item.url_for_purchase_date(), item.purchase_date(standard=true), icon='calendar-line', date=true) }}
+        </div>
+        <div class="col-12 flex-grow-1">
+          {{ form.input('Price', item.fields.id, 'price', item.url_for_purchase_price(), item.fields.purchase_price, suffix=config['PURCHASE_CURRENCY'], icon='wallet-3-line') }}
+        </div>
+        <div class="col-12 flex-grow-1">
+          {% if brickset_purchase_locations | length %}
+            {{ form.select('Location', item, 'purchase_location', brickset_purchase_locations, icon='building-line', delete=delete) }}
+          {% else %}
+            <i class="ri-error-warning-line"></i> No purchase location found.
+          {% endif %}
+        </div>
+      </div>
       <hr>
       <a href="{{ url_for('admin.admin', open_purchase_location=true) }}" class="btn btn-primary" role="button"><i class="ri-settings-4-line"></i> Manage the set purchase locations</a>
     {{ accordion.footer() }}
diff --git a/templates/set/sort.html b/templates/set/sort.html
index 3315a1df..abc37d95 100644
--- a/templates/set/sort.html
+++ b/templates/set/sort.html
@@ -22,6 +22,10 @@
       <button id="sort-damaged" type="button" class="btn btn-outline-primary mb-2"
         data-sort-attribute="damaged" data-sort-desc="true"><i class="ri-error-warning-line"></i><span class="d-none d-xl-inline"> Damaged</span></button>
       {% endif %}
+      <button id="sort-purchase-date" type="button" class="btn btn-outline-primary mb-2"
+        data-sort-attribute="purchase-date" data-sort-desc="true"><i class="ri-calendar-line"></i><span class="d-none d-xl-inline"> Date</span></button>
+      <button id="sort-purchase-price" type="button" class="btn btn-outline-primary mb-2"
+        data-sort-attribute="purchase-price" data-sort-desc="true"><i class="ri-wallet-3-line"></i><span class="d-none d-xl-inline"> Price</span></button>
       <button id="sort-clear" type="button" class="btn btn-outline-dark mb-2"
         data-sort-clear="true"><i class="ri-close-circle-line"></i><span class="d-none d-xl-inline"> Clear</span></button>
     </div>

From 2e995b615d0a91e370aef758ea3eeccd75939db5 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 17:04:09 +0100
Subject: [PATCH 141/154] Configuration doc

---
 .env.sample            | 15 ++++++++++-----
 CHANGELOG.md           |  1 +
 bricktracker/config.py |  2 +-
 3 files changed, 12 insertions(+), 6 deletions(-)

diff --git a/.env.sample b/.env.sample
index f4d6a48b..65a9d306 100644
--- a/.env.sample
+++ b/.env.sample
@@ -60,6 +60,11 @@
 # Legacy name: DOMAIN_NAME
 # BK_DOMAIN_NAME=http://localhost:3333
 
+# Optional: Format of the timestamp for files on disk (instructions, themes)
+# Check https://docs.python.org/3/library/time.html#time.strftime for format details
+# Default: %d/%m/%Y, %H:%M:%S
+# BK_FILE_DATETIME_FORMAT=%m/%d/%Y, %H:%M
+
 # Optional: IP address the server will listen on.
 # Default: 0.0.0.0
 # BK_HOST=0.0.0.0
@@ -91,11 +96,6 @@
 # Default: false
 # BK_HIDE_ADMIN=true
 
-# Optional: Hide the 'Problems' entry from the menu. Does not disable the route.
-# Default: false
-# Legacy name: BK_HIDE_MISSING_PARTS
-# BK_HIDE_ALL_PROBLEMS_PARTS=true
-
 # Optional: Hide the 'Instructions' entry from the menu. Does not disable the route.
 # Default: false
 # BK_HIDE_ALL_INSTRUCTIONS=true
@@ -108,6 +108,11 @@
 # Default: false
 # BK_HIDE_ALL_PARTS=true
 
+# Optional: Hide the 'Problems' entry from the menu. Does not disable the route.
+# Default: false
+# Legacy name: BK_HIDE_MISSING_PARTS
+# BK_HIDE_ALL_PROBLEMS_PARTS=true
+
 # Optional: Hide the 'Sets' entry from the menu. Does not disable the route.
 # Default: false
 # BK_HIDE_ALL_SETS=true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2bbb26cd..46c76b54 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,7 @@
 - Added: `BK_PURCHASE_LOCATION_DEFAULT_ORDER`, ordering of purchase locations
 - Added: `BK_PURCHASE_CURRENCY`, currency to display for purchase prices
 - Added: `BK_PURCHASE_DATE_FORMAT`, date format for purchase dates
+- Documented: `BK_FILE_DATETIME_FORMAT`, date format for files on disk (instructions, theme)
 
 ### Code
 
diff --git a/bricktracker/config.py b/bricktracker/config.py
index 5452f938..e8872e53 100644
--- a/bricktracker/config.py
+++ b/bricktracker/config.py
@@ -28,9 +28,9 @@ CONFIG: Final[list[dict[str, Any]]] = [
     {'n': 'HIDE_ALL_INSTRUCTIONS', 'c': bool},
     {'n': 'HIDE_ALL_MINIFIGURES', 'c': bool},
     {'n': 'HIDE_ALL_PARTS', 'c': bool},
+    {'n': 'HIDE_ALL_PROBLEMS_PARTS', 'e': 'BK_HIDE_MISSING_PARTS', 'c': bool},
     {'n': 'HIDE_ALL_SETS', 'c': bool},
     {'n': 'HIDE_ALL_STORAGES', 'c': bool},
-    {'n': 'HIDE_ALL_PROBLEMS_PARTS', 'e': 'BK_HIDE_MISSING_PARTS', 'c': bool},
     {'n': 'HIDE_SET_INSTRUCTIONS', 'c': bool},
     {'n': 'HIDE_TABLE_DAMAGED_PARTS', 'c': bool},
     {'n': 'HIDE_TABLE_MISSING_PARTS', 'c': bool},

From 9642853d8efd378f0dfc517a6e79c71c3c5c1149 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 17:38:26 +0100
Subject: [PATCH 142/154] Sort metadata lists by name for more consistency

---
 bricktracker/metadata_list.py                            | 5 +++--
 bricktracker/set_owner_list.py                           | 3 ++-
 bricktracker/set_purchase_location_list.py               | 7 +++++--
 bricktracker/set_status_list.py                          | 3 ++-
 bricktracker/set_storage_list.py                         | 7 +++++--
 bricktracker/set_tag_list.py                             | 5 +++--
 bricktracker/sql/set/metadata/owner/base.sql             | 6 +++++-
 bricktracker/sql/set/metadata/purchase_location/base.sql | 4 ++++
 bricktracker/sql/set/metadata/status/base.sql            | 6 +++++-
 bricktracker/sql/set/metadata/tag/base.sql               | 6 +++++-
 10 files changed, 39 insertions(+), 13 deletions(-)

diff --git a/bricktracker/metadata_list.py b/bricktracker/metadata_list.py
index 71245483..a7df7521 100644
--- a/bricktracker/metadata_list.py
+++ b/bricktracker/metadata_list.py
@@ -30,8 +30,9 @@ class BrickMetadataList(BrickRecordList[T]):
     mapping: dict[str, T]
     model: Type[T]
 
-    # Database table
+    # Database
     table: str
+    order: str
 
     # Queries
     select_query: str
@@ -73,7 +74,7 @@ class BrickMetadataList(BrickRecordList[T]):
                 self.__class__.mapping = {}
 
                 # Load the metadata from the database
-                for record in self.select():
+                for record in self.select(order=self.order):
                     metadata = model(record=record)
 
                     self.__class__.records.append(metadata)
diff --git a/bricktracker/set_owner_list.py b/bricktracker/set_owner_list.py
index 7d3b8f5e..74219a73 100644
--- a/bricktracker/set_owner_list.py
+++ b/bricktracker/set_owner_list.py
@@ -11,8 +11,9 @@ logger = logging.getLogger(__name__)
 class BrickSetOwnerList(BrickMetadataList[BrickSetOwner]):
     kind: str = 'set owners'
 
-    # Database table
+    # Database
     table: str = 'bricktracker_set_owners'
+    order: str = '"bricktracker_metadata_owners"."name"'
 
     # Queries
     select_query = 'set/metadata/owner/list'
diff --git a/bricktracker/set_purchase_location_list.py b/bricktracker/set_purchase_location_list.py
index 3ffae4bc..d49a1eb9 100644
--- a/bricktracker/set_purchase_location_list.py
+++ b/bricktracker/set_purchase_location_list.py
@@ -15,9 +15,12 @@ class BrickSetPurchaseLocationList(
 ):
     kind: str = 'set purchase locations'
 
+    # Order
+    order: str = '"bricktracker_metadata_purchase_locations"."name"'
+
     # Queries
-    select_query = 'set/metadata/purchase_location/list'
-    all_query = 'set/metadata/purchase_location/all'
+    select_query: str = 'set/metadata/purchase_location/list'
+    all_query: str = 'set/metadata/purchase_location/all'
 
     # Set value endpoint
     set_value_endpoint: str = 'set.update_purchase_location'
diff --git a/bricktracker/set_status_list.py b/bricktracker/set_status_list.py
index e238f62c..c40731c4 100644
--- a/bricktracker/set_status_list.py
+++ b/bricktracker/set_status_list.py
@@ -11,8 +11,9 @@ logger = logging.getLogger(__name__)
 class BrickSetStatusList(BrickMetadataList[BrickSetStatus]):
     kind: str = 'set statuses'
 
-    # Database table
+    # Database
     table: str = 'bricktracker_set_statuses'
+    order: str = '"bricktracker_metadata_statuses"."name"'
 
     # Queries
     select_query = 'set/metadata/status/list'
diff --git a/bricktracker/set_storage_list.py b/bricktracker/set_storage_list.py
index 77be7164..7e62333b 100644
--- a/bricktracker/set_storage_list.py
+++ b/bricktracker/set_storage_list.py
@@ -13,9 +13,12 @@ logger = logging.getLogger(__name__)
 class BrickSetStorageList(BrickMetadataList[BrickSetStorage]):
     kind: str = 'set storages'
 
+    # Order
+    order: str = '"bricktracker_metadata_storages"."name"'
+
     # Queries
-    select_query = 'set/metadata/storage/list'
-    all_query = 'set/metadata/storage/all'
+    select_query: str = 'set/metadata/storage/list'
+    all_query: str = 'set/metadata/storage/all'
 
     # Set value endpoint
     set_value_endpoint: str = 'set.update_storage'
diff --git a/bricktracker/set_tag_list.py b/bricktracker/set_tag_list.py
index 822ac3bf..9ed0d910 100644
--- a/bricktracker/set_tag_list.py
+++ b/bricktracker/set_tag_list.py
@@ -11,11 +11,12 @@ logger = logging.getLogger(__name__)
 class BrickSetTagList(BrickMetadataList[BrickSetTag]):
     kind: str = 'set tags'
 
-    # Database table
+    # Database
     table: str = 'bricktracker_set_tags'
+    order: str = '"bricktracker_metadata_tags"."name"'
 
     # Queries
-    select_query = 'set/metadata/tag/list'
+    select_query: str = 'set/metadata/tag/list'
 
     # Instantiate the list with the proper class
     @classmethod
diff --git a/bricktracker/sql/set/metadata/owner/base.sql b/bricktracker/sql/set/metadata/owner/base.sql
index 095ae3d6..f36d023a 100644
--- a/bricktracker/sql/set/metadata/owner/base.sql
+++ b/bricktracker/sql/set/metadata/owner/base.sql
@@ -3,4 +3,8 @@ SELECT
     "bricktracker_metadata_owners"."name"
 FROM "bricktracker_metadata_owners"
 
-{% block where %}{% endblock %}
\ No newline at end of file
+{% block where %}{% endblock %}
+
+{% if order %}
+ORDER BY {{ order }}
+{% endif %}
diff --git a/bricktracker/sql/set/metadata/purchase_location/base.sql b/bricktracker/sql/set/metadata/purchase_location/base.sql
index 8ac33ca1..c0a0ce06 100644
--- a/bricktracker/sql/set/metadata/purchase_location/base.sql
+++ b/bricktracker/sql/set/metadata/purchase_location/base.sql
@@ -4,3 +4,7 @@ SELECT
 FROM "bricktracker_metadata_purchase_locations"
 
 {% block where %}{% endblock %}
+
+{% if order %}
+ORDER BY {{ order }}
+{% endif %}
diff --git a/bricktracker/sql/set/metadata/status/base.sql b/bricktracker/sql/set/metadata/status/base.sql
index b1b2167d..d962cc25 100644
--- a/bricktracker/sql/set/metadata/status/base.sql
+++ b/bricktracker/sql/set/metadata/status/base.sql
@@ -4,4 +4,8 @@ SELECT
     "bricktracker_metadata_statuses"."displayed_on_grid"
 FROM "bricktracker_metadata_statuses"
 
-{% block where %}{% endblock %}
\ No newline at end of file
+{% block where %}{% endblock %}
+
+{% if order %}
+ORDER BY {{ order }}
+{% endif %}
diff --git a/bricktracker/sql/set/metadata/tag/base.sql b/bricktracker/sql/set/metadata/tag/base.sql
index 3ec57259..7e6f03bf 100644
--- a/bricktracker/sql/set/metadata/tag/base.sql
+++ b/bricktracker/sql/set/metadata/tag/base.sql
@@ -3,4 +3,8 @@ SELECT
     "bricktracker_metadata_tags"."name"
 FROM "bricktracker_metadata_tags"
 
-{% block where %}{% endblock %}
\ No newline at end of file
+{% block where %}{% endblock %}
+
+{% if order %}
+ORDER BY {{ order }}
+{% endif %}

From 9326c06c3e0e1d97365f0f06e0e6b4006b10757e Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 17:39:47 +0100
Subject: [PATCH 143/154] Remove forced open management accordion

---
 templates/set/management.html | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/templates/set/management.html b/templates/set/management.html
index 7ed92aff..744c9616 100644
--- a/templates/set/management.html
+++ b/templates/set/management.html
@@ -1,5 +1,5 @@
 {% if g.login.is_authenticated() %}
-  {{ accordion.header('Management', 'set-management', 'set-details', icon='settings-4-line', class='p-0', expanded=true) }}
+  {{ accordion.header('Management', 'set-management', 'set-details', icon='settings-4-line', class='p-0') }}
     {{ accordion.header('Owners', 'owner', 'set-management', icon='group-line', class='p-0') }}
       <ul class="list-group list-group-flush">
       {% if brickset_owners | length %}
@@ -14,7 +14,7 @@
         <a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_owner=true) }}"><i class="ri-settings-4-line"></i> Manage the set owners</a>
       </div>
     {{ accordion.footer() }}
-    {{ accordion.header('Purchase', 'purchase', 'set-management', icon='wallet-3-line', expanded=true) }}
+    {{ accordion.header('Purchase', 'purchase', 'set-management', icon='wallet-3-line') }}
       <div class="alert alert-info" role="alert">The expected date format here is <code>yyyy/mm/dd</code> (year/month/day), but you can configured how it is displayed in the set card with the <code>PURCHASE_DATE_FORMAT</code> variable.</div>
       <div class="row row-cols-lg-auto g-1 justify-content-start align-items-center pb-2">
         <div class="col-12">

From 50e6c8bf9ccce6adef0c6a614cdb39bafa6ee54d Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 18:16:27 +0100
Subject: [PATCH 144/154] Merge delete.html with set.html

---
 bricktracker/views/set.py |  3 ++-
 templates/delete.html     | 15 ---------------
 templates/set.html        |  4 ++--
 3 files changed, 4 insertions(+), 18 deletions(-)
 delete mode 100644 templates/delete.html

diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index f1493b8d..c174576b 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -145,7 +145,8 @@ def update_tag(*, id: str, metadata_id: str) -> Response:
 @exception_handler(__file__)
 def delete(*, id: str) -> str:
     return render_template(
-        'delete.html',
+        'set.html',
+        delete=True,
         item=BrickSet().select_specific(id),
         error=request.args.get('error'),
     )
diff --git a/templates/delete.html b/templates/delete.html
deleted file mode 100644
index 07c2b082..00000000
--- a/templates/delete.html
+++ /dev/null
@@ -1,15 +0,0 @@
-{% extends 'base.html' %}
-
-{% block title %} - Delete a set {{ item.fields.set }} ({{ item.fields.id }}){% endblock %}
-
-{% block main %}
-<div class="container">
-  <div class="row">
-    <div class="col-12">
-      {% with solo=true, delete=true %}
-        {% include 'set/card.html' %}
-      {% endwith %}
-    </div>
-  </div>
-</div>
-{% endblock %}
diff --git a/templates/set.html b/templates/set.html
index 69ba6dd1..12df4918 100644
--- a/templates/set.html
+++ b/templates/set.html
@@ -1,12 +1,12 @@
 {% extends 'base.html' %}
 
-{% block title %} - Set {{ item.fields.name}} ({{ item.fields.set }}){% endblock %}
+{% block title %} - {% if delete %}Delete a set{% else %}Set{% endif %} {{ item.fields.name}} ({{ item.fields.set }}){% endblock %}
 
 {% block main %}
 <div class="container">
   <div class="row">
     <div class="col-12">
-      {% with solo=true %}
+      {% with solo=true, delete=delete %}
         {% include 'set/card.html' %}
       {% endwith %}
     </div>

From 90a72130dfe6489e82ae2e8ccc48d12cce148433 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 19:05:38 +0100
Subject: [PATCH 145/154] Make form.select generic

---
 templates/macro/form.html     | 17 ++++++++---------
 templates/set/management.html |  4 ++--
 2 files changed, 10 insertions(+), 11 deletions(-)

diff --git a/templates/macro/form.html b/templates/macro/form.html
index 9564f350..72af87f5 100644
--- a/templates/macro/form.html
+++ b/templates/macro/form.html
@@ -43,26 +43,25 @@
   {% endif %}
 {% endmacro %}
 
-{% macro select(name, item, field, metadata_list, nullable=true, icon=none, delete=false) %}
+{% macro select(name, id, prefix, url, value, metadata_list, nullable=true, icon=none, delete=false) %}
   {% if g.login.is_authenticated() %}
-    {% set prefix=metadata_list.as_prefix() %}
-    <label class="visually-hidden" for="{{ prefix }}-{{ item.fields.id }}">{{ name }}</label>
+    <label class="visually-hidden" for="{{ prefix }}-{{ id }}">{{ name }}</label>
     <div class="input-group">
       {% if icon %}<span class="input-group-text"><i class="ri-{{ icon }} me-1"></i><span class="ms-1 d-none d-md-inline"> {{ name }}</span></span>{% endif %}
-      <select id="{{ prefix }}-{{ item.fields.id }}" class="form-select"
+      <select id="{{ prefix }}-{{ id }}" class="form-select"
         {% if not delete %}
-          data-changer-id="{{ item.fields.id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ metadata_list.url_for_set_value(item.fields.id) }}"
+          data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}"
         {% else %}
           disabled
         {% endif %}
         autocomplete="off">
-        {% if nullable %}<option value="" {% if item.fields[field] is none %}selected{% endif %}><i>None</i></option>{% endif %}
+        {% if nullable %}<option value="" {% if value is none %}selected{% endif %}><i>None</i></option>{% endif %}
         {% for metadata in metadata_list %}
-          <option value="{{ metadata.fields.id }}" {% if metadata.fields.id == item.fields[field] %}selected{% endif %}>{{ metadata.fields.name }}</option>
+          <option value="{{ metadata.fields.id }}" {% if metadata.fields.id == value %}selected{% endif %}>{{ metadata.fields.name }}</option>
         {% endfor %}
       </select>
-      <span id="status-{{ prefix }}-{{ item.fields.id }}" class="input-group-text ri-save-line"></span>
-      <button id="clear-{{ prefix }}-{{ item.fields.id }}" type="button" class="btn btn-sm btn-light btn-outline-danger border"><i class="ri-eraser-line"></i></button>
+      <span id="status-{{ prefix }}-{{ id }}" class="input-group-text ri-save-line"></span>
+      <button id="clear-{{ prefix }}-{{ id }}" type="button" class="btn btn-sm btn-light btn-outline-danger border"><i class="ri-eraser-line"></i></button>
     </div>
   {% endif %}
 {% endmacro %}
diff --git a/templates/set/management.html b/templates/set/management.html
index 744c9616..ade6729c 100644
--- a/templates/set/management.html
+++ b/templates/set/management.html
@@ -25,7 +25,7 @@
         </div>
         <div class="col-12 flex-grow-1">
           {% if brickset_purchase_locations | length %}
-            {{ form.select('Location', item, 'purchase_location', brickset_purchase_locations, icon='building-line', delete=delete) }}
+            {{ form.select('Location', item.fields.id, brickset_purchase_locations.as_prefix(), brickset_purchase_locations.url_for_set_value(item.fields.id), item.fields.purchase_location, brickset_purchase_locations, icon='building-line', delete=delete) }}
           {% else %}
             <i class="ri-error-warning-line"></i> No purchase location found.
           {% endif %}
@@ -36,7 +36,7 @@
     {{ accordion.footer() }}
     {{ accordion.header('Storage', 'storage', 'set-management', icon='archive-2-line') }}
       {% if brickset_storages | length %}
-        {{ form.select('Storage', item, 'storage', brickset_storages, delete=delete) }}
+      {{ form.select('Storage', item.fields.id, brickset_storages.as_prefix(), brickset_storages.url_for_set_value(item.fields.id), item.fields.storage, brickset_storages, icon='building-line', delete=delete) }}
       {% else %}
         <p class="text-center"><i class="ri-error-warning-line"></i> No storage found.</p>
       {% endif %}

From e022a6bc1ef32d10866ffa24c064e828e14eaac9 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 19:06:36 +0100
Subject: [PATCH 146/154] Remove unused logging

---
 bricktracker/rebrickable_minifigure.py     | 3 ---
 bricktracker/rebrickable_part.py           | 3 ---
 bricktracker/set_owner_list.py             | 3 ---
 bricktracker/set_purchase_location_list.py | 3 ---
 bricktracker/set_status_list.py            | 3 ---
 bricktracker/set_storage_list.py           | 3 ---
 bricktracker/set_tag_list.py               | 3 ---
 bricktracker/views/admin/image.py          | 4 ----
 bricktracker/views/admin/instructions.py   | 4 ----
 bricktracker/views/admin/retired.py        | 4 ----
 bricktracker/views/admin/theme.py          | 4 ----
 bricktracker/views/exceptions.py           | 3 ---
 12 files changed, 40 deletions(-)

diff --git a/bricktracker/rebrickable_minifigure.py b/bricktracker/rebrickable_minifigure.py
index 30d61eef..0ef7d43f 100644
--- a/bricktracker/rebrickable_minifigure.py
+++ b/bricktracker/rebrickable_minifigure.py
@@ -1,4 +1,3 @@
-import logging
 from sqlite3 import Row
 from typing import Any, TYPE_CHECKING
 
@@ -10,8 +9,6 @@ from .record import BrickRecord
 if TYPE_CHECKING:
     from .set import BrickSet
 
-logger = logging.getLogger(__name__)
-
 
 # A minifigure from Rebrickable
 class RebrickableMinifigure(BrickRecord):
diff --git a/bricktracker/rebrickable_part.py b/bricktracker/rebrickable_part.py
index 8fdd3cb0..ae34b3a6 100644
--- a/bricktracker/rebrickable_part.py
+++ b/bricktracker/rebrickable_part.py
@@ -1,5 +1,4 @@
 import os
-import logging
 from sqlite3 import Row
 from typing import Any, TYPE_CHECKING
 from urllib.parse import urlparse
@@ -14,8 +13,6 @@ if TYPE_CHECKING:
     from .set import BrickSet
     from .socket import BrickSocket
 
-logger = logging.getLogger(__name__)
-
 
 # A part from Rebrickable
 class RebrickablePart(BrickRecord):
diff --git a/bricktracker/set_owner_list.py b/bricktracker/set_owner_list.py
index 74219a73..ec2af9d4 100644
--- a/bricktracker/set_owner_list.py
+++ b/bricktracker/set_owner_list.py
@@ -1,11 +1,8 @@
-import logging
 from typing import Self
 
 from .metadata_list import BrickMetadataList
 from .set_owner import BrickSetOwner
 
-logger = logging.getLogger(__name__)
-
 
 # Lego sets owner list
 class BrickSetOwnerList(BrickMetadataList[BrickSetOwner]):
diff --git a/bricktracker/set_purchase_location_list.py b/bricktracker/set_purchase_location_list.py
index d49a1eb9..65e5f1b3 100644
--- a/bricktracker/set_purchase_location_list.py
+++ b/bricktracker/set_purchase_location_list.py
@@ -1,4 +1,3 @@
-import logging
 from typing import Self
 
 from flask import current_app
@@ -6,8 +5,6 @@ from flask import current_app
 from .metadata_list import BrickMetadataList
 from .set_purchase_location import BrickSetPurchaseLocation
 
-logger = logging.getLogger(__name__)
-
 
 # Lego sets purchase location list
 class BrickSetPurchaseLocationList(
diff --git a/bricktracker/set_status_list.py b/bricktracker/set_status_list.py
index c40731c4..ff4603be 100644
--- a/bricktracker/set_status_list.py
+++ b/bricktracker/set_status_list.py
@@ -1,11 +1,8 @@
-import logging
 from typing import Self
 
 from .metadata_list import BrickMetadataList
 from .set_status import BrickSetStatus
 
-logger = logging.getLogger(__name__)
-
 
 # Lego sets status list
 class BrickSetStatusList(BrickMetadataList[BrickSetStatus]):
diff --git a/bricktracker/set_storage_list.py b/bricktracker/set_storage_list.py
index 7e62333b..7cd9e136 100644
--- a/bricktracker/set_storage_list.py
+++ b/bricktracker/set_storage_list.py
@@ -1,4 +1,3 @@
-import logging
 from typing import Self
 
 from flask import current_app
@@ -6,8 +5,6 @@ from flask import current_app
 from .metadata_list import BrickMetadataList
 from .set_storage import BrickSetStorage
 
-logger = logging.getLogger(__name__)
-
 
 # Lego sets storage list
 class BrickSetStorageList(BrickMetadataList[BrickSetStorage]):
diff --git a/bricktracker/set_tag_list.py b/bricktracker/set_tag_list.py
index 9ed0d910..93817bac 100644
--- a/bricktracker/set_tag_list.py
+++ b/bricktracker/set_tag_list.py
@@ -1,11 +1,8 @@
-import logging
 from typing import Self
 
 from .metadata_list import BrickMetadataList
 from .set_tag import BrickSetTag
 
-logger = logging.getLogger(__name__)
-
 
 # Lego sets tag list
 class BrickSetTagList(BrickMetadataList[BrickSetTag]):
diff --git a/bricktracker/views/admin/image.py b/bricktracker/views/admin/image.py
index 30dce281..85b995da 100644
--- a/bricktracker/views/admin/image.py
+++ b/bricktracker/views/admin/image.py
@@ -1,5 +1,3 @@
-import logging
-
 from flask import Blueprint, redirect, url_for
 from flask_login import login_required
 from werkzeug.wrappers.response import Response
@@ -10,8 +8,6 @@ from ...part import BrickPart
 from ...rebrickable_image import RebrickableImage
 from ...set import BrickSet
 
-logger = logging.getLogger(__name__)
-
 admin_image_page = Blueprint(
     'admin_image',
     __name__,
diff --git a/bricktracker/views/admin/instructions.py b/bricktracker/views/admin/instructions.py
index 354782d0..90ac201a 100644
--- a/bricktracker/views/admin/instructions.py
+++ b/bricktracker/views/admin/instructions.py
@@ -1,5 +1,3 @@
-import logging
-
 from flask import Blueprint, redirect, url_for
 from flask_login import login_required
 from werkzeug.wrappers.response import Response
@@ -7,8 +5,6 @@ from werkzeug.wrappers.response import Response
 from ..exceptions import exception_handler
 from ...instructions_list import BrickInstructionsList
 
-logger = logging.getLogger(__name__)
-
 admin_instructions_page = Blueprint(
     'admin_instructions',
     __name__,
diff --git a/bricktracker/views/admin/retired.py b/bricktracker/views/admin/retired.py
index c3aa2f23..17ae4f0f 100644
--- a/bricktracker/views/admin/retired.py
+++ b/bricktracker/views/admin/retired.py
@@ -1,5 +1,3 @@
-import logging
-
 from flask import Blueprint, redirect, url_for
 from flask_login import login_required
 from werkzeug.wrappers.response import Response
@@ -7,8 +5,6 @@ from werkzeug.wrappers.response import Response
 from ..exceptions import exception_handler
 from ...retired_list import BrickRetiredList
 
-logger = logging.getLogger(__name__)
-
 admin_retired_page = Blueprint(
     'admin_retired',
     __name__,
diff --git a/bricktracker/views/admin/theme.py b/bricktracker/views/admin/theme.py
index d5f15bb5..ca9511a1 100644
--- a/bricktracker/views/admin/theme.py
+++ b/bricktracker/views/admin/theme.py
@@ -1,5 +1,3 @@
-import logging
-
 from flask import Blueprint, redirect, url_for
 from flask_login import login_required
 from werkzeug.wrappers.response import Response
@@ -7,8 +5,6 @@ from werkzeug.wrappers.response import Response
 from ..exceptions import exception_handler
 from ...theme_list import BrickThemeList
 
-logger = logging.getLogger(__name__)
-
 admin_theme_page = Blueprint(
     'admin_theme',
     __name__,
diff --git a/bricktracker/views/exceptions.py b/bricktracker/views/exceptions.py
index aa01b79c..e51b66b6 100644
--- a/bricktracker/views/exceptions.py
+++ b/bricktracker/views/exceptions.py
@@ -1,13 +1,10 @@
 from functools import wraps
-import logging
 from typing import Callable, ParamSpec, Tuple, Union
 
 from werkzeug.wrappers.response import Response
 
 from .error import error
 
-logger = logging.getLogger(__name__)
-
 # Decorator type hinting is hard.
 # What a view can return (str or Response or (Response, xxx))
 ViewReturn = Union[

From 9e709039c560b9acb34160b557a4e88ae7bd19f8 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 19:35:14 +0100
Subject: [PATCH 147/154] Make form.checkbox generic

---
 templates/macro/form.html     | 15 +++++++--------
 templates/set/card.html       |  2 +-
 templates/set/management.html |  4 ++--
 3 files changed, 10 insertions(+), 11 deletions(-)

diff --git a/templates/macro/form.html b/templates/macro/form.html
index 72af87f5..7f7952be 100644
--- a/templates/macro/form.html
+++ b/templates/macro/form.html
@@ -1,19 +1,18 @@
-{% macro checkbox(item, metadata, parent=none, delete=false) %}
+{% macro checkbox(name, id, prefix, url, checked, parent=none, delete=false) %}
   {% if g.login.is_authenticated() %}
-    {% set prefix=metadata.as_dataset() %}
-    <input class="form-check-input" type="checkbox" id="{{ prefix }}-{{ item.fields.id }}" {% if item.fields[metadata.as_column()] %}checked{% endif %}
+    <input class="form-check-input" type="checkbox" id="{{ prefix }}-{{ id }}" {% if checked %}checked{% endif %}
     {% if not delete %}
-      data-changer-id="{{ item.fields.id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ metadata.url_for_set_state(item.fields.id) }}" {% if parent %}data-changer-parent="{{ parent }}"{% endif %}
+      data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}" {% if parent %}data-changer-parent="{{ parent }}"{% endif %}
     {% else %}
       disabled
     {% endif %}
       autocomplete="off">
-    <label class="form-check-label flex-grow-1 ms-1" for="{{ prefix }}-{{ item.fields.id }}">
-      {{ metadata.fields.name }} <i id="status-{{ prefix }}-{{ item.fields.id }}"></i>
+    <label class="form-check-label flex-grow-1 ms-1" for="{{ prefix }}-{{ id }}">
+      {{ name }} <i id="status-{{ prefix }}-{{ id }}"></i>
     </label>
   {% else %}
-    <input class="form-check-input text-reset" type="checkbox" {% if item.fields[metadata.as_column()] %}checked{% endif %} disabled>
-    {{ text }}
+    <input class="form-check-input text-reset" type="checkbox" {% if checked %}checked{% endif %} disabled>
+    {{ name }}
   {% endif %}
 {% endmacro %}
 
diff --git a/templates/set/card.html b/templates/set/card.html
index 924bcd75..77cf33fa 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -83,7 +83,7 @@
   {% if not tiny and brickset_statuses | length %}
     <ul class="list-group list-group-flush card-check border-bottom-0">
       {% for status in brickset_statuses %}
-        <li class="d-flex list-group-item {% if not solo %}p-1{% endif %} text-nowrap">{{ form.checkbox(item, status, parent='set', delete=delete) }}</li>
+        <li class="d-flex list-group-item {% if not solo %}p-1{% endif %} text-nowrap">{{ form.checkbox(status.fields.name, item.fields.id, status.as_dataset(), status.url_for_set_state(item.fields.id), item.fields[status.as_column()], parent='set', delete=delete) }}</li>
       {% endfor %}
     </ul>
   {% endif %}
diff --git a/templates/set/management.html b/templates/set/management.html
index ade6729c..ea9b5012 100644
--- a/templates/set/management.html
+++ b/templates/set/management.html
@@ -4,7 +4,7 @@
       <ul class="list-group list-group-flush">
       {% if brickset_owners | length %}
         {% for owner in brickset_owners %}
-          <li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(item, owner, delete=delete) }}</li>
+          <li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(owner.fields.name, item.fields.id, owner.as_dataset(), owner.url_for_set_state(item.fields.id), item.fields[owner.as_column()], delete=delete) }}</li>
         {% endfor %}
       {% else %}
         <li class="list-group-item list-group-item-action text-center"><i class="ri-error-warning-line"></i> No owner found.</li>
@@ -47,7 +47,7 @@
       <ul class="list-group list-group-flush">
       {% if brickset_tags | length %}
         {% for tag in brickset_tags %}
-          <li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(item, tag, delete=delete) }}</li>
+          <li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(tag.fields.name, item.fields.id, tag.as_dataset(), tag.url_for_set_state(item.fields.id), item.fields[tag.as_column()], delete=delete) }}</li>
         {% endfor %}
       {% else %}
         <li class="list-group-item list-group-item-action text-center"><i class="ri-error-warning-line"></i> No tag found.</li>

From 5d6b373769e250f11fa739a871c66c19db3b9fe8 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 19:48:39 +0100
Subject: [PATCH 148/154] Add missing metadata when deleting a set

---
 bricktracker/views/set.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index c174576b..bb9e845c 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -149,6 +149,7 @@ def delete(*, id: str) -> str:
         delete=True,
         item=BrickSet().select_specific(id),
         error=request.args.get('error'),
+        **set_metadata_lists(as_class=True)
     )
 
 

From 56c1a46b371ffc33509c1dbbee8c5644ed6c2fd2 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 19:55:34 +0100
Subject: [PATCH 149/154] Differentiate between no sort and no sort-and-filter
 in tables

---
 static/scripts/table.js           | 13 +++++++++++--
 templates/instructions/table.html |  4 ++--
 templates/macro/table.html        |  2 +-
 templates/wish/table.html         |  4 ++--
 4 files changed, 16 insertions(+), 7 deletions(-)

diff --git a/static/scripts/table.js b/static/scripts/table.js
index 669afc57..f96f10bb 100644
--- a/static/scripts/table.js
+++ b/static/scripts/table.js
@@ -1,11 +1,16 @@
 class BrickTable {
     constructor(table, per_page) {
-        const columns = []
+        const columns = [];
+        const no_sort_and_filter = [];
         const no_sort = [];
         const number = [];
 
         // Read the table header for parameters
         table.querySelectorAll('th').forEach((th, index) => {
+            if (th.dataset.tableNoSortAndFilter) {
+                no_sort_and_filter.push(index);
+            }
+
             if (th.dataset.tableNoSort) {
                 no_sort.push(index);
             }
@@ -15,8 +20,12 @@ class BrickTable {
             }
         });
 
+        if (no_sort_and_filter.length) {
+            columns.push({ select: no_sort_and_filter, sortable: false, searchable: false });
+        }
+
         if (no_sort.length) {
-            columns.push({ select: no_sort, sortable: false, searchable: false });
+            columns.push({ select: no_sort, sortable: false });
         }
 
         if (number.length) {
diff --git a/templates/instructions/table.html b/templates/instructions/table.html
index 8ad9f3cf..b2c80077 100644
--- a/templates/instructions/table.html
+++ b/templates/instructions/table.html
@@ -6,9 +6,9 @@
       <tr>
         <th scope="col"><i class="ri-file-line fw-normal"></i> Filename</th>
         <th scope="col"><i class="ri-hashtag fw-normal"></i> Set</th>
-        <th data-table-no-sort="true" class="no-sort" scope="col"><i class="ri-image-line fw-normal"></i> Image</th>
+        <th data-table-no-sort-and-search="true" class="no-sort" scope="col"><i class="ri-image-line fw-normal"></i> Image</th>
         {% if g.login.is_authenticated() %}
-          <th data-table-no-sort="true" class="no-sort" scope="col"><i class="ri-settings-4-line fw-normal"></i> Actions</th>
+          <th data-table-no-sort-and-search="true" class="no-sort" scope="col"><i class="ri-settings-4-line fw-normal"></i> Actions</th>
         {% endif %}
         </tr>
     </thead>
diff --git a/templates/macro/table.html b/templates/macro/table.html
index 0638380f..32cc63c1 100644
--- a/templates/macro/table.html
+++ b/templates/macro/table.html
@@ -2,7 +2,7 @@
   <thead>
     <tr>
       {% if image %}
-        <th data-table-no-sort="true" class="no-sort" scope="col"><i class="ri-image-line fw-normal"></i> Image</th>
+        <th data-table-no-sort-and-search="true" class="no-sort" scope="col"><i class="ri-image-line fw-normal"></i> Image</th>
       {% endif %}
       <th scope="col"><i class="ri-pencil-line fw-normal"></i> Name</th>
       {% if color %}
diff --git a/templates/wish/table.html b/templates/wish/table.html
index 3b037824..ca222427 100644
--- a/templates/wish/table.html
+++ b/templates/wish/table.html
@@ -5,7 +5,7 @@
   <table data-table="{% if all %}true{% endif %}" class="table table-striped align-middle" id="wish">
     <thead>
       <tr>
-        <th data-table-no-sort="true" class="no-sort" scope="col"><i class="ri-image-line fw-normal"></i> Image</th>
+        <th data-table-no-sort-and-search="true" class="no-sort" scope="col"><i class="ri-image-line fw-normal"></i> Image</th>
         <th scope="col"><i class="ri-hashtag fw-normal"></i> Set</th>
         <th scope="col"><i class="ri-pencil-line fw-normal"></i> Name</th>
         <th scope="col"><i class="price-tag-3-line fw-normal"></i> Theme</th>
@@ -13,7 +13,7 @@
         <th scope="col"><i class="ri-shapes-line fw-normal"></i> Parts</th>
         <th scope="col"><i class="ri-calendar-close-line fw-normal"></i> Retirement</th>
         {% if g.login.is_authenticated() %}
-          <th data-table-no-sort="true" class="no-sort" scope="col"><i class="ri-settings-4-line fw-normal"></i> Actions</th>
+          <th data-table-no-sort-and-search="true" class="no-sort" scope="col"><i class="ri-settings-4-line fw-normal"></i> Actions</th>
         {% endif %}
         </tr>
     </thead>

From ad24506ab7e7d3d67453ba63b31b7bd918ed30b3 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 19:56:33 +0100
Subject: [PATCH 150/154] Fix extra comma

---
 bricktracker/sql/set/base/base.sql | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/bricktracker/sql/set/base/base.sql b/bricktracker/sql/set/base/base.sql
index 02ef7711..c19ffc85 100644
--- a/bricktracker/sql/set/base/base.sql
+++ b/bricktracker/sql/set/base/base.sql
@@ -29,7 +29,7 @@ SELECT
     NULL AS "total_damaged", -- dummy for order: total_damaged
     {% endblock %}
     {% block total_quantity %}
-    NULL AS "total_quantity", -- dummy for order: total_quantity
+    NULL AS "total_quantity" -- dummy for order: total_quantity
     {% endblock %}
 FROM "bricktracker_sets"
 

From 64a9e063ec72816f8955da14f9dfcd6657f5b967 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 20:07:15 +0100
Subject: [PATCH 151/154] Wish requesters

---
 CHANGELOG.md                                  |  6 ++
 bricktracker/metadata_list.py                 |  4 +-
 bricktracker/record_list.py                   |  2 +
 bricktracker/reload.py                        |  4 ++
 bricktracker/sql/migrations/0013.sql          |  7 ++
 .../sql/set/metadata/owner/delete.sql         |  4 ++
 .../sql/set/metadata/owner/insert.sql         |  4 ++
 bricktracker/sql/wish/base/base.sql           |  8 +++
 bricktracker/sql/wish/delete/wish.sql         | 12 +++-
 .../sql/wish/metadata/owner/update/state.sql  | 10 +++
 bricktracker/views/wish.py                    | 71 ++++++++++++++++---
 bricktracker/wish.py                          | 19 +++--
 bricktracker/wish_list.py                     |  4 +-
 bricktracker/wish_owner.py                    | 70 ++++++++++++++++++
 bricktracker/wish_owner_list.py               | 21 ++++++
 templates/wish.html                           | 15 ++++
 templates/wish/card.html                      | 49 +++++++++++++
 templates/wish/table.html                     | 11 ++-
 templates/wishes.html                         |  2 +-
 19 files changed, 300 insertions(+), 23 deletions(-)
 create mode 100644 bricktracker/sql/wish/metadata/owner/update/state.sql
 create mode 100644 bricktracker/wish_owner.py
 create mode 100644 bricktracker/wish_owner_list.py
 create mode 100644 templates/wish.html
 create mode 100644 templates/wish/card.html

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 46c76b54..a6f89a8d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -55,6 +55,9 @@
     - Allow for advanced migration scenarios through companion python files
     - Add a bunch of the requested fields into the database for future implementation
 
+- Wish
+    - Requester
+
 ### UI
 
 - Add
@@ -101,6 +104,9 @@
     - Storage list
     - Storage content
 
+- Wish
+    - Requester
+
 ## 1.1.1: PDF Instructions Download
 
 ### Instructions
diff --git a/bricktracker/metadata_list.py b/bricktracker/metadata_list.py
index a7df7521..44bf18fe 100644
--- a/bricktracker/metadata_list.py
+++ b/bricktracker/metadata_list.py
@@ -11,6 +11,7 @@ from .set_purchase_location import BrickSetPurchaseLocation
 from .set_status import BrickSetStatus
 from .set_storage import BrickSetStorage
 from .set_tag import BrickSetTag
+from .wish_owner import BrickWishOwner
 
 logger = logging.getLogger(__name__)
 
@@ -20,7 +21,8 @@ T = TypeVar(
     BrickSetPurchaseLocation,
     BrickSetStatus,
     BrickSetStorage,
-    BrickSetTag
+    BrickSetTag,
+    BrickWishOwner
 )
 
 
diff --git a/bricktracker/record_list.py b/bricktracker/record_list.py
index 18dcb10c..8d862ed2 100644
--- a/bricktracker/record_list.py
+++ b/bricktracker/record_list.py
@@ -14,6 +14,7 @@ if TYPE_CHECKING:
     from .set_storage import BrickSetStorage
     from .set_tag import BrickSetTag
     from .wish import BrickWish
+    from .wish_owner import BrickWishOwner
 
 T = TypeVar(
     'T',
@@ -26,6 +27,7 @@ T = TypeVar(
     'BrickSetStorage',
     'BrickSetTag',
     'BrickWish',
+    'BrickWishOwner',
     'RebrickableSet'
 )
 
diff --git a/bricktracker/reload.py b/bricktracker/reload.py
index 38929f68..99d95bbf 100644
--- a/bricktracker/reload.py
+++ b/bricktracker/reload.py
@@ -6,6 +6,7 @@ from .set_status_list import BrickSetStatusList
 from .set_storage_list import BrickSetStorageList
 from .set_tag_list import BrickSetTagList
 from .theme_list import BrickThemeList
+from .wish_owner_list import BrickWishOwnerList
 
 
 # Reload everything related to a database after an operation
@@ -35,5 +36,8 @@ def reload() -> None:
 
         # Reload themes
         BrickThemeList(force=True)
+
+        # Reload the wish owners
+        BrickWishOwnerList.new(force=True)
     except Exception:
         pass
diff --git a/bricktracker/sql/migrations/0013.sql b/bricktracker/sql/migrations/0013.sql
index 33f8a6f0..469649d5 100644
--- a/bricktracker/sql/migrations/0013.sql
+++ b/bricktracker/sql/migrations/0013.sql
@@ -16,4 +16,11 @@ CREATE TABLE "bricktracker_set_owners" (
     FOREIGN KEY("id") REFERENCES "bricktracker_sets"("id")
 );
 
+-- Create a table for the wish owners
+CREATE TABLE "bricktracker_wish_owners" (
+    "set" TEXT NOT NULL,
+    PRIMARY KEY("set"),
+    FOREIGN KEY("set") REFERENCES "bricktracker_wishes"("set")
+);
+
 COMMIT;
diff --git a/bricktracker/sql/set/metadata/owner/delete.sql b/bricktracker/sql/set/metadata/owner/delete.sql
index e9df18d8..5927bfdb 100644
--- a/bricktracker/sql/set/metadata/owner/delete.sql
+++ b/bricktracker/sql/set/metadata/owner/delete.sql
@@ -3,6 +3,10 @@ BEGIN TRANSACTION;
 ALTER TABLE "bricktracker_set_owners"
 DROP COLUMN "owner_{{ id }}";
 
+-- Also drop from wishes
+ALTER TABLE "bricktracker_wish_owners"
+DROP COLUMN "owner_{{ id }}";
+
 DELETE FROM "bricktracker_metadata_owners"
 WHERE "bricktracker_metadata_owners"."id" IS NOT DISTINCT FROM '{{ id }}';
 
diff --git a/bricktracker/sql/set/metadata/owner/insert.sql b/bricktracker/sql/set/metadata/owner/insert.sql
index cc54a2a5..6b2de775 100644
--- a/bricktracker/sql/set/metadata/owner/insert.sql
+++ b/bricktracker/sql/set/metadata/owner/insert.sql
@@ -3,6 +3,10 @@ BEGIN TRANSACTION;
 ALTER TABLE "bricktracker_set_owners"
 ADD COLUMN "owner_{{ id }}" BOOLEAN NOT NULL DEFAULT 0;
 
+-- Also inject into wishes
+ALTER TABLE "bricktracker_wish_owners"
+ADD COLUMN "owner_{{ id }}" BOOLEAN NOT NULL DEFAULT 0;
+
 INSERT INTO "bricktracker_metadata_owners" (
     "id",
     "name"
diff --git a/bricktracker/sql/wish/base/base.sql b/bricktracker/sql/wish/base/base.sql
index b06c66fd..62f5a7cb 100644
--- a/bricktracker/sql/wish/base/base.sql
+++ b/bricktracker/sql/wish/base/base.sql
@@ -5,9 +5,17 @@ SELECT
     "bricktracker_wishes"."theme_id",
     "bricktracker_wishes"."number_of_parts",
     "bricktracker_wishes"."image",
+    {% block owners %}
+        {% if owners %}{{ owners }},{% endif %}
+    {% endblock %}
     "bricktracker_wishes"."url"
 FROM "bricktracker_wishes"
 
+{% if owners %}
+LEFT JOIN "bricktracker_wish_owners"
+ON "bricktracker_wishes"."set" IS NOT DISTINCT FROM "bricktracker_wish_owners"."set"
+{% endif %}
+
 {% block where %}{% endblock %}
 
 {% if order %}
diff --git a/bricktracker/sql/wish/delete/wish.sql b/bricktracker/sql/wish/delete/wish.sql
index e60b2e48..1adcfc11 100644
--- a/bricktracker/sql/wish/delete/wish.sql
+++ b/bricktracker/sql/wish/delete/wish.sql
@@ -1,2 +1,12 @@
+-- A bit unsafe as it does not use a prepared statement but it
+-- should not be possible to inject anything through the {{ set }} context
+
+BEGIN TRANSACTION;
+
 DELETE FROM "bricktracker_wishes"
-WHERE "bricktracker_wishes"."set" IS NOT DISTINCT FROM :set
\ No newline at end of file
+WHERE "bricktracker_wishes"."set" IS NOT DISTINCT FROM '{{ set }}';
+
+DELETE FROM "bricktracker_wish_owners"
+WHERE "bricktracker_wish_owners"."set" IS NOT DISTINCT FROM '{{ set }}';
+
+COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/wish/metadata/owner/update/state.sql b/bricktracker/sql/wish/metadata/owner/update/state.sql
new file mode 100644
index 00000000..9191ca8d
--- /dev/null
+++ b/bricktracker/sql/wish/metadata/owner/update/state.sql
@@ -0,0 +1,10 @@
+INSERT INTO "bricktracker_wish_owners" (
+    "set",
+    "{{name}}"
+) VALUES (
+    :set,
+    :state
+)
+ON CONFLICT("set")
+DO UPDATE SET "{{name}}" = :state
+WHERE "bricktracker_wish_owners"."set" IS NOT DISTINCT FROM :set
diff --git a/bricktracker/views/wish.py b/bricktracker/views/wish.py
index b0c763b2..416b9005 100644
--- a/bricktracker/views/wish.py
+++ b/bricktracker/views/wish.py
@@ -1,4 +1,11 @@
-from flask import Blueprint, redirect, render_template, request, url_for
+from flask import (
+    Blueprint,
+    jsonify,
+    redirect,
+    render_template,
+    request,
+    url_for
+)
 from flask_login import login_required
 from werkzeug.wrappers.response import Response
 
@@ -6,8 +13,10 @@ from .exceptions import exception_handler
 from ..retired_list import BrickRetiredList
 from ..wish import BrickWish
 from ..wish_list import BrickWishList
+from ..wish_owner_list import BrickWishOwnerList
 
-wish_page = Blueprint('wish', __name__, url_prefix='/wishlist')
+
+wish_page = Blueprint('wish', __name__, url_prefix='/wishes')
 
 
 # Index
@@ -18,7 +27,8 @@ def list() -> str:
         'wishes.html',
         table_collection=BrickWishList().all(),
         retired=BrickRetiredList(),
-        error=request.args.get('error')
+        error=request.args.get('error'),
+        owners=BrickWishOwnerList.list(),
     )
 
 
@@ -27,21 +37,60 @@ def list() -> str:
 @login_required
 @exception_handler(__file__, post_redirect='wish.list')
 def add() -> Response:
-    # Grab the set number
-    number: str = request.form.get('number', '')
+    # Grab the set
+    set: str = request.form.get('set', '')
 
-    if number != '':
-        BrickWishList.add(number)
+    if set != '':
+        BrickWishList.add(set)
 
     return redirect(url_for('wish.list'))
 
 
-# Delete a set from the wishlit
-@wish_page.route('/delete/<number>', methods=['POST'])
+# Ask for deletion of a wish
+@wish_page.route('/<set>/delete', methods=['GET'])
+@login_required
+@exception_handler(__file__)
+def delete(*, set: str) -> str:
+    return render_template(
+        'wish.html',
+        delete=True,
+        item=BrickWish().select_specific(set),
+        error=request.args.get('error'),
+        owners=BrickWishOwnerList.list(),
+    )
+
+
+# Actually delete of a set
+@wish_page.route('/<set>/delete', methods=['POST'])
 @login_required
 @exception_handler(__file__, post_redirect='wish.list')
-def delete(*, number: str) -> Response:
-    brickwish = BrickWish().select_specific(number)
+def do_delete(*, set: str) -> Response:
+    brickwish = BrickWish().select_specific(set)
     brickwish.delete()
 
     return redirect(url_for('wish.list'))
+
+
+# Details
+@wish_page.route('/<set>/details', methods=['GET'])
+@exception_handler(__file__)
+def details(*, set: str) -> str:
+    return render_template(
+        'wish.html',
+        item=BrickWish().select_specific(set),
+        retired=BrickRetiredList(),
+        owners=BrickWishOwnerList.list(),
+    )
+
+
+# Change the state of a owner
+@wish_page.route('/<set>/owner/<metadata_id>', methods=['POST'])
+@login_required
+@exception_handler(__file__, json=True)
+def update_owner(*, set: str, metadata_id: str) -> Response:
+    brickwish = BrickWish().select_specific(set)
+    owner = BrickWishOwnerList.get(metadata_id)
+
+    state = owner.update_wish_state(brickwish, json=request.json)
+
+    return jsonify({'value': state})
diff --git a/bricktracker/wish.py b/bricktracker/wish.py
index def41e28..502792fe 100644
--- a/bricktracker/wish.py
+++ b/bricktracker/wish.py
@@ -5,6 +5,7 @@ from flask import url_for
 from .exceptions import NotFoundException
 from .rebrickable_set import RebrickableSet
 from .sql import BrickSQL
+from .wish_owner_list import BrickWishOwnerList
 
 
 # Lego brick wished set
@@ -16,11 +17,11 @@ class BrickWish(RebrickableSet):
     select_query: str = 'wish/select'
     insert_query: str = 'wish/insert'
 
-    # Delete a wished set
+    # Delete a wish
     def delete(self, /) -> None:
-        BrickSQL().execute_and_commit(
+        BrickSQL().executescript(
             'wish/delete/wish',
-            parameters=self.sql_parameters()
+            set=self.fields.set
         )
 
     # Select a specific part (with a set and an id)
@@ -29,7 +30,7 @@ class BrickWish(RebrickableSet):
         self.fields.set = set
 
         # Load from database
-        if not self.select():
+        if not self.select(owners=BrickWishOwnerList.as_columns()):
             raise NotFoundException(
                 'Wish for set {set} was not found in the database'.format(  # noqa: E501
                     set=self.fields.set,
@@ -38,6 +39,14 @@ class BrickWish(RebrickableSet):
 
         return self
 
+    # Self url
+    def url(self, /) -> str:
+        return url_for('wish.details', set=self.fields.set)
+
     # Deletion url
     def url_for_delete(self, /) -> str:
-        return url_for('wish.delete', number=self.fields.set)
+        return url_for('wish.delete', set=self.fields.set)
+
+    # Actual deletion url
+    def url_for_do_delete(self, /) -> str:
+        return url_for('wish.do_delete', set=self.fields.set)
diff --git a/bricktracker/wish_list.py b/bricktracker/wish_list.py
index 880021b7..d3038b8a 100644
--- a/bricktracker/wish_list.py
+++ b/bricktracker/wish_list.py
@@ -9,6 +9,7 @@ from .rebrickable import Rebrickable
 from .rebrickable_image import RebrickableImage
 from .record_list import BrickRecordList
 from .wish import BrickWish
+from .wish_owner_list import BrickWishOwnerList
 
 logger = logging.getLogger(__name__)
 
@@ -22,7 +23,8 @@ class BrickWishList(BrickRecordList[BrickWish]):
     def all(self, /) -> Self:
         # Load the wished sets from the database
         for record in self.select(
-            order=current_app.config['WISHES_DEFAULT_ORDER']
+            order=current_app.config['WISHES_DEFAULT_ORDER'],
+            owners=BrickWishOwnerList.as_columns(),
         ):
             brickwish = BrickWish(record=record)
 
diff --git a/bricktracker/wish_owner.py b/bricktracker/wish_owner.py
new file mode 100644
index 00000000..c31d2c9f
--- /dev/null
+++ b/bricktracker/wish_owner.py
@@ -0,0 +1,70 @@
+import logging
+from typing import Any, TYPE_CHECKING
+
+from flask import url_for
+
+from .exceptions import DatabaseException
+from .metadata import BrickMetadata
+from .sql import BrickSQL
+if TYPE_CHECKING:
+    from .wish import BrickWish
+
+logger = logging.getLogger(__name__)
+
+
+# Lego wish owner metadata
+class BrickWishOwner(BrickMetadata):
+    kind: str = 'owner'
+
+    # Wish state endpoint
+    wish_state_endpoint: str = 'wish.update_owner'
+
+    # Queries
+    update_wish_state_query: str = 'wish/metadata/owner/update/state'
+
+    # Update the selected state of this metadata item for a wish
+    def update_wish_state(
+        self,
+        brickset: 'BrickWish',
+        /,
+        *,
+        json: Any | None = None,
+        state: Any | None = None
+    ) -> Any:
+        if state is None and json is not None:
+            state = json.get('value', False)
+
+        parameters = self.sql_parameters()
+        parameters['set'] = brickset.fields.set
+        parameters['state'] = state
+
+        rows, _ = BrickSQL().execute_and_commit(
+            self.update_wish_state_query,
+            parameters=parameters,
+            name=self.as_column(),
+        )
+
+        if rows != 1:
+            raise DatabaseException('Could not update the {kind} "{name}" state for wish {set}'.format(  # noqa: E501
+                kind=self.kind,
+                name=self.fields.name,
+                set=brickset.fields.set,
+            ))
+
+        # Info
+        logger.info('{kind} "{name}" state changed to "{state}" for wish {set}'.format(  # noqa: E501
+            kind=self.kind,
+            name=self.fields.name,
+            state=state,
+            set=brickset.fields.set,
+        ))
+
+        return state
+
+    # URL to change the selected state of this metadata item for a wish
+    def url_for_wish_state(self, set: str, /) -> str:
+        return url_for(
+            self.wish_state_endpoint,
+            set=set,
+            metadata_id=self.fields.id
+        )
diff --git a/bricktracker/wish_owner_list.py b/bricktracker/wish_owner_list.py
new file mode 100644
index 00000000..719fc6f5
--- /dev/null
+++ b/bricktracker/wish_owner_list.py
@@ -0,0 +1,21 @@
+from typing import Self
+
+from .metadata_list import BrickMetadataList
+from .wish_owner import BrickWishOwner
+
+
+# Lego sets owner list
+class BrickWishOwnerList(BrickMetadataList[BrickWishOwner]):
+    kind: str = 'wish owners'
+
+    # Database
+    table: str = 'bricktracker_wish_owners'
+    order: str = '"bricktracker_metadata_owners"."name"'
+
+    # Queries
+    select_query = 'set/metadata/owner/list'
+
+    # Instantiate the list with the proper class
+    @classmethod
+    def new(cls, /, *, force: bool = False) -> Self:
+        return cls(BrickWishOwner, force=force)
diff --git a/templates/wish.html b/templates/wish.html
new file mode 100644
index 00000000..6c0f3990
--- /dev/null
+++ b/templates/wish.html
@@ -0,0 +1,15 @@
+{% extends 'base.html' %}
+
+{% block title %} - {% if delete %}Delete a wish{% else %}Wish{% endif %} {{ item.fields.name}} ({{ item.fields.set }}){% endblock %}
+
+{% block main %}
+<div class="container">
+  <div class="row">
+    <div class="col-12">
+      {% with solo=true, delete=delete %}
+        {% include 'wish/card.html' %}
+      {% endwith %}
+    </div>
+  </div>
+</div>
+{% endblock %}
diff --git a/templates/wish/card.html b/templates/wish/card.html
new file mode 100644
index 00000000..fc16d9b9
--- /dev/null
+++ b/templates/wish/card.html
@@ -0,0 +1,49 @@
+{% import 'macro/accordion.html' as accordion %}
+{% import 'macro/badge.html' as badge %}
+{% import 'macro/card.html' as card %}
+{% import 'macro/form.html' as form %}
+
+<div class="card mb-3 flex-fill {% if solo %}card-solo{% endif %}">
+  {{ card.header(item, item.fields.name, solo=solo, identifier=item.fields.set) }}
+  {{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.set) }}
+  <div class="card-body border-bottom-0 {% if not solo %}p-1{% endif %}">
+    {{ badge.theme(item.theme.name, solo=solo, last=last) }}
+    {{ badge.parts(item.fields.number_of_parts, solo=solo, last=last) }}
+    {% for owner in owners %}
+      {{ badge.owner(item, owner, solo=solo, last=last) }}
+    {% endfor %}
+  </div>
+  {% if solo and g.login.is_authenticated() %}
+    <div class="accordion accordion-flush border-top" id="wish-details">
+      {% if not delete %}
+        {{ accordion.header('Requester', 'owner', 'wish-details', icon='group-line', class='p-0') }}
+        <ul class="list-group list-group-flush">
+        {% if owners | length %}
+          {% for owner in owners %}
+            <li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(owner.fields.name, item.fields.set, owner.as_dataset(), owner.url_for_wish_state(item.fields.set), item.fields[owner.as_column()], delete=delete) }}</li>
+          {% endfor %}
+        {% else %}
+          <li class="list-group-item list-group-item-action text-center"><i class="ri-error-warning-line"></i> No requester found.</li>
+        {% endif %}
+        </ul>
+        <div class="list-group list-group-flush border-top">
+          <a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_owner=true) }}"><i class="ri-settings-4-line"></i> Manage the set owners</a>
+        </div>
+        {{ accordion.footer() }}
+      {% endif %}
+      {{ accordion.header('Danger zone', 'danger-zone', 'wish-details', expanded=delete, danger=true, class='text-end') }}
+        {% if delete %}
+          <form action="{{ item.url_for_do_delete() }}" method="post">
+            {% if error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ error }}.</div>{% endif %}
+            <div class="alert alert-danger text-center" role="alert">You are about to delete a wish. This action is irreversible.</div>
+            <a class="btn btn-primary" href="{{ item.url() }}" role="button"><i class="ri-arrow-left-long-line"></i> Back to the wish</a>
+            <button type="submit" class="btn btn-danger"><i class="ri-close-line"></i> Delete the wish</button>
+          </form>
+        {% else %}
+          <a href="{{ item.url_for_delete() }}" class="btn btn-danger" role="button"><i class="ri-close-line"></i> Delete the wish</a>
+        {% endif %}
+      {{ accordion.footer() }}
+    </div>
+    <div class="card-footer"></div>
+  {% endif %}
+</div>
diff --git a/templates/wish/table.html b/templates/wish/table.html
index ca222427..41783ca9 100644
--- a/templates/wish/table.html
+++ b/templates/wish/table.html
@@ -12,6 +12,7 @@
         <th scope="col"><i class="ri-calendar-line fw-normal"></i> Year</th>
         <th scope="col"><i class="ri-shapes-line fw-normal"></i> Parts</th>
         <th scope="col"><i class="ri-calendar-close-line fw-normal"></i> Retirement</th>
+        <th data-table-no-sort="true" class="no-sort" scope="col"><i class="ri-user-line fw-normal"></i> Requesters</th>
         {% if g.login.is_authenticated() %}
           <th data-table-no-sort-and-search="true" class="no-sort" scope="col"><i class="ri-settings-4-line fw-normal"></i> Actions</th>
         {% endif %}
@@ -28,11 +29,15 @@
             <td>{{ item.fields.year }}</td>
             <td>{{ item.fields.number_of_parts }}</td>
             <td>{% if retirement_date %}{{ retirement_date }}{% else %}<span class="badge rounded-pill text-bg-light border">Not found</span>{% endif %}</td>
+            <td>
+              {% for owner in owners %}
+                {{ badge.owner(item, owner) }}
+              {% endfor %}
+            </td>
             {% if g.login.is_authenticated() %}
             <td>
-              <form action="{{ item.url_for_delete() }}" method="post">
-                <button type="submit" class="btn btn-sm btn-danger"><i class="ri-delete-bin-2-line"></i> Delete</button>
-              </form>
+              <a href="{{ item.url() }}" class="btn btn-sm btn-primary mb-1" role="button"><i class="ri-gift-line"></i> Details</a>
+              <a href="{{ item.url_for_delete() }}" class="btn btn-sm btn-danger mb-1" role="button"><i class="ri-delete-bin-2-line"></i> Delete</a>
             </td>
             {% endif %}
           </tr>
diff --git a/templates/wishes.html b/templates/wishes.html
index eac8255f..2c2b2ab9 100644
--- a/templates/wishes.html
+++ b/templates/wishes.html
@@ -12,7 +12,7 @@
         <label class="visually-hidden" for="number">Set number</label>
         <div class="input-group">
           <div class="input-group-text"><i class="ri-hashtag"></i></div>
-          <input type="text" class="form-control" id="number" name="number" placeholder="107-1 or 1642-1 or ..." value="" autocomplete="off">
+          <input type="text" class="form-control" id="set" name="set" placeholder="107-1 or 1642-1 or ..." value="" autocomplete="off">
         </div>
       </div>
       <div class="col-12">

From 6eb09643227ebfbcc488c9391acbdccff0b4b66f Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 21:38:20 +0100
Subject: [PATCH 152/154] Add clear button to the grid search bar

---
 static/scripts/grid/filter.js | 8 ++++++++
 templates/sets.html           | 1 +
 2 files changed, 9 insertions(+)

diff --git a/static/scripts/grid/filter.js b/static/scripts/grid/filter.js
index 2825c53a..6de37a87 100644
--- a/static/scripts/grid/filter.js
+++ b/static/scripts/grid/filter.js
@@ -5,6 +5,7 @@ class BrickGridFilter {
 
         // Grid sort elements (built based on the initial id)
         this.html_search = document.getElementById(`${this.grid.id}-search`);
+        this.html_search_clear = document.getElementById(`${this.grid.id}-search-clear`);
         this.html_filter = document.getElementById(`${this.grid.id}-filter`);
 
         // Search setup
@@ -25,6 +26,13 @@ class BrickGridFilter {
             this.html_search.addEventListener("keyup", ((gridfilter) => () => {
                 gridfilter.filter();
             })(this));
+
+            if (this.html_search_clear) {
+                this.html_search_clear.addEventListener("click", ((gridfilter) => () => {
+                    this.html_search.value = '';
+                    gridfilter.filter();
+                })(this));
+            }
         }
 
         // Filters setup
diff --git a/templates/sets.html b/templates/sets.html
index 4832160c..aa598670 100644
--- a/templates/sets.html
+++ b/templates/sets.html
@@ -11,6 +11,7 @@
       <div class="input-group">
         <span class="input-group-text"><i class="ri-search-line"></i><span class="ms-1 d-none d-md-inline"> Search</span></span>
         <input id="grid-search" data-search-exact="name,number,parts,searchPurchaseLocation,searchStorage,theme,year" data-search-list="searchOwner,searchTag" class="form-control form-control-sm" type="text" placeholder="Set, name, number of parts, theme, year, owner, purchase location, storage, tag" value="">
+        <button id="grid-search-clear" type="button" class="btn btn-light btn-outline-danger border"><i class="ri-eraser-line"></i></button>
       </div>
     </div>
     <div class="col-12">

From b6d69e0f10c853761f8f01863f99e18dc32f7647 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 23:03:56 +0100
Subject: [PATCH 153/154] Revert the checked state of a checkbox if an error
 occured

---
 CHANGELOG.md              | 3 +++
 static/scripts/changer.js | 5 +++++
 2 files changed, 8 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index a6f89a8d..9a6ba43a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,9 @@
 
 ### Code
 
+- Changer
+    - Revert the checked state of a checkbox if an error occured
+
 - Form
     - Migrate missing input fields to BrickChanger
 
diff --git a/static/scripts/changer.js b/static/scripts/changer.js
index ffa41ace..a32af76d 100644
--- a/static/scripts/changer.js
+++ b/static/scripts/changer.js
@@ -174,6 +174,11 @@ class BrickChanger {
             console.log(error.message);
 
             this.status_error(error.message);
+
+            // Reverse the checked state
+            if (this.html_type == "checkbox") {
+                this.html_element.checked = !this.html_element.checked;
+            }
         }
     }
 }

From a99669d9dcec61684c1df6b0d704f94c97ed523b Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 23:05:36 +0100
Subject: [PATCH 154/154] List of sets to be refreshed

---
 CHANGELOG.md                                  |  2 +
 bricktracker/app.py                           |  2 +
 bricktracker/rebrickable_set.py               | 23 +++++++-
 bricktracker/rebrickable_set_list.py          | 13 +++++
 .../sql/rebrickable/set/need_refresh.sql      | 53 +++++++++++++++++++
 bricktracker/sql_counter.py                   |  7 +--
 bricktracker/views/admin/set.py               | 20 +++++++
 bricktracker/views/set.py                     | 15 +++++-
 templates/admin.html                          |  3 ++
 templates/admin/refresh.html                  |  5 ++
 templates/admin/set/refresh.html              | 34 ++++++++++++
 templates/refresh.html                        |  6 ++-
 12 files changed, 175 insertions(+), 8 deletions(-)
 create mode 100644 bricktracker/sql/rebrickable/set/need_refresh.sql
 create mode 100644 bricktracker/views/admin/set.py
 create mode 100644 templates/admin/refresh.html
 create mode 100644 templates/admin/set/refresh.html

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9a6ba43a..baad4bde 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -69,6 +69,7 @@
 - Admin
     - Grey out legacy tables in the database view
     - Checkboxes renamed to Set statuses
+    - List of sets that may need to be refreshed
 
 - Cards
     - Use macros for badge in the card header
@@ -102,6 +103,7 @@
     - Collapsible controls depending on screen size
     - Manually collapsible filters (with configuration variable for default state)
     - Manually collapsible sort (with configuration variable for default state)
+    - Clear search bar
 
 - Storage
     - Storage list
diff --git a/bricktracker/app.py b/bricktracker/app.py
index f0afe429..b4aad9eb 100644
--- a/bricktracker/app.py
+++ b/bricktracker/app.py
@@ -19,6 +19,7 @@ from bricktracker.views.admin.instructions import admin_instructions_page
 from bricktracker.views.admin.owner import admin_owner_page
 from bricktracker.views.admin.purchase_location import admin_purchase_location_page  # noqa: E501
 from bricktracker.views.admin.retired import admin_retired_page
+from bricktracker.views.admin.set import admin_set_page
 from bricktracker.views.admin.status import admin_status_page
 from bricktracker.views.admin.storage import admin_storage_page
 from bricktracker.views.admin.tag import admin_tag_page
@@ -90,6 +91,7 @@ def setup_app(app: Flask) -> None:
     app.register_blueprint(admin_retired_page)
     app.register_blueprint(admin_owner_page)
     app.register_blueprint(admin_purchase_location_page)
+    app.register_blueprint(admin_set_page)
     app.register_blueprint(admin_status_page)
     app.register_blueprint(admin_storage_page)
     app.register_blueprint(admin_tag_page)
diff --git a/bricktracker/rebrickable_set.py b/bricktracker/rebrickable_set.py
index fbf10f1a..6beffc2b 100644
--- a/bricktracker/rebrickable_set.py
+++ b/bricktracker/rebrickable_set.py
@@ -1,9 +1,9 @@
 import logging
 from sqlite3 import Row
 import traceback
-from typing import Any, TYPE_CHECKING
+from typing import Any, Self, TYPE_CHECKING
 
-from flask import current_app
+from flask import current_app, url_for
 
 from .exceptions import ErrorException, NotFoundException
 from .instructions import BrickInstructions
@@ -138,6 +138,21 @@ class RebrickableSet(BrickRecord):
 
         return False
 
+    # Select a specific set (with a set)
+    def select_specific(self, set: str, /) -> Self:
+        # Save the parameters to the fields
+        self.fields.set = set
+
+        # Load from database
+        if not self.select():
+            raise NotFoundException(
+                'Set with set {set} was not found in the database'.format(
+                    set=self.fields.set,
+                ),
+            )
+
+        return self
+
     # Return a short form of the Rebrickable set
     def short(self, /, *, from_download: bool = False) -> dict[str, Any]:
         return {
@@ -164,6 +179,10 @@ class RebrickableSet(BrickRecord):
 
         return ''
 
+    # Compute the url for the refresh button
+    def url_for_refresh(self, /) -> str:
+        return url_for('set.refresh', set=self.fields.set)
+
     # Normalize from Rebrickable
     @staticmethod
     def from_rebrickable(data: dict[str, Any], /, **_) -> dict[str, Any]:
diff --git a/bricktracker/rebrickable_set_list.py b/bricktracker/rebrickable_set_list.py
index 8fe7ee92..0db84b76 100644
--- a/bricktracker/rebrickable_set_list.py
+++ b/bricktracker/rebrickable_set_list.py
@@ -9,6 +9,7 @@ class RebrickableSetList(BrickRecordList[RebrickableSet]):
 
     # Queries
     select_query: str = 'rebrickable/set/list'
+    refresh_query: str = 'rebrickable/set/need_refresh'
 
     # All the sets
     def all(self, /) -> Self:
@@ -19,3 +20,15 @@ class RebrickableSetList(BrickRecordList[RebrickableSet]):
             self.records.append(rebrickable_set)
 
         return self
+
+    # Sets needing refresh
+    def need_refresh(self, /) -> Self:
+        # Load the sets from the database
+        for record in self.select(
+            override_query=self.refresh_query
+        ):
+            rebrickable_set = RebrickableSet(record=record)
+
+            self.records.append(rebrickable_set)
+
+        return self
diff --git a/bricktracker/sql/rebrickable/set/need_refresh.sql b/bricktracker/sql/rebrickable/set/need_refresh.sql
new file mode 100644
index 00000000..8060a60c
--- /dev/null
+++ b/bricktracker/sql/rebrickable/set/need_refresh.sql
@@ -0,0 +1,53 @@
+SELECT
+    "rebrickable_sets"."set",
+    "rebrickable_sets"."name",
+    "rebrickable_sets"."number_of_parts",
+    "rebrickable_sets"."image",
+    "rebrickable_sets"."url",
+    "null_join"."null_rgb",
+    "null_join"."null_transparent",
+    "null_join"."null_url"
+FROM "rebrickable_sets"
+
+INNER JOIN (
+    SELECT
+        "null_sums"."set",
+        "null_sums"."null_rgb",
+        "null_sums"."null_transparent",
+        "null_sums"."null_url"
+    FROM (
+        SELECT
+            "unique_set_parts"."set",
+            SUM(CASE WHEN "unique_set_parts"."color_rgb" IS NULL THEN 1 ELSE 0 END) AS "null_rgb",
+            SUM(CASE WHEN "unique_set_parts"."color_transparent" IS NULL THEN 1 ELSE 0 END) AS "null_transparent",
+            SUM(CASE WHEN "unique_set_parts"."url" IS NULL THEN 1 ELSE 0 END) AS "null_url"
+        FROM (
+            SELECT
+                "bricktracker_sets"."set",
+                "rebrickable_parts"."color_rgb",
+                "rebrickable_parts"."color_transparent",
+                "rebrickable_parts"."url"
+            FROM "bricktracker_sets"
+
+            INNER JOIN "bricktracker_parts"
+            ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_parts"."id"
+
+            LEFT JOIN "rebrickable_parts"
+            ON "bricktracker_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part"
+            AND "bricktracker_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id"
+
+            GROUP BY
+                "bricktracker_sets"."set",
+                "bricktracker_parts"."part",
+                "bricktracker_parts"."color"
+        ) "unique_set_parts"
+
+        GROUP BY "unique_set_parts"."set"
+
+    ) "null_sums"
+
+    WHERE "null_rgb" > 0
+    OR "null_transparent" > 0
+    OR "null_url" > 0
+) "null_join"
+ON "rebrickable_sets"."set" IS NOT DISTINCT FROM "null_join"."set"
diff --git a/bricktracker/sql_counter.py b/bricktracker/sql_counter.py
index e5b92624..4d7a61e8 100644
--- a/bricktracker/sql_counter.py
+++ b/bricktracker/sql_counter.py
@@ -10,11 +10,12 @@ ALIASES: dict[str, Tuple[str, str]] = {
     'bricktracker_minifigures': ('Bricktracker minifigures', 'group-line'),
     'bricktracker_parts': ('Bricktracker parts', 'shapes-line'),
     'bricktracker_set_checkboxes': ('Bricktracker set checkboxes (legacy)', 'checkbox-line'),  # noqa: E501
-    'bricktracker_set_owners': ('Bricktracker set owners', 'checkbox-line'),  # noqa: E501
-    'bricktracker_set_statuses': ('Bricktracker set statuses', 'user-line'),  # noqa: E501
-    'bricktracker_set_tags': ('Bricktracker set tags', 'price-tag-2-line'),  # noqa: E501
+    'bricktracker_set_owners': ('Bricktracker set owners', 'checkbox-line'),
+    'bricktracker_set_statuses': ('Bricktracker set statuses', 'user-line'),
+    'bricktracker_set_tags': ('Bricktracker set tags', 'price-tag-2-line'),
     'bricktracker_sets': ('Bricktracker sets', 'hashtag'),
     'bricktracker_wishes': ('Bricktracker wishes', 'gift-line'),
+    'bricktracker_wish_owners': ('Bricktracker wish owners', 'checkbox-line'),
     'inventory': ('Parts', 'shapes-line'),
     'inventory_old': ('Parts (legacy)', 'shapes-line'),
     'minifigures': ('Minifigures', 'group-line'),
diff --git a/bricktracker/views/admin/set.py b/bricktracker/views/admin/set.py
new file mode 100644
index 00000000..6f00910c
--- /dev/null
+++ b/bricktracker/views/admin/set.py
@@ -0,0 +1,20 @@
+from flask import Blueprint, render_template, request
+from flask_login import login_required
+
+from ..exceptions import exception_handler
+from ...rebrickable_set_list import RebrickableSetList
+
+admin_set_page = Blueprint('admin_set',  __name__, url_prefix='/admin/set')
+
+
+# Sets that need o be refreshed
+@admin_set_page.route('/refresh', methods=['GET'])
+@login_required
+@exception_handler(__file__)
+def refresh() -> str:
+    return render_template(
+        'admin.html',
+        refresh_set=True,
+        table_collection=RebrickableSetList().need_refresh(),
+        set_error=request.args.get('set_error')
+    )
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index bb9e845c..1ffec550 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -13,8 +13,10 @@ from flask_login import login_required
 from werkzeug.wrappers.response import Response
 
 from .exceptions import exception_handler
+from ..exceptions import ErrorException
 from ..minifigure import BrickMinifigure
 from ..part import BrickPart
+from ..rebrickable_set import RebrickableSet
 from ..set import BrickSet
 from ..set_list import BrickSetList, set_metadata_lists
 from ..set_owner_list import BrickSetOwnerList
@@ -241,13 +243,22 @@ def problem_part(
 
 
 # Refresh a set
+@set_page.route('/refresh/<set>/', methods=['GET'])
 @set_page.route('/<id>/refresh', methods=['GET'])
 @login_required
 @exception_handler(__file__)
-def refresh(*, id: str) -> str:
+def refresh(*, id: str | None = None, set: str | None = None) -> str:
+    if id is not None:
+        item = BrickSet().select_specific(id)
+    elif set is not None:
+        item = RebrickableSet().select_specific(set)
+    else:
+        raise ErrorException('Could not load any set to refresh')
+
     return render_template(
         'refresh.html',
-        item=BrickSet().select_specific(id),
+        id=id,
+        item=item,
         path=current_app.config['SOCKET_PATH'],
         namespace=current_app.config['SOCKET_NAMESPACE'],
         messages=MESSAGES
diff --git a/templates/admin.html b/templates/admin.html
index 87dbdb4a..2da5000d 100644
--- a/templates/admin.html
+++ b/templates/admin.html
@@ -32,6 +32,8 @@
             {% include 'admin/database/import.html' %}
             {% elif upgrade_database %}
             {% include 'admin/database/upgrade.html' %}
+            {% elif refresh_set %}
+            {% include 'admin/set/refresh.html' %}
             {% else %}
               {% include 'admin/logout.html' %}
               {% include 'admin/instructions.html' %}
@@ -47,6 +49,7 @@
                 {% include 'admin/storage.html' %}
                 {% include 'admin/tag.html' %}
               {{ accordion.footer() }}
+              {% include 'admin/refresh.html' %}
               {% include 'admin/database.html' %}
               {% include 'admin/configuration.html' %}
             {% endif %}
diff --git a/templates/admin/refresh.html b/templates/admin/refresh.html
new file mode 100644
index 00000000..ab81cc82
--- /dev/null
+++ b/templates/admin/refresh.html
@@ -0,0 +1,5 @@
+{% import 'macro/accordion.html' as accordion %}
+
+{{ accordion.header('Set refresh', 'refresh', 'admin', icon='refresh-line') }}
+<a href="{{ url_for('admin_set.refresh') }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Check for sets that may need a refresh</a>
+{{ accordion.footer() }}
diff --git a/templates/admin/set/refresh.html b/templates/admin/set/refresh.html
new file mode 100644
index 00000000..0f4befcc
--- /dev/null
+++ b/templates/admin/set/refresh.html
@@ -0,0 +1,34 @@
+{% import 'macro/table.html' as table %}
+{% import 'macro/badge.html' as badge %}
+
+<div class="alert alert-info m-2" role="alert">This page lists the sets that may need a refresh because they have some of their newer fields containing empty values.</div>
+<div class="table-responsive-sm">
+  <table data-table="true" class="table table-striped align-middle" id="wish">
+    <thead>
+      <tr>
+        <th data-table-no-sort-and-search="true" class="no-sort" scope="col"><i class="ri-image-line fw-normal"></i> Image</th>
+        <th scope="col"><i class="ri-hashtag fw-normal"></i> Set</th>
+        <th scope="col"><i class="ri-pencil-line fw-normal"></i> Name</th>
+        <th scope="col"><i class="ri-shapes-line fw-normal"></i> Parts</th>
+        <th data-table-number="true" scope="col"><i class="ri-error-warning-line fw-normal"></i> Empty RGB</th>
+        <th data-table-number="true" scope="col"><i class="ri-error-warning-line fw-normal"></i> Empty transparent</th>
+        <th data-table-number="true" scope="col"><i class="ri-error-warning-line fw-normal"></i> Empty URL</th>
+        <th data-table-no-sort-and-search="true" class="no-sort" scope="col"><i class="ri-settings-4-line fw-normal"></i> Actions</th>
+      </tr>
+    </thead>
+    <tbody>
+      {% for item in table_collection %}
+        <tr>
+          {{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.set) }}
+          <td>{{ item.fields.set }} {{ table.rebrickable(item) }}</td>
+          <td>{{ item.fields.name }}</td>
+          <td>{{ item.fields.number_of_parts }}</td>
+          <td>{{ item.fields.null_rgb }}</td>
+          <td>{{ item.fields.null_transparent }}</td>
+          <td>{{ item.fields.null_url }}</td>
+          <td><a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh</a></td>
+        </tr>
+      {% endfor %}
+    </tbody>
+  </table>
+</div>
\ No newline at end of file
diff --git a/templates/refresh.html b/templates/refresh.html
index 5add93d8..3a9804de 100644
--- a/templates/refresh.html
+++ b/templates/refresh.html
@@ -51,7 +51,11 @@
         </div>
         <div class="card-footer text-end">
           <span id="refresh-status-icon" class="me-1"></span><span id="refresh-status" class="me-1"></span>
-          <a href="{{ url_for('set.details', id=item.fields.id) }}" class="btn btn-primary" role="button"><i class="ri-hashtag"></i> Back to the set details</a>
+          {% if id %}
+            <a href="{{ url_for('set.details', id=item.fields.id) }}" class="btn btn-primary" role="button"><i class="ri-hashtag"></i> Back to the set details</a>
+            {% else %}
+            <a href="{{ url_for('admin_set.refresh') }}" class="btn btn-danger" role="button"><i class="ri-hashtag"></i> List of sets to be refreshed</a>
+          {% endif %}
           <button id="refresh" type="button" class="btn btn-primary"><i class="ri-refresh-line"></i> Refresh</button>
         </div>
       </div>