Compare commits

...
10 Commits
15 changed files with 657 additions and 70 deletions
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## 1.4.2
### Bug Fixes
- **Fixed instructions downloads failing with "No instructions found on Rebrickable or Peeron"** (#171): Rebrickable now blocks scraping behind a Cloudflare challenge, so instructions are fetched from LEGO's own official building-instructions page instead
- **Fixed instructions never linking to sets with alphanumeric ids** (#166): Instructions for "Pick a Brick" / promotional builds whose set id begins with letters (e.g. `EG00029-1`) were orphaned and never associated with the set; the matching now handles alphanumeric set ids
## 1.4.1
### Enhancements
+28 -30
View File
@@ -75,10 +75,11 @@ class BrickInstructions(object):
splits = normalized.split('-', 2)
if len(splits) >= 2:
if len(splits) >= 2 and splits[0]:
try:
# Trying to make sense of each part as integers
int(splits[0])
# The set number can be alphanumeric (e.g. Pick-a-Brick
# builds like "EG00029-1"); only the version suffix has
# to be an integer
int(splits[1])
self.set = '-'.join(splits[:2])
@@ -120,18 +121,6 @@ class BrickInstructions(object):
'Cache-Control': 'max-age=0'
})
# Visit the set's instructions listing page first to establish session cookies
set_number = None
if self.rebrickable:
set_number = self.rebrickable.fields.set
elif self.set:
set_number = self.set
if set_number:
instructions_page = f"https://rebrickable.com/instructions/{set_number}/"
session.get(instructions_page)
session.headers.update({"Referer": instructions_page})
resp = session.get(path, stream=True, allow_redirects=True)
if not resp.ok:
raise DownloadException(f"Failed to download: HTTP {resp.status_code}")
@@ -269,10 +258,13 @@ class BrickInstructions(object):
@staticmethod
def find_instructions(set: str, /) -> list[Tuple[str, str]]:
"""
Scrape Rebrickable's HTML and return a list of
(filename_slug, download_url). Duplicate slugs get _1, _2, …
Scrape LEGO's official building-instructions page and return a list
of (filename_slug, download_url). Duplicate slugs get _1, _2, …
"""
page_url = f"https://rebrickable.com/instructions/{set}/"
# LEGO's page wants the bare set number, no "-1" version suffix
number, _, _version = set.partition('-')
page_url = f"https://www.lego.com/en-us/service/building-instructions/{number}" # noqa: E501
logger.debug(f"[find_instructions] fetching HTML from {page_url!r}")
# Use plain requests instead of cloudscraper
@@ -295,23 +287,29 @@ class BrickInstructions(object):
raise ErrorException(f'Failed to load instructions page for {set}. HTTP {resp.status_code}')
soup = BeautifulSoup(resp.content, 'html.parser')
# Match download links with or without query parameters (e.g., ?cfe=timestamp&cfk=key)
link_re = re.compile(r'^/instructions/\d+/.+/download/')
# Each booklet is a "bi-card" div. Attribute order on this page is
# not stable between requests, so every lookup has to stay scoped
# inside its own card rather than collecting all h3s/links
# page-wide and pairing them up by position, that can silently
# mismatch a label with the wrong link.
raw: list[tuple[str, str]] = []
for a in soup.find_all('a', href=link_re):
img = a.find('img', alt=True) # type: ignore
if not img or set not in img['alt']: # type: ignore
for card in soup.find_all('div', attrs={'data-test': 'bi-card'}):
label = card.find('h3')
link = card.find('a', attrs={'data-test': 'bi-card-link'})
if label is None or link is None or not link.get('href'):
continue
# Turn the alt text into a slug
alt_text = img['alt'].removeprefix('LEGO Building Instructions for ') # type: ignore
slug = re.sub(r'[^A-Za-z0-9]+', '-', alt_text).strip('-')
# Prefix with the set number: filenames are how a downloaded
# instruction later gets matched back to its set (see
# BrickInstructions.__init__), and LEGO's own card labels never
# mention the set number, only the product name and booklet.
card_slug = re.sub(r'[^A-Za-z0-9]+', '-', label.get_text(strip=True)).strip('-') # noqa: E501
slug = f'{set}-{card_slug}'
download_url = urljoin('https://www.lego.com', link['href'])
# Build the absolute download URL - this preserves query parameters
# BeautifulSoup's a['href'] includes the full href with ?cfe=...&cfk=... params
download_url = urljoin('https://rebrickable.com', a['href']) # type: ignore
logger.debug(f"[find_instructions] Found download link: {download_url}")
logger.debug(f"[find_instructions] Found download link: {download_url}") # noqa: E501
raw.append((slug, download_url))
if not raw:
+1
View File
@@ -9,6 +9,7 @@ from flask import Flask
# - f: flag name (str, optional=None)
NAVBAR: Final[list[dict[str, Any]]] = [
{'e': 'set.list', 't': 'Sets', 'i': 'grid-line', 'f': 'HIDE_ALL_SETS'}, # noqa: E501
{'e': 'set.table', 't': 'Sets table', 'i': 'table-line', 'f': 'HIDE_ALL_SETS'}, # noqa: E501
{'e': 'add.add', 't': 'Add', 'i': 'add-circle-line', 'f': 'HIDE_ADD_SET'}, # noqa: E501
{'e': 'part.list', 't': 'Parts', 'i': 'shapes-line', 'f': 'HIDE_ALL_PARTS'}, # noqa: E501
{'e': 'part.problem', 't': 'Problems', 'i': 'error-warning-line', 'f': 'HIDE_ALL_PROBLEMS_PARTS'}, # noqa: E501
+22 -5
View File
@@ -288,7 +288,14 @@ class BrickPart(RebrickablePart):
)
# Update a problematic part
def update_problem(self, problem: str, json: Any | None, /) -> int:
def update_problem(
self,
problem: str,
json: Any | None,
/,
*,
commit: bool = True,
) -> int:
amount: str | int = json.get('value', '') # type: ignore
# We need a positive integer
@@ -310,10 +317,20 @@ class BrickPart(RebrickablePart):
setattr(self.fields, problem, amount)
BrickSQL().execute_and_commit(
'part/update/{problem}'.format(problem=problem),
parameters=self.sql_parameters()
)
if commit:
BrickSQL().execute_and_commit(
'part/update/{problem}'.format(problem=problem),
parameters=self.sql_parameters()
)
else:
# Queue the update without committing, so a caller updating many
# parts at once (e.g. a bulk operation) can run every update in
# a single transaction with one final commit.
BrickSQL().execute(
'part/update/{problem}'.format(problem=problem),
parameters=self.sql_parameters(),
defer=True,
)
return amount
+2 -2
View File
@@ -17,9 +17,9 @@ SELECT
{% block total_sets %}
NULL AS "total_sets" -- dummy for order: total_sets
{% endblock %}
FROM "rebrickable_minifigures"
FROM "bricktracker_minifigures"
LEFT JOIN "bricktracker_minifigures"
INNER JOIN "rebrickable_minifigures"
ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
{% block join %}{% endblock %}
+5
View File
@@ -0,0 +1,5 @@
{% extends 'set/base/base.sql' %}
{% block id %}
"bricktracker_sets"."id",
{% endblock %}
+3 -1
View File
@@ -49,4 +49,6 @@ class BrickSQLMigration(object):
))
)
return relative
# Jinja2 template names always use forward slashes, regardless
# of the OS path separator (os.path.relpath uses '\' on Windows)
return relative.replace(os.sep, '/')
+1 -1
View File
@@ -1,4 +1,4 @@
from typing import Final
__version__: Final[str] = '1.4.0'
__version__: Final[str] = '1.4.2'
__database_version__: Final[int] = 27
+5 -5
View File
@@ -147,7 +147,7 @@ def do_upload() -> Response:
return redirect(url_for('instructions.list'))
# Download instructions from Rebrickable
# Download instructions from LEGO.com
@instructions_page.route('/download/', methods=['GET'])
@login_required
@exception_handler(__file__)
@@ -185,11 +185,11 @@ def do_download() -> str:
if request.args.get('peeron_loaded'):
return _render_peeron_select_page(set)
# Try Rebrickable first
# Try LEGO.com first
try:
from .instructions import BrickInstructions
rebrickable_instructions = BrickInstructions.find_instructions(set)
# Standard Rebrickable instructions found
# Standard LEGO.com instructions found
return render_template(
'instructions.html',
download=True,
@@ -200,7 +200,7 @@ def do_download() -> str:
messages=MESSAGES
)
except Exception:
# Rebrickable failed, check if Peeron has instructions (without caching thumbnails yet)
# LEGO.com failed, check if Peeron has instructions (without caching thumbnails yet)
try:
peeron = PeeronInstructions(set)
# Just check if pages exist, don't cache thumbnails yet
@@ -223,7 +223,7 @@ def do_download() -> str:
download=True,
instructions=[],
set=set,
error='No instructions found on Rebrickable or Peeron',
error='No instructions found on LEGO.com or Peeron',
path=current_app.config['SOCKET_PATH'],
namespace=current_app.config['SOCKET_NAMESPACE'],
messages=MESSAGES
+174 -1
View File
@@ -1,4 +1,7 @@
import builtins
import logging
from datetime import datetime
from typing import Any
from flask import (
Blueprint,
@@ -13,7 +16,7 @@ from flask_login import login_required
from werkzeug.wrappers.response import Response
from .exceptions import exception_handler
from ..exceptions import ErrorException
from ..exceptions import ErrorException, NotFoundException
from ..minifigure import BrickMinifigure
from ..pagination_helper import get_pagination_config, build_pagination_context, get_request_params
from ..part import BrickPart
@@ -26,6 +29,8 @@ from ..set_status_list import BrickSetStatusList
from ..set_storage_list import BrickSetStorageList
from ..set_tag_list import BrickSetTagList
from ..socket import MESSAGES
from ..sql import BrickSQL
from ..theme_list import BrickThemeList
logger = logging.getLogger(__name__)
@@ -115,6 +120,98 @@ def list() -> str:
return render_template('sets.html', **template_context)
# Spreadsheet-style (Tabulator) view of all sets
@set_page.route('/table', methods=['GET'])
@exception_handler(__file__)
def table() -> str:
storages = [
{'id': item.fields.id, 'name': item.fields.name}
for item in BrickSetStorageList.list(as_class=False)
]
purchase_locations = [
{'id': item.fields.id, 'name': item.fields.name}
for item in BrickSetPurchaseLocationList.list(as_class=False)
]
return render_template(
'sets_table.html',
brickset_storages=storages,
brickset_purchase_locations=purchase_locations,
)
# JSON data backing the Tabulator view. Read-only listing: edits are saved
# through the existing per-field endpoints (update_storage,
# update_purchase_location, update_purchase_date, update_purchase_price,
# update_description), the same ones BrickChanger already uses elsewhere.
@set_page.route('/table/data', methods=['GET'])
@exception_handler(__file__, json=True)
def table_data() -> Response:
rows = BrickSQL().fetchall(
'set/list/tabulator',
order='"rebrickable_sets"."name"',
)
themes = BrickThemeList()
data: builtins.list[dict[str, Any]] = []
for row in rows:
storage_id = row['storage']
purchase_location_id = row['purchase_location']
theme_id = row['theme_id']
theme_name = ''
if theme_id is not None:
try:
theme_name = themes.get(int(theme_id)).name
except (TypeError, ValueError):
theme_name = ''
storage_name = ''
if storage_id:
try:
storage_name = BrickSetStorageList.get(storage_id).fields.name # noqa: E501
except NotFoundException:
storage_name = ''
purchase_location_name = ''
if purchase_location_id:
try:
purchase_location_name = BrickSetPurchaseLocationList.get(
purchase_location_id
).fields.name
except NotFoundException:
purchase_location_name = ''
purchase_date = ''
if row['purchase_date']:
purchase_date = datetime.fromtimestamp(
row['purchase_date']
).strftime('%Y/%m/%d')
data.append({
'id': row['id'],
'set': row['set'],
'name': row['name'],
'year': row['year'],
'theme_id': theme_id,
'theme': theme_name,
'number_of_parts': row['number_of_parts'],
'image': row['image'],
'description': row['description'] or '',
'storage_id': storage_id or '',
'storage_name': storage_name,
'purchase_location_id': purchase_location_id or '',
'purchase_location_name': purchase_location_name,
'purchase_date': purchase_date,
'purchase_price': row['purchase_price'],
})
return jsonify(data)
# Change the value of purchase date
@set_page.route('/<id>/purchase_date', methods=['POST'])
@login_required
@@ -357,6 +454,82 @@ def problem_part(
return jsonify({problem: amount})
# Bulk update problematic pieces of a set (e.g. "mark all missing")
# Body: {"items": [{"figure": str|None, "part": str, "color": int,
# "spare": int, "value": int}, ...]}
# All updates run in a single database transaction (one commit for the
# whole batch), instead of a fetch per part.
@set_page.route('/<id>/parts/bulk/<problem>', methods=['POST'])
@login_required
@exception_handler(__file__, json=True)
def bulk_problem_parts(*, id: str, problem: str) -> Response:
brickset = BrickSet().select_specific(id)
items = (request.json or {}).get('items', []) # type: ignore
if not isinstance(items, builtins.list):
raise ErrorException('"items" must be a list')
# Cache minifigure lookups so we don't re-select the same one per part
minifigure_cache: dict[str, BrickMinifigure] = {}
updated: builtins.list[dict[str, Any]] = []
for item in items:
part = item.get('part')
color = item.get('color')
spare = item.get('spare')
figure = item.get('figure')
if part is None or color is None or spare is None:
continue
if figure is not None:
if figure not in minifigure_cache:
minifigure_cache[figure] = BrickMinifigure().select_specific(
brickset, figure
)
brickminifigure = minifigure_cache[figure]
else:
brickminifigure = None
brickpart = BrickPart().select_specific(
brickset,
part,
int(color),
int(spare),
minifigure=brickminifigure,
)
# Queue the update (commit=False) rather than committing per part
amount = brickpart.update_problem(
problem,
{'value': item.get('value')},
commit=False,
)
updated.append({
'figure': figure,
'part': part,
'color': color,
'spare': spare,
'value': amount,
})
# Commit every queued update in a single transaction
BrickSQL().commit()
# Info
logger.info('Set {set} ({id}): bulk updated {count} part(s) {problem}'.format( # noqa: E501
set=brickset.fields.set,
id=brickset.fields.id,
count=len(updated),
problem=problem,
))
return jsonify({'updated': updated})
# Update checked state of parts during walkthrough
@set_page.route('/<id>/parts/<part>/<int:color>/<int:spare>/checked', defaults={'figure': None}, methods=['POST']) # noqa: E501
@set_page.route('/<id>/minifigures/<figure>/parts/<part>/<int:color>/<int:spare>/checked', methods=['POST']) # noqa: E501
+107 -22
View File
@@ -103,12 +103,66 @@ class PartsBulkOperations {
modalInstance.show();
}
markAllMissing() {
// Parses BrickChanger's per-row "missing" URL to recover the pieces
// needed to build a bulk request: the leading path prefix (for
// subpath deployments), the set id, and the part's identity
// (figure/part/color/spare). Returns null if the URL doesn't match.
//
// Matches URLs generated by url_for('set.problem_part', ...), e.g.
// /sets/<id>/parts/<part>/<color>/<spare>/missing
// /sets/<id>/minifigures/<figure>/parts/<part>/<color>/<spare>/missing
static MISSING_URL_PATTERN = /^(.*?)\/sets\/([^/]+)\/(?:minifigures\/([^/]+)\/)?parts\/([^/]+)\/([^/]+)\/([^/]+)\/missing$/;
// Send one bulk request updating every part's missing count in a
// single call, instead of one fetch per row. Reloads the page once
// the save is confirmed; shows an alert and does NOT reload on
// failure, so a failed save is never silently discarded.
_bulkUpdateMissing(items, urlPrefix, setId, actionLabel) {
if (!items.length || !setId) return;
fetch(`${urlPrefix}/sets/${setId}/parts/bulk/missing`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ items }),
})
.then(response => {
if (!response.ok) {
throw new Error(`Response status: ${response.status} (${response.statusText})`);
}
return response.json();
})
.then(json => {
// The app's error handler returns HTTP 200 with an "error"
// key for validation/application errors, not a 4xx status.
if (json && 'error' in json) {
throw new Error(json.error);
}
// Only refresh once the save is confirmed, so all derived
// totals (missing counts, etc.) reflect the new state.
window.location.reload();
})
.catch(error => {
console.error(`Bulk ${actionLabel} failed:`, error);
alert(`Failed to save "${actionLabel}": ${error.message}`);
});
}
// Walks the rows of this accordion, calling collect(missingInput,
// quantityCell) for each visible row that has both a quantity cell
// and a "missing" input. collect should return the desired new value
// (a number) to set, or null/undefined to skip that row.
_collectMissingItems(collect) {
const accordionElement = document.getElementById(this.accordionId);
if (!accordionElement) return;
if (!accordionElement) return { items: [], urlPrefix: '', setId: null };
// Find all rows in this accordion
const rows = accordionElement.querySelectorAll('tbody tr');
const items = [];
let setId = null;
let urlPrefix = '';
rows.forEach(row => {
// Skip rows hidden by an active header filter
if (row.classList.contains('parts-filtered-out')) return;
@@ -117,33 +171,64 @@ class PartsBulkOperations {
const quantityCell = row.cells[3]; // Index 3 for quantity column
const missingInput = row.querySelector('input[id*="-missing-"]');
if (quantityCell && missingInput) {
// Extract quantity from cell text content
const quantityText = quantityCell.textContent.trim();
const quantity = parseInt(quantityText) || 1; // Default to 1 if can't parse
if (!quantityCell || !missingInput) return;
if (missingInput.value !== quantity.toString()) {
missingInput.value = quantity.toString();
// Trigger change event to activate BrickChanger
missingInput.dispatchEvent(new Event('change', { bubbles: true }));
}
const newValue = collect(missingInput, quantityCell);
if (newValue === null || newValue === undefined) return;
// Pull (id, figure, part, color, spare) straight out of the
// per-row URL that BrickChanger already uses, so we don't need
// any new data attributes in the template.
const match = (missingInput.dataset.changerUrl || '').match(
PartsBulkOperations.MISSING_URL_PATTERN
);
if (!match) {
console.warn('Could not parse changer URL for a row, skipping it', missingInput);
return;
}
const [, prefix, id, figure, part, color, spare] = match;
setId = id;
urlPrefix = prefix;
// Reflect the new value in the UI immediately
missingInput.value = newValue.toString();
items.push({
figure: figure || null,
part: part,
color: parseInt(color, 10),
spare: parseInt(spare, 10),
value: newValue,
});
});
return { items, urlPrefix, setId };
}
markAllMissing() {
const { items, urlPrefix, setId } = this._collectMissingItems((missingInput, quantityCell) => {
const quantityText = quantityCell.textContent.trim();
const quantity = parseInt(quantityText) || 1; // Default to 1 if can't parse
if (missingInput.value === quantity.toString()) return null;
return quantity;
});
this._bulkUpdateMissing(items, urlPrefix, setId, 'mark all missing');
}
clearAllMissing() {
const accordionElement = document.getElementById(this.accordionId);
if (!accordionElement) return;
const { items, urlPrefix, setId } = this._collectMissingItems((missingInput) => {
if (missingInput.value === '' || missingInput.value === '0') return null;
const missingInputs = accordionElement.querySelectorAll('input[id*="-missing-"]');
missingInputs.forEach(input => {
if (input.closest('tr')?.classList.contains('parts-filtered-out')) return;
if (input.value !== '') {
input.value = '';
// Trigger change event to activate BrickChanger
input.dispatchEvent(new Event('change', { bubbles: true }));
}
return 0;
});
this._bulkUpdateMissing(items, urlPrefix, setId, 'clear all missing');
}
checkAll() {
+254
View File
@@ -0,0 +1,254 @@
// Tabulator-based spreadsheet view for sets
document.addEventListener('DOMContentLoaded', () => {
const tableElement = document.getElementById('sets-table');
if (!tableElement) return;
const urls = JSON.parse(document.getElementById('sets-table-urls').textContent);
const storages = JSON.parse(document.getElementById('sets-table-storages').textContent);
const purchaseLocations = JSON.parse(document.getElementById('sets-table-purchase-locations').textContent);
// id -> name lookups, used by both the cell formatter and the select editor
const storageNames = Object.fromEntries(storages.map(s => [s.id, s.name]));
const purchaseLocationNames = Object.fromEntries(purchaseLocations.map(p => [p.id, p.name]));
// Tabulator's "list" editor wants {value, label} pairs. The actual
// "no storage"/"no purchase location" data value is '', but Tabulator's
// list HEADER FILTER treats an empty-string filter value as "cleared"
// (i.e. no filter applied), so that option would be indistinguishable
// from clearing the filter. Use a non-empty sentinel for filtering
// instead, paired with a custom headerFilterFunc below.
const NONE_SENTINEL = '__none__';
const storageOptions = [
{ value: '', label: 'No storage' },
...storages.map(s => ({ value: s.id, label: s.name })),
];
const purchaseLocationOptions = [
{ value: '', label: 'No purchase location' },
...purchaseLocations.map(p => ({ value: p.id, label: p.name })),
];
const storageFilterOptions = [
{ value: NONE_SENTINEL, label: 'No storage' },
...storages.map(s => ({ value: s.id, label: s.name })),
];
const purchaseLocationFilterOptions = [
{ value: NONE_SENTINEL, label: 'No purchase location' },
...purchaseLocations.map(p => ({ value: p.id, label: p.name })),
];
// Matches the sentinel against an empty row value, otherwise exact match
function noneAwareFilter(headerValue, rowValue) {
if (headerValue === NONE_SENTINEL) return !rowValue;
return rowValue === headerValue;
}
// Column field names (storage_id, purchase_location_id, ...) don't
// always match the URL keys (storage, purchase_location, ...), so map
// between them explicitly instead of assuming they're identical.
const fieldToUrlKey = {
description: 'description',
storage_id: 'storage',
purchase_location_id: 'purchase_location',
purchase_date: 'purchase_date',
purchase_price: 'purchase_price',
};
// Build the save URL for a field on a given row (swap in the real id)
function urlFor(urlKey, id) {
return urls[urlKey].replace('ID_PLACEHOLDER', id);
}
// Save one field for one row. On failure, revert the cell and alert -
// never silently keep an edit that didn't actually persist.
function saveField(cell) {
const field = cell.getColumn().getField();
const urlKey = fieldToUrlKey[field];
const row = cell.getRow().getData();
const id = row.id;
const value = cell.getValue();
if (!urlKey || !(urlKey in urls)) {
console.warn(`No save URL configured for field "${field}", skipping save`);
return;
}
fetch(urlFor(urlKey, id), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ value: value }),
})
.then(response => {
if (!response.ok) {
throw new Error(`Response status: ${response.status} (${response.statusText})`);
}
return response.json();
})
.then(json => {
// The app's error handler returns HTTP 200 with an "error"
// key for validation/application errors, not a 4xx status.
if (json && 'error' in json) {
throw new Error(json.error);
}
})
.catch(error => {
console.error(`Failed to save "${field}":`, error);
alert(`Failed to save "${field}": ${error.message}`);
cell.restoreOldValue();
});
}
const table = new Tabulator('#sets-table', {
ajaxURL: urls.data,
layout: 'fitDataFill',
height: '75vh',
variableHeight: true,
pagination: true,
paginationSize: 50,
paginationSizeSelector: [25, 50, 100, 250, true],
movableColumns: true,
columns: [
{
title: '',
field: 'image',
width: 80,
minWidth: 40,
resizable: true,
hozAlign: 'center',
headerSort: false,
frozen: true,
formatter: (cell) => {
const url = cell.getValue();
if (!url) return '';
return `<img src="${url}" alt="" style="width: 100%; height: auto; display: block;">`;
},
},
{
title: 'Set',
field: 'set',
width: 100,
headerFilter: 'input',
formatter: (cell) => {
const row = cell.getRow().getData();
const url = urlFor('details', row.id);
const value = cell.getValue() ?? '';
return `<a href="${url}">${value}</a>`;
},
},
{
title: 'Name',
field: 'name',
width: 250,
headerFilter: 'input',
},
{
title: 'Year',
field: 'year',
width: 90,
hozAlign: 'right',
headerFilter: 'list',
headerFilterParams: { valuesLookup: true, clearable: true },
headerFilterFunc: '=',
},
{
title: 'Theme',
field: 'theme',
width: 150,
headerFilter: 'list',
headerFilterParams: { valuesLookup: true, clearable: true },
headerFilterFunc: '=',
},
{
title: 'Parts',
field: 'number_of_parts',
width: 90,
hozAlign: 'right',
headerFilter: 'input',
},
{
title: 'Description',
field: 'description',
width: 220,
editor: 'input',
headerFilter: 'input',
cellEdited: saveField,
},
{
title: 'Storage',
field: 'storage_id',
width: 160,
editor: 'list',
editorParams: { values: storageOptions },
headerFilter: 'list',
headerFilterParams: { values: storageFilterOptions, clearable: true },
headerFilterFunc: noneAwareFilter,
formatter: (cell) => storageNames[cell.getValue()] || '',
cellEdited: saveField,
},
{
title: 'Purchase location',
field: 'purchase_location_id',
width: 160,
editor: 'list',
editorParams: { values: purchaseLocationOptions },
headerFilter: 'list',
headerFilterParams: { values: purchaseLocationFilterOptions, clearable: true },
headerFilterFunc: noneAwareFilter,
formatter: (cell) => purchaseLocationNames[cell.getValue()] || '',
cellEdited: saveField,
},
{
title: 'Purchase date',
field: 'purchase_date',
width: 130,
editor: 'input',
editorParams: { elementAttributes: { placeholder: 'YYYY/MM/DD' } },
cellEdited: saveField,
},
{
title: 'Purchase price',
field: 'purchase_price',
width: 130,
hozAlign: 'right',
editor: 'number',
editorParams: { step: 0.01 },
cellEdited: saveField,
},
],
});
const clearFiltersButton = document.getElementById('sets-table-clear-filters');
if (clearFiltersButton) {
clearFiltersButton.addEventListener('click', () => {
table.clearHeaderFilter();
});
}
table.on('columnResized', (column) => {
if (column.getField() === 'image') {
table.getRows().forEach((row) => row.normalizeHeight());
}
});
table.on('renderComplete', () => {
const images = tableElement.querySelectorAll('.tabulator-cell img');
const pending = [];
images.forEach((img) => {
if (!img.complete) {
pending.push(new Promise((resolve) => {
img.addEventListener('load', resolve, { once: true });
img.addEventListener('error', resolve, { once: true }); // don't hang forever on a broken thumbnail
}));
}
});
if (pending.length) {
Promise.all(pending).then(() => {
table.getRows().forEach((row) => row.normalizeHeight());
});
}
});
});
+5
View File
@@ -10,9 +10,11 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/simple-datatables@9.2.1/dist/style.min.css">
<link href="https://cdn.jsdelivr.net/npm/remixicon@4.6.0/fonts/remixicon.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vanillajs-datepicker@1.3.4/dist/css/datepicker-bs5.min.css">
<link href="https://cdn.jsdelivr.net/npm/tabulator-tables@6.4.0/dist/css/tabulator.min.css" rel="stylesheet">
<link href="{{ url_for('static', filename='styles.css') }}" rel="stylesheet">
<link rel="icon" type="image/png" sizes="48x48" href="{{ url_for('static', filename='brick.png') }}">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/tabulator-tables@6.4.0/dist/js/tabulator.min.js"></script>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark sticky-top">
@@ -190,6 +192,9 @@
{% if request.endpoint == 'set.list' %}
<script src="{{ url_for('static', filename='scripts/sets.js') }}"></script>
{% endif %}
{% if request.endpoint == 'set.table' %}
<script src="{{ url_for('static', filename='scripts/sets-table.js') }}"></script>
{% endif %}
{% if request.endpoint == 'set.details' %}
<script src="{{ url_for('static', filename='scripts/parts-table-filter.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/parts-bulk-operations.js') }}"></script>
+3 -3
View File
@@ -10,7 +10,7 @@
<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>
<h5 class="mb-0"><i class="ri-download-line"></i> Download instructions from LEGO.com</h5>
</div>
<div class="card-body">
<div class="mb-3">
@@ -25,7 +25,7 @@
</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...
<i class="ri-information-line"></i> <strong>Found on Peeron:</strong> {{ set }} was not available on LEGO.com, loading instruction pages from Peeron...
</div>
<!-- Socket elements for peeron-loader -->
@@ -55,7 +55,7 @@
{% 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.
<i class="ri-information-line"></i> <strong>Instructions found on Peeron:</strong> {{ set }} was not available on LEGO.com, 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>
+40
View File
@@ -0,0 +1,40 @@
{% extends 'base.html' %}
{% block title %} - Sets table{% endblock %}
{% block main %}
<div class="container-fluid">
<h2 class="border-bottom lh-base pb-1">
<i class="ri-table-line"></i> Sets table
<button type="button" class="btn btn-sm btn-outline-secondary ms-1" id="sets-table-clear-filters">
<i class="ri-filter-off-line"></i> Clear filters
</button>
</h2>
<div id="sets-table"></div>
</div>
<!-- Data for building the storage/purchase location select editors -->
<script type="application/json" id="sets-table-storages">
{{ brickset_storages | tojson }}
</script>
<script type="application/json" id="sets-table-purchase-locations">
{{ brickset_purchase_locations | tojson }}
</script>
<!-- URL templates: the literal string ID_PLACEHOLDER is swapped for the -->
<!-- real set id client-side, so the JS never has to hardcode paths -->
<script type="application/json" id="sets-table-urls">
{{
{
'data': url_for('set.table_data'),
'description': url_for('set.update_description', id='ID_PLACEHOLDER'),
'storage': url_for('set.update_storage', id='ID_PLACEHOLDER'),
'purchase_location': url_for('set.update_purchase_location', id='ID_PLACEHOLDER'),
'purchase_date': url_for('set.update_purchase_date', id='ID_PLACEHOLDER'),
'purchase_price': url_for('set.update_purchase_price', id='ID_PLACEHOLDER'),
'details': url_for('set.details', id='ID_PLACEHOLDER'),
} | tojson
}}
</script>
{% endblock %}