diff --git a/.env.sample b/.env.sample index 0de6bf1..12541e5 100644 --- a/.env.sample +++ b/.env.sample @@ -332,6 +332,12 @@ # Default: sets # BK_SETS_FOLDER=sets +# Optional: Enable set consolidation/grouping on the main sets page +# When enabled, multiple copies of the same set are grouped together showing instance count +# When disabled, each set copy is displayed individually (original behavior) +# Default: false +# BK_SETS_CONSOLIDATION=true + # Optional: Make the grid filters displayed by default, rather than collapsed # Default: false # BK_SHOW_GRID_FILTERS=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 1abcbd5..cc51561 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,29 @@ - Preserves selection state during dropdown consolidation - Consistent search behavior (instant for client-side, Enter key for server-side) - Mobile-friendly pagination navigation - +- Add Peeron instructions integration + - Full image caching system with automatic thumbnail generation + - Optimized HTTP calls by downloading full images once and generating thumbnails locally + - Automatic cache cleanup after PDF generation to save disk space +- Add parts checking/inventory system + - New "Checked" column in parts tables for tracking inventory progress + - Checkboxes to mark parts as verified during set walkthrough + - `BK_HIDE_TABLE_CHECKED_PARTS`: Environment variable to hide checked column +- Add set consolidation/grouping functionality + - Automatic grouping of duplicate sets on main sets page + - Shows instance count with stack icon badge (e.g., "3 copies") + - Expandable drawer interface to view all set copies individually + - Full set cards for each instance with all badges, statuses, and functionality + - `BK_SETS_CONSOLIDATION`: Environment variable to enable/disable consolidation (default: false) + - Backwards compatible - when disabled, behaves exactly like original individual view + - Improved theme filtering: handles duplicate theme names correctly + - Fixed set number sorting: proper numeric sorting in both ascending and descending order + - Mixed status indicators for consolidated sets: three-state checkboxes (unchecked/partial/checked) with count badges + - Enhanced SQL aggregation with `IFNULL(SUM())` for accurate count statistics + - Template logic handles three states: none (0/2), all (2/2), partial (1/2) with visual indicators + - Purple overlay styling for partial states, disabled checkboxes for read-only consolidated status display + - Individual sets maintain full interactive checkbox functionality + ### 1.2.4 > **Warning** diff --git a/bricktracker/config.py b/bricktracker/config.py index 9c662f1..84dda85 100644 --- a/bricktracker/config.py +++ b/bricktracker/config.py @@ -77,6 +77,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': 'SETS_CONSOLIDATION', 'd': False, 'c': bool}, {'n': 'SHOW_GRID_FILTERS', 'c': bool}, {'n': 'SHOW_GRID_SORT', 'c': bool}, {'n': 'SKIP_SPARE_PARTS', 'c': bool}, diff --git a/bricktracker/metadata_list.py b/bricktracker/metadata_list.py index 13b9629..aa2eb67 100644 --- a/bricktracker/metadata_list.py +++ b/bricktracker/metadata_list.py @@ -111,6 +111,16 @@ class BrickMetadataList(BrickRecordList[T]): in new.filter(**kwargs) ]) + # Return the items as a dictionary mapping column names to UUIDs + @classmethod + def as_column_mapping(cls, /, **kwargs) -> dict: + new = cls.new() + + return { + record.as_column(): record.fields.id + for record in new.filter(**kwargs) + } + # Grab a specific status @classmethod def get(cls, id: str | None, /, *, allow_none: bool = False) -> T: diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py index 693035a..2b0c978 100644 --- a/bricktracker/set_list.py +++ b/bricktracker/set_list.py @@ -24,6 +24,7 @@ class BrickSetList(BrickRecordList[BrickSet]): # Queries all_query: str = 'set/list/all' + consolidated_query: str = 'set/list/consolidated' damaged_minifigure_query: str = 'set/list/damaged_minifigure' damaged_part_query: str = 'set/list/damaged_part' generic_query: str = 'set/list/generic' @@ -46,8 +47,25 @@ class BrickSetList(BrickRecordList[BrickSet]): # All the sets def all(self, /) -> Self: - # Load the sets from the database - self.list(do_theme=True) + # Load the sets from the database with metadata context for filtering + filter_context = { + 'owners': BrickSetOwnerList.as_columns(), + 'statuses': BrickSetStatusList.as_columns(), + 'tags': BrickSetTagList.as_columns(), + } + self.list(do_theme=True, **filter_context) + + return self + + # All sets in consolidated/grouped view + def all_consolidated(self, /) -> Self: + # Load the sets from the database using consolidated query with metadata context + filter_context = { + 'owners_dict': BrickSetOwnerList.as_column_mapping(), + 'statuses_dict': BrickSetStatusList.as_column_mapping(), + 'tags_dict': BrickSetTagList.as_column_mapping(), + } + self.list(override_query=self.consolidated_query, do_theme=True, **filter_context) return self @@ -64,7 +82,8 @@ class BrickSetList(BrickRecordList[BrickSet]): owner_filter: str | None = None, purchase_location_filter: str | None = None, storage_filter: str | None = None, - tag_filter: str | None = None + tag_filter: str | None = None, + use_consolidated: bool = True ) -> tuple[Self, int]: # Convert theme name to theme ID for filtering theme_id_filter = None @@ -86,24 +105,51 @@ class BrickSetList(BrickRecordList[BrickSet]): 'owners': BrickSetOwnerList.as_columns(), 'statuses': BrickSetStatusList.as_columns(), 'tags': BrickSetTagList.as_columns(), + 'owners_dict': BrickSetOwnerList.as_column_mapping(), + 'statuses_dict': BrickSetStatusList.as_column_mapping(), + 'tags_dict': BrickSetTagList.as_column_mapping(), } + + # Field mapping for sorting - field_mapping = { - 'set': '"rebrickable_sets"."set"', - 'name': '"rebrickable_sets"."name"', - 'year': '"rebrickable_sets"."year"', - 'parts': '"rebrickable_sets"."number_of_parts"', - 'theme': '"rebrickable_sets"."theme_id"', - 'minifigures': '"total_minifigures"', # Use the alias from the SQL query - 'missing': '"total_missing"', # Use the alias from the SQL query - 'damaged': '"total_damaged"', # Use the alias from the SQL query - 'purchase-date': '"bricktracker_sets"."purchase_date"', - 'purchase-price': '"bricktracker_sets"."purchase_price"' - } + if use_consolidated: + field_mapping = { + 'set': '"rebrickable_sets"."number", "rebrickable_sets"."version"', + 'name': '"rebrickable_sets"."name"', + 'year': '"rebrickable_sets"."year"', + 'parts': '"rebrickable_sets"."number_of_parts"', + 'theme': '"rebrickable_sets"."theme_id"', + 'minifigures': '"total_minifigures"', + 'missing': '"total_missing"', + 'damaged': '"total_damaged"', + 'instances': '"instance_count"', # New field for consolidated view + 'purchase-date': '"purchase_date"', # Use the MIN aggregated value + 'purchase-price': '"purchase_price"' # Use the MIN aggregated value + } + else: + field_mapping = { + 'set': '"rebrickable_sets"."number", "rebrickable_sets"."version"', + 'name': '"rebrickable_sets"."name"', + 'year': '"rebrickable_sets"."year"', + 'parts': '"rebrickable_sets"."number_of_parts"', + 'theme': '"rebrickable_sets"."theme_id"', + 'minifigures': '"total_minifigures"', # Use the alias from the SQL query + 'missing': '"total_missing"', # Use the alias from the SQL query + 'damaged': '"total_damaged"', # Use the alias from the SQL query + 'purchase-date': '"bricktracker_sets"."purchase_date"', + 'purchase-price': '"bricktracker_sets"."purchase_price"' + } - # Choose query based on whether filters are applied - query_to_use = 'set/list/all_filtered' if has_filters else self.all_query + # Choose query based on consolidation preference and filter complexity + # Owner/tag filters still need to fall back to non-consolidated for now + # due to complex aggregation requirements + complex_filters = [owner_filter, tag_filter] + if use_consolidated and not any(complex_filters): + query_to_use = self.consolidated_query + else: + # Use filtered query when consolidation is disabled or complex filters applied + query_to_use = 'set/list/all_filtered' # Handle instructions filtering if status_filter in ['has-missing-instructions', '-has-missing-instructions']: @@ -114,6 +160,18 @@ class BrickSetList(BrickRecordList[BrickSet]): purchase_location_filter, storage_filter, tag_filter ) + # Handle special case for set sorting with multiple columns + if sort_field == 'set' and field_mapping: + # Create custom order clause for set sorting + direction = 'DESC' if sort_order.lower() == 'desc' else 'ASC' + custom_order = f'"rebrickable_sets"."number" {direction}, "rebrickable_sets"."version" {direction}' + filter_context['order'] = custom_order + # Remove set from field mapping to avoid double-processing + field_mapping_copy = field_mapping.copy() + field_mapping_copy.pop('set', None) + field_mapping = field_mapping_copy + sort_field = None # Disable automatic ORDER BY construction + # Normal SQL-based filtering and pagination result, total_count = self.paginate( page=page, @@ -125,8 +183,11 @@ class BrickSetList(BrickRecordList[BrickSet]): **filter_context ) - # Populate themes for filter dropdown from ALL sets, not just current page - result._populate_themes_global() + # Populate themes for filter dropdown from filtered dataset (not just current page) + result._populate_themes_from_filtered_dataset( + query_to_use, + **filter_context + ) return result, total_count @@ -143,11 +204,36 @@ class BrickSetList(BrickRecordList[BrickSet]): def _theme_name_to_id(self, theme_name: str) -> str | None: """Convert a theme name to theme ID for filtering""" try: + from .sql import BrickSQL theme_list = BrickThemeList() + + # Find all theme IDs that match the name + matching_theme_ids = [] for theme_id, theme in theme_list.themes.items(): if theme.name.lower() == theme_name.lower(): - return str(theme_id) - return None + matching_theme_ids.append(str(theme_id)) + + if not matching_theme_ids: + return None + + # If only one match, return it + if len(matching_theme_ids) == 1: + return matching_theme_ids[0] + + # Multiple matches - check which theme ID actually has sets in the user's collection + sql = BrickSQL() + for theme_id in matching_theme_ids: + result = sql.fetchone( + 'set/check_theme_exists', + theme_id=theme_id + ) + count = result['count'] if result else 0 + if count > 0: + return theme_id + + # If none have sets, return the first match (fallback) + return matching_theme_ids[0] + except Exception: # If themes can't be loaded, return None to disable theme filtering return None @@ -240,21 +326,53 @@ class BrickSetList(BrickRecordList[BrickSet]): purchase_location_filter, storage_filter, tag_filter ) - def _populate_themes_global(self) -> None: - """Populate themes list from ALL sets, not just current page""" + def _populate_themes_from_filtered_dataset(self, query_name: str, **filter_context) -> None: + """Populate themes list from filtered dataset (all pages, not just current page)""" try: - # Load all sets to get all possible themes - all_sets = BrickSetList().all() + from .theme_list import BrickThemeList + + # Use a simplified query to get just distinct theme_ids + theme_context = dict(filter_context) + theme_context.pop('limit', None) + theme_context.pop('offset', None) + + # Use a special lightweight query for themes + theme_records = super().select( + override_query='set/list/themes_only', + **theme_context + ) + + # Convert to theme names + theme_list = BrickThemeList() themes = set() - for record in all_sets.records: - if hasattr(record, 'theme') and hasattr(record.theme, 'name'): - themes.add(record.theme.name) + for record in theme_records: + theme_id = record.get('theme_id') + if theme_id: + theme = theme_list.get(theme_id) + if theme and hasattr(theme, 'name'): + themes.add(theme.name) self.themes = list(themes) self.themes.sort() + except Exception: - # Fall back to current page themes - self._populate_themes() + # Fall back to simpler approach: get themes from ALL sets (ignoring filters) + # This is better than showing only current page themes + try: + from .theme_list import BrickThemeList + all_sets = BrickSetList() + all_sets.list(do_theme=True) + + themes = set() + for record in all_sets.records: + if hasattr(record, 'theme') and hasattr(record.theme, 'name'): + themes.add(record.theme.name) + + self.themes = list(themes) + self.themes.sort() + except Exception: + # Final fallback to current page themes + self._populate_themes() def _matches_search(self, record, search_query: str) -> bool: """Check if record matches search query""" @@ -301,7 +419,7 @@ class BrickSetList(BrickRecordList[BrickSet]): reverse = sort_order == 'desc' if sort_field == 'set': - return sorted(records, key=lambda r: r.fields.set, reverse=reverse) + return sorted(records, key=lambda r: self._set_sort_key(r.fields.set), reverse=reverse) elif sort_field == 'name': return sorted(records, key=lambda r: r.fields.name, reverse=reverse) elif sort_field == 'year': @@ -312,6 +430,19 @@ class BrickSetList(BrickRecordList[BrickSet]): return records + + def _set_sort_key(self, set_number: str) -> tuple: + """Generate sort key for set numbers like '10121-1' -> (10121, 1)""" + try: + if '-' in set_number: + main_part, version_part = set_number.split('-', 1) + return (int(main_part), int(version_part)) + else: + return (int(set_number), 0) + except (ValueError, TypeError): + # Fallback to string sorting if parsing fails + return (float('inf'), set_number) + # Sets with a minifigure part damaged def damaged_minifigure(self, figure: str, /) -> Self: # Save the parameters to the fields diff --git a/bricktracker/sql/set/check_theme_exists.sql b/bricktracker/sql/set/check_theme_exists.sql new file mode 100644 index 0000000..93ff65d --- /dev/null +++ b/bricktracker/sql/set/check_theme_exists.sql @@ -0,0 +1,4 @@ +SELECT COUNT(*) as count +FROM "bricktracker_sets" +INNER JOIN "rebrickable_sets" ON "bricktracker_sets"."set" = "rebrickable_sets"."set" +WHERE "rebrickable_sets"."theme_id" = {{ theme_id }} \ No newline at end of file diff --git a/bricktracker/sql/set/list/all_filtered.sql b/bricktracker/sql/set/list/all_filtered.sql index 9a9495f..f080401 100644 --- a/bricktracker/sql/set/list/all_filtered.sql +++ b/bricktracker/sql/set/list/all_filtered.sql @@ -8,7 +8,7 @@ AND (LOWER("rebrickable_sets"."name") LIKE LOWER('%{{ search_query }}%') {% endif %} {% if theme_filter %} -AND "rebrickable_sets"."theme_id" = '{{ theme_filter }}' +AND "rebrickable_sets"."theme_id" = {{ theme_filter }} {% endif %} {% if storage_filter %} diff --git a/bricktracker/sql/set/list/consolidated.sql b/bricktracker/sql/set/list/consolidated.sql new file mode 100644 index 0000000..f25a814 --- /dev/null +++ b/bricktracker/sql/set/list/consolidated.sql @@ -0,0 +1,167 @@ +SELECT + (SELECT MIN("id") FROM "bricktracker_sets" WHERE "set" = "rebrickable_sets"."set") AS "id", + "rebrickable_sets"."set", + "rebrickable_sets"."number", + "rebrickable_sets"."version", + "rebrickable_sets"."name", + "rebrickable_sets"."year", + "rebrickable_sets"."theme_id", + "rebrickable_sets"."number_of_parts", + "rebrickable_sets"."image", + "rebrickable_sets"."url", + COUNT("bricktracker_sets"."id") AS "instance_count", + IFNULL(SUM("problem_join"."total_missing"), 0) AS "total_missing", + IFNULL(SUM("problem_join"."total_damaged"), 0) AS "total_damaged", + IFNULL(MAX("minifigures_join"."total"), 0) AS "total_minifigures", + -- Keep one representative instance for display purposes + GROUP_CONCAT("bricktracker_sets"."id", '|') AS "instance_ids", + REPLACE(GROUP_CONCAT(DISTINCT "bricktracker_sets"."storage"), ',', '|') AS "storage", + MIN("bricktracker_sets"."purchase_date") AS "purchase_date", + REPLACE(GROUP_CONCAT(DISTINCT "bricktracker_sets"."purchase_location"), ',', '|') AS "purchase_location", + MIN("bricktracker_sets"."purchase_price") AS "purchase_price" + {% block owners %} + {% if owners_dict %} + {% for column, uuid in owners_dict.items() %} + , MAX("bricktracker_set_owners"."{{ column }}") AS "{{ column }}" + {% endfor %} + {% endif %} + {% endblock %} + {% block tags %} + {% if tags_dict %} + {% for column, uuid in tags_dict.items() %} + , MAX("bricktracker_set_tags"."{{ column }}") AS "{{ column }}" + {% endfor %} + {% endif %} + {% endblock %} + {% block statuses %} + {% if statuses_dict %} + {% for column, uuid in statuses_dict.items() %} + , MAX("bricktracker_set_statuses"."{{ column }}") AS "{{ column }}" + , IFNULL(SUM("bricktracker_set_statuses"."{{ column }}"), 0) AS "{{ column }}_count" + {% endfor %} + {% endif %} + {% endblock %} +FROM "bricktracker_sets" + +INNER JOIN "rebrickable_sets" +ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set" + +-- LEFT JOIN + SELECT to avoid messing the total +LEFT JOIN ( + SELECT + "bricktracker_parts"."id", + SUM("bricktracker_parts"."missing") AS "total_missing", + SUM("bricktracker_parts"."damaged") AS "total_damaged" + FROM "bricktracker_parts" + GROUP BY "bricktracker_parts"."id" +) "problem_join" +ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "problem_join"."id" + +-- LEFT JOIN + SELECT to avoid messing the total +LEFT JOIN ( + SELECT + "bricktracker_minifigures"."id", + SUM("bricktracker_minifigures"."quantity") AS "total" + FROM "bricktracker_minifigures" + GROUP BY "bricktracker_minifigures"."id" +) "minifigures_join" +ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "minifigures_join"."id" + +{% if owners_dict %} +LEFT JOIN "bricktracker_set_owners" +ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id" +{% endif %} + +{% if statuses_dict %} +LEFT JOIN "bricktracker_set_statuses" +ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_statuses"."id" +{% endif %} + +{% if tags_dict %} +LEFT JOIN "bricktracker_set_tags" +ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_tags"."id" +{% endif %} + +{% block where %} +WHERE 1=1 +{% if search_query %} +AND (LOWER("rebrickable_sets"."name") LIKE LOWER('%{{ search_query }}%') + OR LOWER("rebrickable_sets"."set") LIKE LOWER('%{{ search_query }}%')) +{% endif %} + +{% if theme_filter %} +AND "rebrickable_sets"."theme_id" = {{ theme_filter }} +{% endif %} + +{% if storage_filter %} +AND EXISTS ( + SELECT 1 FROM "bricktracker_sets" bs_filter + WHERE bs_filter."set" = "rebrickable_sets"."set" + AND bs_filter."storage" = '{{ storage_filter }}' +) +{% endif %} + +{% if purchase_location_filter %} +AND EXISTS ( + SELECT 1 FROM "bricktracker_sets" bs_filter + WHERE bs_filter."set" = "rebrickable_sets"."set" + AND bs_filter."purchase_location" = '{{ purchase_location_filter }}' +) +{% endif %} + +{% if status_filter %} +{% if status_filter == 'has-storage' %} +AND EXISTS ( + SELECT 1 FROM "bricktracker_sets" bs_filter + WHERE bs_filter."set" = "rebrickable_sets"."set" + AND bs_filter."storage" IS NOT NULL AND bs_filter."storage" != '' +) +{% elif status_filter == '-has-storage' %} +AND NOT EXISTS ( + SELECT 1 FROM "bricktracker_sets" bs_filter + WHERE bs_filter."set" = "rebrickable_sets"."set" + AND bs_filter."storage" IS NOT NULL AND bs_filter."storage" != '' +) +{% elif status_filter.startswith('status-') %} +AND EXISTS ( + SELECT 1 FROM "bricktracker_sets" bs_filter + JOIN "bricktracker_set_statuses" ON bs_filter."id" = "bricktracker_set_statuses"."id" + WHERE bs_filter."set" = "rebrickable_sets"."set" + AND "bricktracker_set_statuses"."{{ status_filter.replace('-', '_') }}" = 1 +) +{% elif status_filter.startswith('-status-') %} +AND NOT EXISTS ( + SELECT 1 FROM "bricktracker_sets" bs_filter + JOIN "bricktracker_set_statuses" ON bs_filter."id" = "bricktracker_set_statuses"."id" + WHERE bs_filter."set" = "rebrickable_sets"."set" + AND "bricktracker_set_statuses"."{{ status_filter[1:].replace('-', '_') }}" = 1 +) +{% endif %} +{% endif %} +{% endblock %} + +GROUP BY "rebrickable_sets"."set" + +{% if status_filter %} +{% if status_filter == 'has-missing' %} +HAVING IFNULL(SUM("problem_join"."total_missing"), 0) > 0 +{% elif status_filter == '-has-missing' %} +HAVING IFNULL(SUM("problem_join"."total_missing"), 0) = 0 +{% elif status_filter == 'has-damaged' %} +HAVING IFNULL(SUM("problem_join"."total_damaged"), 0) > 0 +{% elif status_filter == '-has-damaged' %} +HAVING IFNULL(SUM("problem_join"."total_damaged"), 0) = 0 +{% endif %} +{% endif %} + +{% if order %} +ORDER BY {{ order }} +{% endif %} + +{% if limit %} +LIMIT {{ limit }} +{% endif %} + +{% if offset %} +OFFSET {{ offset }} +{% endif %} \ No newline at end of file diff --git a/bricktracker/sql/set/list/themes_only.sql b/bricktracker/sql/set/list/themes_only.sql new file mode 100644 index 0000000..d0551f8 --- /dev/null +++ b/bricktracker/sql/set/list/themes_only.sql @@ -0,0 +1,87 @@ +SELECT DISTINCT "rebrickable_sets"."theme_id" +FROM "bricktracker_sets" + +INNER JOIN "rebrickable_sets" +ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set" + +{% block where %} +WHERE 1=1 +{% if search_query %} +AND (LOWER("rebrickable_sets"."name") LIKE LOWER('%{{ search_query }}%') + OR LOWER("rebrickable_sets"."set") LIKE LOWER('%{{ search_query }}%')) +{% endif %} + +{% if storage_filter %} +AND EXISTS ( + SELECT 1 FROM "bricktracker_sets" bs_filter + WHERE bs_filter."set" = "rebrickable_sets"."set" + AND bs_filter."storage" = '{{ storage_filter }}' +) +{% endif %} + +{% if purchase_location_filter %} +AND EXISTS ( + SELECT 1 FROM "bricktracker_sets" bs_filter + WHERE bs_filter."set" = "rebrickable_sets"."set" + AND bs_filter."purchase_location" = '{{ purchase_location_filter }}' +) +{% endif %} + +{% if status_filter %} +{% if status_filter == 'has-storage' %} +AND EXISTS ( + SELECT 1 FROM "bricktracker_sets" bs_filter + WHERE bs_filter."set" = "rebrickable_sets"."set" + AND bs_filter."storage" IS NOT NULL AND bs_filter."storage" != '' +) +{% elif status_filter == '-has-storage' %} +AND NOT EXISTS ( + SELECT 1 FROM "bricktracker_sets" bs_filter + WHERE bs_filter."set" = "rebrickable_sets"."set" + AND bs_filter."storage" IS NOT NULL AND bs_filter."storage" != '' +) +{% elif status_filter.startswith('status-') %} +AND EXISTS ( + SELECT 1 FROM "bricktracker_sets" bs_filter + JOIN "bricktracker_set_statuses" ON bs_filter."id" = "bricktracker_set_statuses"."id" + WHERE bs_filter."set" = "rebrickable_sets"."set" + AND "bricktracker_set_statuses"."{{ status_filter.replace('-', '_') }}" = 1 +) +{% elif status_filter.startswith('-status-') %} +AND NOT EXISTS ( + SELECT 1 FROM "bricktracker_sets" bs_filter + JOIN "bricktracker_set_statuses" ON bs_filter."id" = "bricktracker_set_statuses"."id" + WHERE bs_filter."set" = "rebrickable_sets"."set" + AND "bricktracker_set_statuses"."{{ status_filter[1:].replace('-', '_') }}" = 1 +) +{% elif status_filter == 'has-missing' %} +AND EXISTS ( + SELECT 1 FROM "bricktracker_sets" bs_filter + JOIN "bricktracker_parts" ON bs_filter."id" = "bricktracker_parts"."id" + WHERE bs_filter."set" = "rebrickable_sets"."set" + AND "bricktracker_parts"."missing" > 0 +) +{% elif status_filter == '-has-missing' %} +AND NOT EXISTS ( + SELECT 1 FROM "bricktracker_sets" bs_filter + JOIN "bricktracker_parts" ON bs_filter."id" = "bricktracker_parts"."id" + WHERE bs_filter."set" = "rebrickable_sets"."set" + AND "bricktracker_parts"."missing" > 0 +) +{% elif status_filter == 'has-damaged' %} +AND EXISTS ( + SELECT 1 FROM "bricktracker_sets" bs_filter + JOIN "bricktracker_parts" ON bs_filter."id" = "bricktracker_parts"."id" + WHERE bs_filter."set" = "rebrickable_sets"."set" + AND "bricktracker_parts"."damaged" > 0 +) +{% elif status_filter == '-has-damaged' %} +AND NOT EXISTS ( + SELECT 1 FROM "bricktracker_sets" bs_filter + JOIN "bricktracker_parts" ON bs_filter."id" = "bricktracker_parts"."id" + WHERE bs_filter."set" = "rebrickable_sets"."set" + AND "bricktracker_parts"."damaged" > 0 +) +{% endif %} +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py index 63a48b8..3fa4001 100644 --- a/bricktracker/views/set.py +++ b/bricktracker/views/set.py @@ -64,13 +64,17 @@ def list() -> str: owner_filter=owner_filter, purchase_location_filter=purchase_location_filter, storage_filter=storage_filter, - tag_filter=tag_filter + tag_filter=tag_filter, + use_consolidated=current_app.config['SETS_CONSOLIDATION'] ) pagination_context = build_pagination_context(page, per_page, total_count, is_mobile) else: # ORIGINAL MODE - Single page with all data for client-side search - sets = BrickSetList().all() + if current_app.config['SETS_CONSOLIDATION']: + sets = BrickSetList().all_consolidated() + else: + sets = BrickSetList().all() pagination_context = None template_context = { @@ -239,13 +243,44 @@ def deleted(*, id: str) -> str: @set_page.route('//details', methods=['GET']) @exception_handler(__file__) def details(*, id: str) -> str: - return render_template( - 'set.html', - item=BrickSet().select_specific(id), - open_instructions=request.args.get('open_instructions'), - brickset_statuses=BrickSetStatusList.list(all=True), - **set_metadata_lists(as_class=True) - ) + # Load the specific set + item = BrickSet().select_specific(id) + + # Check if there are multiple instances of this set + all_instances = BrickSetList() + # Load all sets with metadata context for tags, owners, etc. + filter_context = { + 'owners': BrickSetOwnerList.as_columns(), + 'statuses': BrickSetStatusList.as_columns(), + 'tags': BrickSetTagList.as_columns(), + } + all_instances.list(do_theme=True, **filter_context) + + # Find all instances with the same set number + same_set_instances = [ + record for record in all_instances.records + if record.fields.set == item.fields.set + ] + + # If consolidation is enabled and multiple instances exist, show consolidated view + if current_app.config['SETS_CONSOLIDATION'] and len(same_set_instances) > 1: + return render_template( + 'set.html', + item=item, + all_instances=same_set_instances, + open_instructions=request.args.get('open_instructions'), + brickset_statuses=BrickSetStatusList.list(all=True), + **set_metadata_lists(as_class=True) + ) + else: + # Single instance or consolidation disabled, show normal view + return render_template( + 'set.html', + item=item, + open_instructions=request.args.get('open_instructions'), + brickset_statuses=BrickSetStatusList.list(all=True), + **set_metadata_lists(as_class=True) + ) # Update problematic pieces of a set diff --git a/static/scripts/grid/filter.js b/static/scripts/grid/filter.js index 6de37a8..6466c5f 100644 --- a/static/scripts/grid/filter.js +++ b/static/scripts/grid/filter.js @@ -91,7 +91,7 @@ class BrickGridFilter { attribute: select.value, bool: true, value: "1" - }) + }); } break; } @@ -106,19 +106,48 @@ class BrickGridFilter { const attribute = current.getAttribute(`data-${filter.attribute}`); // Bool check - // Attribute not equal value, or undefined and value is truthy + // For boolean attributes (like owner/tag filtering) if (filter.bool) { - if ((attribute != null && attribute != filter.value) || (attribute == null && filter.value == "1")) { - current.parentElement.classList.add("d-none"); - return; + if (filter.value == "1") { + // Looking for sets WITH this attribute + // Hide if attribute is missing (null) or explicitly "0" + // For owner/tag attributes: missing = doesn't have this owner/tag + if (attribute == null || attribute == "0") { + current.parentElement.classList.add("d-none"); + return; + } + } else if (filter.value == "0") { + // Looking for sets WITHOUT this attribute + // Hide if attribute is present and "1" + // For owner/tag attributes: present and "1" = has this owner/tag + if (attribute == "1") { + current.parentElement.classList.add("d-none"); + return; + } + // Note: null (missing) is treated as "doesn't have" which is what we want for value="0" } } // Value check - // Attribute not equal value, or attribute undefined - else if ((attribute != null && attribute != filter.value) || attribute == null) { + // For consolidated cards, attributes may be comma or pipe-separated (e.g., "storage1,storage2" or "storage1|storage2") + else if (attribute == null) { + // Hide if attribute is missing current.parentElement.classList.add("d-none"); return; + } else if (attribute.includes(',') || attribute.includes('|')) { + // Handle comma or pipe-separated values (consolidated cards) + const separator = attribute.includes('|') ? '|' : ','; + const values = attribute.split(separator).map(v => v.trim()); + if (!values.includes(filter.value)) { + current.parentElement.classList.add("d-none"); + return; + } + } else { + // Handle single values (regular cards) + if (attribute != filter.value) { + current.parentElement.classList.add("d-none"); + return; + } } } diff --git a/static/scripts/set-details.js b/static/scripts/set-details.js new file mode 100644 index 0000000..5265374 --- /dev/null +++ b/static/scripts/set-details.js @@ -0,0 +1,15 @@ +// Set details page functionality +document.addEventListener('DOMContentLoaded', function() { + const collapseElement = document.getElementById('all-instances'); + const toggleIcon = document.getElementById('copies-toggle-icon'); + + if (collapseElement && toggleIcon) { + collapseElement.addEventListener('shown.bs.collapse', function() { + toggleIcon.className = 'ri-arrow-up-s-line fs-4'; + }); + + collapseElement.addEventListener('hidden.bs.collapse', function() { + toggleIcon.className = 'ri-arrow-down-s-line fs-4'; + }); + } +}); \ No newline at end of file diff --git a/static/scripts/sets.js b/static/scripts/sets.js index a1dfbcc..84fa4e9 100644 --- a/static/scripts/sets.js +++ b/static/scripts/sets.js @@ -193,4 +193,170 @@ function setupPaginationFilterDropdowns() { currentUrl.searchParams.set('page', '1'); window.location.href = currentUrl.toString(); } +} + +// Set grouping functionality +function initializeSetGrouping() { + const groupToggle = document.getElementById('group-identical-sets'); + if (!groupToggle) return; + + // Load saved state from localStorage + const savedState = localStorage.getItem('groupIdenticalSets') === 'true'; + groupToggle.checked = savedState; + + // Apply grouping on page load if enabled + if (savedState) { + applySetGrouping(); + } + + // Listen for toggle changes + groupToggle.addEventListener('change', function() { + // Save state to localStorage + localStorage.setItem('groupIdenticalSets', this.checked); + + if (this.checked) { + applySetGrouping(); + } else { + removeSetGrouping(); + } + }); +} + +function applySetGrouping() { + const grid = document.getElementById('grid'); + if (!grid) return; + + const setCards = Array.from(grid.children); + const groupedSets = {}; + + // Group sets by rebrickable_set_id + setCards.forEach(cardCol => { + const setCard = cardCol.querySelector('.card[data-set-id]'); + if (!setCard) return; + + const setId = setCard.getAttribute('data-set-id'); + const rebrickableId = setCard.getAttribute('data-rebrickable-id'); + + if (!rebrickableId) return; + + if (!groupedSets[rebrickableId]) { + groupedSets[rebrickableId] = []; + } + + groupedSets[rebrickableId].push({ + cardCol: cardCol, + setId: setId, + rebrickableId: rebrickableId + }); + }); + + // Process each group + Object.keys(groupedSets).forEach(rebrickableId => { + const group = groupedSets[rebrickableId]; + + if (group.length > 1) { + createGroupedSetDisplay(group); + } + }); +} + +function createGroupedSetDisplay(setGroup) { + const firstSet = setGroup[0]; + const firstCard = firstSet.cardCol.querySelector('.card'); + + if (!firstCard) return; + + // Calculate aggregate stats + let totalMissing = 0; + let totalDamaged = 0; + let allSetIds = []; + + setGroup.forEach(set => { + const card = set.cardCol.querySelector('.card'); + + // Get missing and damaged counts from existing data attributes + const missingCount = parseInt(card.getAttribute('data-missing') || '0'); + const damagedCount = parseInt(card.getAttribute('data-damaged') || '0'); + + totalMissing += missingCount; + totalDamaged += damagedCount; + allSetIds.push(set.setId); + }); + + // Create grouped card container + const groupContainer = document.createElement('div'); + groupContainer.className = firstSet.cardCol.className + ' set-group-container'; + groupContainer.innerHTML = ` +
+
+
+ + ${firstCard.querySelector('.card-title')?.textContent || 'Set'} + ${setGroup.length} sets +
+
+ ${totalMissing > 0 ? `${totalMissing} missing` : ''} + ${totalDamaged > 0 ? `${totalDamaged} damaged` : ''} +
+
+
+
+
+
+
+
+ `; + + // Add individual set cards to the group + const groupItems = groupContainer.querySelector('.set-group-items'); + setGroup.forEach(set => { + const itemContainer = document.createElement('div'); + itemContainer.className = 'col-12 mb-2'; + + // Clone the original card but make it smaller + const clonedCard = set.cardCol.querySelector('.card').cloneNode(true); + clonedCard.classList.add('set-group-item'); + + itemContainer.appendChild(clonedCard); + groupItems.appendChild(itemContainer); + + // Hide the original card + set.cardCol.style.display = 'none'; + set.cardCol.classList.add('grouped-set-hidden'); + }); + + // Insert the grouped container before the first hidden set + firstSet.cardCol.parentNode.insertBefore(groupContainer, firstSet.cardCol); + + // Add event listener to toggle arrow icon + const toggleBtn = groupContainer.querySelector('.group-toggle-btn'); + const collapseElement = groupContainer.querySelector('.collapse'); + + collapseElement.addEventListener('shown.bs.collapse', () => { + toggleBtn.querySelector('i').className = 'ri-arrow-down-line'; + }); + + collapseElement.addEventListener('hidden.bs.collapse', () => { + toggleBtn.querySelector('i').className = 'ri-arrow-right-line'; + }); +} + +function removeSetGrouping() { + // Show all hidden sets + const hiddenSets = document.querySelectorAll('.grouped-set-hidden'); + hiddenSets.forEach(setCol => { + setCol.style.display = ''; + setCol.classList.remove('grouped-set-hidden'); + }); + + // Remove all group containers + const groupContainers = document.querySelectorAll('.set-group-container'); + groupContainers.forEach(container => { + container.remove(); + }); } \ No newline at end of file diff --git a/static/styles.css b/static/styles.css index 0907b95..b943d76 100644 --- a/static/styles.css +++ b/static/styles.css @@ -160,4 +160,22 @@ overflow: hidden; text-overflow: ellipsis; min-width: 0; +} + +/* Partial Status Checkbox Styling */ +.partial-status { + position: relative; +} + +.partial-status::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + right: 2px; + bottom: 2px; + background: #6f42c1; + border-radius: 2px; + opacity: 0.8; + pointer-events: none; } \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 7dcfac3..310bccb 100644 --- a/templates/base.html +++ b/templates/base.html @@ -107,6 +107,7 @@ {% endif %} {% if request.endpoint == 'set.details' %} + {% endif %} {% if request.endpoint == 'instructions.download' or request.endpoint == 'instructions.do_download' %} diff --git a/templates/macro/badge.html b/templates/macro/badge.html index ee68a69..7a363f2 100644 --- a/templates/macro/badge.html +++ b/templates/macro/badge.html @@ -75,14 +75,34 @@ {% 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 %} + {% if purchase_locations and item.fields.purchase_location %} + {% if '|' in item.fields.purchase_location or ',' in item.fields.purchase_location %} + {# Consolidated mode - multiple purchase locations #} + {% set separator = '|' if '|' in item.fields.purchase_location else ',' %} + {% set location_list = item.fields.purchase_location.split(separator) %} + {% for location_id in location_list %} + {% if location_id and location_id.strip() and location_id.strip() in purchase_locations.mapping %} + {% set purchase_location = purchase_locations.mapping[location_id.strip()] %} + {% 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, collapsible='Location:') }} + {% endif %} + {% endfor %} {% else %} - {% set text=purchase_location.fields.name %} + {# Single purchase location #} + {% if item.fields.purchase_location.strip() and item.fields.purchase_location.strip() in purchase_locations.mapping %} + {% set purchase_location = purchase_locations.mapping[item.fields.purchase_location.strip()] %} + {% 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, collapsible='Location:') }} + {% endif %} {% endif %} - {{ badge(check=purchase_location, solo=solo, last=last, color='light border', icon='building-line', text=text, tooltip=tooltip, collapsible='Location:') }} {% endif %} {% endmacro %} @@ -103,14 +123,34 @@ {% endmacro %} {% macro storage(item, storages, solo=false, last=false) %} - {% if storages and item.fields.storage in storages.mapping %} - {% set storage = storages.mapping[item.fields.storage] %} - {% if last %} - {% set tooltip=storage.fields.name %} + {% if storages and item.fields.storage %} + {% if '|' in item.fields.storage or ',' in item.fields.storage %} + {# Consolidated mode - multiple storage locations #} + {% set separator = '|' if '|' in item.fields.storage else ',' %} + {% set storage_list = item.fields.storage.split(separator) %} + {% for storage_id in storage_list %} + {% if storage_id and storage_id.strip() and storage_id.strip() in storages.mapping %} + {% set storage = storages.mapping[storage_id.strip()] %} + {% if last %} + {% set tooltip=storage.fields.name %} + {% else %} + {% set text=storage.fields.name %} + {% endif %} + {{ badge(url=storage.url(), 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 %} + {% endfor %} {% else %} - {% set text=storage.fields.name %} + {# Single storage location #} + {% if item.fields.storage.strip() and item.fields.storage.strip() in storages.mapping %} + {% set storage = storages.mapping[item.fields.storage.strip()] %} + {% if last %} + {% set tooltip=storage.fields.name %} + {% else %} + {% set text=storage.fields.name %} + {% endif %} + {{ badge(url=storage.url(), 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 %} {% endif %} - {{ 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/set.html b/templates/set.html index 12df491..f004d6f 100644 --- a/templates/set.html +++ b/templates/set.html @@ -6,9 +6,51 @@
- {% with solo=true, delete=delete %} - {% include 'set/card.html' %} - {% endwith %} + {% if all_instances and all_instances | length > 1 %} + +
+ + + + +
+
+
+ {% set current_item_id = item.fields.id %} + {% for instance in all_instances %} +
+ {% with item=instance, index=loop.index0, tiny=false, current_viewing=(instance.fields.id == current_item_id) %} +
+ {% include 'set/card.html' %} +
+ {% endwith %} +
+ {% endfor %} +
+
+
+
+ + + {% with solo=true, delete=delete %} + {% include 'set/card.html' %} + {% endwith %} + {% else %} + + {% with solo=true, delete=delete %} + {% include 'set/card.html' %} + {% endwith %} + {% endif %}
diff --git a/templates/set/card.html b/templates/set/card.html index 0d505d9..a04df8e 100644 --- a/templates/set/card.html +++ b/templates/set/card.html @@ -3,9 +3,11 @@ {% import 'macro/card.html' as card %} {% import 'macro/form.html' as form %} -
{{ 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) }} -
+
{{ badge.theme(item.theme.name, solo=solo, last=last) }} {% for tag in brickset_tags %} {{ badge.tag(item, tag, solo=solo, last=last) }} @@ -63,6 +65,9 @@ {{ badge.year(item.fields.year, solo=solo, last=last) }} {% endif %} {{ badge.parts(item.fields.number_of_parts, solo=solo, last=last) }} + {% if item.fields.instance_count is defined and item.fields.instance_count > 1 %} + {{ item.fields.instance_count }} copies + {% endif %} {{ 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) }} @@ -81,14 +86,45 @@ {% endif %}
{% if not tiny and brickset_statuses | length %} -
    +
      {% for status in brickset_statuses %} -
    • {{ 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) }}
    • +
    • + {% if item.fields.instance_count is defined and item.fields.instance_count > 1 %} + {# Consolidated set - show mixed status indicator #} + {% set status_count = item.fields[status.as_column() + '_count'] %} + {% set total_count = item.fields.instance_count %} + {% if status_count == 0 %} + {# None checked #} + + + {% elif status_count == total_count %} + {# All checked #} + + + {% else %} + {# Partial - some checked #} + + + {% endif %} + {% else %} + {# Individual set - use normal checkbox #} + {{ 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) }} + {% endif %} +
    • {% endfor %}
    {% endif %} {% if solo %} -
    +
    {% if not delete %} {% 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') }}