Fixed consolidating sets.

This commit is contained in:
2025-09-27 23:06:06 +02:00
parent 0bff20215c
commit aa6c969a6b
16 changed files with 844 additions and 65 deletions

View File

@@ -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

View File

@@ -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},

View File

@@ -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:

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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 %}

View File

@@ -0,0 +1,166 @@
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(SUM("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 }}"
{% 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 %}

View File

@@ -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 %}

View File

@@ -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('/<id>/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

View File

@@ -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;
}
}
}

View File

@@ -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';
});
}
});

View File

@@ -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 = `
<div class="card set-group-card">
<div class="card-header d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<button class="btn btn-sm btn-outline-primary me-2 group-toggle-btn"
type="button" data-bs-toggle="collapse"
data-bs-target="#group-${setGroup[0].rebrickableId}"
aria-expanded="false">
<i class="ri-arrow-right-line"></i>
</button>
<span class="fw-bold">${firstCard.querySelector('.card-title')?.textContent || 'Set'}</span>
<span class="badge bg-secondary ms-2">${setGroup.length} sets</span>
</div>
<div class="d-flex gap-1">
${totalMissing > 0 ? `<span class="badge bg-warning text-dark">${totalMissing} missing</span>` : ''}
${totalDamaged > 0 ? `<span class="badge bg-danger">${totalDamaged} damaged</span>` : ''}
</div>
</div>
<div class="collapse" id="group-${setGroup[0].rebrickableId}">
<div class="card-body">
<div class="row set-group-items"></div>
</div>
</div>
</div>
`;
// 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();
});
}

View File

@@ -107,6 +107,7 @@
{% endif %}
{% if request.endpoint == 'set.details' %}
<script src="{{ url_for('static', filename='scripts/parts-bulk-operations.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/set-details.js') }}"></script>
{% endif %}
{% if request.endpoint == 'instructions.download' or request.endpoint == 'instructions.do_download' %}
<script src="{{ url_for('static', filename='scripts/socket/peeron.js') }}"></script>

View File

@@ -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(url=purchase_location.url(), 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(url=purchase_location.url(), 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 %}

View File

@@ -6,9 +6,92 @@
<div class="container">
<div class="row">
<div class="col-12">
{% with solo=true, delete=delete %}
{% include 'set/card.html' %}
{% endwith %}
{% if all_instances and all_instances | length > 1 %}
<!-- Multiple instances view with drawer-style header -->
<div class="alert alert-info p-0">
<!-- Clickable drawer header -->
<div class="alert alert-info mb-0" style="cursor: pointer;" data-bs-toggle="collapse"
data-bs-target="#all-instances" aria-expanded="false" aria-controls="all-instances">
<div class="d-flex justify-content-between align-items-center">
<div>
<h4 class="mb-1">
<i class="ri-stack-line me-2"></i>Multiple Copies Available
<span class="badge bg-primary ms-2">{{ all_instances | length }}</span>
</h4>
<p class="mb-0">This set has {{ all_instances | length }} copies in your collection. Click to view all copies individually.</p>
</div>
<i class="ri-arrow-down-s-line fs-4" id="copies-toggle-icon"></i>
</div>
</div>
<!-- Collapsible instances section -->
<div class="collapse" id="all-instances">
<div class="px-3 pb-3">
<div class="row">
{% set current_item_id = item.fields.id %}
{% for instance in all_instances %}
<div class="col-md-6 col-xl-4 d-flex align-items-stretch mb-3">
{% with item=instance, index=loop.index0, tiny=false %}
<div class="position-relative w-100">
{% include 'set/card.html' %}
<!-- Custom label overlay for distinguishing instances -->
{% set instance_name = [] %}
{% if instance.fields.storage %}
{% set _ = instance_name.append(instance.fields.storage) %}
{% endif %}
{% if instance.fields.purchase_location %}
{% set _ = instance_name.append("from " + instance.fields.purchase_location) %}
{% endif %}
{% set instance_tags = [] %}
{% for tag in brickset_tags %}
{% if instance.fields[tag.as_column()] %}
{% set _ = instance_tags.append(tag.fields.name) %}
{% endif %}
{% endfor %}
{% if instance_tags %}
{% set _ = instance_name.append("[" + instance_tags | join(", ") + "]") %}
{% endif %}
{% if instance.fields.purchase_date %}
{% set _ = instance_name.append("(" + instance.purchase_date() + ")") %}
{% endif %}
{% if instance_name %}
<div class="position-absolute top-0 end-0 m-2">
<span class="badge bg-dark bg-opacity-75 text-wrap text-start" style="max-width: 150px;">
{{ instance_name | join(" ") }}
{% if instance.fields.id == current_item_id %}
<br><small class="text-info">Currently Viewing</small>
{% endif %}
</span>
</div>
{% else %}
<div class="position-absolute top-0 end-0 m-2">
<span class="badge bg-secondary">
Copy #{{ loop.index }}
{% if instance.fields.id == current_item_id %}
<br><small class="text-info">Current</small>
{% endif %}
</span>
</div>
{% endif %}
</div>
{% endwith %}
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% with solo=true, delete=delete %}
{% include 'set/card.html' %}
{% endwith %}
{% else %}
<!-- Single instance view -->
{% with solo=true, delete=delete %}
{% include 'set/card.html' %}
{% endwith %}
{% endif %}
</div>
</div>
</div>

View File

@@ -3,9 +3,11 @@
{% import 'macro/card.html' as card %}
{% 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 %}"
<div {% if not solo %}id="set-{{ item.fields.consolidated_id or item.fields.id }}"{% endif %} class="card mb-3 flex-fill {% if solo %}card-solo{% endif %}"
data-set-id="{{ item.fields.consolidated_id or item.fields.id }}" data-rebrickable-id="{{ item.fields.set }}"
{% if item.fields.instance_count is defined %}data-instance-count="{{ item.fields.instance_count }}" data-instance-ids="{{ item.fields.instance_ids }}"{% endif %}
{% 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-index="{{ index }}" data-number="{{ item.fields.set }}" data-set="{{ item.fields.set.split('-')[0] | int }}{{ '%05d' | format(item.fields.set.split('-')[1] | int) }}" data-name="{{ item.fields.name | lower }}" data-parts="{{ item.fields.number_of_parts }}"
data-year="{{ item.fields.year }}" data-theme="{{ item.theme.name | lower }}"
{% if not config['HIDE_SET_INSTRUCTIONS'] %}
data-has-missing-instructions="{{ (item.instructions | length == 0) | int }}"
@@ -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 %}
<span class="badge bg-primary"><i class="ri-stack-line"></i> {{ item.fields.instance_count }} copies</span>
{% 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) }}