Compare commits

..

62 Commits

Author SHA1 Message Date
FrederikBaerentsen 40871a1c10 Changed download string 2025-09-26 11:37:49 +02:00
FrederikBaerentsen caac283905 Updated peeron download logic with proper socket. 2025-09-26 11:31:22 +02:00
FrederikBaerentsen 4bc0ef5cc4 Peeron thumbnails cache, as peeron uses http and cant live link to https 2025-09-25 22:09:36 +02:00
FrederikBaerentsen ec4f44a3ab Removed unused import 2025-09-25 21:46:58 +02:00
FrederikBaerentsen 0a29543939 Cleanup of peeron download 2025-09-25 21:42:15 +02:00
FrederikBaerentsen 74fe14f09b Added rotation, moved select all, added link after download 2025-09-25 20:47:41 +02:00
FrederikBaerentsen 787624c432 Added env variables and fixed socket for peeron 2025-09-24 21:59:10 +02:00
FrederikBaerentsen eddf4311d3 Feat(peeron): Initial upload 2025-09-24 21:59:10 +02:00
FrederikBaerentsen 90c0c20d75 Merge pull request 'feature/pagination' (#101) from feature/pagination into release/1.3
Reviewed-on: FrederikBaerentsen/BrickTracker#101
2025-09-24 21:49:05 +02:00
FrederikBaerentsen d2d388b142 Merge branch 'release/1.3' into feature/pagination 2025-09-24 21:47:54 +02:00
FrederikBaerentsen acf06e1955 Updated change log 2025-09-24 21:36:40 +02:00
FrederikBaerentsen c465e9814c Fixed duplicate color in parts dropdown 2025-09-24 21:24:51 +02:00
FrederikBaerentsen 046493294f Moved sort/filter buttons 2025-09-24 20:44:50 +02:00
FrederikBaerentsen 1096fbdef6 Fixed sorting icon on sets page 2025-09-24 20:40:46 +02:00
FrederikBaerentsen fc405e0832 Consolidated parts.js, problems.js and minifigures.js 2025-09-24 20:18:30 +02:00
FrederikBaerentsen cce96af09b Consolidate duplicate collapsible state management 2025-09-24 19:53:01 +02:00
FrederikBaerentsen f953a44593 Disabled table sort using headers, if server-side pagination is enabled. 2025-09-24 19:08:34 +02:00
FrederikBaerentsen e87cb90e20 Updated gitignore 2025-09-23 18:07:42 +02:00
FrederikBaerentsen f3fada9dd8 Updated gitignore 2025-09-23 17:58:15 +02:00
FrederikBaerentsen 4eae6b19dc Updated gitignore 2025-09-23 17:55:26 +02:00
FrederikBaerentsen 064b79bf9e Merge remote-tracking branch 'origin/master' into feature/pagination 2025-09-23 17:24:58 +02:00
FrederikBaerentsen 7c1cb66f67 Merge pull request 'hotfix/pagination-bug' (#99) from hotfix/pagination-bug into master
Reviewed-on: FrederikBaerentsen/BrickTracker#99
2025-09-23 17:16:30 +02:00
FrederikBaerentsen 5641b3e740 Merge branch 'master' into hotfix/pagination-bug 2025-09-23 17:12:37 +02:00
FrederikBaerentsen 9317a1baae Removed code for another feature 2025-09-23 17:10:13 +02:00
FrederikBaerentsen 6f6d90aa60 fix(pagination): Fixed socket gevent (#95) 2025-09-23 17:06:18 +02:00
FrederikBaerentsen 83a45795c3 Merge pull request 'fix(pagination): Fix #95. Switch from eventlet to gevent' (#98) from hotfix/pagination-bug into master
Reviewed-on: FrederikBaerentsen/BrickTracker#98
2025-09-23 16:51:27 +02:00
FrederikBaerentsen 572c52dada fix(pagination): Added requirements.txt 2025-09-23 16:46:43 +02:00
FrederikBaerentsen 909655c10a fix(pagination): Fix #95. Switch from eventlet to gevent 2025-09-23 16:42:03 +02:00
FrederikBaerentsen d1b79de411 Updated .env.sample with new variables 2025-09-23 16:41:38 +02:00
FrederikBaerentsen 1e767537b9 fix(pagination): Fix #95. Switch from eventlet to gevent 2025-09-23 16:36:22 +02:00
FrederikBaerentsen 8ee0d144be Updated gitignore 2025-09-23 15:16:51 +02:00
FrederikBaerentsen f7963b4723 Removed datatable-search field from minifigures page 2025-09-22 10:08:41 +02:00
FrederikBaerentsen 52b6c94483 Fixed problems pagination 2025-09-22 10:01:16 +02:00
FrederikBaerentsen b5236fae51 Added filter/search/pagination to 'Problems' 2025-09-22 09:36:25 +02:00
FrederikBaerentsen 9d0a48ee2a Fixed gitignore 2025-09-21 19:03:08 +02:00
FrederikBaerentsen 5677d731e4 Updated gitignore 2025-09-21 18:56:56 +02:00
FrederikBaerentsen fcdcd12184 Updated .env sample file 2025-09-21 18:21:29 +02:00
FrederikBaerentsen e1891e8bd6 Added more pagination options 2025-09-21 18:18:26 +02:00
FrederikBaerentsen af53b29818 Removed print log spam 2025-09-21 17:32:11 +02:00
FrederikBaerentsen 8a0a7837dc Fixed filtering on /sets page. 2025-09-21 17:26:57 +02:00
FrederikBaerentsen 4b3aef577a Fixed sorting and filtering on /sets. 2025-09-21 15:58:32 +02:00
FrederikBaerentsen 9a32a3f193 Merge remote-tracking branch 'origin/master' into feature/pagination 2025-09-17 18:32:53 +02:00
FrederikBaerentsen c71667cd41 Fix: #80, default images not downloading (also present in feature/pagination) 2025-09-17 18:07:28 +02:00
FrederikBaerentsen 421d635dd3 Moved import and added ignore to BeautifulSoup type annotation issues 2025-09-17 17:03:24 +02:00
FrederikBaerentsen 6bc406b70d Fixed broken wishlist page 2025-09-17 16:49:53 +02:00
FrederikBaerentsen 5fa145a9d7 Fixed pagination button size 2025-09-17 16:34:29 +02:00
FrederikBaerentsen 3bfd1c17dd Sets, Parts and Minifigures have pagination now 2025-09-17 07:06:34 +02:00
FrederikBaerentsen 46dada312a Added page size option 2025-09-16 18:26:21 +02:00
FrederikBaerentsen c876e1e3a4 Added pagination to /parts page. 2025-09-16 15:30:54 +02:00
FrederikBaerentsen 787a376553 Fixed sorting issue on minifigs, updated change log and version. 2025-09-16 10:44:47 +02:00
FrederikBaerentsen 7f4be9da36 Updated gitignore 2025-09-16 10:43:52 +02:00
FrederikBaerentsen d6f69bca9d Merge pull request 'Added migration to get new bricklink data fields, fixed bricklink links, added set refresh based on missing bricklink data' (#88) from feature/bricklink-data into master
Reviewed-on: FrederikBaerentsen/BrickTracker#88

Fixes #87 and #55
2025-09-16 10:34:44 +02:00
FrederikBaerentsen 3adeef086b Added migration to get new bricklink data fields, fixed bricklink links, added set refresh based on missing bricklink data 2025-09-16 09:43:01 +02:00
FrederikBaerentsen 40b63fff6a Merge pull request 'Added filter/sort/search to /minifigures and /parts' (#86) from feature/sort-search-filter into master
Reviewed-on: FrederikBaerentsen/BrickTracker#86
2025-09-16 09:37:33 +02:00
FrederikBaerentsen 1cac17a420 Added filter/sort/search to /minifigures and /parts 2025-09-15 20:29:26 +02:00
FrederikBaerentsen 7bfbbbf298 Merge pull request 'Fixed the rebrickable scraping to deal with changes' (#81) from hiddenside/BrickTracker:fix-instructions-download into master
Reviewed-on: FrederikBaerentsen/BrickTracker#81
2025-08-08 19:47:14 +02:00
hiddenside 79f348178c Tweaks to get the progress bar working as expected. 2025-08-02 12:01:01 -07:00
jl 07be7b6004 Fixed the rebrickable scraping to deal with changes
Created a common naming schema for the instructions when downloaded
	setnumber-set-name-rebrickable-name
so set 3816-1 Glove World would end up
	3816-1-Glove-World-BI-3004-32-3816-V-29-39
If there is ever a duplicate name it appends _1+++
2025-08-02 12:01:01 -07:00
FrederikBaerentsen cb24cfc014 Merge pull request 'Fix legibility of "Damaged" and "Missing" fields for tiny screen by reducing horizontal padding' (#74) from gregoo/BrickTracker:master into master
Reviewed-on: FrederikBaerentsen/BrickTracker#74
2025-08-02 13:20:37 +02:00
FrederikBaerentsen 418bd5cd9d Merge pull request 'Fixed broken URLs in quickstart.md and setup.md' (#75) from KingColton1/BrickTracker:master into master
Reviewed-on: FrederikBaerentsen/BrickTracker#75
2025-08-02 13:20:17 +02:00
kingcolton 9953e3921a Fixed broken URLs in quickstart.md and setup.md 2025-04-08 19:10:59 -04:00
gregoo 2d0fa7bf89 Fix legibility of "Damaged" and "Missing" fields for tiny screen by reducing horizontal padding 2025-03-31 16:08:53 +02:00
81 changed files with 4653 additions and 237 deletions
+73 -4
View File
@@ -28,7 +28,8 @@
# BK_AUTHENTICATION_KEY=change-this-to-something-random # BK_AUTHENTICATION_KEY=change-this-to-something-random
# Optional: Pattern of the link to Bricklink for a part. Will be passed to Python .format() # 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={part} # Supports {part} and {color} parameters. BrickLink part numbers and color IDs are used when available.
# Default: https://www.bricklink.com/v2/catalog/catalogitem.page?P={part}&C={color}
# BK_BRICKLINK_LINK_PART_PATTERN= # BK_BRICKLINK_LINK_PART_PATTERN=
# Optional: Display Bricklink links wherever applicable # Optional: Display Bricklink links wherever applicable
@@ -169,6 +170,47 @@
# Default: parts # Default: parts
# BK_PARTS_FOLDER=parts # BK_PARTS_FOLDER=parts
# Optional: Enable server-side pagination for individual pages (recommended for large collections)
# When enabled, pages use server-side pagination with configurable page sizes
# When disabled, pages load all data at once with instant client-side search
# Default: false for all
# BK_SETS_SERVER_SIDE_PAGINATION=true
# BK_PARTS_SERVER_SIDE_PAGINATION=true
# BK_MINIFIGURES_SERVER_SIDE_PAGINATION=true
# BK_PROBLEMS_SERVER_SIDE_PAGINATION=true
# Optional: Number of parts to show per page on desktop devices (when server-side pagination is enabled)
# Default: 10
# BK_PARTS_PAGINATION_SIZE_DESKTOP=10
# Optional: Number of parts to show per page on mobile devices (when server-side pagination is enabled)
# Default: 5
# BK_PARTS_PAGINATION_SIZE_MOBILE=5
# Optional: Number of sets to show per page on desktop devices (when server-side pagination is enabled)
# Should be divisible by 4 for grid layout. Default: 12
# BK_SETS_PAGINATION_SIZE_DESKTOP=12
# Optional: Number of sets to show per page on mobile devices (when server-side pagination is enabled)
# Default: 4
# BK_SETS_PAGINATION_SIZE_MOBILE=4
# Optional: Number of minifigures to show per page on desktop devices (when server-side pagination is enabled)
# Default: 10
# BK_MINIFIGURES_PAGINATION_SIZE_DESKTOP=10
# Optional: Number of minifigures to show per page on mobile devices (when server-side pagination is enabled)
# Default: 5
# BK_MINIFIGURES_PAGINATION_SIZE_MOBILE=5
# Optional: Number of problems to show per page on desktop devices (when server-side pagination is enabled)
# Default: 10
# BK_PROBLEMS_PAGINATION_SIZE_DESKTOP=10
# Optional: Number of problems to show per page on mobile devices (when server-side pagination is enabled)
# Default: 5
# BK_PROBLEMS_PAGINATION_SIZE_MOBILE=5
# Optional: Port the server will listen on. # Optional: Port the server will listen on.
# Default: 3333 # Default: 3333
# BK_PORT=3333 # BK_PORT=3333
@@ -220,9 +262,36 @@
# Default: https://rebrickable.com/instructions/{path} # Default: https://rebrickable.com/instructions/{path}
# BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN= # BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN=
# Optional: User-Agent to use when querying Rebrickable outside of the Rebrick python library # Optional: User-Agent to use when querying Rebrickable and Peeron outside of the Rebrick python library
# Default: 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' # Default: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36
# BK_REBRICKABLE_USER_AGENT= # BK_USER_AGENT=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36
# Legacy: User-Agent for Rebrickable (use BK_USER_AGENT instead)
# BK_REBRICKABLE_USER_AGENT=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36
# Optional: Delay in milliseconds between Peeron page downloads to avoid being potentially blocked
# Default: 1000
# BK_PEERON_DOWNLOAD_DELAY=1000
# Optional: Minimum image size (width/height) for valid Peeron instruction pages
# Images smaller than this are considered error placeholders and will be rejected
# Default: 100
# BK_PEERON_MIN_IMAGE_SIZE=100
# Optional: Pattern for Peeron instruction page URLs. Will be passed to Python .format()
# Supports {set_number} and {version_number} parameters
# Default: http://peeron.com/scans/{set_number}-{version_number}
# BK_PEERON_INSTRUCTION_PATTERN=
# Optional: Pattern for Peeron thumbnail URLs. Will be passed to Python .format()
# Supports {set_number} and {version_number} parameters
# Default: http://belay.peeron.com/thumbs/{set_number}-{version_number}/
# BK_PEERON_THUMBNAIL_PATTERN=
# Optional: Pattern for Peeron scan URLs. Will be passed to Python .format()
# Supports {set_number} and {version_number} parameters
# Default: http://belay.peeron.com/scans/{set_number}-{version_number}/
# BK_PEERON_SCAN_PATTERN=
# Optional: Display Rebrickable links wherever applicable # Optional: Display Rebrickable links wherever applicable
# Default: false # Default: false
+9
View File
@@ -21,6 +21,15 @@ static/sets/
# Temporary # Temporary
*.csv *.csv
/local/ /local/
run_local.sh
settings.local.json
# Apple idiocy # Apple idiocy
.DS_Store .DS_Store
# Documentation
docusaurus/
vitepress/
# Local data
offline/
+45
View File
@@ -1,5 +1,50 @@
# Changelog # Changelog
## Unreleased
### 1.3
- Add individual pagination control system per entity type
- `BK_SETS_SERVER_SIDE_PAGINATION`: Enable/disable pagination for sets
- `BK_PARTS_SERVER_SIDE_PAGINATION`: Enable/disable pagination for parts
- `BK_MINIFIGURES_SERVER_SIDE_PAGINATION`: Enable/disable pagination for minifigures
- Device-specific pagination sizes (desktop/mobile) for each entity type
- Supports search, filtering, and sorting in both server-side and client-side modes
- Consolidated duplicate code across parts.js, problems.js, and minifigures.js
- Created shared functions in collapsible-state.js for common operations
- Fixed dynamic sort icons across all pages
- Sort icons now properly toggle between ascending/descending states
- Improved DataTable integration
- Disabled column header sorting when server-side pagination is enabled
- Prevents conflicting sort mechanisms between DataTable and server-side sorting
- Enhanced color dropdown functionality
- Automatic merging of duplicate color entries with same color_id
- Keeps entries with valid RGB data, removes entries with None/empty RGB
- Preserves selection state during dropdown consolidation
- Consistent search behavior (instant for client-side, Enter key for server-side)
- Mobile-friendly pagination navigation
### 1.2.4
> **Warning**
> To use the new BrickLink color parameter in URLs, update your `.env` file:
> `BK_BRICKLINK_LINK_PART_PATTERN=https://www.bricklink.com/v2/catalog/catalogitem.page?P={part}&C={color}`
- Add BrickLink color and part number support for accurate BrickLink URLs
- Database migrations to store BrickLink color ID, color name, and part number
- Updated Rebrickable API integration to extract BrickLink data from external_ids
- Enhanced BrickLink URL generation with proper part number fallback
- Extended admin set refresh to detect and track missing BrickLink data
## 1.2.3
Added search/filter/sort options to `parts` and `minifigures`.
## 1.2.2
Fix legibility of "Damaged" and "Missing" fields for tiny screen by reducing horizontal padding
Fixed instructions download from Rebrickable
## 1.2.2: ## 1.2.2:
This release fixes a bug where orphaned parts in the `inventory` table are blocking the database upgrade. This release fixes a bug where orphaned parts in the `inventory` table are blocking the database upgrade.
+3
View File
@@ -5,6 +5,9 @@ WORKDIR /app
# Bricktracker # Bricktracker
COPY . . COPY . .
# Fix line endings and set executable permissions for entrypoint script
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh
# Python library requirements # Python library requirements
RUN pip --no-cache-dir install -r requirements.txt RUN pip --no-cache-dir install -r requirements.txt
+2 -2
View File
@@ -1,6 +1,6 @@
# This need to be first # This need to be first
import eventlet import gevent.monkey
eventlet.monkey_patch() gevent.monkey.patch_all()
import logging # noqa: E402 import logging # noqa: E402
+6 -1
View File
@@ -10,6 +10,7 @@ from bricktracker.configuration_list import BrickConfigurationList
from bricktracker.login import LoginManager from bricktracker.login import LoginManager
from bricktracker.navbar import Navbar from bricktracker.navbar import Navbar
from bricktracker.sql import close from bricktracker.sql import close
from bricktracker.template_filters import replace_query_filter
from bricktracker.version import __version__ from bricktracker.version import __version__
from bricktracker.views.add import add_page from bricktracker.views.add import add_page
from bricktracker.views.admin.admin import admin_page from bricktracker.views.admin.admin import admin_page
@@ -59,7 +60,8 @@ def setup_app(app: Flask) -> None:
# Setup the login manager # Setup the login manager
LoginManager(app) LoginManager(app)
# I don't know :-) # Configure proxy header handling for reverse proxy deployments (nginx, Apache, etc.)
# This ensures proper client IP detection and HTTPS scheme recognition
app.wsgi_app = ProxyFix( app.wsgi_app = ProxyFix(
app.wsgi_app, app.wsgi_app,
x_for=1, x_for=1,
@@ -121,6 +123,9 @@ def setup_app(app: Flask) -> None:
# Version # Version
g.version = __version__ g.version = __version__
# Register custom Jinja2 filters
app.jinja_env.filters['replace_query'] = replace_query_filter
# Make sure all connections are closed at the end # Make sure all connections are closed at the end
@app.teardown_request @app.teardown_request
def teardown_request(_: BaseException | None) -> None: def teardown_request(_: BaseException | None) -> None:
+19 -1
View File
@@ -10,7 +10,7 @@ from typing import Any, Final
CONFIG: Final[list[dict[str, Any]]] = [ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'AUTHENTICATION_PASSWORD', 'd': ''}, {'n': 'AUTHENTICATION_PASSWORD', 'd': ''},
{'n': 'AUTHENTICATION_KEY', 'd': ''}, {'n': 'AUTHENTICATION_KEY', 'd': ''},
{'n': 'BRICKLINK_LINK_PART_PATTERN', 'd': 'https://www.bricklink.com/v2/catalog/catalogitem.page?P={part}'}, # noqa: E501 {'n': 'BRICKLINK_LINK_PART_PATTERN', 'd': 'https://www.bricklink.com/v2/catalog/catalogitem.page?P={part}&C={color}'}, # noqa: E501
{'n': 'BRICKLINK_LINKS', 'c': bool}, {'n': 'BRICKLINK_LINKS', 'c': bool},
{'n': 'DATABASE_PATH', 'd': './app.db'}, {'n': 'DATABASE_PATH', 'd': './app.db'},
{'n': 'DATABASE_TIMESTAMP_FORMAT', 'd': '%Y-%m-%d-%H-%M-%S'}, {'n': 'DATABASE_TIMESTAMP_FORMAT', 'd': '%Y-%m-%d-%H-%M-%S'},
@@ -37,9 +37,21 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'HIDE_WISHES', 'c': bool}, {'n': 'HIDE_WISHES', 'c': bool},
{'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': '"rebrickable_minifigures"."name" ASC'}, # noqa: E501 {'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': '"rebrickable_minifigures"."name" ASC'}, # noqa: E501
{'n': 'MINIFIGURES_FOLDER', 'd': 'minifigs', 's': True}, {'n': 'MINIFIGURES_FOLDER', 'd': 'minifigs', 's': True},
{'n': 'MINIFIGURES_PAGINATION_SIZE_DESKTOP', 'd': 10, 'c': int},
{'n': 'MINIFIGURES_PAGINATION_SIZE_MOBILE', 'd': 5, 'c': int},
{'n': 'MINIFIGURES_SERVER_SIDE_PAGINATION', 'c': bool},
{'n': 'NO_THREADED_SOCKET', 'c': bool}, {'n': 'NO_THREADED_SOCKET', 'c': bool},
{'n': 'PARTS_SERVER_SIDE_PAGINATION', 'c': bool},
{'n': 'SETS_SERVER_SIDE_PAGINATION', 'c': bool},
{'n': 'PARTS_DEFAULT_ORDER', 'd': '"rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "bricktracker_parts"."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': 'PARTS_FOLDER', 'd': 'parts', 's': True},
{'n': 'PARTS_PAGINATION_SIZE_DESKTOP', 'd': 10, 'c': int},
{'n': 'PARTS_PAGINATION_SIZE_MOBILE', 'd': 5, 'c': int},
{'n': 'PROBLEMS_PAGINATION_SIZE_DESKTOP', 'd': 10, 'c': int},
{'n': 'PROBLEMS_PAGINATION_SIZE_MOBILE', 'd': 10, 'c': int},
{'n': 'PROBLEMS_SERVER_SIDE_PAGINATION', 'c': bool},
{'n': 'SETS_PAGINATION_SIZE_DESKTOP', 'd': 12, 'c': int},
{'n': 'SETS_PAGINATION_SIZE_MOBILE', 'd': 4, 'c': int},
{'n': 'PORT', 'd': 3333, 'c': int}, {'n': 'PORT', 'd': 3333, 'c': int},
{'n': 'PURCHASE_DATE_FORMAT', 'd': '%d/%m/%Y'}, {'n': 'PURCHASE_DATE_FORMAT', 'd': '%d/%m/%Y'},
{'n': 'PURCHASE_CURRENCY', 'd': ''}, {'n': 'PURCHASE_CURRENCY', 'd': ''},
@@ -52,6 +64,12 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'REBRICKABLE_LINK_PART_PATTERN', 'd': 'https://rebrickable.com/parts/{part}/_/{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_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_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': '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': 'PEERON_DOWNLOAD_DELAY', 'd': 1000, 'c': int},
{'n': 'PEERON_INSTRUCTION_PATTERN', 'd': 'http://peeron.com/scans/{set_number}-{version_number}'},
{'n': 'PEERON_MIN_IMAGE_SIZE', 'd': 100, 'c': int},
{'n': 'PEERON_SCAN_PATTERN', 'd': 'http://belay.peeron.com/scans/{set_number}-{version_number}/'},
{'n': 'PEERON_THUMBNAIL_PATTERN', 'd': 'http://belay.peeron.com/thumbs/{set_number}-{version_number}/'},
{'n': 'REBRICKABLE_LINKS', 'e': 'LINKS', 'c': bool}, {'n': 'REBRICKABLE_LINKS', 'e': 'LINKS', 'c': bool},
{'n': 'REBRICKABLE_PAGE_SIZE', 'd': 100, 'c': int}, {'n': 'REBRICKABLE_PAGE_SIZE', 'd': 100, 'c': int},
{'n': 'RETIRED_SETS_FILE_URL', 'd': 'https://docs.google.com/spreadsheets/d/1rlYfEXtNKxUOZt2Mfv0H17DvK7bj6Pe0CuYwq6ay8WA/gviz/tq?tqx=out:csv&sheet=Sorted%20by%20Retirement%20Date'}, # noqa: E501 {'n': 'RETIRED_SETS_FILE_URL', 'd': 'https://docs.google.com/spreadsheets/d/1rlYfEXtNKxUOZt2Mfv0H17DvK7bj6Pe0CuYwq6ay8WA/gviz/tq?tqx=out:csv&sheet=Sorted%20by%20Retirement%20Date'}, # noqa: E501
+96 -98
View File
@@ -1,6 +1,7 @@
from datetime import datetime, timezone from datetime import datetime, timezone
import logging import logging
import os import os
from urllib.parse import urljoin
from shutil import copyfileobj from shutil import copyfileobj
import traceback import traceback
from typing import Tuple, TYPE_CHECKING from typing import Tuple, TYPE_CHECKING
@@ -11,6 +12,8 @@ import humanize
import requests import requests
from werkzeug.datastructures import FileStorage from werkzeug.datastructures import FileStorage
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
import re
import cloudscraper
from .exceptions import ErrorException, DownloadException from .exceptions import ErrorException, DownloadException
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -89,91 +92,74 @@ class BrickInstructions(object):
# Download an instruction file # Download an instruction file
def download(self, path: str, /) -> None: def download(self, path: str, /) -> None:
"""
Streams the PDF in chunks and uses self.socket.update_total
+ self.socket.progress_count to drive a determinate bar.
"""
try: try:
# Just to make sure that the progress is initiated
self.socket.progress(
message='Downloading {file}'.format(
file=self.filename,
)
)
target = self.path(filename=secure_filename(self.filename)) target = self.path(filename=secure_filename(self.filename))
# Skipping rather than failing here # Skip if we already have it
if os.path.isfile(target): if os.path.isfile(target):
self.socket.complete( pdf_url = self.url()
message='File {file} already exists, skipped'.format( return self.socket.complete(
file=self.filename, message=f'File {self.filename} already exists, skipped - <a href="{pdf_url}" target="_blank" class="btn btn-sm btn-primary ms-2"><i class="ri-external-link-line"></i> Open PDF</a>'
)
) )
else: # Fetch PDF via cloudscraper (to bypass Cloudflare)
url = current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format( # noqa: E501 scraper = cloudscraper.create_scraper()
path=path scraper.headers.update({
) "User-Agent": current_app.config['REBRICKABLE_USER_AGENT']
trimmed_url = current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format( # noqa: E501 })
path=path.partition('/')[0] resp = scraper.get(path, stream=True)
) if not resp.ok:
raise DownloadException(f"Failed to download: HTTP {resp.status_code}")
# Request the file # Tell the socket how many bytes in total
total = int(resp.headers.get("Content-Length", 0))
self.socket.update_total(total)
# Reset the counter and kick off at 0%
self.socket.progress_count = 0
self.socket.progress(message=f"Starting download {self.filename}")
# Write out in 8 KiB chunks and update the counter
with open(target, "wb") as f:
for chunk in resp.iter_content(chunk_size=8192):
if not chunk:
continue
f.write(chunk)
# Bump the internal counter and emit
self.socket.progress_count += len(chunk)
self.socket.progress( self.socket.progress(
message='Requesting {url}'.format( message=(
url=trimmed_url, f"Downloading {self.filename} "
f"({humanize.naturalsize(self.socket.progress_count)}/"
f"{humanize.naturalsize(self.socket.progress_total)})"
) )
) )
response = requests.get(url, stream=True) # Done!
if response.ok: logger.info(f"Downloaded {self.filename}")
pdf_url = self.url()
# Store the content header as size
try:
self.size = int(
response.headers.get('Content-length', 0)
)
except Exception:
self.size = 0
# Downloading the file
self.socket.progress(
message='Downloading {url} ({size})'.format(
url=trimmed_url,
size=self.human_size(),
)
)
with open(target, 'wb') as f:
copyfileobj(response.raw, f)
else:
raise DownloadException('failed to download: {code}'.format( # noqa: E501
code=response.status_code
))
# Info
logger.info('The instruction file {file} has been downloaded'.format( # noqa: E501
file=self.filename
))
# Complete
self.socket.complete( self.socket.complete(
message='File {file} downloaded ({size})'.format( # noqa: E501 message=f'File {self.filename} downloaded ({self.human_size()}) - <a href="{pdf_url}" target="_blank" class="btn btn-sm btn-primary ms-2"><i class="ri-external-link-line"></i> Open PDF</a>'
file=self.filename,
size=self.human_size()
)
) )
except Exception as e: except Exception as e:
self.socket.fail(
message='Error while downloading instruction {file}: {error}'.format( # noqa: E501
file=self.filename,
error=e,
)
)
logger.debug(traceback.format_exc()) logger.debug(traceback.format_exc())
self.socket.fail(
message=f"Error downloading {self.filename}: {e}"
)
# Display the size in a human format # Display the size in a human format
def human_size(self) -> str: def human_size(self) -> str:
return humanize.naturalsize(self.size) try:
size = self.size
except AttributeError:
size = os.path.getsize(self.path())
return humanize.naturalsize(size)
# Display the time in a human format # Display the time in a human format
def human_time(self) -> str: def human_time(self) -> str:
@@ -250,40 +236,52 @@ class BrickInstructions(object):
# Find the instructions for a set # Find the instructions for a set
@staticmethod @staticmethod
def find_instructions(set: str, /) -> list[Tuple[str, str]]: def find_instructions(set: str, /) -> list[Tuple[str, str]]:
response = requests.get( """
current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format( Scrape Rebrickables HTML and return a list of
path=set, (filename_slug, download_url). Duplicate slugs get _1, _2, …
), """
headers={ page_url = f"https://rebrickable.com/instructions/{set}/"
'User-Agent': current_app.config['REBRICKABLE_USER_AGENT'] logger.debug(f"[find_instructions] fetching HTML from {page_url!r}")
}
)
if not response.ok: # Solve Cloudflares challenge
raise ErrorException('Failed to load the Rebrickable instructions page. Status code: {code}'.format( # noqa: E501 scraper = cloudscraper.create_scraper()
code=response.status_code scraper.headers.update({'User-Agent': current_app.config['REBRICKABLE_USER_AGENT']})
)) resp = scraper.get(page_url)
if not resp.ok:
raise ErrorException(f'Failed to load instructions page for {set}. HTTP {resp.status_code}')
# Parse the HTML content soup = BeautifulSoup(resp.content, 'html.parser')
soup = BeautifulSoup(response.content, 'html.parser') link_re = re.compile(r'^/instructions/\d+/.+/download/')
# Collect all <img> tags with "LEGO Building Instructions" in the raw: list[tuple[str, str]] = []
# alt attribute for a in soup.find_all('a', href=link_re):
found_tags: list[Tuple[str, str]] = [] img = a.find('img', alt=True) # type: ignore
for a_tag in soup.find_all('a', href=True): if not img or set not in img['alt']: # type: ignore
img_tag = a_tag.find('img', alt=True) continue
if img_tag and "LEGO Building Instructions" in img_tag['alt']:
found_tags.append(
(
img_tag['alt'].removeprefix('LEGO Building Instructions for '), # noqa: E501
a_tag['href']
)
) # Save alt and href
# Raise an error if nothing found # Turn the alt text into a slug
if not len(found_tags): alt_text = img['alt'].removeprefix('LEGO Building Instructions for ') # type: ignore
raise ErrorException('No instruction found for set {set}'.format( slug = re.sub(r'[^A-Za-z0-9]+', '-', alt_text).strip('-')
set=set
))
return found_tags # Build the absolute download URL
download_url = urljoin('https://rebrickable.com', a['href']) # type: ignore
raw.append((slug, download_url))
if not raw:
raise ErrorException(f'No download links found on instructions page for {set}')
# Disambiguate duplicate slugs by appending _1, _2, …
from collections import Counter, defaultdict
counts = Counter(name for name, _ in raw)
seen: dict[str, int] = defaultdict(int)
unique: list[tuple[str, str]] = []
for name, url in raw:
idx = seen[name]
if counts[name] > 1 and idx > 0:
final_name = f"{name}_{idx}"
else:
final_name = name
seen[name] += 1
unique.append((final_name, url))
return unique
+66
View File
@@ -21,6 +21,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
# Queries # Queries
all_query: str = 'minifigure/list/all' all_query: str = 'minifigure/list/all'
all_by_owner_query: str = 'minifigure/list/all_by_owner'
damaged_part_query: str = 'minifigure/list/damaged_part' damaged_part_query: str = 'minifigure/list/damaged_part'
last_query: str = 'minifigure/list/last' last_query: str = 'minifigure/list/last'
missing_part_query: str = 'minifigure/list/missing_part' missing_part_query: str = 'minifigure/list/missing_part'
@@ -42,6 +43,58 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
return self return self
# Load all minifigures by owner
def all_by_owner(self, owner_id: str | None = None, /) -> Self:
# Save the owner_id parameter
self.fields.owner_id = owner_id
# Load the minifigures from the database
self.list(override_query=self.all_by_owner_query)
return self
# Load minifigures with pagination support
def all_filtered_paginated(
self,
owner_id: str | None = None,
search_query: str | None = None,
page: int = 1,
per_page: int = 50,
sort_field: str | None = None,
sort_order: str = 'asc'
) -> tuple[Self, int]:
# Prepare filter context
filter_context = {}
if owner_id and owner_id != 'all':
filter_context['owner_id'] = owner_id
list_query = self.all_by_owner_query
else:
list_query = self.all_query
if search_query:
filter_context['search_query'] = search_query
# Field mapping for sorting
field_mapping = {
'name': '"rebrickable_minifigures"."name"',
'parts': '"rebrickable_minifigures"."number_of_parts"',
'quantity': '"total_quantity"',
'missing': '"total_missing"',
'damaged': '"total_damaged"',
'sets': '"total_sets"'
}
# Use the base pagination method
return self.paginate(
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order,
list_query=list_query,
field_mapping=field_mapping,
**filter_context
)
# Minifigures with a part damaged part # Minifigures with a part damaged part
def damaged_part(self, part: str, color: int, /) -> Self: def damaged_part(self, part: str, color: int, /) -> Self:
# Save the parameters to the fields # Save the parameters to the fields
@@ -83,11 +136,20 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
else: else:
brickset = None brickset = None
# Prepare template context for owner filtering
context_vars = {}
if hasattr(self.fields, 'owner_id') and self.fields.owner_id is not None:
context_vars['owner_id'] = self.fields.owner_id
# Merge with any additional context passed in
context_vars.update(context)
# Load the sets from the database # Load the sets from the database
for record in super().select( for record in super().select(
override_query=override_query, override_query=override_query,
order=order, order=order,
limit=limit, limit=limit,
**context_vars
): ):
minifigure = BrickMinifigure(brickset=brickset, record=record) minifigure = BrickMinifigure(brickset=brickset, record=record)
@@ -132,6 +194,10 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
if self.brickset is not None: if self.brickset is not None:
parameters['id'] = self.brickset.fields.id parameters['id'] = self.brickset.fields.id
# Add owner_id parameter for owner filtering
if hasattr(self.fields, 'owner_id') and self.fields.owner_id is not None:
parameters['owner_id'] = self.fields.owner_id
return parameters return parameters
# Import the minifigures from Rebrickable # Import the minifigures from Rebrickable
+52
View File
@@ -0,0 +1,52 @@
from flask import current_app, request
from typing import Any, Dict, Tuple
def get_pagination_config(entity_type: str) -> Tuple[int, bool]:
"""Get pagination configuration for an entity type (sets, parts, minifigures)"""
# Check if pagination is enabled for this specific entity type
pagination_key = f'{entity_type.upper()}_SERVER_SIDE_PAGINATION'
use_pagination = current_app.config.get(pagination_key, False)
if not use_pagination:
return 0, False
# Determine page size based on device type and entity
user_agent = request.headers.get('User-Agent', '').lower()
is_mobile = any(device in user_agent for device in ['mobile', 'android', 'iphone', 'ipad'])
# Get appropriate config keys based on entity type
entity_upper = entity_type.upper()
desktop_key = f'{entity_upper}_PAGINATION_SIZE_DESKTOP'
mobile_key = f'{entity_upper}_PAGINATION_SIZE_MOBILE'
per_page = current_app.config[mobile_key] if is_mobile else current_app.config[desktop_key]
return per_page, is_mobile
def build_pagination_context(page: int, per_page: int, total_count: int, is_mobile: bool) -> Dict[str, Any]:
"""Build pagination context for templates"""
total_pages = (total_count + per_page - 1) // per_page if total_count > 0 else 1
has_prev = page > 1
has_next = page < total_pages
return {
'page': page,
'per_page': per_page,
'total_count': total_count,
'total_pages': total_pages,
'has_prev': has_prev,
'has_next': has_next,
'is_mobile': is_mobile
}
def get_request_params() -> Tuple[str, str, str, int]:
"""Extract common request parameters for pagination"""
search_query = request.args.get('search', '').strip()
sort_field = request.args.get('sort', '')
sort_order = request.args.get('order', 'asc')
page = int(request.args.get('page', 1))
return search_query, sort_field, sort_order, page
+162 -3
View File
@@ -23,6 +23,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
# Queries # Queries
all_query: str = 'part/list/all' all_query: str = 'part/list/all'
all_by_owner_query: str = 'part/list/all_by_owner'
different_color_query = 'part/list/with_different_color' different_color_query = 'part/list/with_different_color'
last_query: str = 'part/list/last' last_query: str = 'part/list/last'
minifigure_query: str = 'part/list/from_minifigure' minifigure_query: str = 'part/list/from_minifigure'
@@ -46,6 +47,88 @@ class BrickPartList(BrickRecordList[BrickPart]):
return self return self
# Load all parts by owner
def all_by_owner(self, owner_id: str | None = None, /) -> Self:
# Save the owner_id parameter
self.fields.owner_id = owner_id
# Load the parts from the database
self.list(override_query=self.all_by_owner_query)
return self
# Load all parts with filters (owner and/or color)
def all_filtered(self, owner_id: str | None = None, color_id: str | None = None, /) -> Self:
# Save the filter parameters
if owner_id is not None:
self.fields.owner_id = owner_id
if color_id is not None:
self.fields.color_id = color_id
# Choose query based on whether owner filtering is needed
if owner_id and owner_id != 'all':
query = self.all_by_owner_query
else:
query = self.all_query
# Prepare context for query
context = {}
if current_app.config.get('SKIP_SPARE_PARTS', False):
context['skip_spare_parts'] = True
# Load the parts from the database
self.list(override_query=query, **context)
return self
# Load parts with pagination support
def all_filtered_paginated(
self,
owner_id: str | None = None,
color_id: str | None = None,
search_query: str | None = None,
page: int = 1,
per_page: int = 50,
sort_field: str | None = None,
sort_order: str = 'asc'
) -> tuple[Self, int]:
# Prepare filter context
filter_context = {}
if owner_id and owner_id != 'all':
filter_context['owner_id'] = owner_id
list_query = self.all_by_owner_query
else:
list_query = self.all_query
if color_id and color_id != 'all':
filter_context['color_id'] = color_id
if search_query:
filter_context['search_query'] = search_query
if current_app.config.get('SKIP_SPARE_PARTS', False):
filter_context['skip_spare_parts'] = True
# Field mapping for sorting
field_mapping = {
'name': '"rebrickable_parts"."name"',
'color': '"rebrickable_parts"."color_name"',
'quantity': '"total_quantity"',
'missing': '"total_missing"',
'damaged': '"total_damaged"',
'sets': '"total_sets"',
'minifigures': '"total_minifigures"'
}
# Use the base pagination method
return self.paginate(
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order,
list_query=list_query,
field_mapping=field_mapping,
**filter_context
)
# Base part list # Base part list
def list( def list(
self, self,
@@ -54,6 +137,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
override_query: str | None = None, override_query: str | None = None,
order: str | None = None, order: str | None = None,
limit: int | None = None, limit: int | None = None,
offset: int | None = None,
**context: Any, **context: Any,
) -> None: ) -> None:
if order is None: if order is None:
@@ -69,11 +153,25 @@ class BrickPartList(BrickRecordList[BrickPart]):
else: else:
minifigure = None minifigure = None
# Prepare template context for filtering
context_vars = {}
if hasattr(self.fields, 'owner_id') and self.fields.owner_id is not None:
context_vars['owner_id'] = self.fields.owner_id
if hasattr(self.fields, 'color_id') and self.fields.color_id is not None:
context_vars['color_id'] = self.fields.color_id
if hasattr(self.fields, 'search_query') and self.fields.search_query:
context_vars['search_query'] = self.fields.search_query
# Merge with any additional context passed in
context_vars.update(context)
# Load the sets from the database # Load the sets from the database
for record in super().select( for record in super().select(
override_query=override_query, override_query=override_query,
order=order, order=order,
limit=limit, limit=limit,
offset=offset,
**context_vars
): ):
part = BrickPart( part = BrickPart(
brickset=brickset, brickset=brickset,
@@ -81,9 +179,6 @@ class BrickPartList(BrickRecordList[BrickPart]):
record=record, record=record,
) )
if current_app.config['SKIP_SPARE_PARTS'] and part.fields.spare:
continue
self.records.append(part) self.records.append(part)
# List specific parts from a brickset or minifigure # List specific parts from a brickset or minifigure
@@ -143,6 +238,70 @@ class BrickPartList(BrickRecordList[BrickPart]):
return self return self
def problem_filtered(self, owner_id: str | None = None, color_id: str | None = None, /) -> Self:
# Save the filter parameters for client-side filtering
if owner_id is not None:
self.fields.owner_id = owner_id
if color_id is not None:
self.fields.color_id = color_id
# Prepare context for query
context = {}
if owner_id and owner_id != 'all':
context['owner_id'] = owner_id
if color_id and color_id != 'all':
context['color_id'] = color_id
if current_app.config.get('SKIP_SPARE_PARTS', False):
context['skip_spare_parts'] = True
# Load the problematic parts from the database
self.list(override_query=self.problem_query, **context)
return self
def problem_paginated(
self,
owner_id: str | None = None,
color_id: str | None = None,
search_query: str | None = None,
page: int = 1,
per_page: int = 50,
sort_field: str | None = None,
sort_order: str = 'asc'
) -> tuple[Self, int]:
# Prepare filter context
filter_context = {}
if owner_id and owner_id != 'all':
filter_context['owner_id'] = owner_id
if color_id and color_id != 'all':
filter_context['color_id'] = color_id
if search_query:
filter_context['search_query'] = search_query
if current_app.config.get('SKIP_SPARE_PARTS', False):
filter_context['skip_spare_parts'] = True
# Field mapping for sorting
field_mapping = {
'name': '"rebrickable_parts"."name"',
'color': '"rebrickable_parts"."color_name"',
'quantity': '"total_quantity"',
'missing': '"total_missing"',
'damaged': '"total_damaged"',
'sets': '"total_sets"',
'minifigures': '"total_minifigures"'
}
# Use the base pagination method with problem query
return self.paginate(
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order,
list_query=self.problem_query,
field_mapping=field_mapping,
**filter_context
)
# Return a dict with common SQL parameters for a parts list # Return a dict with common SQL parameters for a parts list
def sql_parameters(self, /) -> dict[str, Any]: def sql_parameters(self, /) -> dict[str, Any]:
parameters: dict[str, Any] = super().sql_parameters() parameters: dict[str, Any] = super().sql_parameters()
+437
View File
@@ -0,0 +1,437 @@
import hashlib
import logging
import os
from pathlib import Path
import time
from typing import Any, NamedTuple, TYPE_CHECKING
from urllib.parse import urljoin
from bs4 import BeautifulSoup
import cloudscraper
from flask import current_app, url_for
import requests
from .exceptions import ErrorException
if TYPE_CHECKING:
from .socket import BrickSocket
logger = logging.getLogger(__name__)
def get_peeron_user_agent():
"""Get the User-Agent string for Peeron requests from config"""
return current_app.config.get('REBRICKABLE_USER_AGENT',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36')
def get_peeron_download_delay():
"""Get the delay in milliseconds between Peeron page downloads from config"""
return current_app.config.get('PEERON_DOWNLOAD_DELAY', 1000)
def get_min_image_size():
"""Get the minimum image size for valid Peeron instruction pages from config"""
return current_app.config.get('PEERON_MIN_IMAGE_SIZE', 100)
def get_peeron_instruction_url(set_number: str, version_number: str):
"""Get the Peeron instruction page URL using the configured pattern"""
pattern = current_app.config.get('PEERON_INSTRUCTION_PATTERN', 'http://peeron.com/scans/{set_number}-{version_number}')
return pattern.format(set_number=set_number, version_number=version_number)
def get_peeron_thumbnail_url(set_number: str, version_number: str):
"""Get the Peeron thumbnail base URL using the configured pattern"""
pattern = current_app.config.get('PEERON_THUMBNAIL_PATTERN', 'http://belay.peeron.com/thumbs/{set_number}-{version_number}/')
return pattern.format(set_number=set_number, version_number=version_number)
def get_peeron_scan_url(set_number: str, version_number: str):
"""Get the Peeron scan base URL using the configured pattern"""
pattern = current_app.config.get('PEERON_SCAN_PATTERN', 'http://belay.peeron.com/scans/{set_number}-{version_number}/')
return pattern.format(set_number=set_number, version_number=version_number)
def create_peeron_scraper():
"""Create a cloudscraper instance configured for Peeron"""
scraper = cloudscraper.create_scraper()
scraper.headers.update({
"User-Agent": get_peeron_user_agent()
})
return scraper
def get_peeron_cache_dir():
"""Get the base directory for Peeron caching"""
static_dir = Path(current_app.static_folder)
cache_dir = static_dir / 'images' / 'peeron_cache'
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir
def get_set_cache_dir(set_number: str, version_number: str) -> tuple[Path, Path]:
"""Get cache directories for a specific set"""
base_cache_dir = get_peeron_cache_dir()
set_cache_key = f"{set_number}-{version_number}"
full_cache_dir = base_cache_dir / 'full' / set_cache_key
thumb_cache_dir = base_cache_dir / 'thumbs' / set_cache_key
full_cache_dir.mkdir(parents=True, exist_ok=True)
thumb_cache_dir.mkdir(parents=True, exist_ok=True)
return full_cache_dir, thumb_cache_dir
def cache_full_image_and_generate_thumbnail(image_url: str, page_number: str, set_number: str, version_number: str, session=None) -> tuple[str | None, str | None]:
"""
Download and cache full-size image, then generate a thumbnail preview.
Uses the full-size scan URLs from Peeron.
Returns (cached_image_path, thumbnail_url) or (None, None) if caching fails.
"""
try:
full_cache_dir, thumb_cache_dir = get_set_cache_dir(set_number, version_number)
full_filename = f"{page_number}.jpg"
thumb_filename = f"{page_number}.jpg"
full_cache_path = full_cache_dir / full_filename
thumb_cache_path = thumb_cache_dir / thumb_filename
# Return existing cached files if they exist
if full_cache_path.exists() and thumb_cache_path.exists():
set_cache_key = f"{set_number}-{version_number}"
thumbnail_url = url_for('static', filename=f'images/peeron_cache/thumbs/{set_cache_key}/{thumb_filename}')
return str(full_cache_path), thumbnail_url
# Download the full-size image using provided session or create new one
if session is None:
session = create_peeron_scraper()
response = session.get(image_url, timeout=30)
if response.status_code == 200 and len(response.content) > 0:
# Validate it's actually an image by checking minimum size
min_size = get_min_image_size()
if len(response.content) < min_size:
logger.warning(f"Image too small, skipping cache: {image_url}")
return None, None
# Write full-size image to cache
with open(full_cache_path, 'wb') as f:
f.write(response.content)
logger.debug(f"Cached full image: {image_url} -> {full_cache_path}")
# Generate thumbnail from the cached full image
try:
from PIL import Image
with Image.open(full_cache_path) as img:
# Create thumbnail (max 150px on longest side to match template)
img.thumbnail((150, 150), Image.Resampling.LANCZOS)
img.save(thumb_cache_path, 'JPEG', quality=85)
logger.debug(f"Generated thumbnail: {full_cache_path} -> {thumb_cache_path}")
set_cache_key = f"{set_number}-{version_number}"
thumbnail_url = url_for('static', filename=f'images/peeron_cache/thumbs/{set_cache_key}/{thumb_filename}')
return str(full_cache_path), thumbnail_url
except Exception as thumb_error:
logger.error(f"Failed to generate thumbnail for {page_number}: {thumb_error}")
# Clean up the full image if thumbnail generation failed
if full_cache_path.exists():
full_cache_path.unlink()
return None, None
else:
logger.warning(f"Failed to download full image: {image_url}")
return None, None
except Exception as e:
logger.error(f"Error caching full image {image_url}: {e}")
return None, None
def clear_set_cache(set_number: str, version_number: str) -> int:
"""
Clear all cached files for a specific set after PDF generation.
Returns the number of files deleted.
"""
try:
full_cache_dir, thumb_cache_dir = get_set_cache_dir(set_number, version_number)
deleted_count = 0
# Delete full images
if full_cache_dir.exists():
for cache_file in full_cache_dir.glob('*.jpg'):
try:
cache_file.unlink()
deleted_count += 1
logger.debug(f"Deleted cached full image: {cache_file}")
except OSError as e:
logger.warning(f"Failed to delete cache file {cache_file}: {e}")
# Remove directory if empty
try:
full_cache_dir.rmdir()
except OSError:
pass # Directory not empty or other error
# Delete thumbnails
if thumb_cache_dir.exists():
for cache_file in thumb_cache_dir.glob('*.jpg'):
try:
cache_file.unlink()
deleted_count += 1
logger.debug(f"Deleted cached thumbnail: {cache_file}")
except OSError as e:
logger.warning(f"Failed to delete cache file {cache_file}: {e}")
# Remove directory if empty
try:
thumb_cache_dir.rmdir()
except OSError:
pass # Directory not empty or other error
# Try to remove set directory if empty
try:
set_cache_key = f"{set_number}-{version_number}"
full_cache_dir.parent.rmdir() if full_cache_dir.parent.name == set_cache_key else None
thumb_cache_dir.parent.rmdir() if thumb_cache_dir.parent.name == set_cache_key else None
except OSError:
pass # Directory not empty or other error
logger.info(f"Set cache cleanup completed for {set_number}-{version_number}: {deleted_count} files deleted")
return deleted_count
except Exception as e:
logger.error(f"Error during set cache cleanup for {set_number}-{version_number}: {e}")
return 0
def clear_old_cache(max_age_days: int = 7) -> int:
"""
Clear old cache files across all sets.
Returns the number of files deleted.
"""
try:
base_cache_dir = get_peeron_cache_dir()
if not base_cache_dir.exists():
return 0
deleted_count = 0
max_age_seconds = max_age_days * 24 * 60 * 60
current_time = time.time()
# Clean both full and thumbs directories
for cache_type in ['full', 'thumbs']:
cache_type_dir = base_cache_dir / cache_type
if cache_type_dir.exists():
for set_dir in cache_type_dir.iterdir():
if set_dir.is_dir():
for cache_file in set_dir.glob('*.jpg'):
file_age = current_time - os.path.getmtime(cache_file)
if file_age > max_age_seconds:
try:
cache_file.unlink()
deleted_count += 1
logger.debug(f"Deleted old cache file: {cache_file}")
except OSError as e:
logger.warning(f"Failed to delete cache file {cache_file}: {e}")
# Remove empty directories
try:
if not any(set_dir.iterdir()):
set_dir.rmdir()
except OSError:
pass
logger.info(f"Old cache cleanup completed: {deleted_count} files deleted")
return deleted_count
except Exception as e:
logger.error(f"Error during old cache cleanup: {e}")
return 0
class PeeronPage(NamedTuple):
"""Represents a single instruction page from Peeron"""
page_number: str
original_image_url: str # Original Peeron full-size image URL
cached_full_image_path: str # Local full-size cached image path
cached_thumbnail_url: str # Local thumbnail URL for preview
alt_text: str
rotation: int = 0 # Rotation in degrees (0, 90, 180, 270)
# Peeron instruction scraper
class PeeronInstructions(object):
socket: 'BrickSocket | None'
set_number: str
version_number: str
pages: list[PeeronPage]
def __init__(
self,
set_number: str,
version_number: str = '1',
/,
*,
socket: 'BrickSocket | None' = None,
):
# Save the socket
self.socket = socket
# Parse set number (handle both "4011" and "4011-1" formats)
if '-' in set_number:
parts = set_number.split('-', 1)
self.set_number = parts[0]
self.version_number = parts[1] if len(parts) > 1 else '1'
else:
self.set_number = set_number
self.version_number = version_number
# Placeholder for pages
self.pages = []
# Check if instructions exist on Peeron (lightweight)
def exists(self, /) -> bool:
"""Check if the set exists on Peeron without caching thumbnails"""
try:
base_url = get_peeron_instruction_url(self.set_number, self.version_number)
scraper = create_peeron_scraper()
response = scraper.get(base_url)
if response.status_code != 200:
return False
soup = BeautifulSoup(response.text, 'html.parser')
# Check for "Browse instruction library" header (set not found)
if soup.find('h1', string="Browse instruction library"):
return False
# Look for thumbnail images to confirm instructions exist
thumbnails = soup.select('table[cellspacing="5"] a img[src^="http://belay.peeron.com/thumbs/"]')
return len(thumbnails) > 0
except Exception:
return False
# Find all available instruction pages on Peeron
def find_pages(self, /) -> list[PeeronPage]:
"""
Scrape Peeron's HTML and return a list of available instruction pages.
Similar to BrickInstructions.find_instructions() but for Peeron.
"""
base_url = get_peeron_instruction_url(self.set_number, self.version_number)
thumb_base_url = get_peeron_thumbnail_url(self.set_number, self.version_number)
scan_base_url = get_peeron_scan_url(self.set_number, self.version_number)
logger.debug(f"[find_pages] fetching HTML from {base_url!r}")
# Set up session with persistent cookies for Peeron (like working dl_peeron.py)
scraper = create_peeron_scraper()
# Download the main HTML page to establish session and cookies
try:
logger.debug(f"[find_pages] Establishing session by visiting: {base_url}")
response = scraper.get(base_url)
logger.debug(f"[find_pages] Main page visit: HTTP {response.status_code}")
if response.status_code != 200:
raise ErrorException(f'Failed to load Peeron page for {self.set_number}-{self.version_number}. HTTP {response.status_code}')
except requests.exceptions.RequestException as e:
raise ErrorException(f'Failed to connect to Peeron: {e}')
# Parse HTML to locate instruction pages
soup = BeautifulSoup(response.text, 'html.parser')
# Check for "Browse instruction library" header (set not found)
if soup.find('h1', string="Browse instruction library"):
raise ErrorException(f'Set {self.set_number}-{self.version_number} not found on Peeron')
# Locate all thumbnail images in the expected table structure
# Use the configured thumbnail pattern to build the expected URL prefix
thumb_base_url = get_peeron_thumbnail_url(self.set_number, self.version_number)
thumbnails = soup.select(f'table[cellspacing="5"] a img[src^="{thumb_base_url}"]')
if not thumbnails:
raise ErrorException(f'No instruction pages found for {self.set_number}-{self.version_number} on Peeron')
pages: list[PeeronPage] = []
total_thumbnails = len(thumbnails)
# Initialize progress if socket is available
if self.socket:
self.socket.progress_total = total_thumbnails
self.socket.progress_count = 0
self.socket.progress(message=f"Starting to cache {total_thumbnails} full images")
for idx, img in enumerate(thumbnails, 1):
thumb_url = img['src']
# Extract the page number from the thumbnail URL
page_number = thumb_url.split('/')[-2]
# Build the full-size scan URL using the page number
full_size_url = f"{scan_base_url}{page_number}/"
logger.debug(f"[find_pages] Page {page_number}: thumb={thumb_url}, full_size={full_size_url}")
# Create alt text for the page
alt_text = f"LEGO Instructions {self.set_number}-{self.version_number} Page {page_number}"
# Report progress if socket is available
if self.socket:
self.socket.progress_count = idx
self.socket.progress(message=f"Caching full image {idx} of {total_thumbnails}")
# Cache the full-size image and generate thumbnail preview using established session
cached_full_path, cached_thumb_url = cache_full_image_and_generate_thumbnail(
full_size_url, page_number, self.set_number, self.version_number, session=scraper
)
# Skip this page if caching failed
if not cached_full_path or not cached_thumb_url:
logger.warning(f"[find_pages] Skipping page {page_number} due to caching failure")
continue
page = PeeronPage(
page_number=page_number,
original_image_url=full_size_url,
cached_full_image_path=cached_full_path,
cached_thumbnail_url=cached_thumb_url,
alt_text=alt_text
)
pages.append(page)
# Cache the pages for later use
self.pages = pages
logger.debug(f"[find_pages] found {len(pages)} pages for {self.set_number}-{self.version_number}")
return pages
# Find instructions with fallback to Peeron
@staticmethod
def find_instructions_with_peeron_fallback(set: str, /) -> tuple[list[tuple[str, str]], list[PeeronPage] | None]:
"""
Enhanced version of BrickInstructions.find_instructions() that falls back to Peeron.
Returns (rebrickable_instructions, peeron_pages).
If rebrickable_instructions is empty, peeron_pages will contain Peeron data.
"""
from .instructions import BrickInstructions
# First try Rebrickable
try:
rebrickable_instructions = BrickInstructions.find_instructions(set)
return rebrickable_instructions, None
except ErrorException as e:
logger.info(f"Rebrickable failed for {set}: {e}. Trying Peeron fallback...")
# Fallback to Peeron
try:
peeron = PeeronInstructions(set)
peeron_pages = peeron.find_pages()
return [], peeron_pages
except ErrorException as peeron_error:
# Both failed, re-raise original Rebrickable error
logger.info(f"Peeron also failed for {set}: {peeron_error}")
raise e from peeron_error
+200
View File
@@ -0,0 +1,200 @@
import logging
import os
import tempfile
import time
from typing import Any, TYPE_CHECKING
import cloudscraper
from flask import current_app
from PIL import Image
from .exceptions import DownloadException, ErrorException
from .instructions import BrickInstructions
from .peeron_instructions import PeeronPage, get_min_image_size, get_peeron_download_delay, get_peeron_instruction_url, create_peeron_scraper
if TYPE_CHECKING:
from .socket import BrickSocket
logger = logging.getLogger(__name__)
# PDF generator for Peeron instruction pages
class PeeronPDF(object):
socket: 'BrickSocket'
set_number: str
version_number: str
pages: list[PeeronPage]
filename: str
def __init__(
self,
set_number: str,
version_number: str,
pages: list[PeeronPage],
/,
*,
socket: 'BrickSocket',
):
# Save the socket
self.socket = socket
# Save set information
self.set_number = set_number
self.version_number = version_number
self.pages = pages
# Generate filename following BrickTracker conventions
self.filename = f"{set_number}-{version_number}_peeron.pdf"
# Download pages and create PDF
def create_pdf(self, /) -> None:
"""
Downloads selected Peeron pages and merges them into a PDF.
Uses progress updates via socket similar to BrickInstructions.download()
"""
try:
target_path = self._get_target_path()
# Skip if we already have it
if os.path.isfile(target_path):
# Create BrickInstructions instance to get PDF URL
instructions = BrickInstructions(self.filename)
pdf_url = instructions.url()
return self.socket.complete(
message=f'File {self.filename} already exists, skipped - <a href="{pdf_url}" target="_blank" class="btn btn-sm btn-primary ms-2"><i class="ri-external-link-line"></i> Open PDF</a>'
)
# Set up progress tracking
total_pages = len(self.pages)
self.socket.update_total(total_pages)
self.socket.progress_count = 0
self.socket.progress(message=f"Starting PDF creation from {total_pages} cached pages")
# Use cached images directly - no downloads needed!
cached_files_with_rotation = []
missing_pages = []
for i, page in enumerate(self.pages):
# Check if cached file exists
if os.path.isfile(page.cached_full_image_path):
cached_files_with_rotation.append((page.cached_full_image_path, page.rotation))
# Update progress
self.socket.progress_count += 1
self.socket.progress(
message=f"Processing cached page {page.page_number} ({i + 1}/{total_pages})"
)
else:
missing_pages.append(page.page_number)
logger.warning(f"Cached image missing for page {page.page_number}: {page.cached_full_image_path}")
if not cached_files_with_rotation:
raise DownloadException(f"No cached images available for set {self.set_number}-{self.version_number}. Cache may have been cleared.")
elif len(cached_files_with_rotation) < total_pages:
# Partial success
error_msg = f"Only found {len(cached_files_with_rotation)}/{total_pages} cached images."
if missing_pages:
error_msg += f" Missing pages: {', '.join(missing_pages)}."
logger.warning(error_msg)
# Create PDF from cached images with rotation
self._create_pdf_from_images(cached_files_with_rotation, target_path)
# Success
logger.info(f"Created PDF {self.filename} with {len(cached_files_with_rotation)} pages")
# Create BrickInstructions instance to get PDF URL
instructions = BrickInstructions(self.filename)
pdf_url = instructions.url()
self.socket.complete(
message=f'PDF {self.filename} created with {len(cached_files_with_rotation)} pages - <a href="{pdf_url}" target="_blank" class="btn btn-sm btn-primary ms-2"><i class="ri-external-link-line"></i> Open PDF</a>'
)
# Clean up set cache after successful PDF creation
try:
from .peeron_instructions import clear_set_cache
deleted_count = clear_set_cache(self.set_number, self.version_number)
if deleted_count > 0:
logger.info(f"[create_pdf] Cleaned up {deleted_count} cache files for set {self.set_number}-{self.version_number}")
except Exception as e:
logger.warning(f"[create_pdf] Failed to clean set cache: {e}")
except Exception as e:
logger.error(f"Error creating PDF {self.filename}: {e}")
self.socket.fail(
message=f"Error creating PDF {self.filename}: {e}"
)
# Create PDF from downloaded images
def _create_pdf_from_images(self, image_paths_and_rotations: list[tuple[str, int]], output_path: str, /) -> None:
"""Create a PDF from a list of image files with their rotations"""
try:
# Import FPDF (should be available from requirements)
from fpdf import FPDF
except ImportError:
raise ErrorException("FPDF library not available. Install with: pip install fpdf2")
pdf = FPDF()
for i, (img_path, rotation) in enumerate(image_paths_and_rotations):
try:
# Open image and apply rotation if needed
with Image.open(img_path) as image:
# Apply rotation if specified
if rotation != 0:
# PIL rotation is counter-clockwise, so we negate for clockwise rotation
image = image.rotate(-rotation, expand=True)
width, height = image.size
# Add page with image dimensions (convert pixels to mm)
# 1 pixel = 0.264583 mm (assuming 96 DPI)
page_width = width * 0.264583
page_height = height * 0.264583
pdf.add_page(format=(page_width, page_height))
# Save rotated image to temporary file for FPDF
temp_rotated_path = None
if rotation != 0:
import tempfile
temp_fd, temp_rotated_path = tempfile.mkstemp(suffix='.jpg', prefix=f'peeron_rotated_{i}_')
try:
os.close(temp_fd) # Close file descriptor, we'll use the path
image.save(temp_rotated_path, 'JPEG', quality=95)
pdf.image(temp_rotated_path, x=0, y=0, w=page_width, h=page_height)
finally:
# Clean up rotated temp file
if temp_rotated_path and os.path.exists(temp_rotated_path):
os.remove(temp_rotated_path)
else:
pdf.image(img_path, x=0, y=0, w=page_width, h=page_height)
# Update progress
progress_msg = f"Processing page {i + 1}/{len(image_paths_and_rotations)} into PDF"
if rotation != 0:
progress_msg += f" (rotated {rotation}°)"
self.socket.progress(message=progress_msg)
except Exception as e:
logger.warning(f"Failed to add image {img_path} to PDF: {e}")
continue
# Save the PDF
pdf.output(output_path)
# Get target file path
def _get_target_path(self, /) -> str:
"""Get the full path where the PDF should be saved"""
instructions_folder = os.path.join(
current_app.static_folder, # type: ignore
current_app.config['INSTRUCTIONS_FOLDER']
)
return os.path.join(instructions_folder, self.filename)
# Create BrickInstructions instance for the generated PDF
def get_instructions(self, /) -> BrickInstructions:
"""Return a BrickInstructions instance for the generated PDF"""
return BrickInstructions(self.filename)
+37 -1
View File
@@ -91,8 +91,17 @@ class RebrickablePart(BrickRecord):
def url_for_bricklink(self, /) -> str: def url_for_bricklink(self, /) -> str:
if current_app.config['BRICKLINK_LINKS']: if current_app.config['BRICKLINK_LINKS']:
try: try:
# Use BrickLink part number if available and not None/empty, otherwise fall back to Rebrickable part
bricklink_part = getattr(self.fields, 'bricklink_part_num', None)
part_param = bricklink_part if bricklink_part else self.fields.part
# Use BrickLink color ID if available and not None, otherwise fall back to Rebrickable color
bricklink_color = getattr(self.fields, 'bricklink_color_id', None)
color_param = bricklink_color if bricklink_color is not None else self.fields.color
# print(f'BrickLink URL parameters: part={part_param}, color={color_param}') # Debugging line, can be removed later
return current_app.config['BRICKLINK_LINK_PART_PATTERN'].format( # noqa: E501 return current_app.config['BRICKLINK_LINK_PART_PATTERN'].format( # noqa: E501
part=self.fields.part, part=part_param,
color=color_param,
) )
except Exception: except Exception:
pass pass
@@ -168,6 +177,9 @@ class RebrickablePart(BrickRecord):
'color_name': data['color']['name'], 'color_name': data['color']['name'],
'color_rgb': data['color']['rgb'], 'color_rgb': data['color']['rgb'],
'color_transparent': data['color']['is_trans'], 'color_transparent': data['color']['is_trans'],
'bricklink_color_id': None,
'bricklink_color_name': None,
'bricklink_part_num': None,
'name': data['part']['name'], 'name': data['part']['name'],
'category': data['part']['part_cat_id'], 'category': data['part']['part_cat_id'],
'image': data['part']['part_img_url'], 'image': data['part']['part_img_url'],
@@ -176,6 +188,30 @@ class RebrickablePart(BrickRecord):
'print': data['part']['print_of'] 'print': data['part']['print_of']
} }
# Extract BrickLink color info if available in external_ids
if 'color' in data and 'external_ids' in data['color']:
external_ids = data['color']['external_ids']
if 'BrickLink' in external_ids and external_ids['BrickLink']:
bricklink_data = external_ids['BrickLink']
# Extract BrickLink color ID and name from the nested structure
if isinstance(bricklink_data, dict):
if 'ext_ids' in bricklink_data and bricklink_data['ext_ids']:
record['bricklink_color_id'] = bricklink_data['ext_ids'][0]
if 'ext_descrs' in bricklink_data and bricklink_data['ext_descrs']:
# ext_descrs is a list of lists, get the first description from the first list
if len(bricklink_data['ext_descrs']) > 0 and len(bricklink_data['ext_descrs'][0]) > 0:
record['bricklink_color_name'] = bricklink_data['ext_descrs'][0][0]
# Extract BrickLink part number if available
if 'part' in data and 'external_ids' in data['part']:
part_external_ids = data['part']['external_ids']
if 'BrickLink' in part_external_ids and part_external_ids['BrickLink']:
bricklink_parts = part_external_ids['BrickLink']
if isinstance(bricklink_parts, list) and len(bricklink_parts) > 0:
record['bricklink_part_num'] = bricklink_parts[0]
if brickset is not None: if brickset is not None:
record['id'] = brickset.fields.id record['id'] = brickset.fields.id
+7 -12
View File
@@ -11,24 +11,19 @@ class RebrickableSetList(BrickRecordList[RebrickableSet]):
select_query: str = 'rebrickable/set/list' select_query: str = 'rebrickable/set/list'
refresh_query: str = 'rebrickable/set/need_refresh' refresh_query: str = 'rebrickable/set/need_refresh'
# All the sets # Implementation of abstract list method
def all(self, /) -> Self: def list(self, /, *, override_query: str | None = None, **context) -> None:
# Load the sets from the database # Load the sets from the database
for record in self.select(): for record in self.select(override_query=override_query, **context):
rebrickable_set = RebrickableSet(record=record) rebrickable_set = RebrickableSet(record=record)
self.records.append(rebrickable_set) self.records.append(rebrickable_set)
# All the sets
def all(self, /) -> Self:
self.list()
return self return self
# Sets needing refresh # Sets needing refresh
def need_refresh(self, /) -> Self: def need_refresh(self, /) -> Self:
# Load the sets from the database self.list(override_query=self.refresh_query)
for record in self.select(
override_query=self.refresh_query
):
rebrickable_set = RebrickableSet(record=record)
self.records.append(rebrickable_set)
return self return self
+86 -1
View File
@@ -1,5 +1,6 @@
import re
from sqlite3 import Row from sqlite3 import Row
from typing import Any, Generator, Generic, ItemsView, TypeVar, TYPE_CHECKING from typing import Any, Generator, Generic, ItemsView, Self, TypeVar, TYPE_CHECKING
from .fields import BrickRecordFields from .fields import BrickRecordFields
from .sql import BrickSQL from .sql import BrickSQL
@@ -72,6 +73,90 @@ class BrickRecordList(Generic[T]):
**context **context
) )
# Generic pagination method for all record lists
def paginate(
self,
page: int = 1,
per_page: int = 50,
sort_field: str | None = None,
sort_order: str = 'asc',
count_query: str | None = None,
list_query: str | None = None,
field_mapping: dict[str, str] | None = None,
**filter_context: Any
) -> tuple['Self', int]:
"""Generic pagination implementation for all record lists"""
from .sql import BrickSQL
# Use provided queries or fall back to defaults
list_query = list_query or getattr(self, 'all_query', None)
if not list_query:
raise NotImplementedError("Subclass must define all_query")
# Calculate offset
offset = (page - 1) * per_page
# Get total count by wrapping the main query
if count_query:
# Use provided count query
count_result = BrickSQL().fetchone(count_query, **filter_context)
total_count = count_result['total_count'] if count_result else 0
else:
# Generate count by wrapping the main query (without ORDER BY, LIMIT, OFFSET)
count_context = {k: v for k, v in filter_context.items()
if k not in ['order', 'limit', 'offset']}
# Get the main query SQL without pagination clauses
main_sql = BrickSQL().load_query(list_query, **count_context)
# Remove ORDER BY, LIMIT, OFFSET clauses for counting
# Remove ORDER BY clause and everything after it that's not part of subqueries
count_sql = re.sub(r'\s+ORDER\s+BY\s+[^)]*?(\s+LIMIT|\s+OFFSET|$)', r'\1', main_sql, flags=re.IGNORECASE)
# Remove LIMIT and OFFSET
count_sql = re.sub(r'\s+LIMIT\s+\d+', '', count_sql, flags=re.IGNORECASE)
count_sql = re.sub(r'\s+OFFSET\s+\d+', '', count_sql, flags=re.IGNORECASE)
# Wrap in COUNT(*)
wrapped_sql = f"SELECT COUNT(*) as total_count FROM ({count_sql.strip()})"
count_result = BrickSQL().raw_execute(wrapped_sql, {}).fetchone()
total_count = count_result['total_count'] if count_result else 0
# Prepare sort order
order_clause = None
if sort_field and field_mapping and sort_field in field_mapping:
sql_field = field_mapping[sort_field]
direction = 'DESC' if sort_order.lower() == 'desc' else 'ASC'
order_clause = f'{sql_field} {direction}'
# Build pagination context
pagination_context = {
'limit': per_page,
'offset': offset,
'order': order_clause or getattr(self, 'order', None),
**filter_context
}
# Load paginated results using the existing list() method
# Check if this is a set list that needs do_theme parameter
if hasattr(self, 'themes'): # Only BrickSetList has this attribute
self.list(override_query=list_query, do_theme=True, **pagination_context)
else:
self.list(override_query=list_query, **pagination_context)
return self, total_count
# Base method that subclasses can override
def list(
self,
/,
*,
override_query: str | None = None,
**context: Any,
) -> None:
"""Load records from database - should be implemented by subclasses that use pagination"""
raise NotImplementedError("Subclass must implement list() method")
# Generic SQL parameters from fields # Generic SQL parameters from fields
def sql_parameters(self, /) -> dict[str, Any]: def sql_parameters(self, /) -> dict[str, Any]:
parameters: dict[str, Any] = {} parameters: dict[str, Any] = {}
+265 -3
View File
@@ -13,6 +13,8 @@ from .set_storage_list import BrickSetStorageList
from .set_tag import BrickSetTag from .set_tag import BrickSetTag
from .set_tag_list import BrickSetTagList from .set_tag_list import BrickSetTagList
from .set import BrickSet from .set import BrickSet
from .theme_list import BrickThemeList
from .instructions_list import BrickInstructionsList
# All the sets from the database # All the sets from the database
@@ -21,6 +23,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
order: str order: str
# Queries # Queries
all_query: str = 'set/list/all'
damaged_minifigure_query: str = 'set/list/damaged_minifigure' damaged_minifigure_query: str = 'set/list/damaged_minifigure'
damaged_part_query: str = 'set/list/damaged_part' damaged_part_query: str = 'set/list/damaged_part'
generic_query: str = 'set/list/generic' generic_query: str = 'set/list/generic'
@@ -48,6 +51,267 @@ class BrickSetList(BrickRecordList[BrickSet]):
return self return self
# All sets with pagination and filtering
def all_filtered_paginated(
self,
search_query: str | None = None,
page: int = 1,
per_page: int = 50,
sort_field: str | None = None,
sort_order: str = 'asc',
status_filter: str | None = None,
theme_filter: str | None = None,
owner_filter: str | None = None,
purchase_location_filter: str | None = None,
storage_filter: str | None = None,
tag_filter: str | None = None
) -> tuple[Self, int]:
# Convert theme name to theme ID for filtering
theme_id_filter = None
if theme_filter:
theme_id_filter = self._theme_name_to_id(theme_filter)
# Check if any filters are applied
has_filters = any([status_filter, theme_id_filter, owner_filter, purchase_location_filter, storage_filter, tag_filter])
# Prepare filter context
filter_context = {
'search_query': search_query,
'status_filter': status_filter,
'theme_filter': theme_id_filter, # Use converted theme ID
'owner_filter': owner_filter,
'purchase_location_filter': purchase_location_filter,
'storage_filter': storage_filter,
'tag_filter': tag_filter,
'owners': BrickSetOwnerList.as_columns(),
'statuses': BrickSetStatusList.as_columns(),
'tags': BrickSetTagList.as_columns(),
}
# 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"'
}
# Choose query based on whether filters are applied
query_to_use = 'set/list/all_filtered' if has_filters else self.all_query
# Handle instructions filtering
if status_filter in ['has-missing-instructions', '-has-missing-instructions']:
# For instructions filter, we need to load all sets first, then filter and paginate
return self._all_filtered_paginated_with_instructions(
search_query, page, per_page, sort_field, sort_order,
status_filter, theme_id_filter, owner_filter,
purchase_location_filter, storage_filter, tag_filter
)
# Normal SQL-based filtering and pagination
result, total_count = self.paginate(
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order,
list_query=query_to_use,
field_mapping=field_mapping,
**filter_context
)
# Populate themes for filter dropdown from ALL sets, not just current page
result._populate_themes_global()
return result, total_count
def _populate_themes(self) -> None:
"""Populate themes list from the current records"""
themes = set()
for record in self.records:
if hasattr(record, 'theme') and hasattr(record.theme, 'name'):
themes.add(record.theme.name)
self.themes = list(themes)
self.themes.sort()
def _theme_name_to_id(self, theme_name: str) -> str | None:
"""Convert a theme name to theme ID for filtering"""
try:
theme_list = BrickThemeList()
for theme_id, theme in theme_list.themes.items():
if theme.name.lower() == theme_name.lower():
return str(theme_id)
return None
except Exception:
# If themes can't be loaded, return None to disable theme filtering
return None
def _all_filtered_paginated_with_instructions(
self,
search_query: str | None,
page: int,
per_page: int,
sort_field: str | None,
sort_order: str,
status_filter: str,
theme_id_filter: str | None,
owner_filter: str | None,
purchase_location_filter: str | None,
storage_filter: str | None,
tag_filter: str | None
) -> tuple[Self, int]:
"""Handle filtering when instructions filter is involved"""
try:
# Load all sets first (without pagination) with full metadata
all_sets = BrickSetList()
filter_context = {
'owners': BrickSetOwnerList.as_columns(),
'statuses': BrickSetStatusList.as_columns(),
'tags': BrickSetTagList.as_columns(),
}
all_sets.list(do_theme=True, **filter_context)
# Load instructions list
instructions_list = BrickInstructionsList()
instruction_sets = set(instructions_list.sets.keys())
# Apply all filters manually
filtered_records = []
for record in all_sets.records:
# Apply instructions filter
set_id = record.fields.set
has_instructions = set_id in instruction_sets
if status_filter == 'has-missing-instructions' and has_instructions:
continue # Skip sets that have instructions
elif status_filter == '-has-missing-instructions' and not has_instructions:
continue # Skip sets that don't have instructions
# Apply other filters manually
if search_query and not self._matches_search(record, search_query):
continue
if theme_id_filter and not self._matches_theme(record, theme_id_filter):
continue
if owner_filter and not self._matches_owner(record, owner_filter):
continue
if purchase_location_filter and not self._matches_purchase_location(record, purchase_location_filter):
continue
if storage_filter and not self._matches_storage(record, storage_filter):
continue
if tag_filter and not self._matches_tag(record, tag_filter):
continue
filtered_records.append(record)
# Apply sorting
if sort_field:
filtered_records = self._sort_records(filtered_records, sort_field, sort_order)
# Calculate pagination
total_count = len(filtered_records)
start_index = (page - 1) * per_page
end_index = start_index + per_page
paginated_records = filtered_records[start_index:end_index]
# Create result
result = BrickSetList()
result.records = paginated_records
# Copy themes from the source that has all sets
result.themes = all_sets.themes if hasattr(all_sets, 'themes') else []
# If themes weren't populated, populate them globally
if not result.themes:
result._populate_themes_global()
return result, total_count
except Exception:
# Fall back to normal pagination without instructions filter
return self.all_filtered_paginated(
search_query, page, per_page, sort_field, sort_order,
None, theme_id_filter, owner_filter,
purchase_location_filter, storage_filter, tag_filter
)
def _populate_themes_global(self) -> None:
"""Populate themes list from ALL sets, not just current page"""
try:
# Load all sets to get all possible themes
all_sets = BrickSetList().all()
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:
# Fall back to current page themes
self._populate_themes()
def _matches_search(self, record, search_query: str) -> bool:
"""Check if record matches search query"""
search_lower = search_query.lower()
return (search_lower in record.fields.name.lower() or
search_lower in record.fields.set.lower())
def _matches_theme(self, record, theme_id: str) -> bool:
"""Check if record matches theme filter"""
return str(record.fields.theme_id) == theme_id
def _matches_owner(self, record, owner_filter: str) -> bool:
"""Check if record matches owner filter"""
if not owner_filter.startswith('owner-'):
return True
# Convert owner-uuid format to owner_uuid column name
owner_column = owner_filter.replace('-', '_')
# Check if record has this owner attribute set to 1
return hasattr(record.fields, owner_column) and getattr(record.fields, owner_column) == 1
def _matches_purchase_location(self, record, location_filter: str) -> bool:
"""Check if record matches purchase location filter"""
return record.fields.purchase_location == location_filter
def _matches_storage(self, record, storage_filter: str) -> bool:
"""Check if record matches storage filter"""
return record.fields.storage == storage_filter
def _matches_tag(self, record, tag_filter: str) -> bool:
"""Check if record matches tag filter"""
if not tag_filter.startswith('tag-'):
return True
# Convert tag-uuid format to tag_uuid column name
tag_column = tag_filter.replace('-', '_')
# Check if record has this tag attribute set to 1
return hasattr(record.fields, tag_column) and getattr(record.fields, tag_column) == 1
def _sort_records(self, records, sort_field: str, sort_order: str):
"""Sort records manually"""
reverse = sort_order == 'desc'
if sort_field == 'set':
return sorted(records, key=lambda r: r.fields.set, reverse=reverse)
elif sort_field == 'name':
return sorted(records, key=lambda r: r.fields.name, reverse=reverse)
elif sort_field == 'year':
return sorted(records, key=lambda r: r.fields.year, reverse=reverse)
elif sort_field == 'parts':
return sorted(records, key=lambda r: r.fields.number_of_parts, reverse=reverse)
# Add more sort fields as needed
return records
# Sets with a minifigure part damaged # Sets with a minifigure part damaged
def damaged_minifigure(self, figure: str, /) -> Self: def damaged_minifigure(self, figure: str, /) -> Self:
# Save the parameters to the fields # Save the parameters to the fields
@@ -102,9 +366,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
override_query=override_query, override_query=override_query,
order=order, order=order,
limit=limit, limit=limit,
owners=BrickSetOwnerList.as_columns(), **context
statuses=BrickSetStatusList.as_columns(),
tags=BrickSetTagList.as_columns(),
): ):
brickset = BrickSet(record=record) brickset = BrickSet(record=record)
+83 -1
View File
@@ -6,6 +6,8 @@ from flask_socketio import SocketIO
from .instructions import BrickInstructions from .instructions import BrickInstructions
from .instructions_list import BrickInstructionsList from .instructions_list import BrickInstructionsList
from .peeron_instructions import PeeronInstructions, PeeronPage
from .peeron_pdf import PeeronPDF
from .set import BrickSet from .set import BrickSet
from .socket_decorator import authenticated_socket, rebrickable_socket from .socket_decorator import authenticated_socket, rebrickable_socket
from .sql import close as sql_close from .sql import close as sql_close
@@ -18,8 +20,10 @@ MESSAGES: Final[dict[str, str]] = {
'CONNECT': 'connect', 'CONNECT': 'connect',
'DISCONNECT': 'disconnect', 'DISCONNECT': 'disconnect',
'DOWNLOAD_INSTRUCTIONS': 'download_instructions', 'DOWNLOAD_INSTRUCTIONS': 'download_instructions',
'DOWNLOAD_PEERON_PAGES': 'download_peeron_pages',
'FAIL': 'fail', 'FAIL': 'fail',
'IMPORT_SET': 'import_set', 'IMPORT_SET': 'import_set',
'LOAD_PEERON_PAGES': 'load_peeron_pages',
'LOAD_SET': 'load_set', 'LOAD_SET': 'load_set',
'PROGRESS': 'progress', 'PROGRESS': 'progress',
'SET_LOADED': 'set_loaded', 'SET_LOADED': 'set_loaded',
@@ -70,7 +74,7 @@ class BrickSocket(object):
*args, *args,
**kwargs, **kwargs,
path=app.config['SOCKET_PATH'], path=app.config['SOCKET_PATH'],
async_mode='eventlet', async_mode='gevent',
) )
# Store the socket in the app config # Store the socket in the app config
@@ -106,6 +110,84 @@ class BrickSocket(object):
BrickInstructionsList(force=True) BrickInstructionsList(force=True)
@self.socket.on(MESSAGES['LOAD_PEERON_PAGES'], namespace=self.namespace) # noqa: E501
def load_peeron_pages(data: dict[str, Any], /) -> None:
logger.debug('Socket: LOAD_PEERON_PAGES={data} (from: {fr})'.format(
data=data, fr=request.remote_addr))
try:
set_number = data.get('set', '')
if not set_number:
self.fail(message="Set number is required")
return
# Create Peeron instructions instance with socket for progress reporting
peeron = PeeronInstructions(set_number, socket=self)
# Find pages (this will report progress for thumbnail caching)
pages = peeron.find_pages()
# Complete the operation (JavaScript will handle redirect)
self.complete(message=f"Found {len(pages)} instruction pages on Peeron")
except Exception as e:
logger.error(f"Error in load_peeron_pages: {e}")
self.fail(message=f"Error loading Peeron pages: {e}")
@self.socket.on(MESSAGES['DOWNLOAD_PEERON_PAGES'], namespace=self.namespace) # noqa: E501
@authenticated_socket(self)
def download_peeron_pages(data: dict[str, Any], /) -> None:
logger.debug('Socket: DOWNLOAD_PEERON_PAGES={data} (from: {fr})'.format(
data=data,
fr=request.sid, # type: ignore
))
try:
# Extract data from the request
set_number = data.get('set', '')
pages_data = data.get('pages', [])
if not set_number:
raise ValueError("Set number is required")
if not pages_data:
raise ValueError("No pages selected")
# Parse set number
if '-' in set_number:
parts = set_number.split('-', 1)
set_num = parts[0]
version_num = parts[1] if len(parts) > 1 else '1'
else:
set_num = set_number
version_num = '1'
# Convert page data to PeeronPage objects
pages = []
for page_data in pages_data:
page = PeeronPage(
page_number=page_data.get('page_number', ''),
original_image_url=page_data.get('original_image_url', ''),
cached_full_image_path=page_data.get('cached_full_image_path', ''),
cached_thumbnail_url='', # Not needed for PDF generation
alt_text=page_data.get('alt_text', ''),
rotation=page_data.get('rotation', 0)
)
pages.append(page)
# Create PDF generator and start download
pdf_generator = PeeronPDF(set_num, version_num, pages, socket=self)
pdf_generator.create_pdf()
# Note: Cache cleanup is handled automatically by pdf_generator.create_pdf()
# Refresh instructions list to include new PDF
BrickInstructionsList(force=True)
except Exception as e:
logger.error(f"Error in download_peeron_pages: {e}")
self.fail(message=f"Error downloading Peeron pages: {e}")
@self.socket.on(MESSAGES['IMPORT_SET'], namespace=self.namespace) @self.socket.on(MESSAGES['IMPORT_SET'], namespace=self.namespace)
@rebrickable_socket(self) @rebrickable_socket(self)
def import_set(data: dict[str, Any], /) -> None: def import_set(data: dict[str, Any], /) -> None:
+9
View File
@@ -0,0 +1,9 @@
-- description: Add BrickLink color fields to rebrickable_parts table
BEGIN TRANSACTION;
-- Add BrickLink color fields to the rebrickable_parts table
ALTER TABLE "rebrickable_parts" ADD COLUMN "bricklink_color_id" INTEGER;
ALTER TABLE "rebrickable_parts" ADD COLUMN "bricklink_color_name" TEXT;
COMMIT;
+8
View File
@@ -0,0 +1,8 @@
-- description: Add BrickLink part number field to rebrickable_parts table
BEGIN TRANSACTION;
-- Add BrickLink part number field to the rebrickable_parts table
ALTER TABLE "rebrickable_parts" ADD COLUMN "bricklink_part_num" TEXT;
COMMIT;
@@ -35,3 +35,7 @@ ORDER BY {{ order }}
{% if limit %} {% if limit %}
LIMIT {{ limit }} LIMIT {{ limit }}
{% endif %} {% endif %}
{% if offset %}
OFFSET {{ offset }}
{% endif %}
+6
View File
@@ -34,6 +34,12 @@ ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "problem_join"."id"
AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "problem_join"."figure" AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "problem_join"."figure"
{% endblock %} {% endblock %}
{% block where %}
{% if search_query %}
WHERE (LOWER("rebrickable_minifigures"."name") LIKE LOWER('%{{ search_query }}%'))
{% endif %}
{% endblock %}
{% block group %} {% block group %}
GROUP BY GROUP BY
"rebrickable_minifigures"."figure" "rebrickable_minifigures"."figure"
@@ -0,0 +1,78 @@
{% extends 'minifigure/base/base.sql' %}
{% block 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 %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("bricktracker_minifigures"."quantity", 0) ELSE 0 END) AS "total_quantity",
{% else %}
SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_quantity",
{% endif %}
{% endblock %}
{% block total_sets %}
{% if owner_id and owner_id != 'all' %}
COUNT(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_minifigures"."id" ELSE NULL END) AS "total_sets"
{% else %}
COUNT("bricktracker_minifigures"."id") AS "total_sets"
{% endif %}
{% endblock %}
{% block join %}
-- Join with sets to get owner information
INNER JOIN "bricktracker_sets"
ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
-- Left join with set owners (using dynamic columns)
LEFT JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
-- LEFT JOIN + SELECT to avoid messing the total
LEFT JOIN (
SELECT
"bricktracker_parts"."id",
"bricktracker_parts"."figure",
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "owner_parts"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."missing" ELSE 0 END) AS "total_missing",
SUM(CASE WHEN "owner_parts"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."damaged" ELSE 0 END) AS "total_damaged"
{% else %}
SUM("bricktracker_parts"."missing") AS "total_missing",
SUM("bricktracker_parts"."damaged") AS "total_damaged"
{% endif %}
FROM "bricktracker_parts"
INNER JOIN "bricktracker_sets" AS "parts_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "parts_sets"."id"
LEFT JOIN "bricktracker_set_owners" AS "owner_parts"
ON "parts_sets"."id" IS NOT DISTINCT FROM "owner_parts"."id"
WHERE "bricktracker_parts"."figure" IS NOT NULL
GROUP BY
"bricktracker_parts"."id",
"bricktracker_parts"."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 where %}
{% set conditions = [] %}
{% if owner_id and owner_id != 'all' %}
{% set _ = conditions.append('"bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1') %}
{% endif %}
{% if search_query %}
{% set _ = conditions.append('(LOWER("rebrickable_minifigures"."name") LIKE LOWER(\'%' ~ search_query ~ '%\'))') %}
{% endif %}
{% if conditions %}
WHERE {{ conditions | join(' AND ') }}
{% endif %}
{% endblock %}
{% block group %}
GROUP BY
"rebrickable_minifigures"."figure"
{% endblock %}
+7
View File
@@ -14,6 +14,9 @@ SELECT
"rebrickable_parts"."color_name", "rebrickable_parts"."color_name",
"rebrickable_parts"."color_rgb", "rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent", "rebrickable_parts"."color_transparent",
"rebrickable_parts"."bricklink_color_id",
"rebrickable_parts"."bricklink_color_name",
"rebrickable_parts"."bricklink_part_num",
"rebrickable_parts"."name", "rebrickable_parts"."name",
--"rebrickable_parts"."category", --"rebrickable_parts"."category",
"rebrickable_parts"."image", "rebrickable_parts"."image",
@@ -57,3 +60,7 @@ ORDER BY {{ order }}
{% if limit %} {% if limit %}
LIMIT {{ limit }} LIMIT {{ limit }}
{% endif %} {% endif %}
{% if offset %}
OFFSET {{ offset }}
{% endif %}
+16
View File
@@ -0,0 +1,16 @@
SELECT DISTINCT
"rebrickable_parts"."color_id" AS "color_id",
"rebrickable_parts"."color_name" AS "color_name",
"rebrickable_parts"."color_rgb" AS "color_rgb"
FROM "rebrickable_parts"
INNER JOIN "bricktracker_parts"
ON "bricktracker_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part"
AND "bricktracker_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id"
{% if owner_id and owner_id != 'all' %}
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
INNER JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
WHERE "bricktracker_set_owners"."owner_{{ owner_id }}" = 1
{% endif %}
ORDER BY "rebrickable_parts"."color_name" ASC
@@ -0,0 +1,19 @@
SELECT DISTINCT
"rebrickable_parts"."color_id" AS "color_id",
"rebrickable_parts"."color_name" AS "color_name",
"rebrickable_parts"."color_rgb" AS "color_rgb"
FROM "rebrickable_parts"
INNER JOIN "bricktracker_parts"
ON "bricktracker_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part"
AND "bricktracker_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id"
{% if owner_id and owner_id != 'all' %}
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
INNER JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
{% endif %}
WHERE ("bricktracker_parts"."missing" > 0 OR "bricktracker_parts"."damaged" > 0)
{% if owner_id and owner_id != 'all' %}
AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1
{% endif %}
ORDER BY "rebrickable_parts"."color_name" ASC
+17
View File
@@ -26,6 +26,23 @@ ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id
AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure" AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
{% endblock %} {% endblock %}
{% block where %}
{% set conditions = [] %}
{% if color_id and color_id != 'all' %}
{% set _ = conditions.append('"bricktracker_parts"."color" = ' ~ color_id) %}
{% endif %}
{% if search_query %}
{% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("bricktracker_parts"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %}
{% set _ = conditions.append(search_condition) %}
{% endif %}
{% if skip_spare_parts %}
{% set _ = conditions.append('"bricktracker_parts"."spare" = 0') %}
{% endif %}
{% if conditions %}
WHERE {{ conditions | join(' AND ') }}
{% endif %}
{% endblock %}
{% block group %} {% block group %}
GROUP BY GROUP BY
"bricktracker_parts"."part", "bricktracker_parts"."part",
@@ -0,0 +1,83 @@
{% extends 'part/base/base.sql' %}
{% block total_missing %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."missing" ELSE 0 END) AS "total_missing",
{% else %}
SUM("bricktracker_parts"."missing") AS "total_missing",
{% endif %}
{% endblock %}
{% block total_damaged %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."damaged" ELSE 0 END) AS "total_damaged",
{% else %}
SUM("bricktracker_parts"."damaged") AS "total_damaged",
{% endif %}
{% endblock %}
{% block total_quantity %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1) ELSE 0 END) AS "total_quantity",
{% else %}
SUM("bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity",
{% endif %}
{% endblock %}
{% block total_sets %}
{% if owner_id and owner_id != 'all' %}
COUNT(DISTINCT CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."id" ELSE NULL END) AS "total_sets",
{% else %}
COUNT(DISTINCT "bricktracker_parts"."id") AS "total_sets",
{% endif %}
{% endblock %}
{% block total_minifigures %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("bricktracker_minifigures"."quantity", 0) ELSE 0 END) AS "total_minifigures"
{% else %}
SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_minifigures"
{% endif %}
{% endblock %}
{% block join %}
-- Join with sets to get owner information
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
-- Left join with set owners (using dynamic columns)
LEFT JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
-- Left join with minifigures
LEFT JOIN "bricktracker_minifigures"
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 %}
{% set conditions = [] %}
{% if owner_id and owner_id != 'all' %}
{% set _ = conditions.append('"bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1') %}
{% endif %}
{% if color_id and color_id != 'all' %}
{% set _ = conditions.append('"bricktracker_parts"."color" = ' ~ color_id) %}
{% endif %}
{% if search_query %}
{% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("bricktracker_parts"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %}
{% set _ = conditions.append(search_condition) %}
{% endif %}
{% if skip_spare_parts %}
{% set _ = conditions.append('"bricktracker_parts"."spare" = 0') %}
{% endif %}
{% if conditions %}
WHERE {{ conditions | join(' AND ') }}
{% endif %}
{% endblock %}
{% block group %}
GROUP BY
"bricktracker_parts"."part",
"bricktracker_parts"."color",
"bricktracker_parts"."spare"
{% endblock %}
+51 -3
View File
@@ -1,30 +1,78 @@
{% extends 'part/base/base.sql' %} {% extends 'part/base/base.sql' %}
{% block total_missing %} {% block total_missing %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."missing" ELSE 0 END) AS "total_missing",
{% else %}
SUM("bricktracker_parts"."missing") AS "total_missing", SUM("bricktracker_parts"."missing") AS "total_missing",
{% endif %}
{% endblock %} {% endblock %}
{% block total_damaged %} {% block total_damaged %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."damaged" ELSE 0 END) AS "total_damaged",
{% else %}
SUM("bricktracker_parts"."damaged") AS "total_damaged", SUM("bricktracker_parts"."damaged") AS "total_damaged",
{% endif %}
{% endblock %}
{% block total_quantity %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1) ELSE 0 END) AS "total_quantity",
{% else %}
SUM("bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity",
{% endif %}
{% endblock %} {% endblock %}
{% block total_sets %} {% block total_sets %}
IFNULL(COUNT("bricktracker_parts"."id"), 0) - IFNULL(COUNT("bricktracker_parts"."figure"), 0) AS "total_sets", {% if owner_id and owner_id != 'all' %}
COUNT(DISTINCT CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."id" ELSE NULL END) AS "total_sets",
{% else %}
COUNT(DISTINCT "bricktracker_parts"."id") AS "total_sets",
{% endif %}
{% endblock %} {% endblock %}
{% block total_minifigures %} {% block total_minifigures %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("bricktracker_minifigures"."quantity", 0) ELSE 0 END) AS "total_minifigures"
{% else %}
SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_minifigures" SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_minifigures"
{% endif %}
{% endblock %} {% endblock %}
{% block join %} {% block join %}
-- Join with sets to get owner information
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
-- Left join with set owners (using dynamic columns)
LEFT JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
-- Left join with minifigures
LEFT JOIN "bricktracker_minifigures" LEFT JOIN "bricktracker_minifigures"
ON "bricktracker_parts"."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" AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
{% endblock %} {% endblock %}
{% block where %} {% block where %}
WHERE "bricktracker_parts"."missing" > 0 {% set conditions = [] %}
OR "bricktracker_parts"."damaged" > 0 -- Always filter for problematic parts
{% set _ = conditions.append('("bricktracker_parts"."missing" > 0 OR "bricktracker_parts"."damaged" > 0)') %}
{% if owner_id and owner_id != 'all' %}
{% set _ = conditions.append('"bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1') %}
{% endif %}
{% if color_id and color_id != 'all' %}
{% set _ = conditions.append('"bricktracker_parts"."color" = ' ~ color_id) %}
{% endif %}
{% if search_query %}
{% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("bricktracker_parts"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %}
{% set _ = conditions.append(search_condition) %}
{% endif %}
{% if skip_spare_parts %}
{% set _ = conditions.append('"bricktracker_parts"."spare" = 0') %}
{% endif %}
WHERE {{ conditions | join(' AND ') }}
{% endblock %} {% endblock %}
{% block group %} {% block group %}
@@ -4,6 +4,9 @@ INSERT OR IGNORE INTO "rebrickable_parts" (
"color_name", "color_name",
"color_rgb", "color_rgb",
"color_transparent", "color_transparent",
"bricklink_color_id",
"bricklink_color_name",
"bricklink_part_num",
"name", "name",
"category", "category",
"image", "image",
@@ -16,6 +19,9 @@ INSERT OR IGNORE INTO "rebrickable_parts" (
:color_name, :color_name,
:color_rgb, :color_rgb,
:color_transparent, :color_transparent,
:bricklink_color_id,
:bricklink_color_name,
:bricklink_part_num,
:name, :name,
:category, :category,
:image, :image,
@@ -28,6 +34,9 @@ DO UPDATE SET
"color_name" = :color_name, "color_name" = :color_name,
"color_rgb" = :color_rgb, "color_rgb" = :color_rgb,
"color_transparent" = :color_transparent, "color_transparent" = :color_transparent,
"bricklink_color_id" = :bricklink_color_id,
"bricklink_color_name" = :bricklink_color_name,
"bricklink_part_num" = :bricklink_part_num,
"name" = :name, "name" = :name,
"category" = :category, "category" = :category,
"image" = :image, "image" = :image,
@@ -4,6 +4,9 @@ SELECT
"rebrickable_parts"."color_name", "rebrickable_parts"."color_name",
"rebrickable_parts"."color_rgb", "rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent", "rebrickable_parts"."color_transparent",
"rebrickable_parts"."bricklink_color_id",
"rebrickable_parts"."bricklink_color_name",
"rebrickable_parts"."bricklink_part_num",
"rebrickable_parts"."name", "rebrickable_parts"."name",
"rebrickable_parts"."category", "rebrickable_parts"."category",
"rebrickable_parts"."image", "rebrickable_parts"."image",
@@ -4,6 +4,9 @@ SELECT
"rebrickable_parts"."color_name", "rebrickable_parts"."color_name",
"rebrickable_parts"."color_rgb", "rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent", "rebrickable_parts"."color_transparent",
"rebrickable_parts"."bricklink_color_id",
"rebrickable_parts"."bricklink_color_name",
"rebrickable_parts"."bricklink_part_num",
"rebrickable_parts"."name", "rebrickable_parts"."name",
"rebrickable_parts"."category", "rebrickable_parts"."category",
"rebrickable_parts"."image", "rebrickable_parts"."image",
@@ -6,7 +6,10 @@ SELECT
"rebrickable_sets"."url", "rebrickable_sets"."url",
"null_join"."null_rgb", "null_join"."null_rgb",
"null_join"."null_transparent", "null_join"."null_transparent",
"null_join"."null_url" "null_join"."null_url",
"null_join"."null_bricklink_color_id",
"null_join"."null_bricklink_color_name",
"null_join"."null_bricklink_part_num"
FROM "rebrickable_sets" FROM "rebrickable_sets"
INNER JOIN ( INNER JOIN (
@@ -14,19 +17,28 @@ INNER JOIN (
"null_sums"."set", "null_sums"."set",
"null_sums"."null_rgb", "null_sums"."null_rgb",
"null_sums"."null_transparent", "null_sums"."null_transparent",
"null_sums"."null_url" "null_sums"."null_url",
"null_sums"."null_bricklink_color_id",
"null_sums"."null_bricklink_color_name",
"null_sums"."null_bricklink_part_num"
FROM ( FROM (
SELECT SELECT
"unique_set_parts"."set", "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_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"."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" SUM(CASE WHEN "unique_set_parts"."url" IS NULL THEN 1 ELSE 0 END) AS "null_url",
SUM(CASE WHEN "unique_set_parts"."bricklink_color_id" IS NULL THEN 1 ELSE 0 END) AS "null_bricklink_color_id",
SUM(CASE WHEN "unique_set_parts"."bricklink_color_name" IS NULL THEN 1 ELSE 0 END) AS "null_bricklink_color_name",
SUM(CASE WHEN "unique_set_parts"."bricklink_part_num" IS NULL THEN 1 ELSE 0 END) AS "null_bricklink_part_num"
FROM ( FROM (
SELECT SELECT
"bricktracker_sets"."set", "bricktracker_sets"."set",
"rebrickable_parts"."color_rgb", "rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent", "rebrickable_parts"."color_transparent",
"rebrickable_parts"."url" "rebrickable_parts"."url",
"rebrickable_parts"."bricklink_color_id",
"rebrickable_parts"."bricklink_color_name",
"rebrickable_parts"."bricklink_part_num"
FROM "bricktracker_sets" FROM "bricktracker_sets"
INNER JOIN "bricktracker_parts" INNER JOIN "bricktracker_parts"
@@ -49,5 +61,8 @@ INNER JOIN (
WHERE "null_rgb" > 0 WHERE "null_rgb" > 0
OR "null_transparent" > 0 OR "null_transparent" > 0
OR "null_url" > 0 OR "null_url" > 0
OR "null_bricklink_color_id" > 0
OR "null_bricklink_color_name" > 0
OR "null_bricklink_part_num" > 0
) "null_join" ) "null_join"
ON "rebrickable_sets"."set" IS NOT DISTINCT FROM "null_join"."set" ON "rebrickable_sets"."set" IS NOT DISTINCT FROM "null_join"."set"
+4
View File
@@ -49,3 +49,7 @@ ORDER BY {{ order }}
{% if limit %} {% if limit %}
LIMIT {{ limit }} LIMIT {{ limit }}
{% endif %} {% endif %}
{% if offset %}
OFFSET {{ offset }}
{% endif %}
+7
View File
@@ -1 +1,8 @@
{% extends 'set/base/full.sql' %} {% extends 'set/base/full.sql' %}
{% block where %}
{% if search_query %}
WHERE (LOWER("rebrickable_sets"."name") LIKE LOWER('%{{ search_query }}%')
OR LOWER("rebrickable_sets"."set") LIKE LOWER('%{{ search_query }}%'))
{% endif %}
{% endblock %}
@@ -0,0 +1,69 @@
{% extends 'set/base/full.sql' %}
{% 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 "bricktracker_sets"."storage" = '{{ storage_filter }}'
{% endif %}
{% if purchase_location_filter %}
AND "bricktracker_sets"."purchase_location" = '{{ purchase_location_filter }}'
{% endif %}
{% if status_filter %}
{% if status_filter == 'has-missing' %}
AND IFNULL("problem_join"."total_missing", 0) > 0
{% elif status_filter == '-has-missing' %}
AND IFNULL("problem_join"."total_missing", 0) = 0
{% elif status_filter == 'has-damaged' %}
AND IFNULL("problem_join"."total_damaged", 0) > 0
{% elif status_filter == '-has-damaged' %}
AND IFNULL("problem_join"."total_damaged", 0) = 0
{% elif status_filter == 'has-storage' %}
AND "bricktracker_sets"."storage" IS NOT NULL AND "bricktracker_sets"."storage" != ''
{% elif status_filter == '-has-storage' %}
AND ("bricktracker_sets"."storage" IS NULL OR "bricktracker_sets"."storage" = '')
{% elif status_filter.startswith('status-') %}
AND EXISTS (
SELECT 1 FROM "bricktracker_set_statuses"
WHERE "bricktracker_set_statuses"."id" = "bricktracker_sets"."id"
AND "bricktracker_set_statuses"."{{ status_filter.replace('-', '_') }}" = 1
)
{% elif status_filter.startswith('-status-') %}
AND NOT EXISTS (
SELECT 1 FROM "bricktracker_set_statuses"
WHERE "bricktracker_set_statuses"."id" = "bricktracker_sets"."id"
AND "bricktracker_set_statuses"."{{ status_filter[1:].replace('-', '_') }}" = 1
)
{% endif %}
{% endif %}
{% if owner_filter %}
{% if owner_filter.startswith('owner-') %}
AND EXISTS (
SELECT 1 FROM "bricktracker_set_owners"
WHERE "bricktracker_set_owners"."id" = "bricktracker_sets"."id"
AND "bricktracker_set_owners"."{{ owner_filter.replace('-', '_') }}" = 1
)
{% endif %}
{% endif %}
{% if tag_filter %}
{% if tag_filter.startswith('tag-') %}
AND EXISTS (
SELECT 1 FROM "bricktracker_set_tags"
WHERE "bricktracker_set_tags"."id" = "bricktracker_sets"."id"
AND "bricktracker_set_tags"."{{ tag_filter.replace('-', '_') }}" = 1
)
{% endif %}
{% endif %}
{% endblock %}
+13
View File
@@ -0,0 +1,13 @@
"""Custom Jinja2 template filters for BrickTracker."""
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
def replace_query_filter(url, key, value):
"""Replace or add a query parameter in a URL"""
parsed = urlparse(url)
query_dict = parse_qs(parsed.query, keep_blank_values=True)
query_dict[key] = [str(value)]
new_query = urlencode(query_dict, doseq=True)
return urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params, new_query, parsed.fragment))
+2 -2
View File
@@ -1,4 +1,4 @@
from typing import Final from typing import Final
__version__: Final[str] = '1.2.2' __version__: Final[str] = '1.2.5'
__database_version__: Final[int] = 15 __database_version__: Final[int] = 17
+4 -1
View File
@@ -25,6 +25,7 @@ def update() -> Response:
BrickSet(), BrickSet(),
minifigure=BrickMinifigure(record={ minifigure=BrickMinifigure(record={
'set_img_url': None, 'set_img_url': None,
'image': None,
}) })
).download() ).download()
@@ -33,7 +34,9 @@ def update() -> Response:
BrickSet(), BrickSet(),
part=BrickPart(record={ part=BrickPart(record={
'part_img_url': None, 'part_img_url': None,
'part_img_url_id': None 'part_img_url_id': None,
'image_id': None,
'image': None,
}) })
).download() ).download()
+1 -1
View File
@@ -7,7 +7,7 @@ from ...rebrickable_set_list import RebrickableSetList
admin_set_page = Blueprint('admin_set', __name__, url_prefix='/admin/set') admin_set_page = Blueprint('admin_set', __name__, url_prefix='/admin/set')
# Sets that need o be refreshed # Sets that need to be refreshed
@admin_set_page.route('/refresh', methods=['GET']) @admin_set_page.route('/refresh', methods=['GET'])
@login_required @login_required
@exception_handler(__file__) @exception_handler(__file__)
+60 -1
View File
@@ -14,6 +14,7 @@ from .exceptions import exception_handler
from ..instructions import BrickInstructions from ..instructions import BrickInstructions
from ..instructions_list import BrickInstructionsList from ..instructions_list import BrickInstructionsList
from ..parser import parse_set from ..parser import parse_set
from ..peeron_instructions import PeeronInstructions
from ..socket import MESSAGES from ..socket import MESSAGES
from .upload import upload_helper from .upload import upload_helper
@@ -24,6 +25,22 @@ instructions_page = Blueprint(
) )
def _render_peeron_select_page(set: str) -> str:
"""Helper function to render the Peeron page selection interface with cached thumbnails."""
peeron = PeeronInstructions(set)
peeron_pages = peeron.find_pages() # This will use the cached thumbnails
current_app.logger.debug(f"[peeron_loaded] Found {len(peeron_pages)} pages for {set}")
return render_template(
'peeron_select.html',
download=True,
pages=peeron_pages,
set=set,
path=current_app.config['SOCKET_PATH'],
namespace=current_app.config['SOCKET_NAMESPACE'],
messages=MESSAGES
)
# Index # Index
@instructions_page.route('/', methods=['GET']) @instructions_page.route('/', methods=['GET'])
@exception_handler(__file__) @exception_handler(__file__)
@@ -141,6 +158,10 @@ def download() -> str:
except Exception: except Exception:
set = '' set = ''
# Check if this is a redirect after Peeron pages were loaded
if request.args.get('peeron_loaded'):
return _render_peeron_select_page(set)
return render_template( return render_template(
'instructions.html', 'instructions.html',
download=True, download=True,
@@ -160,12 +181,50 @@ def do_download() -> str:
except Exception: except Exception:
set = '' set = ''
# Check if this is a redirect after Peeron pages were loaded
if request.args.get('peeron_loaded'):
return _render_peeron_select_page(set)
# Try Rebrickable first
try:
from .instructions import BrickInstructions
rebrickable_instructions = BrickInstructions.find_instructions(set)
# Standard Rebrickable instructions found
return render_template( return render_template(
'instructions.html', 'instructions.html',
download=True, download=True,
instructions=BrickInstructions.find_instructions(set), instructions=rebrickable_instructions,
set=set, set=set,
path=current_app.config['SOCKET_PATH'], path=current_app.config['SOCKET_PATH'],
namespace=current_app.config['SOCKET_NAMESPACE'], namespace=current_app.config['SOCKET_NAMESPACE'],
messages=MESSAGES messages=MESSAGES
) )
except Exception:
# Rebrickable failed, check if Peeron has instructions (without caching thumbnails yet)
try:
peeron = PeeronInstructions(set)
# Just check if pages exist, don't cache thumbnails yet
if peeron.exists():
# Peeron has instructions - show loading interface
return render_template(
'peeron_select.html',
download=True,
loading_peeron=True, # Flag to show loading state
set=set,
path=current_app.config['SOCKET_PATH'],
namespace=current_app.config['SOCKET_NAMESPACE'],
messages=MESSAGES
)
else:
raise Exception("Not found on Peeron either")
except Exception:
return render_template(
'instructions.html',
download=True,
instructions=[],
set=set,
error='No instructions found on Rebrickable or Peeron',
path=current_app.config['SOCKET_PATH'],
namespace=current_app.config['SOCKET_NAMESPACE'],
messages=MESSAGES
)
+48 -4
View File
@@ -1,9 +1,11 @@
from flask import Blueprint, render_template from flask import Blueprint, current_app, render_template, request
from .exceptions import exception_handler from .exceptions import exception_handler
from ..minifigure import BrickMinifigure from ..minifigure import BrickMinifigure
from ..minifigure_list import BrickMinifigureList from ..minifigure_list import BrickMinifigureList
from ..pagination_helper import get_pagination_config, build_pagination_context, get_request_params
from ..set_list import BrickSetList, set_metadata_lists from ..set_list import BrickSetList, set_metadata_lists
from ..set_owner_list import BrickSetOwnerList
minifigure_page = Blueprint('minifigure', __name__, url_prefix='/minifigures') minifigure_page = Blueprint('minifigure', __name__, url_prefix='/minifigures')
@@ -12,11 +14,53 @@ minifigure_page = Blueprint('minifigure', __name__, url_prefix='/minifigures')
@minifigure_page.route('/', methods=['GET']) @minifigure_page.route('/', methods=['GET'])
@exception_handler(__file__) @exception_handler(__file__)
def list() -> str: def list() -> str:
return render_template( # Get filter parameters from request
'minifigures.html', owner_id = request.args.get('owner', 'all')
table_collection=BrickMinifigureList().all(), search_query, sort_field, sort_order, page = get_request_params()
# Get pagination configuration
per_page, is_mobile = get_pagination_config('minifigures')
use_pagination = per_page > 0
if use_pagination:
# PAGINATION MODE - Server-side pagination with search
minifigures, total_count = BrickMinifigureList().all_filtered_paginated(
owner_id=owner_id,
search_query=search_query,
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order
) )
pagination_context = build_pagination_context(page, per_page, total_count, is_mobile)
else:
# ORIGINAL MODE - Single page with all data for client-side search
if owner_id == 'all' or owner_id is None or owner_id == '':
minifigures = BrickMinifigureList().all()
else:
minifigures = BrickMinifigureList().all_by_owner(owner_id)
pagination_context = None
# Get list of owners for filter dropdown
owners = BrickSetOwnerList.list()
template_context = {
'table_collection': minifigures,
'owners': owners,
'selected_owner': owner_id,
'search_query': search_query,
'use_pagination': use_pagination,
'current_sort': sort_field,
'current_order': sort_order
}
if pagination_context:
template_context['pagination'] = pagination_context
return render_template('minifigures.html', **template_context)
# Minifigure details # Minifigure details
@minifigure_page.route('/<figure>/details') @minifigure_page.route('/<figure>/details')
+107 -5
View File
@@ -1,10 +1,13 @@
from flask import Blueprint, render_template from flask import Blueprint, render_template, request
from .exceptions import exception_handler from .exceptions import exception_handler
from ..minifigure_list import BrickMinifigureList from ..minifigure_list import BrickMinifigureList
from ..pagination_helper import get_pagination_config, build_pagination_context, get_request_params
from ..part import BrickPart from ..part import BrickPart
from ..part_list import BrickPartList from ..part_list import BrickPartList
from ..set_list import BrickSetList, set_metadata_lists from ..set_list import BrickSetList, set_metadata_lists
from ..set_owner_list import BrickSetOwnerList
from ..sql import BrickSQL
part_page = Blueprint('part', __name__, url_prefix='/parts') part_page = Blueprint('part', __name__, url_prefix='/parts')
@@ -13,19 +16,118 @@ part_page = Blueprint('part', __name__, url_prefix='/parts')
@part_page.route('/', methods=['GET']) @part_page.route('/', methods=['GET'])
@exception_handler(__file__) @exception_handler(__file__)
def list() -> str: def list() -> str:
return render_template( # Get filter parameters from request
'parts.html', owner_id = request.args.get('owner', 'all')
table_collection=BrickPartList().all() color_id = request.args.get('color', 'all')
search_query, sort_field, sort_order, page = get_request_params()
# Get pagination configuration
per_page, is_mobile = get_pagination_config('parts')
use_pagination = per_page > 0
if use_pagination:
# PAGINATION MODE - Server-side pagination with search
parts, total_count = BrickPartList().all_filtered_paginated(
owner_id=owner_id,
color_id=color_id,
search_query=search_query,
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order
) )
pagination_context = build_pagination_context(page, per_page, total_count, is_mobile)
else:
# ORIGINAL MODE - Single page with all data for client-side search
parts = BrickPartList().all_filtered(owner_id, color_id)
pagination_context = None
# Get list of owners for filter dropdown
owners = BrickSetOwnerList.list()
# Get list of colors for filter dropdown
# Prepare context for color query (filter by owner if selected)
color_context = {}
if owner_id != 'all' and owner_id:
color_context['owner_id'] = owner_id
colors = BrickSQL().fetchall('part/colors/list', **color_context)
template_context = {
'table_collection': parts,
'owners': owners,
'selected_owner': owner_id,
'colors': colors,
'selected_color': color_id,
'search_query': search_query,
'use_pagination': use_pagination,
'current_sort': sort_field,
'current_order': sort_order
}
if pagination_context:
template_context['pagination'] = pagination_context
return render_template('parts.html', **template_context)
# Problem # Problem
@part_page.route('/problem', methods=['GET']) @part_page.route('/problem', methods=['GET'])
@exception_handler(__file__) @exception_handler(__file__)
def problem() -> str: def problem() -> str:
# Get filter parameters from request
owner_id = request.args.get('owner', 'all')
color_id = request.args.get('color', 'all')
search_query, sort_field, sort_order, page = get_request_params()
# Get pagination configuration
per_page, is_mobile = get_pagination_config('problems')
use_pagination = per_page > 0
if use_pagination:
# PAGINATION MODE - Server-side pagination with search and filters
parts, total_count = BrickPartList().problem_paginated(
owner_id=owner_id,
color_id=color_id,
search_query=search_query,
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order
)
pagination_context = build_pagination_context(page, per_page, total_count, is_mobile)
else:
# ORIGINAL MODE - Single page with all data for client-side search
parts = BrickPartList().problem_filtered(owner_id, color_id)
pagination_context = None
# Get list of owners for filter dropdown
owners = BrickSetOwnerList.list()
# Get list of colors for filter dropdown
# Prepare context for color query (filter by owner if selected)
color_context = {}
if owner_id != 'all':
color_context['owner_id'] = owner_id
# Get colors from problem parts (following same pattern as parts page)
colors = BrickSQL().fetchall('part/colors/list_problem', **color_context)
return render_template( return render_template(
'problem.html', 'problem.html',
table_collection=BrickPartList().problem() table_collection=parts,
pagination=pagination_context,
search_query=search_query,
sort_field=sort_field,
sort_order=sort_order,
use_pagination=use_pagination,
owners=owners,
colors=colors,
selected_owner=owner_id,
selected_color=color_id
) )
+57 -5
View File
@@ -15,6 +15,7 @@ from werkzeug.wrappers.response import Response
from .exceptions import exception_handler from .exceptions import exception_handler
from ..exceptions import ErrorException from ..exceptions import ErrorException
from ..minifigure import BrickMinifigure from ..minifigure import BrickMinifigure
from ..pagination_helper import get_pagination_config, build_pagination_context, get_request_params
from ..part import BrickPart from ..part import BrickPart
from ..rebrickable_set import RebrickableSet from ..rebrickable_set import RebrickableSet
from ..set import BrickSet from ..set import BrickSet
@@ -35,13 +36,64 @@ set_page = Blueprint('set', __name__, url_prefix='/sets')
@set_page.route('/', methods=['GET']) @set_page.route('/', methods=['GET'])
@exception_handler(__file__) @exception_handler(__file__)
def list() -> str: def list() -> str:
return render_template( # Get filter parameters from request
'sets.html', search_query, sort_field, sort_order, page = get_request_params()
collection=BrickSetList().all(),
brickset_statuses=BrickSetStatusList.list(), # Get filter parameters
**set_metadata_lists(as_class=True) status_filter = request.args.get('status')
theme_filter = request.args.get('theme')
owner_filter = request.args.get('owner')
purchase_location_filter = request.args.get('purchase_location')
storage_filter = request.args.get('storage')
tag_filter = request.args.get('tag')
# Get pagination configuration
per_page, is_mobile = get_pagination_config('sets')
use_pagination = per_page > 0
if use_pagination:
# PAGINATION MODE - Server-side pagination with search and filters
sets, total_count = BrickSetList().all_filtered_paginated(
search_query=search_query,
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order,
status_filter=status_filter,
theme_filter=theme_filter,
owner_filter=owner_filter,
purchase_location_filter=purchase_location_filter,
storage_filter=storage_filter,
tag_filter=tag_filter
) )
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()
pagination_context = None
template_context = {
'collection': sets,
'search_query': search_query,
'use_pagination': use_pagination,
'current_sort': sort_field,
'current_order': sort_order,
'current_status_filter': status_filter,
'current_theme_filter': theme_filter,
'current_owner_filter': owner_filter,
'current_purchase_location_filter': purchase_location_filter,
'current_storage_filter': storage_filter,
'current_tag_filter': tag_filter,
'brickset_statuses': BrickSetStatusList.list(),
**set_metadata_lists(as_class=True)
}
if pagination_context:
template_context['pagination'] = pagination_context
return render_template('sets.html', **template_context)
# Change the value of purchase date # Change the value of purchase date
@set_page.route('/<id>/purchase_date', methods=['POST']) @set_page.route('/<id>/purchase_date', methods=['POST'])
+11 -4
View File
@@ -19,17 +19,24 @@ class BrickWishList(BrickRecordList[BrickWish]):
# Queries # Queries
select_query: str = 'wish/list/all' select_query: str = 'wish/list/all'
# All the wished sets # Implementation of abstract list method
def all(self, /) -> Self: def list(self, /, *, override_query: str | None = None, **context) -> None:
# Use provided order or default
order = context.pop('order', current_app.config['WISHES_DEFAULT_ORDER'])
# Load the wished sets from the database # Load the wished sets from the database
for record in self.select( for record in self.select(
order=current_app.config['WISHES_DEFAULT_ORDER'], override_query=override_query,
order=order,
owners=BrickWishOwnerList.as_columns(), owners=BrickWishOwnerList.as_columns(),
**context
): ):
brickwish = BrickWish(record=record) brickwish = BrickWish(record=record)
self.records.append(brickwish) self.records.append(brickwish)
# All the wished sets
def all(self, /) -> Self:
self.list()
return self return self
# Add a set to the wishlist # Add a set to the wishlist
+4 -4
View File
@@ -61,9 +61,9 @@ docker compose up -d
2. Access BrickTracker at `http://localhost:3333` 2. Access BrickTracker at `http://localhost:3333`
Please refer to [Environment Variables Reference](docs/env.md) for a list of available variables. Please refer to [Environment Variables Reference](env.md) for a list of available variables.
3. Read more in [First steps](docs/first-steps.md) 3. Read more in [First steps](first-steps.md)
## Troubleshooting ## Troubleshooting
@@ -85,6 +85,6 @@ Please refer to [Environment Variables Reference](docs/env.md) for a list of ava
- Check for any syntax errors in `.env` file - Check for any syntax errors in `.env` file
- Verify no conflicting environment variables are set in the shell - Verify no conflicting environment variables are set in the shell
For more troubleshooting, take a look at [Common Errors](docs/common-errors.md) For more troubleshooting, take a look at [Common Errors](common-errors.md)
Please refer to [Setup](docs/setup.md) for more information. Please refer to [Setup](setup.md) for more information.
+1 -1
View File
@@ -53,7 +53,7 @@ services:
The [.env.sample](../.env.sample) file provides ample documentation on all the configurable options. Have a look at it. The [.env.sample](../.env.sample) file provides ample documentation on all the configurable options. Have a look at it.
You can make a copy of `.env.sample` as `.env` with your options or create an `.env` file from scratch. You can make a copy of `.env.sample` as `.env` with your options or create an `.env` file from scratch.
[Environment Variables Reference](docs/env.md) contains a table of the available variables. [Environment Variables Reference](env.md) contains a table of the available variables.
## Database file ## Database file
+1 -1
View File
@@ -13,4 +13,4 @@ then
fi fi
# Execute the WSGI server # Execute the WSGI server
gunicorn --bind "${BK_SERVER}:${BK_PORT}" "app:create_app()" --worker-class "eventlet" "$@" gunicorn --bind "${BK_HOST}:${BK_PORT}" "wsgi:application" --worker-class "gevent" --workers 1 "$@"
+4
View File
@@ -2,6 +2,7 @@ eventlet
flask flask
flask_socketio flask_socketio
flask-login flask-login
gevent
gunicorn gunicorn
humanize humanize
jinja2 jinja2
@@ -9,3 +10,6 @@ rebrick
requests requests
tzdata tzdata
bs4 bs4
cloudscraper
fpdf2
pillow
+442
View File
@@ -0,0 +1,442 @@
/**
* Shared collapsible state management for filters and sort sections
* Handles BK_SHOW_GRID_FILTERS and BK_SHOW_GRID_SORT configuration with user preferences
*/
// Generic state management for collapsible sections (filter and sort)
function initializeCollapsibleState(elementId, storageKey) {
const element = document.getElementById(elementId);
const toggleButton = document.querySelector(`[data-bs-target="#${elementId}"]`);
if (!element || !toggleButton) return;
// Restore state on page load
const savedState = sessionStorage.getItem(storageKey);
if (savedState === 'open') {
// User explicitly opened it
element.classList.add('show');
toggleButton.setAttribute('aria-expanded', 'true');
} else if (savedState === 'closed') {
// User explicitly closed it, override template state
element.classList.remove('show');
toggleButton.setAttribute('aria-expanded', 'false');
}
// If no saved state, keep the template state (respects BK_SHOW_GRID_FILTERS/BK_SHOW_GRID_SORT)
// Listen for toggle events
element.addEventListener('show.bs.collapse', () => {
sessionStorage.setItem(storageKey, 'open');
});
element.addEventListener('hide.bs.collapse', () => {
sessionStorage.setItem(storageKey, 'closed');
});
}
// Initialize filter and sort states for a specific page
function initializePageCollapsibleStates(pagePrefix, filterElementId = 'table-filter', sortElementId = 'table-sort') {
initializeCollapsibleState(filterElementId, `${pagePrefix}-filter-state`);
initializeCollapsibleState(sortElementId, `${pagePrefix}-sort-state`);
// Initialize sort icons based on current URL parameters (for all pages)
const urlParams = new URLSearchParams(window.location.search);
const currentSort = urlParams.get('sort');
const currentOrder = urlParams.get('order');
if (currentSort || currentOrder) {
updateSortIcon(currentOrder);
}
}
// Shared function to preserve filter state during filter changes
function preserveCollapsibleStateOnChange(elementId, storageKey) {
const element = document.getElementById(elementId);
const wasOpen = element && element.classList.contains('show');
// Store the state to restore after page reload
if (wasOpen) {
sessionStorage.setItem(storageKey, 'open');
}
}
// Setup color dropdown with visual indicators (shared implementation)
function setupColorDropdown() {
const colorSelect = document.getElementById('filter-color');
if (!colorSelect) return;
// Merge duplicate color options where one has color_rgb and the other is None
const colorMap = new Map();
const allOptions = colorSelect.querySelectorAll('option[data-color-id]');
// First pass: collect all options by color_id
allOptions.forEach(option => {
const colorId = option.dataset.colorId;
const colorRgb = option.dataset.colorRgb;
const colorName = option.textContent.trim();
if (!colorMap.has(colorId)) {
colorMap.set(colorId, []);
}
colorMap.get(colorId).push({
element: option,
colorRgb: colorRgb,
colorName: colorName,
selected: option.selected
});
});
// Second pass: merge duplicates, keeping the one with color_rgb
colorMap.forEach((options, colorId) => {
if (options.length > 1) {
// Find option with color_rgb (not empty/null/undefined)
const withRgb = options.find(opt => opt.colorRgb && opt.colorRgb !== 'None' && opt.colorRgb !== '');
const withoutRgb = options.find(opt => !opt.colorRgb || opt.colorRgb === 'None' || opt.colorRgb === '');
if (withRgb && withoutRgb) {
// Keep the selected state from either option
const wasSelected = withRgb.selected || withoutRgb.selected;
// Update the option with RGB to be selected if either was selected
if (wasSelected) {
withRgb.element.selected = true;
}
// Remove the option without RGB
withoutRgb.element.remove();
}
}
});
// Add color squares to remaining option text
const remainingOptions = colorSelect.querySelectorAll('option[data-color-rgb]');
remainingOptions.forEach(option => {
const colorRgb = option.dataset.colorRgb;
const colorId = option.dataset.colorId;
const colorName = option.textContent.trim();
if (colorRgb && colorRgb !== 'None' && colorRgb !== '' && colorId !== '9999') {
// Create a visual indicator (using Unicode square)
option.textContent = `${colorName}`; //■
//option.style.color = `#${colorRgb}`;
}
});
}
// Check if pagination mode is enabled for a specific table
function isPaginationModeForTable(tableId) {
const tableElement = document.querySelector(`#${tableId}`);
return tableElement && tableElement.getAttribute('data-table') === 'false';
}
// Update sort icon based on current sort direction
function updateSortIcon(sortDirection = null) {
// Find the main sort icon (could be in grid-sort or table-sort)
const sortIcon = document.querySelector('#grid-sort .ri-sort-asc, #grid-sort .ri-sort-desc, #table-sort .ri-sort-asc, #table-sort .ri-sort-desc');
if (!sortIcon) return;
// Remove existing sort classes
sortIcon.classList.remove('ri-sort-asc', 'ri-sort-desc');
// Add appropriate class based on sort direction
if (sortDirection === 'desc') {
sortIcon.classList.add('ri-sort-desc');
} else {
sortIcon.classList.add('ri-sort-asc');
}
}
// Initialize sort button states and icons for pagination mode
window.initializeSortButtonStates = function(currentSort, currentOrder) {
const sortButtons = document.querySelectorAll('[data-sort-attribute]');
// Update main sort icon
updateSortIcon(currentOrder);
if (currentSort) {
sortButtons.forEach(btn => {
// Clear all buttons first
btn.classList.remove('btn-primary');
btn.classList.add('btn-outline-primary');
btn.removeAttribute('data-current-direction');
// Set active state for current sort
if (btn.dataset.sortAttribute === currentSort) {
btn.classList.remove('btn-outline-primary');
btn.classList.add('btn-primary');
btn.dataset.currentDirection = currentOrder || 'asc';
}
});
}
};
// Shared sort button setup function
window.setupSharedSortButtons = function(tableId, tableInstanceGlobal, columnMap) {
const sortButtons = document.querySelectorAll('[data-sort-attribute]');
const clearButton = document.querySelector('[data-sort-clear]');
const isPaginationMode = isPaginationModeForTable(tableId);
sortButtons.forEach(button => {
button.addEventListener('click', () => {
const attribute = button.dataset.sortAttribute;
const isDesc = button.dataset.sortDesc === 'true';
if (isPaginationMode) {
// PAGINATION MODE - Server-side sorting via URL parameters
const currentUrl = new URL(window.location);
const currentSort = currentUrl.searchParams.get('sort');
const currentOrder = currentUrl.searchParams.get('order');
const isCurrentlyActive = currentSort === attribute;
let newDirection;
if (isCurrentlyActive) {
// Toggle direction if same attribute
newDirection = currentOrder === 'asc' ? 'desc' : 'asc';
} else {
// Use default direction for new attribute
newDirection = isDesc ? 'desc' : 'asc';
}
// Set sort parameters and reset to first page
currentUrl.searchParams.set('sort', attribute);
currentUrl.searchParams.set('order', newDirection);
currentUrl.searchParams.set('page', '1');
// Navigate to sorted results
window.location.href = currentUrl.toString();
} else {
// ORIGINAL MODE - Client-side sorting via Simple DataTables
const columnIndex = columnMap[attribute];
const tableInstance = window[tableInstanceGlobal];
if (columnIndex !== undefined && tableInstance) {
// Determine sort direction
const isCurrentlyActive = button.classList.contains('btn-primary');
const currentDirection = button.dataset.currentDirection || (isDesc ? 'desc' : 'asc');
const newDirection = isCurrentlyActive ?
(currentDirection === 'asc' ? 'desc' : 'asc') :
(isDesc ? 'desc' : 'asc');
// Clear other active buttons
sortButtons.forEach(btn => {
btn.classList.remove('btn-primary');
btn.classList.add('btn-outline-primary');
btn.removeAttribute('data-current-direction');
});
// Mark this button as active
button.classList.remove('btn-outline-primary');
button.classList.add('btn-primary');
button.dataset.currentDirection = newDirection;
// Apply sort using Simple DataTables API
tableInstance.table.columns.sort(columnIndex, newDirection);
// Update sort icon to reflect new direction
updateSortIcon(newDirection);
}
}
});
});
if (clearButton) {
clearButton.addEventListener('click', () => {
if (isPaginationMode) {
// PAGINATION MODE - Clear server-side sorting via URL parameters
const currentUrl = new URL(window.location);
currentUrl.searchParams.delete('sort');
currentUrl.searchParams.delete('order');
currentUrl.searchParams.set('page', '1');
window.location.href = currentUrl.toString();
} else {
// ORIGINAL MODE - Clear client-side sorting
// Clear all sort buttons
sortButtons.forEach(btn => {
btn.classList.remove('btn-primary');
btn.classList.add('btn-outline-primary');
btn.removeAttribute('data-current-direction');
});
// Reset sort icon to default ascending
updateSortIcon('asc');
// Reset table sort - remove all sorting
const tableInstance = window[tableInstanceGlobal];
if (tableInstance) {
const tableElement = document.querySelector(`#${tableId}`);
const currentPerPage = tableInstance.table.options.perPage;
tableInstance.table.destroy();
setTimeout(() => {
// Create new instance using the globally available BrickTable class
const newInstance = new window.BrickTable(tableElement, currentPerPage);
window[tableInstanceGlobal] = newInstance;
// Re-enable search functionality
newInstance.table.searchable = true;
}, 50);
}
}
});
}
};
// =================================================================
// SHARED FUNCTIONS FOR PAGE-SPECIFIC OPERATIONS
// =================================================================
// Shared pagination mode detection (works for any table/grid ID)
window.isPaginationModeForPage = function(elementId, attributeName = 'data-table') {
const element = document.querySelector(`#${elementId}`);
return element && element.getAttribute(attributeName) === 'false';
};
// Shared URL parameter update helper
window.updateUrlParams = function(params, resetPage = true) {
const currentUrl = new URL(window.location);
// Apply parameter updates
Object.entries(params).forEach(([key, value]) => {
if (value === null || value === undefined || value === '' || value === 'all') {
currentUrl.searchParams.delete(key);
} else {
currentUrl.searchParams.set(key, value);
}
});
// Reset to page 1 if requested
if (resetPage) {
currentUrl.searchParams.set('page', '1');
}
// Navigate to updated URL
window.location.href = currentUrl.toString();
};
// Shared filter application (supports owner and color filters)
window.applyPageFilters = function(tableId) {
const ownerSelect = document.getElementById('filter-owner');
const colorSelect = document.getElementById('filter-color');
const params = {};
// Handle owner filter
if (ownerSelect) {
params.owner = ownerSelect.value;
}
// Handle color filter
if (colorSelect) {
params.color = colorSelect.value;
}
// Update URL with new parameters
window.updateUrlParams(params, true);
};
// Shared search setup for both pagination and client-side modes
window.setupPageSearch = function(tableId, searchInputId, clearButtonId, tableInstanceGlobal) {
const searchInput = document.getElementById(searchInputId);
const searchClear = document.getElementById(clearButtonId);
if (!searchInput || !searchClear) return;
const isPaginationMode = window.isPaginationModeForPage(tableId);
if (isPaginationMode) {
// PAGINATION MODE - Server-side search with Enter key
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const searchValue = e.target.value.trim();
window.updateUrlParams({ search: searchValue }, true);
}
});
// Clear search
searchClear.addEventListener('click', () => {
searchInput.value = '';
window.updateUrlParams({ search: null }, true);
});
} else {
// ORIGINAL MODE - Client-side instant search via Simple DataTables
const setupClientSearch = () => {
const tableElement = document.querySelector(`table[data-table="true"]#${tableId}`);
const tableInstance = window[tableInstanceGlobal];
if (tableElement && tableInstance) {
// Enable search functionality
tableInstance.table.searchable = true;
// Instant search as user types
searchInput.addEventListener('input', (e) => {
const searchValue = e.target.value.trim();
tableInstance.table.search(searchValue);
});
// Clear search
searchClear.addEventListener('click', () => {
searchInput.value = '';
tableInstance.table.search('');
});
} else {
// If table instance not ready, try again
setTimeout(setupClientSearch, 100);
}
};
setTimeout(setupClientSearch, 100);
}
};
// Shared function to preserve filter state and apply filters
window.applyFiltersAndKeepState = function(tableId, storageKey) {
const filterElement = document.getElementById('table-filter');
const wasOpen = filterElement && filterElement.classList.contains('show');
// Apply the filters
window.applyPageFilters(tableId);
// Store the state to restore after page reload
if (wasOpen) {
sessionStorage.setItem(storageKey, 'open');
}
};
// Shared initialization for table pages (parts, problems, minifigures)
window.initializeTablePage = function(config) {
const {
pagePrefix, // e.g., 'parts', 'problems', 'minifigures'
tableId, // e.g., 'parts', 'problems', 'minifigures'
searchInputId = 'table-search',
clearButtonId = 'table-search-clear',
tableInstanceGlobal, // e.g., 'partsTableInstance', 'problemsTableInstance'
sortColumnMap, // Column mapping for sort buttons
hasColorDropdown = true
} = config;
// Initialize collapsible states (filter and sort)
initializePageCollapsibleStates(pagePrefix);
// Setup search functionality
window.setupPageSearch(tableId, searchInputId, clearButtonId, tableInstanceGlobal);
// Setup color dropdown if needed
if (hasColorDropdown) {
setupColorDropdown();
}
// Setup sort buttons with shared functionality
if (sortColumnMap) {
window.setupSharedSortButtons(tableId, tableInstanceGlobal, sortColumnMap);
}
// Initialize sort button states and icons for pagination mode
if (window.isPaginationModeForPage(tableId)) {
const urlParams = new URLSearchParams(window.location.search);
const currentSort = urlParams.get('sort');
const currentOrder = urlParams.get('order');
window.initializeSortButtonStates(currentSort, currentOrder);
}
};
+136
View File
@@ -0,0 +1,136 @@
// Minifigures page functionality
// Check if we're in pagination mode (server-side) or original mode (client-side)
function isPaginationMode() {
const tableElement = document.querySelector('#minifigures');
return tableElement && tableElement.getAttribute('data-table') === 'false';
}
function filterByOwner() {
const select = document.getElementById('filter-owner');
const selectedOwner = select.value;
const currentUrl = new URL(window.location);
if (selectedOwner === 'all') {
currentUrl.searchParams.delete('owner');
} else {
currentUrl.searchParams.set('owner', selectedOwner);
}
// Reset to page 1 when filtering
if (isPaginationMode()) {
currentUrl.searchParams.set('page', '1');
}
window.location.href = currentUrl.toString();
}
// Initialize filter and sort states for minifigures page
function initializeCollapsibleStates() {
initializePageCollapsibleStates('minifigures');
}
// Keep filters expanded after selection
function filterByOwnerAndKeepOpen() {
preserveCollapsibleStateOnChange('table-filter', 'minifigures-filter-state');
filterByOwner();
}
// Setup table search and sort functionality
document.addEventListener("DOMContentLoaded", () => {
const searchInput = document.getElementById('table-search');
const searchClear = document.getElementById('table-search-clear');
// Initialize collapsible states (filter and sort)
initializeCollapsibleStates();
if (searchInput && searchClear) {
if (isPaginationMode()) {
// PAGINATION MODE - Server-side search
const searchForm = document.createElement('form');
searchForm.style.display = 'none';
searchInput.parentNode.appendChild(searchForm);
searchForm.appendChild(searchInput.cloneNode(true));
// Handle Enter key for search
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
performServerSearch();
}
});
// Handle search button click (if exists)
const searchButton = document.querySelector('[data-search-trigger]');
if (searchButton) {
searchButton.addEventListener('click', performServerSearch);
}
// Clear search
searchClear.addEventListener('click', () => {
searchInput.value = '';
performServerSearch();
});
function performServerSearch() {
const currentUrl = new URL(window.location);
const searchQuery = searchInput.value.trim();
if (searchQuery) {
currentUrl.searchParams.set('search', searchQuery);
} else {
currentUrl.searchParams.delete('search');
}
// Reset to page 1 when searching
currentUrl.searchParams.set('page', '1');
window.location.href = currentUrl.toString();
}
} else {
// ORIGINAL MODE - Client-side search with Simple DataTables
setTimeout(() => {
const tableElement = document.querySelector('table[data-table="true"]');
if (tableElement && window.brickTableInstance) {
// Enable custom search for minifigures table
window.brickTableInstance.table.searchable = true;
// Connect search input to table
searchInput.addEventListener('input', (e) => {
window.brickTableInstance.table.search(e.target.value);
});
// Clear search
searchClear.addEventListener('click', () => {
searchInput.value = '';
window.brickTableInstance.table.search('');
});
}
}, 100);
}
}
// Setup sort buttons for both modes
setupSortButtons();
// Initialize sort button states and icons for pagination mode
if (isPaginationMode()) {
const urlParams = new URLSearchParams(window.location.search);
const currentSort = urlParams.get('sort');
const currentOrder = urlParams.get('order');
window.initializeSortButtonStates(currentSort, currentOrder);
}
});
function setupSortButtons() {
const columnMap = {
'name': 1,
'parts': 2,
'quantity': 3,
'missing': 4,
'damaged': 5,
'sets': 6
};
// Use shared sort buttons setup from collapsible-state.js
window.setupSharedSortButtons('minifigures', 'brickTableInstance', columnMap);
}
+27
View File
@@ -0,0 +1,27 @@
// Parts page functionality - now uses shared functions
// Keep filters expanded after selection
function applyFiltersAndKeepOpen() {
window.applyFiltersAndKeepState('parts', 'parts-filter-state');
}
// Initialize parts page
document.addEventListener("DOMContentLoaded", () => {
// Use shared table page initialization
window.initializeTablePage({
pagePrefix: 'parts',
tableId: 'parts',
tableInstanceGlobal: 'partsTableInstance',
sortColumnMap: {
'name': 1,
'color': 2,
'quantity': 3,
'missing': 4,
'damaged': 5,
'sets': 6,
'minifigures': 7
},
hasColorDropdown: true
});
});
+26
View File
@@ -0,0 +1,26 @@
// Problems page functionality - now uses shared functions
// Keep filters expanded after selection
function applyFiltersAndKeepOpen() {
window.applyFiltersAndKeepState('problems', 'problems-filter-state');
}
// Initialize problems page
document.addEventListener("DOMContentLoaded", () => {
// Use shared table page initialization
window.initializeTablePage({
pagePrefix: 'problems',
tableId: 'problems',
tableInstanceGlobal: 'problemsTableInstance',
sortColumnMap: {
'name': 1,
'color': 2,
'quantity': 3,
'missing': 4,
'damaged': 5,
'sets': 6,
'minifigures': 7
},
hasColorDropdown: true
});
});
+196
View File
@@ -0,0 +1,196 @@
// Sets page functionality
// Check if we're in pagination mode (server-side) or original mode (client-side)
function isPaginationMode() {
const gridElement = document.querySelector('#grid');
return gridElement && gridElement.getAttribute('data-grid') === 'false';
}
// Initialize filter and sort states for sets page
function initializeCollapsibleStates() {
initializePageCollapsibleStates('sets', 'grid-filter', 'grid-sort');
}
// Setup page functionality
document.addEventListener("DOMContentLoaded", () => {
// Initialize collapsible states (filter and sort)
initializeCollapsibleStates();
const searchInput = document.getElementById('grid-search');
const searchClear = document.getElementById('grid-search-clear');
if (searchInput && searchClear) {
if (isPaginationMode()) {
// PAGINATION MODE - Server-side search
const searchForm = document.createElement('form');
searchForm.style.display = 'none';
searchInput.parentNode.appendChild(searchForm);
searchForm.appendChild(searchInput.cloneNode(true));
// Handle Enter key for search
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
performServerSearch();
}
});
// Handle search button click (if exists)
const searchButton = document.querySelector('[data-search-trigger]');
if (searchButton) {
searchButton.addEventListener('click', performServerSearch);
}
// Clear search
searchClear.addEventListener('click', () => {
searchInput.value = '';
performServerSearch();
});
function performServerSearch() {
const currentUrl = new URL(window.location);
const searchQuery = searchInput.value.trim();
if (searchQuery) {
currentUrl.searchParams.set('search', searchQuery);
} else {
currentUrl.searchParams.delete('search');
}
// Reset to page 1 when searching
currentUrl.searchParams.set('page', '1');
window.location.href = currentUrl.toString();
}
// Setup sort buttons for pagination mode
setupPaginationSortButtons();
// Setup filter dropdowns for pagination mode
setupPaginationFilterDropdowns();
// Initialize sort button states and icons for pagination mode
const urlParams = new URLSearchParams(window.location.search);
const currentSort = urlParams.get('sort');
const currentOrder = urlParams.get('order');
window.initializeSortButtonStates(currentSort, currentOrder);
} else {
// ORIGINAL MODE - Grid search functionality is handled by existing grid scripts
// No additional setup needed here
}
}
});
function setupPaginationSortButtons() {
// Sort button functionality for pagination mode
const sortButtons = document.querySelectorAll('[data-sort-attribute]');
const clearButton = document.querySelector('[data-sort-clear]');
sortButtons.forEach(button => {
button.addEventListener('click', () => {
const attribute = button.dataset.sortAttribute;
const isDesc = button.dataset.sortDesc === 'true';
// PAGINATION MODE - Server-side sorting
const currentUrl = new URL(window.location);
const currentSort = currentUrl.searchParams.get('sort');
const currentOrder = currentUrl.searchParams.get('order');
// Determine new sort direction
let newOrder = isDesc ? 'desc' : 'asc';
if (currentSort === attribute) {
// Toggle direction if clicking the same column
newOrder = currentOrder === 'asc' ? 'desc' : 'asc';
}
currentUrl.searchParams.set('sort', attribute);
currentUrl.searchParams.set('order', newOrder);
// Update sort icon immediately before navigation
updateSortIcon(newOrder);
// Reset to page 1 when sorting
currentUrl.searchParams.set('page', '1');
window.location.href = currentUrl.toString();
});
});
if (clearButton) {
clearButton.addEventListener('click', () => {
// PAGINATION MODE - Clear server-side sorting
const currentUrl = new URL(window.location);
currentUrl.searchParams.delete('sort');
currentUrl.searchParams.delete('order');
// Reset sort icon to default ascending
updateSortIcon('asc');
currentUrl.searchParams.set('page', '1');
window.location.href = currentUrl.toString();
});
}
}
function setupPaginationFilterDropdowns() {
// Filter dropdown functionality for pagination mode
const filterDropdowns = document.querySelectorAll('#grid-filter select');
filterDropdowns.forEach(dropdown => {
dropdown.addEventListener('change', () => {
performServerFilter();
});
});
function performServerFilter() {
const currentUrl = new URL(window.location);
// Get all filter values
const statusFilter = document.getElementById('grid-status')?.value || '';
const themeFilter = document.getElementById('grid-theme')?.value || '';
const ownerFilter = document.getElementById('grid-owner')?.value || '';
const purchaseLocationFilter = document.getElementById('grid-purchase-location')?.value || '';
const storageFilter = document.getElementById('grid-storage')?.value || '';
const tagFilter = document.getElementById('grid-tag')?.value || '';
// Update URL parameters
if (statusFilter) {
currentUrl.searchParams.set('status', statusFilter);
} else {
currentUrl.searchParams.delete('status');
}
if (themeFilter) {
currentUrl.searchParams.set('theme', themeFilter);
} else {
currentUrl.searchParams.delete('theme');
}
if (ownerFilter) {
currentUrl.searchParams.set('owner', ownerFilter);
} else {
currentUrl.searchParams.delete('owner');
}
if (purchaseLocationFilter) {
currentUrl.searchParams.set('purchase_location', purchaseLocationFilter);
} else {
currentUrl.searchParams.delete('purchase_location');
}
if (storageFilter) {
currentUrl.searchParams.set('storage', storageFilter);
} else {
currentUrl.searchParams.delete('storage');
}
if (tagFilter) {
currentUrl.searchParams.set('tag', tagFilter);
} else {
currentUrl.searchParams.delete('tag');
}
// Reset to page 1 when filtering
currentUrl.searchParams.set('page', '1');
window.location.href = currentUrl.toString();
}
}
+206
View File
@@ -0,0 +1,206 @@
// Peeron Socket class
class BrickPeeronSocket extends BrickSocket {
constructor(id, path, namespace, messages) {
super(id, path, namespace, messages, true);
// Form elements (built based on the initial id)
this.html_button = document.getElementById(id);
this.html_files = document.getElementById(`${id}-files`);
if (this.html_button) {
this.html_button.addEventListener("click", (e) => {
this.execute();
});
}
// Setup select all button
this.setup_select_all_button();
// Setup rotation buttons
this.setup_rotation_buttons();
// Setup the socket
this.setup();
}
setup_select_all_button() {
const selectAllButton = document.getElementById('peeron-select-all');
if (selectAllButton) {
selectAllButton.addEventListener('click', () => {
const checkboxes = this.get_files();
const allChecked = checkboxes.every(cb => cb.checked);
checkboxes.forEach(cb => cb.checked = !allChecked);
// Update button text and icon
if (allChecked) {
selectAllButton.innerHTML = '<i class="ri-checkbox-multiple-line"></i> Select All';
} else {
selectAllButton.innerHTML = '<i class="ri-checkbox-blank-line"></i> Deselect All';
}
});
}
}
setup_rotation_buttons() {
document.querySelectorAll('.peeron-rotate-btn').forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault(); // Prevent label click
e.stopPropagation(); // Stop event bubbling
const targetId = button.getAttribute('data-target');
const checkboxId = button.getAttribute('data-checkbox');
const targetImg = document.getElementById(targetId);
const checkbox = document.getElementById(checkboxId);
if (targetImg && checkbox) {
let currentRotation = parseInt(button.getAttribute('data-rotation') || '0');
currentRotation = (currentRotation + 90) % 360;
// Update image rotation
targetImg.style.transform = `rotate(${currentRotation}deg)`;
button.setAttribute('data-rotation', currentRotation.toString());
// Store rotation in checkbox data for later use
checkbox.setAttribute('data-rotation', currentRotation.toString());
// Update the rotation icon to indicate current state
const icon = button.querySelector('i');
if (icon) {
// Rotate the icon to match the image rotation
icon.style.transform = `rotate(${currentRotation}deg)`;
}
}
});
});
}
// Upon receiving a complete message
complete(data) {
super.complete(data);
// Clear progress display after completion
if (this.html_progress_message) {
this.html_progress_message.classList.add("d-none");
this.html_progress_message.textContent = "";
}
if (this.html_count) {
this.html_count.classList.add("d-none");
this.html_count.textContent = "";
}
// Ensure spinner is hidden
this.spinner(false);
this.toggle(true);
}
// Execute the action
execute() {
if (!this.disabled && this.socket !== undefined && this.socket.connected) {
this.toggle(false);
this.download_peeron_pages();
}
}
// Get the list of checkboxes describing files
get_files(checked=false) {
let files = [];
if (this.html_files) {
files = [...this.html_files.querySelectorAll('input[type="checkbox"]')];
if (checked) {
files = files.filter(file => file.checked);
}
}
return files;
}
// Download Peeron pages
download_peeron_pages() {
if (this.html_files) {
const selectedFiles = this.get_files(true);
if (selectedFiles.length === 0) {
this.fail({message: "Please select at least one page to download."});
this.toggle(true);
return;
}
const pages = selectedFiles.map(checkbox => ({
page_number: checkbox.getAttribute('data-page-number'),
original_image_url: checkbox.getAttribute('data-original-image-url'),
cached_full_image_path: checkbox.getAttribute('data-cached-full-image-path'),
alt_text: checkbox.getAttribute('data-alt-text'),
rotation: parseInt(checkbox.getAttribute('data-rotation') || '0')
}));
this.clear();
this.spinner(true);
const setElement = document.querySelector('input[name="download-set"]');
const set = setElement ? setElement.value : '';
this.socket.emit(this.messages.DOWNLOAD_PEERON_PAGES, {
set: set,
pages: pages,
total: pages.length,
current: 0
});
} else {
this.fail({message: "Could not find the list of pages to download"});
}
}
// Toggle clicking on the button, or sending events
toggle(enabled) {
super.toggle(enabled);
if (this.html_files) {
this.get_files().forEach(el => el.disabled = !enabled);
}
if (this.html_button) {
this.html_button.disabled = !enabled;
}
}
}
// Simple Peeron page loader using standard socket pattern
class BrickPeeronPageLoader extends BrickSocket {
constructor(set, path, namespace, messages) {
// Use 'peeron-loader' as the ID for socket elements
super('peeron-loader', path, namespace, messages, false);
this.set = set;
this.setup();
// Auto-start loading when connected
setTimeout(() => {
if (this.socket && this.socket.connected) {
this.loadPages();
} else {
this.socket.on('connect', () => this.loadPages());
}
}, 100);
}
loadPages() {
this.socket.emit(this.messages.LOAD_PEERON_PAGES, {
set: this.set
});
}
// Override complete to redirect when done
complete(data) {
super.complete(data);
// Redirect to show the pages selection interface
const params = new URLSearchParams();
params.set('set', this.set);
params.set('peeron_loaded', '1');
window.location.href = `${window.location.pathname}?${params.toString()}`;
}
}
+24 -3
View File
@@ -1,4 +1,5 @@
class BrickTable { // Make BrickTable globally accessible
window.BrickTable = class BrickTable {
constructor(table, per_page) { constructor(table, per_page) {
const columns = []; const columns = [];
const no_sort_and_filter = []; const no_sort_and_filter = [];
@@ -32,12 +33,22 @@ class BrickTable {
columns.push({ select: number, type: "number", searchable: false }); columns.push({ select: number, type: "number", searchable: false });
} }
// Special configuration for tables with custom search/sort
const isMinifiguresTable = table.id === 'minifigures';
const isPartsTable = table.id === 'parts';
const isProblemsTable = table.id === 'problems';
const isPartsTablePaginationMode = isPartsTable && table.getAttribute('data-table') === 'false';
const isProblemsTablePaginationMode = isProblemsTable && table.getAttribute('data-table') === 'false';
const hasCustomInterface = isMinifiguresTable || isPartsTablePaginationMode || isProblemsTablePaginationMode;
const hasCustomSearch = isPartsTable || isProblemsTable || isMinifiguresTable; // Parts, problems, and minifigures always have custom search
this.table = new simpleDatatables.DataTable(`#${table.id}`, { this.table = new simpleDatatables.DataTable(`#${table.id}`, {
columns: columns, columns: columns,
paging: !(isPartsTablePaginationMode || isProblemsTablePaginationMode), // Disable built-in pagination for tables in pagination mode
pagerDelta: 1, pagerDelta: 1,
perPage: per_page, perPage: per_page,
perPageSelect: [10, 25, 50, 100, 500, 1000], perPageSelect: [10, 25, 50, 100, 500, 1000],
searchable: true, searchable: !hasCustomSearch, // Disable built-in search for tables with custom search
searchMethod: (table => (terms, cell, row, column, source) => table.search(terms, cell, row, column, source))(this), searchMethod: (table => (terms, cell, row, column, source) => table.search(terms, cell, row, column, source))(this),
searchQuerySeparator: "", searchQuerySeparator: "",
tableRender: () => { tableRender: () => {
@@ -92,5 +103,15 @@ class BrickTable {
// Helper to setup the tables // Helper to setup the tables
const setup_tables = (per_page) => document.querySelectorAll('table[data-table="true"]').forEach( const setup_tables = (per_page) => document.querySelectorAll('table[data-table="true"]').forEach(
el => new BrickTable(el, per_page) el => {
const brickTable = new window.BrickTable(el, per_page);
// Store the instance globally for external access
if (el.id === 'minifigures') {
window.brickTableInstance = brickTable;
} else if (el.id === 'parts') {
window.partsTableInstance = brickTable;
} else if (el.id === 'problems') {
window.problemsTableInstance = brickTable;
}
}
); );
+22
View File
@@ -78,3 +78,25 @@
linear-gradient(336deg, rgb(0 0 255 / 80%), rgb(0 0 255 / 0%) 70.71%) linear-gradient(336deg, rgb(0 0 255 / 80%), rgb(0 0 255 / 0%) 70.71%)
; ;
} }
/* Mobile Pagination Fixes */
.mobile-pagination {
display: flex !important;
width: 100% !important;
gap: 2px;
}
.mobile-pagination .btn:first-child,
.mobile-pagination .btn:last-child {
flex: 0 0 auto;
min-width: 90px;
max-width: 120px;
}
.mobile-pagination .btn:nth-child(2) {
flex: 1 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
+3
View File
@@ -1,5 +1,6 @@
{% import 'macro/table.html' as table %} {% import 'macro/table.html' as table %}
{% import 'macro/badge.html' as badge %} {% import 'macro/badge.html' as badge %}
{% import 'macro/form.html' as form %}
<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="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"> <div class="table-responsive-sm">
@@ -13,6 +14,7 @@
<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 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 transparent</th>
<th data-table-number="true" scope="col"><i class="ri-error-warning-line fw-normal"></i> Empty URL</th> <th data-table-number="true" scope="col"><i class="ri-error-warning-line fw-normal"></i> Empty URL</th>
<th data-table-number="true" scope="col"><i class="ri-palette-line fw-normal"></i>BrickLink Issues</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> <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> </tr>
</thead> </thead>
@@ -26,6 +28,7 @@
<td>{{ item.fields.null_rgb }}</td> <td>{{ item.fields.null_rgb }}</td>
<td>{{ item.fields.null_transparent }}</td> <td>{{ item.fields.null_transparent }}</td>
<td>{{ item.fields.null_url }}</td> <td>{{ item.fields.null_url }}</td>
<td>{{ item.fields.null_bricklink_color_id + item.fields.null_bricklink_color_name + item.fields.null_bricklink_part_num }}</td>
<td><a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh</a></td> <td><a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh</a></td>
</tr> </tr>
{% endfor %} {% endfor %}
+17
View File
@@ -81,6 +81,7 @@
<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/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> <script src="https://cdn.jsdelivr.net/npm/vanillajs-datepicker@1.3.4/dist/js/datepicker-full.min.js"></script>
<!-- BrickTracker scripts --> <!-- BrickTracker scripts -->
<script src="{{ url_for('static', filename='scripts/collapsible-state.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/changer.js') }}"></script> <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/filter.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/grid/grid.js') }}"></script> <script src="{{ url_for('static', filename='scripts/grid/grid.js') }}"></script>
@@ -91,6 +92,22 @@
<script src="{{ url_for('static', filename='scripts/socket/instructions.js') }}"></script> <script src="{{ url_for('static', filename='scripts/socket/instructions.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/socket/set.js') }}"></script> <script src="{{ url_for('static', filename='scripts/socket/set.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/table.js') }}"></script> <script src="{{ url_for('static', filename='scripts/table.js') }}"></script>
{% if request.endpoint == 'minifigure.list' %}
<script src="{{ url_for('static', filename='scripts/minifigures.js') }}"></script>
{% endif %}
{% if request.endpoint == 'part.list' %}
<script src="{{ url_for('static', filename='scripts/parts.js') }}"></script>
{% endif %}
{% if request.endpoint == 'part.problem' %}
<script src="{{ url_for('static', filename='scripts/parts.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/problems.js') }}"></script>
{% endif %}
{% if request.endpoint == 'set.list' %}
<script src="{{ url_for('static', filename='scripts/sets.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>
{% endif %}
<script type="text/javascript"> <script type="text/javascript">
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
setup_grids(); setup_grids();
+1 -1
View File
@@ -16,7 +16,7 @@
{% if g.login.is_authenticated() %} {% if g.login.is_authenticated() %}
<p class="border-bottom pb-2 px-2 text-center"> <p class="border-bottom pb-2 px-2 text-center">
<a class="btn btn-primary" href="{{ url_for('instructions.upload') }}"><i class="ri-upload-line"></i> Upload an instructions file</a> <a class="btn btn-primary" href="{{ url_for('instructions.upload') }}"><i class="ri-upload-line"></i> Upload an instructions file</a>
<a class="btn btn-primary" href="{{ url_for('instructions.download') }}"><i class="ri-download-line"></i> Download instructions from Rebrickable</a> <a class="btn btn-primary" href="{{ url_for('instructions.download') }}"><i class="ri-download-line"></i> Download instructions</a>
<a href="{{ url_for('admin.admin', open_instructions=true) }}" class="btn btn-light border" role="button"><i class="ri-refresh-line"></i> Refresh the instructions cache</a> <a href="{{ url_for('admin.admin', open_instructions=true) }}" class="btn btn-light border" role="button"><i class="ri-refresh-line"></i> Refresh the instructions cache</a>
</p> </p>
{% endif %} {% endif %}
+1 -1
View File
@@ -5,7 +5,7 @@
<form method="POST" action="{{ url_for('instructions.do_download') }}"> <form method="POST" action="{{ url_for('instructions.do_download') }}">
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0"><i class="ri-download-line"></i> Download instructions from Rebrickable</h5> <h5 class="mb-0"><i class="ri-download-line"></i> Download instructions</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="mb-3"> <div class="mb-3">
@@ -0,0 +1,15 @@
<script type="text/javascript">
document.addEventListener("DOMContentLoaded", () => {
new BrickPeeronPageLoader(
'{{ set }}',
'{{ path }}',
'{{ namespace }}',
{
COMPLETE: '{{ messages['COMPLETE'] }}',
FAIL: '{{ messages['FAIL'] }}',
LOAD_PEERON_PAGES: '{{ messages['LOAD_PEERON_PAGES'] }}',
PROGRESS: '{{ messages['PROGRESS'] }}',
}
);
});
</script>
+10
View File
@@ -0,0 +1,10 @@
<script type="text/javascript">
document.addEventListener("DOMContentLoaded", () => {
new BrickPeeronSocket('peeron-download', '{{ path }}', '{{ namespace }}', {
COMPLETE: '{{ messages['COMPLETE'] }}',
DOWNLOAD_PEERON_PAGES: 'download_peeron_pages',
FAIL: '{{ messages['FAIL'] }}',
PROGRESS: '{{ messages['PROGRESS'] }}',
});
});
</script>
+11 -11
View File
@@ -1,6 +1,6 @@
{% macro checkbox(name, id, prefix, url, checked, parent=none, delete=false) %} {% macro checkbox(name, id, prefix, url, checked, parent=none, delete=false) %}
{% if g.login.is_authenticated() %} {% if g.login.is_authenticated() %}
<input class="form-check-input" type="checkbox" id="{{ prefix }}-{{ id }}" {% if checked %}checked{% endif %} <input class="form-check-input px-1" type="checkbox" id="{{ prefix }}-{{ id }}" {% if checked %}checked{% endif %}
{% if not delete %} {% if not delete %}
data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}" {% 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 %} {% else %}
@@ -22,8 +22,8 @@
{% else %} {% else %}
<label class="visually-hidden" for="{{ prefix }}-{{ id }}">{{ name }}</label> <label class="visually-hidden" for="{{ prefix }}-{{ id }}">{{ name }}</label>
<div class="input-group"> <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 %} {% if icon %}<span class="input-group-text px-1"><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 %}" <input class="form-control form-control-sm flex-shrink-1 px-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 }}" data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}"
{% if date %}data-changer-date="true"{% endif %} {% if date %}data-changer-date="true"{% endif %}
@@ -31,12 +31,12 @@
disabled disabled
{% endif %} {% endif %}
autocomplete="off"> autocomplete="off">
{% if suffix %}<span class="input-group-text d-none d-md-inline">{{ suffix }}</span>{% endif %} {% if suffix %}<span class="input-group-text d-none d-md-inline px-1">{{ suffix }}</span>{% endif %}
{% if g.login.is_authenticated() %} {% if g.login.is_authenticated() %}
<span id="status-{{ prefix }}-{{ id }}" class="input-group-text ri-save-line"></span> <span id="status-{{ prefix }}-{{ id }}" class="input-group-text ri-save-line px-1"></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> <button id="clear-{{ prefix }}-{{ id }}" type="button" class="btn btn-sm btn-light btn-outline-danger border px-1"><i class="ri-eraser-line"></i></button>
{% else %} {% else %}
<span class="input-group-text ri-prohibited-line"></span> <span class="input-group-text ri-prohibited-line px-1"></span>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
@@ -46,8 +46,8 @@
{% if g.login.is_authenticated() %} {% if g.login.is_authenticated() %}
<label class="visually-hidden" for="{{ prefix }}-{{ id }}">{{ name }}</label> <label class="visually-hidden" for="{{ prefix }}-{{ id }}">{{ name }}</label>
<div class="input-group"> <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 %} {% if icon %}<span class="input-group-text px-1"><i class="ri-{{ icon }} me-1"></i><span class="ms-1 d-none d-md-inline"> {{ name }}</span></span>{% endif %}
<select id="{{ prefix }}-{{ id }}" class="form-select" <select id="{{ prefix }}-{{ id }}" class="form-select px-1"
{% if not delete %} {% if not delete %}
data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}" data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}"
{% else %} {% else %}
@@ -59,8 +59,8 @@
<option value="{{ metadata.fields.id }}" {% if metadata.fields.id == value %}selected{% endif %}>{{ metadata.fields.name }}</option> <option value="{{ metadata.fields.id }}" {% if metadata.fields.id == value %}selected{% endif %}>{{ metadata.fields.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
<span id="status-{{ prefix }}-{{ id }}" class="input-group-text ri-save-line"></span> <span id="status-{{ prefix }}-{{ id }}" class="input-group-text ri-save-line px-1"></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> <button id="clear-{{ prefix }}-{{ id }}" type="button" class="btn btn-sm btn-light btn-outline-danger border px-1"><i class="ri-eraser-line"></i></button>
</div> </div>
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
+15
View File
@@ -0,0 +1,15 @@
<div id="table-filter" class="collapse {% if config['SHOW_GRID_FILTERS'] %}show{% endif %} row row-cols-lg-auto g-1 justify-content-center align-items-center">
{% if owners | length %}
<div class="col-12 flex-grow-1">
<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="filter-owner" class="form-select" onchange="filterByOwnerAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_owner == 'all' %}selected{% endif %}>All owners</option>
{% for owner in owners %}
<option value="{{ owner.fields.id }}" {% if selected_owner == owner.fields.id %}selected{% endif %}>{{ owner.fields.name }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
</div>
+25
View File
@@ -0,0 +1,25 @@
<div id="table-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-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-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-quantity" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="quantity" data-sort-desc="true"><i class="ri-functions"></i><span class="d-none d-xl-inline"> Quantity</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-sets" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="sets" data-sort-desc="true"><i class="ri-hashtag"></i><span class="d-none d-xl-inline"> Sets</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>
+6 -6
View File
@@ -7,17 +7,17 @@
{% for item in table_collection %} {% for item in table_collection %}
<tr> <tr>
{{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.figure) }} {{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.figure) }}
<td > <td data-sort="name">
<a class="text-reset" href="{{ item.url() }}" style="max-width:auto">{{ item.fields.name }}</a> <a class="text-reset" href="{{ item.url() }}" style="max-width:auto">{{ item.fields.name }}</a>
{% if all %} {% if all %}
{{ table.rebrickable(item) }} {{ table.rebrickable(item) }}
{% endif %} {% endif %}
</td> </td>
<td>{{ item.fields.number_of_parts }}</td> <td data-sort="parts">{{ item.fields.number_of_parts }}</td>
<td>{{ item.fields.total_quantity }}</td> <td data-sort="quantity">{{ item.fields.total_quantity }}</td>
<td>{{ item.fields.total_missing }}</td> <td data-sort="missing">{{ item.fields.total_missing }}</td>
<td>{{ item.fields.total_damaged }}</td> <td data-sort="damaged">{{ item.fields.total_damaged }}</td>
<td>{{ item.fields.total_sets }}</td> <td data-sort="sets">{{ item.fields.total_sets }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
+22
View File
@@ -0,0 +1,22 @@
{% import 'macro/table.html' as table %}
<tbody>
{% for minifigure in table_collection %}
<tr>
{{ table.image(minifigure.url_for_image(), caption=minifigure.fields.name, alt=minifigure.fields.figure) }}
<td data-sort="{{ minifigure.fields.name }}">
<a class="text-reset" href="{{ minifigure.url() }}">{{ minifigure.fields.name }}</a>
{{ table.rebrickable(minifigure) }}
</td>
<td data-sort="{{ minifigure.fields.number_of_parts }}">{{ minifigure.fields.number_of_parts }}</td>
<td data-sort="{{ minifigure.fields.total_quantity }}">{{ minifigure.fields.total_quantity }}</td>
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
<td data-sort="{{ minifigure.fields.total_missing }}">{{ minifigure.fields.total_missing }}</td>
{% endif %}
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
<td data-sort="{{ minifigure.fields.total_damaged }}">{{ minifigure.fields.total_damaged }}</td>
{% endif %}
<td data-sort="{{ minifigure.fields.total_sets }}">{{ minifigure.fields.total_sets }}</td>
</tr>
{% endfor %}
</tbody>
+184 -4
View File
@@ -1,11 +1,191 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% import 'macro/table.html' as table %}
{% block title %} - All minifigures{% endblock %} {% block title %} - All minifigures{% endblock %}
{% block main %} {% block main %}
<div class="container-fluid px-0"> {% if table_collection | length %}
{% with all=true %} <div class="container-fluid">
{% include 'minifigure/table.html' %} <div class="row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
{% endwith %} <div class="col-12 flex-grow-1">
<label class="visually-hidden" for="table-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="table-search" class="form-control form-control-sm px-1" type="text" placeholder="Figure name, parts count, sets" value="{{ search_query or '' }}">
<button id="table-search-clear" type="button" class="btn btn-light btn-outline-danger border"><i class="ri-eraser-line"></i></button>
</div> </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="#table-sort" aria-expanded="{% if config['SHOW_GRID_SORT'] %}true{% else %}false{% endif %}" aria-controls="table-sort">
<i class="ri-sort-asc"></i> Sort
</button>
<button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#table-filter" aria-expanded="{% if config['SHOW_GRID_FILTERS'] %}true{% else %}false{% endif %}" aria-controls="table-filter">
<i class="ri-filter-line"></i> Filters
</button>
</div>
</div>
</div>
{% include 'minifigure/sort.html' %}
{% include 'minifigure/filter.html' %}
{% if use_pagination %}
<!-- PAGINATION MODE -->
<div class="table-responsive-sm">
<table data-table="false" class="table table-striped align-middle mb-0" id="minifigures">
{{ table.header(parts=true, quantity=true, missing=true, damaged=true, sets=true, minifigures=false) }}
{% include 'minifigure/table_body.html' %}
</table>
</div>
<!-- Pagination -->
<div>
{% if pagination and pagination.total_pages > 1 %}
<div class="row mt-4">
<div class="col-12">
<!-- Desktop Pagination -->
<div class="d-none d-md-block">
<nav aria-label="Minifigures pagination">
<ul class="pagination justify-content-center">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.page - 1) }}">
<i class="ri-arrow-left-line"></i> Previous
</a>
</li>
{% endif %}
<!-- Show page numbers (with smart truncation) -->
{% set start_page = [1, pagination.page - 2] | max %}
{% set end_page = [pagination.total_pages, pagination.page + 2] | min %}
{% if start_page > 1 %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', 1) }}">1</a>
</li>
{% if start_page > 2 %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
{% endif %}
{% for page_num in range(start_page, end_page + 1) %}
{% if page_num == pagination.page %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', page_num) }}">{{ page_num }}</a>
</li>
{% endif %}
{% endfor %}
{% if end_page < pagination.total_pages %}
{% if end_page < pagination.total_pages - 1 %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.total_pages) }}">{{ pagination.total_pages }}</a>
</li>
{% endif %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.page + 1) }}">
Next <i class="ri-arrow-right-line"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
<!-- Mobile Pagination -->
<div class="d-md-none">
<div class="mobile-pagination" role="group" aria-label="Mobile pagination">
{% if pagination.has_prev %}
<a href="{{ request.url | replace_query('page', pagination.page - 1) }}"
class="btn btn-outline-primary">
<i class="ri-arrow-left-line"></i> Previous
</a>
{% else %}
<button class="btn btn-outline-secondary" disabled>
<i class="ri-arrow-left-line"></i> Previous
</button>
{% endif %}
<span class="btn btn-light">
Page {{ pagination.page }} of {{ pagination.total_pages }}
</span>
{% if pagination.has_next %}
<a href="{{ request.url | replace_query('page', pagination.page + 1) }}"
class="btn btn-outline-primary">
Next <i class="ri-arrow-right-line"></i>
</a>
{% else %}
<button class="btn btn-outline-secondary" disabled>
Next <i class="ri-arrow-right-line"></i>
</button>
{% endif %}
</div>
</div>
<!-- Results Info -->
<div class="text-center mt-3">
<small class="text-muted">
Showing {{ ((pagination.page - 1) * pagination.per_page + 1) }} to
{{ [pagination.page * pagination.per_page, pagination.total_count] | min }}
of {{ pagination.total_count }} minifigures
</small>
</div>
</div>
</div>
{% endif %}
</div>
{% else %}
<!-- ORIGINAL MODE - Single page with client-side search -->
<div class="table-responsive-sm">
<table data-table="true" class="table table-striped align-middle {% if not all %}sortable mb-0{% endif %}" id="minifigures">
{{ table.header(parts=true, quantity=true, missing=true, damaged=true, sets=true, minifigures=false) }}
<tbody>
{% for minifigure in table_collection %}
<tr>
{{ table.image(minifigure.url_for_image(), caption=minifigure.fields.name, alt=minifigure.fields.figure) }}
<td data-sort="{{ minifigure.fields.name }}">
<a class="text-reset" href="{{ minifigure.url() }}">{{ minifigure.fields.name }}</a>
{{ table.rebrickable(minifigure) }}
</td>
<td data-sort="{{ minifigure.fields.number_of_parts }}">{{ minifigure.fields.number_of_parts }}</td>
<td data-sort="{{ minifigure.fields.total_quantity }}">{{ minifigure.fields.total_quantity }}</td>
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
<td data-sort="{{ minifigure.fields.total_missing }}">{{ minifigure.fields.total_missing }}</td>
{% endif %}
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
<td data-sort="{{ minifigure.fields.total_damaged }}">{{ minifigure.fields.total_damaged }}</td>
{% endif %}
<td data-sort="{{ minifigure.fields.total_sets }}">{{ minifigure.fields.total_sets }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{% else %}
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="text-center">
<i class="ri-group-line" style="font-size: 4rem; color: #6c757d;"></i>
<h3 class="mt-3">No minifigures found</h3>
<p class="text-muted">No minifigures are available for the selected owner.</p>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %} {% endblock %}
+30
View File
@@ -0,0 +1,30 @@
<div id="table-filter" class="collapse {% if config['SHOW_GRID_FILTERS'] %}show{% endif %} row row-cols-lg-auto g-1 justify-content-center align-items-center">
{% if owners | length %}
<div class="col-12 col-md-6 flex-grow-1">
<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="filter-owner" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_owner == 'all' %}selected{% endif %}>All owners</option>
{% for owner in owners %}
<option value="{{ owner.fields.id }}" {% if selected_owner == owner.fields.id %}selected{% endif %}>{{ owner.fields.name }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
{% if colors | length %}
<div class="col-12 col-md-6 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-palette-line"></i><span class="ms-1 d-none d-md-inline"> Color</span></span>
<select id="filter-color" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_color == 'all' %}selected{% endif %}>All colors</option>
{% for color in colors %}
<option value="{{ color.color_id }}" {% if selected_color == color.color_id|string %}selected{% endif %} data-color-rgb="{{ color.color_rgb }}" data-color-id="{{ color.color_id }}">
{{ color.color_name }}
</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
</div>
+27
View File
@@ -0,0 +1,27 @@
<div id="table-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-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-color" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="color"><i class="ri-palette-line"></i><span class="d-none d-xl-inline"> Color</span></button>
<button id="sort-quantity" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="quantity" data-sort-desc="true"><i class="ri-functions"></i><span class="d-none d-xl-inline"> Quantity</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-sets" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="sets" data-sort-desc="true"><i class="ri-hashtag"></i><span class="d-none d-xl-inline"> Sets</span></button>
<button id="sort-minifigures" 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-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>
+35
View File
@@ -0,0 +1,35 @@
{% import 'macro/form.html' as form %}
{% import 'macro/table.html' as table %}
<tbody>
{% for item in table_collection %}
<tr>
{{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.part, accordion=solo) }}
<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 %}
{{ table.rebrickable(item) }}
{{ table.bricklink(item) }}
{% endif %}
</td>
<td data-sort="{{ item.fields.color_name }}">
{% 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>
<td>{{ item.fields.total_quantity }}</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 %}
<td>{{ item.fields.total_sets }}</td>
<td>{{ item.fields.total_minifigures }}</td>
</tr>
{% endfor %}
</tbody>
+197 -4
View File
@@ -1,11 +1,204 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% import 'macro/form.html' as form %}
{% import 'macro/table.html' as table %}
{% block title %} - All parts{% endblock %} {% block title %} - All parts{% endblock %}
{% block main %} {% block main %}
<div class="container-fluid px-0"> {% if table_collection | length %}
{% with all=true %} <div class="container-fluid">
{% include 'part/table.html' %} <div class="row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
{% endwith %} <div class="col-12 flex-grow-1">
<label class="visually-hidden" for="table-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="table-search" class="form-control form-control-sm px-1" type="text" placeholder="Part name, color, quantity, sets" value="{{ search_query or '' }}">
<button id="table-search-clear" type="button" class="btn btn-light btn-outline-danger border"><i class="ri-eraser-line"></i></button>
</div> </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="#table-sort" aria-expanded="{% if config['SHOW_GRID_SORT'] %}true{% else %}false{% endif %}" aria-controls="table-sort">
<i class="ri-sort-asc"></i> Sort
</button>
<button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#table-filter" aria-expanded="{% if config['SHOW_GRID_FILTERS'] %}true{% else %}false{% endif %}" aria-controls="table-filter">
<i class="ri-filter-line"></i> Filters
</button>
</div>
</div>
</div>
{% include 'part/sort.html' %}
{% include 'part/filter.html' %}
{% if use_pagination %}
<!-- PAGINATION MODE -->
<div class="table-responsive-sm">
<table data-table="false" class="table table-striped align-middle mb-0" id="parts">
{{ table.header(color=true, quantity=not no_quantity, sets=true, minifigures=true) }}
{% with all=true %}
{% include 'part/table_body.html' %}
{% endwith %}
</table>
</div>
<!-- Pagination -->
<div>
{% if pagination and pagination.total_pages > 1 %}
<div class="row mt-4">
<div class="col-12">
<!-- Desktop Pagination -->
<div class="d-none d-md-block">
<nav aria-label="Parts pagination">
<ul class="pagination justify-content-center">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.page - 1) }}">
<i class="ri-arrow-left-line"></i> Previous
</a>
</li>
{% endif %}
<!-- Show page numbers (with smart truncation) -->
{% set start_page = [1, pagination.page - 2] | max %}
{% set end_page = [pagination.total_pages, pagination.page + 2] | min %}
{% if start_page > 1 %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', 1) }}">1</a>
</li>
{% if start_page > 2 %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
{% endif %}
{% for page_num in range(start_page, end_page + 1) %}
{% if page_num == pagination.page %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', page_num) }}">{{ page_num }}</a>
</li>
{% endif %}
{% endfor %}
{% if end_page < pagination.total_pages %}
{% if end_page < pagination.total_pages - 1 %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.total_pages) }}">{{ pagination.total_pages }}</a>
</li>
{% endif %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.page + 1) }}">
Next <i class="ri-arrow-right-line"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
<!-- Mobile Pagination -->
<div class="d-md-none">
<div class="mobile-pagination" role="group" aria-label="Mobile pagination">
{% if pagination.has_prev %}
<a href="{{ request.url | replace_query('page', pagination.page - 1) }}"
class="btn btn-outline-primary">
<i class="ri-arrow-left-line"></i> Previous
</a>
{% else %}
<button class="btn btn-outline-secondary" disabled>
<i class="ri-arrow-left-line"></i> Previous
</button>
{% endif %}
<span class="btn btn-light">
Page {{ pagination.page }} of {{ pagination.total_pages }}
</span>
{% if pagination.has_next %}
<a href="{{ request.url | replace_query('page', pagination.page + 1) }}"
class="btn btn-outline-primary">
Next <i class="ri-arrow-right-line"></i>
</a>
{% else %}
<button class="btn btn-outline-secondary" disabled>
Next <i class="ri-arrow-right-line"></i>
</button>
{% endif %}
</div>
</div>
<!-- Results Info -->
<div class="text-center mt-3">
<small class="text-muted">
Showing {{ ((pagination.page - 1) * pagination.per_page + 1) }} to
{{ [pagination.page * pagination.per_page, pagination.total_count] | min }}
of {{ pagination.total_count }} parts
</small>
</div>
</div>
</div>
{% endif %}
</div>
{% else %}
<!-- ORIGINAL MODE - Single page with client-side search -->
<div class="table-responsive-sm">
<table data-table="true" class="table table-striped align-middle {% if not all %}sortable mb-0{% endif %}" id="parts">
{{ table.header(color=true, quantity=not no_quantity, sets=true, minifigures=true) }}
<tbody>
{% for item in table_collection %}
<tr>
{{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.part, accordion=solo) }}
<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 %}
{{ table.rebrickable(item) }}
{{ table.bricklink(item) }}
</td>
<td data-sort="{{ item.fields.color_name }}">
{% 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>
<td>{{ item.fields.total_quantity }}</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=true, 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=true, read_only=read_only) }}
</td>
{% endif %}
<td>{{ item.fields.total_sets }}</td>
<td>{{ item.fields.total_minifigures }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{% else %}
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="text-center">
<i class="ri-shapes-line" style="font-size: 4rem; color: #6c757d;"></i>
<h3 class="mt-3">No parts found</h3>
<p class="text-muted">No parts are available for the selected owner.</p>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %} {% endblock %}
+144
View File
@@ -0,0 +1,144 @@
{% extends 'base.html' %}
{% block title %} - Download instructions from Peeron{% endblock %}
{% block main %}
<div class="container">
{% if error %}<div class="alert alert-danger" role="alert"><strong>Error:</strong> {{ error }}.</div>{% endif %}
<div class="row">
<div class="col-12">
<form method="POST" action="{{ url_for('instructions.do_download') }}">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0"><i class="ri-download-line"></i> Download instructions from Rebrickable</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label for="download-set" class="form-label">Set number (only one)</label>
<input type="text" class="form-control" id="download-set" name="download-set" placeholder="107-1 or 1642-1 or ..." value="{{ set }}">
</div>
</div>
<div class="card-footer text-end">
<button type="submit" class="btn btn-primary"><i class="ri-search-line"></i> Search</button>
</div>
</div>
</form>
{% if loading_peeron %}
<div class="alert alert-info" role="alert">
<i class="ri-information-line"></i> <strong>Found on Peeron:</strong> {{ set }} was not available on Rebrickable, loading instruction pages from Peeron...
</div>
<!-- Socket elements for peeron-loader -->
<div id="peeron-loader-fail" class="alert alert-danger d-none" role="alert"></div>
<div id="peeron-loader-complete" class="alert alert-success d-none" role="alert"></div>
<div class="mb-3">
<p>
Progress <span id="peeron-loader-count"></span>
<span id="peeron-loader-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="peeron-loader-progress" class="progress" role="progressbar" aria-label="Loading Peeron pages" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<div id="peeron-loader-progress-bar" class="progress-bar" style="width: 0%"></div>
</div>
<p id="peeron-loader-progress-message" class="text-center d-none"></p>
</div>
{% endif %}
{% if loading_peeron %}
<!-- Include socket for automatic loading -->
{% with set=set, path=path, namespace=namespace, messages=messages %}
{% include 'instructions/peeron_loader_socket.html' %}
{% endwith %}
{% endif %}
{% if pages %}
<div id="peeron-loading-alert" class="alert alert-info" role="alert">
<i class="ri-information-line"></i> <strong>Instructions found on Peeron:</strong> {{ set }} was not available on Rebrickable, but {{ pages|length }} instruction pages were found on Peeron.
<div id="peeron-cache-progress" class="mt-2 d-none">
<div class="progress" role="progressbar" aria-label="Caching thumbnails" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<div id="peeron-cache-progress-bar" class="progress-bar" style="width: 0%"></div>
</div>
<small id="peeron-cache-message" class="text-muted">Caching thumbnails...</small>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0"><i class="ri-checkbox-line"></i> Select instructions to download</h5>
</div>
<div class="card-body">
<div class="mb-3">
<div id="peeron-download-fail" class="alert alert-danger d-none" role="alert"></div>
<div id="peeron-download-complete"></div>
<div class="d-flex justify-content-between align-items-center border-bottom mb-3">
<h5 class="mb-0">Available Instructions</h5>
<button id="peeron-select-all" type="button" class="btn btn-sm btn-outline-secondary">
<i class="ri-checkbox-multiple-line"></i> Select All
</button>
</div>
<div id="peeron-download-files" class="row g-2">
{% for page in pages %}
<div class="col-12 col-md-6 col-lg-4">
<div class="card border-0 shadow-sm">
<div class="card-body p-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="peeron-page-{{ loop.index }}"
data-page-number="{{ page.page_number }}"
data-original-image-url="{{ page.original_image_url }}"
data-cached-full-image-path="{{ page.cached_full_image_path }}"
data-alt-text="{{ page.alt_text }}"
data-rotation="0"
autocomplete="off">
<label class="form-check-label w-100" for="peeron-page-{{ loop.index }}">
<div class="text-center position-relative">
<div class="position-relative d-inline-block">
<img id="peeron-img-{{ loop.index }}" src="{{ page.cached_thumbnail_url }}" alt="{{ page.alt_text }}"
class="img-fluid mb-2 border rounded peeron-thumbnail" style="max-height: 150px; transform: rotate(0deg); transition: transform 0.3s ease;"
data-index="{{ loop.index }}" data-total="{{ pages|length }}">
<button type="button" class="btn btn-sm btn-light position-absolute top-0 end-0 p-1 me-1 mt-1 peeron-rotate-btn"
data-target="peeron-img-{{ loop.index }}" data-checkbox="peeron-page-{{ loop.index }}" data-rotation="0"
title="Rotate page" style="font-size: 0.7rem; line-height: 1;">
<i class="ri-refresh-line"></i>
</button>
</div>
<div class="small fw-bold">Page {{ page.page_number }}</div>
</div>
</label>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<hr>
<div class="mb-3">
<p>
Progress <span id="peeron-download-count"></span>
<span id="peeron-download-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="peeron-download-progress" class="progress" role="progressbar" aria-label="Download Peeron instructions progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<div id="peeron-download-progress-bar" class="progress-bar" style="width: 0%"></div>
</div>
<p id="peeron-download-progress-message" class="text-center d-none"></p>
</div>
</div>
<div class="card-footer text-end">
<span id="peeron-download-status-icon" class="me-1"></span><span id="peeron-download-status" class="me-1"></span><button id="peeron-download" type="button" class="btn btn-primary"><i class="ri-download-line"></i> Download selected files</button>
</div>
</div>
{% if not loading_peeron %}
<!-- Include normal socket for downloading -->
{% include 'instructions/peeron_socket.html' %}
{% endif %}
{% endif %}
</div>
</div>
</div>
{% endblock %}
+222 -4
View File
@@ -1,11 +1,229 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% import 'macro/form.html' as form %}
{% import 'macro/table.html' as table %}
{% block title %} - Problematic parts{% endblock %} {% block title %} - Problematic parts{% endblock %}
{% block main %} {% block main %}
<div class="container-fluid px-0"> {% if table_collection | length %}
{% with all=true, no_quantity=true %} <div class="container-fluid">
{% include 'part/table.html' %} <div class="row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
{% endwith %} <div class="col-12 flex-grow-1">
<label class="visually-hidden" for="table-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="table-search" class="form-control form-control-sm px-1" type="text" placeholder="Part name, color" value="{{ search_query or '' }}">
<button id="table-search-clear" type="button" class="btn btn-light btn-outline-danger border"><i class="ri-eraser-line"></i></button>
</div> </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="#table-sort" aria-expanded="{% if config['SHOW_GRID_SORT'] %}true{% else %}false{% endif %}" aria-controls="table-sort">
<i class="ri-sort-asc"></i> Sort
</button>
<button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#table-filter" aria-expanded="{% if config['SHOW_GRID_FILTERS'] %}true{% else %}false{% endif %}" aria-controls="table-filter">
<i class="ri-filter-line"></i> Filters
</button>
</div>
</div>
</div>
{% include 'problem/sort.html' %}
{% include 'problem/filter.html' %}
{% if use_pagination %}
<!-- PAGINATION MODE -->
<div class="table-responsive-sm">
<table data-table="false" class="table table-striped align-middle mb-0" id="problems">
{{ table.header(color=true, quantity=false, sets=true, minifigures=true) }}
<tbody>
{% for item in table_collection %}
<tr>
{{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.part) }}
<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 %}
{{ table.rebrickable(item) }}
{{ table.bricklink(item) }}
</td>
<td data-sort="{{ item.fields.color_name }}">
{% 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 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=true, 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=true, read_only=read_only) }}
</td>
{% endif %}
<td>{{ item.fields.total_sets }}</td>
<td>{{ item.fields.total_minifigures }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
<div>
{% if pagination and pagination.total_pages > 1 %}
<div class="row mt-4">
<div class="col-12">
<!-- Desktop Pagination -->
<div class="d-none d-md-block">
<nav aria-label="Problems pagination">
<ul class="pagination justify-content-center">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.page - 1) }}">
<i class="ri-arrow-left-line"></i> Previous
</a>
</li>
{% endif %}
<!-- Show page numbers (with smart truncation) -->
{% set start_page = [1, pagination.page - 2] | max %}
{% set end_page = [pagination.total_pages, pagination.page + 2] | min %}
{% if start_page > 1 %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', 1) }}">1</a>
</li>
{% if start_page > 2 %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
{% endif %}
{% for page_num in range(start_page, end_page + 1) %}
{% if page_num == pagination.page %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', page_num) }}">{{ page_num }}</a>
</li>
{% endif %}
{% endfor %}
{% if end_page < pagination.total_pages %}
{% if end_page < pagination.total_pages - 1 %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.total_pages) }}">{{ pagination.total_pages }}</a>
</li>
{% endif %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.page + 1) }}">
Next <i class="ri-arrow-right-line"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
<!-- Mobile Pagination -->
<div class="d-md-none">
<div class="mobile-pagination" role="group" aria-label="Mobile pagination">
{% if pagination.has_prev %}
<a href="{{ request.url | replace_query('page', pagination.page - 1) }}"
class="btn btn-outline-primary">
<i class="ri-arrow-left-line"></i> Previous
</a>
{% else %}
<button class="btn btn-outline-secondary" disabled>
<i class="ri-arrow-left-line"></i> Previous
</button>
{% endif %}
<span class="btn btn-light">
Page {{ pagination.page }} of {{ pagination.total_pages }}
</span>
{% if pagination.has_next %}
<a href="{{ request.url | replace_query('page', pagination.page + 1) }}"
class="btn btn-outline-primary">
Next <i class="ri-arrow-right-line"></i>
</a>
{% else %}
<button class="btn btn-outline-secondary" disabled>
Next <i class="ri-arrow-right-line"></i>
</button>
{% endif %}
</div>
</div>
<!-- Results Info -->
<div class="text-center mt-3">
<small class="text-muted">
Showing {{ ((pagination.page - 1) * pagination.per_page + 1) }} to
{{ [pagination.page * pagination.per_page, pagination.total_count] | min }}
of {{ pagination.total_count }} problematic parts
</small>
</div>
</div>
</div>
{% endif %}
</div>
{% else %}
<!-- ORIGINAL MODE - Single page with client-side search -->
<div class="table-responsive-sm">
<table data-table="true" class="table table-striped align-middle sortable mb-0" id="problems">
{{ table.header(color=true, quantity=false, sets=true, minifigures=true) }}
<tbody>
{% for item in table_collection %}
<tr>
{{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.part) }}
<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 %}
{{ table.rebrickable(item) }}
{{ table.bricklink(item) }}
</td>
<td data-sort="{{ item.fields.color_name }}">
{% 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 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=true, 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=true, read_only=read_only) }}
</td>
{% endif %}
<td>{{ item.fields.total_sets }}</td>
<td>{{ item.fields.total_minifigures }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{% else %}
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="text-center">
<i class="ri-error-warning-line" style="font-size: 4rem; color: #6c757d;"></i>
<h3 class="mt-3">No problematic parts found</h3>
<p class="text-muted">Great! All your parts are in perfect condition.</p>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %} {% endblock %}
+30
View File
@@ -0,0 +1,30 @@
<div id="table-filter" class="collapse {% if config['SHOW_GRID_FILTERS'] %}show{% endif %} row row-cols-lg-auto g-1 justify-content-center align-items-center">
{% if owners | length %}
<div class="col-12 col-md-6 flex-grow-1">
<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="filter-owner" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_owner == 'all' %}selected{% endif %}>All owners</option>
{% for owner in owners %}
<option value="{{ owner.fields.id }}" {% if selected_owner == owner.fields.id %}selected{% endif %}>{{ owner.fields.name }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
{% if colors | length %}
<div class="col-12 col-md-6 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-palette-line"></i><span class="ms-1 d-none d-md-inline"> Color</span></span>
<select id="filter-color" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_color == 'all' %}selected{% endif %}>All colors</option>
{% for color in colors %}
<option value="{{ color.color_id }}" {% if selected_color == color.color_id|string %}selected{% endif %} data-color-rgb="{{ color.color_rgb }}" data-color-id="{{ color.color_id }}">
{{ color.color_name }}
</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
</div>
+25
View File
@@ -0,0 +1,25 @@
<div id="table-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-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-color" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="color"><i class="ri-palette-line"></i><span class="d-none d-xl-inline"> Color</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-sets" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="sets" data-sort-desc="true"><i class="ri-hashtag"></i><span class="d-none d-xl-inline"> Sets</span></button>
<button id="sort-minifigures" 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-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>
+21 -21
View File
@@ -6,26 +6,26 @@
<select id="grid-status" class="form-select" <select id="grid-status" class="form-select"
data-filter="metadata" data-filter="metadata"
autocomplete="off"> autocomplete="off">
<option value="" selected>All</option> <option value="" {% if not current_status_filter %}selected{% endif %}>All</option>
{% if not config['HIDE_TABLE_MISSING_PARTS'] %} {% if not config['HIDE_TABLE_MISSING_PARTS'] %}
<option value="has-missing">Has missing pieces</option> <option value="has-missing" {% if current_status_filter == 'has-missing' %}selected{% endif %}>Has missing pieces</option>
<option value="-has-missing">Has NO missing pieces</option> <option value="-has-missing" {% if current_status_filter == '-has-missing' %}selected{% endif %}>Has NO missing pieces</option>
{% endif %} {% endif %}
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %} {% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
<option value="has-damaged">Has damaged pieces</option> <option value="has-damaged" {% if current_status_filter == 'has-damaged' %}selected{% endif %}>Has damaged pieces</option>
<option value="-has-damaged">Has NO damaged pieces</option> <option value="-has-damaged" {% if current_status_filter == '-has-damaged' %}selected{% endif %}>Has NO damaged pieces</option>
{% endif %} {% endif %}
{% if not config['HIDE_SET_INSTRUCTIONS'] %} {% if not config['HIDE_SET_INSTRUCTIONS'] %}
<option value="-has-missing-instructions">Has instructions</option> <option value="-has-missing-instructions" {% if current_status_filter == '-has-missing-instructions' %}selected{% endif %}>Has instructions</option>
<option value="has-missing-instructions">Is MISSING instructions</option> <option value="has-missing-instructions" {% if current_status_filter == 'has-missing-instructions' %}selected{% endif %}>Is MISSING instructions</option>
{% endif %} {% endif %}
{% if brickset_storages | length %} {% if brickset_storages | length %}
<option value="has-storage">Is in storage</option> <option value="has-storage" {% if current_status_filter == 'has-storage' %}selected{% endif %}>Is in storage</option>
<option value="-has-storage">Is NOT in storage</option> <option value="-has-storage" {% if current_status_filter == '-has-storage' %}selected{% endif %}>Is NOT in storage</option>
{% endif %} {% endif %}
{% for status in brickset_statuses %} {% for status in brickset_statuses %}
<option value="{{ status.as_dataset() }}">{{ status.fields.name }}</option> <option value="{{ status.as_dataset() }}" {% if current_status_filter == status.as_dataset() %}selected{% endif %}>{{ status.fields.name }}</option>
<option value="-{{ status.as_dataset() }}">NOT: {{ status.fields.name }}</option> <option value="-{{ status.as_dataset() }}" {% if current_status_filter == ('-' + status.as_dataset()) %}selected{% endif %}>NOT: {{ status.fields.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -37,9 +37,9 @@
<select id="grid-theme" class="form-select" <select id="grid-theme" class="form-select"
data-filter="value" data-filter-attribute="theme" data-filter="value" data-filter-attribute="theme"
autocomplete="off"> autocomplete="off">
<option value="" selected>All</option> <option value="" {% if not current_theme_filter %}selected{% endif %}>All</option>
{% for theme in collection.themes %} {% for theme in collection.themes %}
<option value="{{ theme | lower }}">{{ theme }}</option> <option value="{{ theme | lower }}" {% if current_theme_filter == (theme | lower) %}selected{% endif %}>{{ theme }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -52,9 +52,9 @@
<select id="grid-owner" class="form-select" <select id="grid-owner" class="form-select"
data-filter="metadata" data-filter="metadata"
autocomplete="off"> autocomplete="off">
<option value="" selected>All</option> <option value="" {% if not current_owner_filter %}selected{% endif %}>All</option>
{% for owner in brickset_owners %} {% for owner in brickset_owners %}
<option value="{{ owner.as_dataset() }}">{{ owner.fields.name }}</option> <option value="{{ owner.as_dataset() }}" {% if current_owner_filter == owner.as_dataset() %}selected{% endif %}>{{ owner.fields.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -68,9 +68,9 @@
<select id="grid-purchase-location" class="form-select" <select id="grid-purchase-location" class="form-select"
data-filter="value" data-filter-attribute="purchase-location" data-filter="value" data-filter-attribute="purchase-location"
autocomplete="off"> autocomplete="off">
<option value="" selected>All</option> <option value="" {% if not current_purchase_location_filter %}selected{% endif %}>All</option>
{% for purchase_location in brickset_purchase_locations %} {% for purchase_location in brickset_purchase_locations %}
<option value="{{ purchase_location.fields.id }}">{{ purchase_location.fields.name }}</option> <option value="{{ purchase_location.fields.id }}" {% if current_purchase_location_filter == purchase_location.fields.id %}selected{% endif %}>{{ purchase_location.fields.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -84,9 +84,9 @@
<select id="grid-storage" class="form-select" <select id="grid-storage" class="form-select"
data-filter="value" data-filter-attribute="storage" data-filter="value" data-filter-attribute="storage"
autocomplete="off"> autocomplete="off">
<option value="" selected>All</option> <option value="" {% if not current_storage_filter %}selected{% endif %}>All</option>
{% for storage in brickset_storages %} {% for storage in brickset_storages %}
<option value="{{ storage.fields.id }}">{{ storage.fields.name }}</option> <option value="{{ storage.fields.id }}" {% if current_storage_filter == storage.fields.id %}selected{% endif %}>{{ storage.fields.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -100,9 +100,9 @@
<select id="grid-tag" class="form-select" <select id="grid-tag" class="form-select"
data-filter="metadata" data-filter="metadata"
autocomplete="off"> autocomplete="off">
<option value="" selected>All</option> <option value="" {% if not current_tag_filter %}selected{% endif %}>All</option>
{% for tag in brickset_tags %} {% for tag in brickset_tags %}
<option value="{{ tag.as_dataset() }}">{{ tag.fields.name }}</option> <option value="{{ tag.as_dataset() }}" {% if current_tag_filter == tag.as_dataset() %}selected{% endif %}>{{ tag.fields.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
+2 -2
View File
@@ -2,8 +2,8 @@
<div class="col-12 flex-grow-1"> <div class="col-12 flex-grow-1">
<div class="input-group"> <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> <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" <button id="sort-set" 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> data-sort-attribute="set" 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" <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> 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" <button id="sort-theme" type="button" class="btn btn-outline-primary mb-2"
+129 -6
View File
@@ -3,14 +3,14 @@
{% block title %} - All sets{% endblock %} {% block title %} - All sets{% endblock %}
{% block main %} {% block main %}
{% if collection | length %} {% if collection | length or use_pagination %}
<div class="container-fluid"> <div class="container-fluid">
<div class="row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2"> <div class="row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
<div class="col-12 flex-grow-1"> <div class="col-12 flex-grow-1">
<label class="visually-hidden" for="grid-search">Search</label> <label class="visually-hidden" for="grid-search">Search</label>
<div class="input-group"> <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> <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=""> <input id="grid-search" {% if not use_pagination %}data-search-exact="name,number,parts,searchPurchaseLocation,searchStorage,theme,year" data-search-list="searchOwner,searchTag"{% endif %} class="form-control form-control-sm px-1" type="text" placeholder="Set, name, number of parts, theme, year{% if not use_pagination %}, owner, purchase location, storage, tag{% endif %}" value="{{ search_query or '' }}">
<button id="grid-search-clear" type="button" class="btn btn-light btn-outline-danger border"><i class="ri-eraser-line"></i></button> <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> </div>
@@ -19,10 +19,6 @@
<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"> <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 <i class="ri-sort-asc"></i> Sort
</button> </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"> <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 <i class="ri-filter-line"></i> Filters
</button> </button>
@@ -31,6 +27,131 @@
</div> </div>
{% include 'set/sort.html' %} {% include 'set/sort.html' %}
{% include 'set/filter.html' %} {% include 'set/filter.html' %}
{% if use_pagination %}
<!-- PAGINATION MODE -->
<div class="row" data-grid="false" id="grid">
{% for item in collection %}
<div class="col-md-6 col-xl-3 d-flex align-items-stretch">
{% with index=loop.index0 %}
{% include 'set/card.html' %}
{% endwith %}
</div>
{% endfor %}
</div>
<!-- Pagination -->
<div>
{% if pagination and pagination.total_pages > 1 %}
<div class="row mt-4">
<div class="col-12">
<!-- Desktop Pagination -->
<div class="d-none d-md-block">
<nav aria-label="Sets pagination">
<ul class="pagination justify-content-center">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.page - 1) }}">
<i class="ri-arrow-left-line"></i> Previous
</a>
</li>
{% endif %}
<!-- Show page numbers (with smart truncation) -->
{% set start_page = [1, pagination.page - 2] | max %}
{% set end_page = [pagination.total_pages, pagination.page + 2] | min %}
{% if start_page > 1 %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', 1) }}">1</a>
</li>
{% if start_page > 2 %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
{% endif %}
{% for page_num in range(start_page, end_page + 1) %}
{% if page_num == pagination.page %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', page_num) }}">{{ page_num }}</a>
</li>
{% endif %}
{% endfor %}
{% if end_page < pagination.total_pages %}
{% if end_page < pagination.total_pages - 1 %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.total_pages) }}">{{ pagination.total_pages }}</a>
</li>
{% endif %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.page + 1) }}">
Next <i class="ri-arrow-right-line"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
<!-- Mobile Pagination -->
<div class="d-md-none">
<div class="mobile-pagination" role="group" aria-label="Mobile pagination">
{% if pagination.has_prev %}
<a href="{{ request.url | replace_query('page', pagination.page - 1) }}"
class="btn btn-outline-primary">
<i class="ri-arrow-left-line"></i> Previous
</a>
{% else %}
<button class="btn btn-outline-secondary" disabled>
<i class="ri-arrow-left-line"></i> Previous
</button>
{% endif %}
<span class="btn btn-light">
Page {{ pagination.page }} of {{ pagination.total_pages }}
</span>
{% if pagination.has_next %}
<a href="{{ request.url | replace_query('page', pagination.page + 1) }}"
class="btn btn-outline-primary">
Next <i class="ri-arrow-right-line"></i>
</a>
{% else %}
<button class="btn btn-outline-secondary" disabled>
Next <i class="ri-arrow-right-line"></i>
</button>
{% endif %}
</div>
</div>
<!-- Results Info -->
<div class="text-center mt-3">
<small class="text-muted">
{% if pagination.total_count > 0 %}
Showing {{ ((pagination.page - 1) * pagination.per_page + 1) }} to
{{ [pagination.page * pagination.per_page, pagination.total_count] | min }}
of {{ pagination.total_count }} sets
{% else %}
No sets found
{% endif %}
</small>
</div>
</div>
</div>
{% endif %}
</div>
{% else %}
<!-- ORIGINAL MODE - Single page with client-side search and filters -->
<div class="row" data-grid="true" id="grid"> <div class="row" data-grid="true" id="grid">
{% for item in collection %} {% for item in collection %}
<div class="col-md-6 col-xl-3 d-flex align-items-stretch"> <div class="col-md-6 col-xl-3 d-flex align-items-stretch">
@@ -40,6 +161,8 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %}
</div> </div>
{% else %} {% else %}
{% include 'set/empty.html' %} {% include 'set/empty.html' %}
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env python3
"""
WSGI entry point for BrickTracker - Production Docker deployment
This ensures proper gevent monkey patching before any imports
"""
# CRITICAL: Monkey patch must be first, before ANY other imports
import gevent.monkey
gevent.monkey.patch_all()
# Now import the regular app factory
from app import create_app
# Create the application - this will be a BrickSocket instance
app_instance = create_app()
# For gunicorn, we need the Flask app, not the BrickSocket wrapper
application = app_instance.app if hasattr(app_instance, 'app') else app_instance