From 053bf75e05b32a1bebe803630402701b0f3f4f40 Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Wed, 22 Jan 2025 22:41:35 +0100 Subject: [PATCH 01/16] Added instructions downloader from Rebrickable. --- bricktracker/instructions.py | 29 ++++++++++- bricktracker/views/instructions.py | 73 +++++++++++++++++++++++++++- templates/instructions.html | 3 ++ templates/instructions/download.html | 49 +++++++++++++++++++ templates/set/card.html | 1 + 5 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 templates/instructions/download.html diff --git a/bricktracker/instructions.py b/bricktracker/instructions.py index 6aaa050..9813501 100644 --- a/bricktracker/instructions.py +++ b/bricktracker/instructions.py @@ -3,11 +3,13 @@ import logging import os from typing import TYPE_CHECKING -from flask import current_app, g, url_for +from flask import current_app, g, url_for, flash import humanize from werkzeug.datastructures import FileStorage from werkzeug.utils import secure_filename +import requests + from .exceptions import ErrorException if TYPE_CHECKING: from .set import BrickSet @@ -112,6 +114,31 @@ class BrickInstructions(object): logger.info('The instruction file {file} has been imported'.format( file=self.filename )) + + def download(self, href: str, /) -> None: + target = self.path(secure_filename(self.filename)) + + if os.path.isfile(target): + raise ErrorException('Cannot upload {target} as it already exists'.format( # noqa: E501 + target=self.filename + )) + + url = f"https://rebrickable.com/{href}" + + response = requests.get(url) + if response.status_code == 200: + # Save the file + with open(target, 'wb') as file: + file.write(response.content) + print(f"Downloaded {self.filename} to {target}") + else: + print(f"Failed to download {self.filename}. Status code: {response.status_code}", 'danger') + + + # Info + logger.info('The instruction file {file} has been imported'.format( + file=self.filename + )) # Compute the url for a set instructions file def url(self, /) -> str: diff --git a/bricktracker/views/instructions.py b/bricktracker/views/instructions.py index 6145914..db3491a 100644 --- a/bricktracker/views/instructions.py +++ b/bricktracker/views/instructions.py @@ -4,7 +4,8 @@ from flask import ( redirect, render_template, request, - url_for + url_for, + flash ) from flask_login import login_required from werkzeug.wrappers.response import Response @@ -15,6 +16,9 @@ from ..instructions import BrickInstructions from ..instructions_list import BrickInstructionsList from .upload import upload_helper +import requests +from bs4 import BeautifulSoup + instructions_page = Blueprint( 'instructions', __name__, @@ -126,3 +130,70 @@ def do_upload() -> Response: BrickInstructionsList(force=True) return redirect(url_for('instructions.list')) + + +# Download instructions from Rebrickable +@instructions_page.route('/download/', methods=['GET']) +@login_required +@exception_handler(__file__) +def download() -> str: + return render_template( + 'instructions.html', + download=True, + error=request.args.get('error') + ) + +# Actually download an instructions file +@instructions_page.route('/download/', methods=['POST']) +@login_required +@exception_handler(__file__, post_redirect='instructions.download') +def do_download() -> Response: + set_id: str = request.form.get('add-set', '') + + url = f"https://rebrickable.com/instructions/{set_id}" + + headers = { + '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' + } + response = requests.get(url, headers=headers) + if response.status_code != 200: + flash(f"Failed to load page. Status code: {response.status_code}", 'danger') + return redirect(url_for('instructions.download')) + + # Parse the HTML content + soup = BeautifulSoup(response.content, 'html.parser') + + # Collect all tags with "LEGO Building Instructions" in the alt attribute + found_tags = [] + for a_tag in soup.find_all('a', href=True): + img_tag = a_tag.find('img', alt=True) + if img_tag and "LEGO Building Instructions" in img_tag['alt']: + found_tags.append((img_tag['alt'].replace('LEGO Building Instructions for ', ''), a_tag['href'])) # Save alt and href + + return render_template('instructions.html', download=True, found_tags=found_tags) + +@instructions_page.route('/confirm_download', methods=['POST']) +@login_required +@exception_handler(__file__, post_redirect='instructions.download') +def confirm_download() -> Response: + selected_instructions = [] + for key in request.form: + if key.startswith('instruction-') and request.form.get(key) == 'on': # Checkbox is checked + index = key.split('-')[-1] + alt_text = request.form.get(f'instruction-alt-text-{index}') + href_text = request.form.get(f'instruction-href-text-{index}') + selected_instructions.append((href_text,alt_text)) + + if not selected_instructions: + flash("No instructions selected", 'danger') + return redirect(url_for('instructions.download')) + + for href, filename in selected_instructions: + print(f"Downloading {filename} from {href}") + BrickInstructions(f"{filename}.pdf").download(href) + + + BrickInstructionsList(force=True) + + #flash("Selected instructions have been downloaded", 'success') + return redirect(url_for('instructions.list')) diff --git a/templates/instructions.html b/templates/instructions.html index 54bb4c0..28734f3 100644 --- a/templates/instructions.html +++ b/templates/instructions.html @@ -5,6 +5,8 @@ {% block main %} {% if upload %} {% include 'instructions/upload.html' %} + {% elif download %} + {% include 'instructions/download.html' %} {% elif rename %} {% include 'instructions/rename.html' %} {% elif delete %} @@ -14,6 +16,7 @@ {% if g.login.is_authenticated() %}

Upload an instructions file + Download instructions from Rebrickable Refresh the instructions cache

{% endif %} diff --git a/templates/instructions/download.html b/templates/instructions/download.html new file mode 100644 index 0000000..79b3ccc --- /dev/null +++ b/templates/instructions/download.html @@ -0,0 +1,49 @@ +
+
+
+
+
+
+
Download instructions from Rebrickable
+
+
+ + +
+ + +
+ +
+
+
+ {% if found_tags %} +
+
+
Select instructions to download
+
+
+
+
+ +
+ {% for alt_text, href in found_tags %} +
+ + + + +
+ {% endfor %} +
+
+ +
+
+
+ {% endif %} +
+
+
\ No newline at end of file diff --git a/templates/set/card.html b/templates/set/card.html index d639516..31900d1 100644 --- a/templates/set/card.html +++ b/templates/set/card.html @@ -50,6 +50,7 @@ No instructions file found. {% if g.login.is_authenticated() %} Upload an instructions file + Download instruction from Rebrickable {% endif %} {% endif %} From 9aa5bd43ec94a9391bf94bcc932e4303bcf06232 Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Wed, 22 Jan 2025 22:59:45 +0100 Subject: [PATCH 02/16] Added bs4 to requirements.txt --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index aedd691..d2ca909 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ humanize jinja2 rebrick requests -tzdata \ No newline at end of file +tzdata +bs4 From 4a785df532c878962aa17553f96e053e46dbb595 Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Fri, 24 Jan 2025 17:08:56 +0100 Subject: [PATCH 03/16] Moved from code around --- bricktracker/instructions.py | 29 ++++++++++++++++++++++++++--- bricktracker/views/instructions.py | 23 +---------------------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/bricktracker/instructions.py b/bricktracker/instructions.py index 9813501..1c4f753 100644 --- a/bricktracker/instructions.py +++ b/bricktracker/instructions.py @@ -9,6 +9,7 @@ from werkzeug.datastructures import FileStorage from werkzeug.utils import secure_filename import requests +from bs4 import BeautifulSoup from .exceptions import ErrorException if TYPE_CHECKING: @@ -115,11 +116,34 @@ class BrickInstructions(object): file=self.filename )) + def find_instructions(self, set_id: str, /) -> None: + + url = f"https://rebrickable.com/instructions/{set_id}" + print(url) + + headers = { + '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' + } + response = requests.get(url, headers=headers) + if response.status_code != 200: + raise ErrorException('Failed to load page. Status code: {response.status_code}') + + # Parse the HTML content + soup = BeautifulSoup(response.content, 'html.parser') + + # Collect all tags with "LEGO Building Instructions" in the alt attribute + found_tags = [] + for a_tag in soup.find_all('a', href=True): + img_tag = a_tag.find('img', alt=True) + if img_tag and "LEGO Building Instructions" in img_tag['alt']: + found_tags.append((img_tag['alt'].replace('LEGO Building Instructions for ', ''), a_tag['href'])) # Save alt and href + return found_tags + def download(self, href: str, /) -> None: target = self.path(secure_filename(self.filename)) if os.path.isfile(target): - raise ErrorException('Cannot upload {target} as it already exists'.format( # noqa: E501 + raise ErrorException('Cannot download {target} as it already exists'.format( # noqa: E501 target=self.filename )) @@ -130,9 +154,8 @@ class BrickInstructions(object): # Save the file with open(target, 'wb') as file: file.write(response.content) - print(f"Downloaded {self.filename} to {target}") else: - print(f"Failed to download {self.filename}. Status code: {response.status_code}", 'danger') + raise ErrorException(f"Failed to download {self.filename}. Status code: {response.status_code}") # Info diff --git a/bricktracker/views/instructions.py b/bricktracker/views/instructions.py index db3491a..c11a407 100644 --- a/bricktracker/views/instructions.py +++ b/bricktracker/views/instructions.py @@ -16,9 +16,6 @@ from ..instructions import BrickInstructions from ..instructions_list import BrickInstructionsList from .upload import upload_helper -import requests -from bs4 import BeautifulSoup - instructions_page = Blueprint( 'instructions', __name__, @@ -150,25 +147,7 @@ def download() -> str: def do_download() -> Response: set_id: str = request.form.get('add-set', '') - url = f"https://rebrickable.com/instructions/{set_id}" - - headers = { - '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' - } - response = requests.get(url, headers=headers) - if response.status_code != 200: - flash(f"Failed to load page. Status code: {response.status_code}", 'danger') - return redirect(url_for('instructions.download')) - - # Parse the HTML content - soup = BeautifulSoup(response.content, 'html.parser') - - # Collect all tags with "LEGO Building Instructions" in the alt attribute - found_tags = [] - for a_tag in soup.find_all('a', href=True): - img_tag = a_tag.find('img', alt=True) - if img_tag and "LEGO Building Instructions" in img_tag['alt']: - found_tags.append((img_tag['alt'].replace('LEGO Building Instructions for ', ''), a_tag['href'])) # Save alt and href + found_tags = BrickInstructions(set_id).find_instructions(set_id) return render_template('instructions.html', download=True, found_tags=found_tags) From 6320629b07fc07234781dbd9a069c1d5a8ea52d8 Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Fri, 24 Jan 2025 17:20:53 +0100 Subject: [PATCH 04/16] Moved code from views to instructions.py --- bricktracker/instructions.py | 12 ++++++++++++ bricktracker/views/instructions.py | 14 +++++--------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/bricktracker/instructions.py b/bricktracker/instructions.py index 1c4f753..d776db5 100644 --- a/bricktracker/instructions.py +++ b/bricktracker/instructions.py @@ -138,6 +138,18 @@ class BrickInstructions(object): if img_tag and "LEGO Building Instructions" in img_tag['alt']: found_tags.append((img_tag['alt'].replace('LEGO Building Instructions for ', ''), a_tag['href'])) # Save alt and href return found_tags + + def get_list(self, request_form, /) -> list: + selected_instructions = [] + # Get the list of instructions + for key in request_form: + if key.startswith('instruction-') and request_form.get(key) == 'on': # Checkbox is checked + index = key.split('-')[-1] + alt_text = request_form.get(f'instruction-alt-text-{index}') + href_text = request_form.get(f'instruction-href-text-{index}') + selected_instructions.append((href_text,alt_text)) + + return selected_instructions def download(self, href: str, /) -> None: target = self.path(secure_filename(self.filename)) diff --git a/bricktracker/views/instructions.py b/bricktracker/views/instructions.py index c11a407..de2c3b9 100644 --- a/bricktracker/views/instructions.py +++ b/bricktracker/views/instructions.py @@ -147,6 +147,7 @@ def download() -> str: def do_download() -> Response: set_id: str = request.form.get('add-set', '') + # BrickInstructions require an argument. Not sure which makes sense here. found_tags = BrickInstructions(set_id).find_instructions(set_id) return render_template('instructions.html', download=True, found_tags=found_tags) @@ -155,20 +156,15 @@ def do_download() -> Response: @login_required @exception_handler(__file__, post_redirect='instructions.download') def confirm_download() -> Response: - selected_instructions = [] - for key in request.form: - if key.startswith('instruction-') and request.form.get(key) == 'on': # Checkbox is checked - index = key.split('-')[-1] - alt_text = request.form.get(f'instruction-alt-text-{index}') - href_text = request.form.get(f'instruction-href-text-{index}') - selected_instructions.append((href_text,alt_text)) + + # BrickInstructions require an argument. Not sure which makes sense here. + selected_instructions = BrickInstructions("").get_list(request.form) if not selected_instructions: - flash("No instructions selected", 'danger') + # No instructions selected return redirect(url_for('instructions.download')) for href, filename in selected_instructions: - print(f"Downloading {filename} from {href}") BrickInstructions(f"{filename}.pdf").download(href) From 52f73d5bf98b0f4bf62b967ac16db918f905db06 Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Fri, 24 Jan 2025 21:22:57 +0100 Subject: [PATCH 05/16] Moved code and added env variables --- .env.sample | 8 ++++++ bricktracker/config.py | 2 ++ bricktracker/instructions.py | 41 +++++++++++++++++----------- bricktracker/views/instructions.py | 20 +++++++------- templates/instructions/download.html | 4 +-- templates/set/card.html | 2 +- 6 files changed, 48 insertions(+), 29 deletions(-) diff --git a/.env.sample b/.env.sample index 27de81a..51845fe 100644 --- a/.env.sample +++ b/.env.sample @@ -178,6 +178,14 @@ # Default: https://rebrickable.com/parts/{number}/_/{color} # BK_REBRICKABLE_LINK_PART_PATTERN= +# Optional: Pattern of the link to Rebrickable for instructions. Will be passed to Python .format() +# Default: https://rebrickable.com/instructions/{number} +# BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN= + +# Optional: User-Agent to use when querying Rebrickable 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' +# BK_REBRICKABLE_USER_AGENT= + # Optional: Display Rebrickable links wherever applicable # Default: false # Legacy name: LINKS diff --git a/bricktracker/config.py b/bricktracker/config.py index 8cfdbba..36defc6 100644 --- a/bricktracker/config.py +++ b/bricktracker/config.py @@ -43,6 +43,8 @@ CONFIG: Final[list[dict[str, Any]]] = [ {'n': 'REBRICKABLE_IMAGE_NIL_MINIFIGURE', 'd': 'https://rebrickable.com/static/img/nil_mf.jpg'}, # noqa: E501 {'n': 'REBRICKABLE_LINK_MINIFIGURE_PATTERN', 'd': 'https://rebrickable.com/minifigs/{number}'}, # noqa: E501 {'n': 'REBRICKABLE_LINK_PART_PATTERN', 'd': 'https://rebrickable.com/parts/{number}/_/{color}'}, # noqa: E501 + {'n': 'REBRICKABLE_LINK_INSTRUCTIONS_PATTERN', 'd': 'https://rebrickable.com/instructions/{number}'}, # 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'}, {'n': 'REBRICKABLE_LINKS', 'e': 'LINKS', 'c': bool}, {'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 diff --git a/bricktracker/instructions.py b/bricktracker/instructions.py index 1924aba..0c966d3 100644 --- a/bricktracker/instructions.py +++ b/bricktracker/instructions.py @@ -8,6 +8,7 @@ import humanize from werkzeug.datastructures import FileStorage from werkzeug.utils import secure_filename +from io import BytesIO import requests from bs4 import BeautifulSoup @@ -117,21 +118,30 @@ class BrickInstructions(object): )) file.save(target) - + # Info logger.info('The instruction file {file} has been imported'.format( file=self.filename )) - - def find_instructions(self, set_id: str, /) -> None: - - url = f"https://rebrickable.com/instructions/{set_id}" - print(url) + + # Compute the url for the rebrickable instructions page + def url_for_instructions(self, /) -> str: + try: + return current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format( # noqa: E501 + number=self.filename, + ) + except Exception: + pass + + return '' + def find_instructions(self, set: str, /) -> None: + headers = { - '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' + 'User-Agent': current_app.config['REBRICKABLE_USER_AGENT'] } - response = requests.get(url, headers=headers) + + response = requests.get(BrickInstructions.url_for_instructions(self), headers=headers) if response.status_code != 200: raise ErrorException('Failed to load page. Status code: {response.status_code}') @@ -144,6 +154,7 @@ class BrickInstructions(object): img_tag = a_tag.find('img', alt=True) if img_tag and "LEGO Building Instructions" in img_tag['alt']: found_tags.append((img_tag['alt'].replace('LEGO Building Instructions for ', ''), a_tag['href'])) # Save alt and href + return found_tags def get_list(self, request_form, /) -> list: @@ -153,30 +164,28 @@ class BrickInstructions(object): if key.startswith('instruction-') and request_form.get(key) == 'on': # Checkbox is checked index = key.split('-')[-1] alt_text = request_form.get(f'instruction-alt-text-{index}') - href_text = request_form.get(f'instruction-href-text-{index}') + href_text = request_form.get(f'instruction-href-text-{index}').replace('/instructions/', '') # Remove the /instructions/ part selected_instructions.append((href_text,alt_text)) return selected_instructions - def download(self, href: str, /) -> None: - target = self.path(secure_filename(self.filename)) + def download(self, href: str, /) -> None: + target = self.path(filename=secure_filename(self.filename)) if os.path.isfile(target): raise ErrorException('Cannot download {target} as it already exists'.format( # noqa: E501 target=self.filename )) - url = f"https://rebrickable.com/{href}" + url = current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format(number=href) response = requests.get(url) if response.status_code == 200: - # Save the file - with open(target, 'wb') as file: - file.write(response.content) + # Save the content to the target path + FileStorage(stream=BytesIO(response.content)).save(target) else: raise ErrorException(f"Failed to download {self.filename}. Status code: {response.status_code}") - # Info logger.info('The instruction file {file} has been imported'.format( file=self.filename diff --git a/bricktracker/views/instructions.py b/bricktracker/views/instructions.py index 159f8db..585a981 100644 --- a/bricktracker/views/instructions.py +++ b/bricktracker/views/instructions.py @@ -140,35 +140,35 @@ def download() -> str: error=request.args.get('error') ) -# Actually download an instructions file +# Show search results @instructions_page.route('/download/', methods=['POST']) @login_required @exception_handler(__file__, post_redirect='instructions.download') def do_download() -> Response: + # get set_id from input field set_id: str = request.form.get('add-set', '') - # BrickInstructions require an argument. Not sure which makes sense here. - found_tags = BrickInstructions(set_id).find_instructions(set_id) - - return render_template('instructions.html', download=True, found_tags=found_tags) + # get list of instructions for the set and offer them to download + instructions = BrickInstructions(set_id).find_instructions(set_id) + + return render_template('instructions.html', download=True, instructions=instructions) @instructions_page.route('/confirm_download', methods=['POST']) @login_required @exception_handler(__file__, post_redirect='instructions.download') def confirm_download() -> Response: - # BrickInstructions require an argument. Not sure which makes sense here. + # Get list of selected instructions selected_instructions = BrickInstructions("").get_list(request.form) - + + # No instructions selected if not selected_instructions: - # No instructions selected return redirect(url_for('instructions.download')) + # Loop over selected instructions and download them for href, filename in selected_instructions: BrickInstructions(f"{filename}.pdf").download(href) - BrickInstructionsList(force=True) - #flash("Selected instructions have been downloaded", 'success') return redirect(url_for('instructions.list')) diff --git a/templates/instructions/download.html b/templates/instructions/download.html index 79b3ccc..20cb688 100644 --- a/templates/instructions/download.html +++ b/templates/instructions/download.html @@ -17,7 +17,7 @@ - {% if found_tags %} + {% if instructions %}
Select instructions to download
@@ -27,7 +27,7 @@
- {% for alt_text, href in found_tags %} + {% for alt_text, href in instructions %}
From 6d3285dbc9888d3de235b8a88575e5a19d606b95 Mon Sep 17 00:00:00 2001 From: Gregoo Date: Sat, 25 Jan 2025 08:50:04 +0100 Subject: [PATCH 06/16] Move parse_number out of RebrickableSet as it imports way too much for such a simple function --- bricktracker/parser.py | 37 +++++++++++++++++++++++++++++++ bricktracker/rebrickable_set.py | 39 ++------------------------------- bricktracker/wish_list.py | 4 ++-- 3 files changed, 41 insertions(+), 39 deletions(-) create mode 100644 bricktracker/parser.py diff --git a/bricktracker/parser.py b/bricktracker/parser.py new file mode 100644 index 0000000..d3602e2 --- /dev/null +++ b/bricktracker/parser.py @@ -0,0 +1,37 @@ +from .exceptions import ErrorException + + +# Make sense of string supposed to contain a set ID +def parse_set(set: str, /) -> str: + number, _, version = set.partition('-') + + # Making sure both are integers + if version == '': + version = 1 + + try: + number = int(number) + except Exception: + raise ErrorException('Number "{number}" is not a number'.format( + number=number, + )) + + try: + version = int(version) + except Exception: + raise ErrorException('Version "{version}" is not a number'.format( + version=version, + )) + + # Make sure both are positive + if number < 0: + raise ErrorException('Number "{number}" should be positive'.format( + number=number, + )) + + if version < 0: + raise ErrorException('Version "{version}" should be positive'.format( # noqa: E501 + version=version, + )) + + return '{number}-{version}'.format(number=number, version=version) diff --git a/bricktracker/rebrickable_set.py b/bricktracker/rebrickable_set.py index 37e26b3..5a1c41f 100644 --- a/bricktracker/rebrickable_set.py +++ b/bricktracker/rebrickable_set.py @@ -7,6 +7,7 @@ from flask import current_app from .exceptions import ErrorException, NotFoundException from .instructions import BrickInstructions +from .parser import parse_set from .rebrickable import Rebrickable from .rebrickable_image import RebrickableImage from .record import BrickRecord @@ -98,7 +99,7 @@ class RebrickableSet(BrickRecord): try: self.socket.auto_progress(message='Parsing set number') - set = RebrickableSet.parse_number(str(data['set'])) + set = parse_set(str(data['set'])) self.socket.auto_progress( message='Set {set}: loading from Rebrickable'.format( @@ -187,39 +188,3 @@ class RebrickableSet(BrickRecord): 'url': str(data['set_url']), 'last_modified': str(data['last_modified_dt']), } - - # Make sense of the number from the data - @staticmethod - def parse_number(set: str, /) -> str: - number, _, version = set.partition('-') - - # Making sure both are integers - if version == '': - version = 1 - - try: - number = int(number) - except Exception: - raise ErrorException('Number "{number}" is not a number'.format( - number=number, - )) - - try: - version = int(version) - except Exception: - raise ErrorException('Version "{version}" is not a number'.format( - version=version, - )) - - # Make sure both are positive - if number < 0: - raise ErrorException('Number "{number}" should be positive'.format( - number=number, - )) - - if version < 0: - raise ErrorException('Version "{version}" should be positive'.format( # noqa: E501 - version=version, - )) - - return '{number}-{version}'.format(number=number, version=version) diff --git a/bricktracker/wish_list.py b/bricktracker/wish_list.py index dfba800..880021b 100644 --- a/bricktracker/wish_list.py +++ b/bricktracker/wish_list.py @@ -4,9 +4,9 @@ from typing import Self from flask import current_app from .exceptions import NotFoundException +from .parser import parse_set from .rebrickable import Rebrickable from .rebrickable_image import RebrickableImage -from .rebrickable_set import RebrickableSet from .record_list import BrickRecordList from .wish import BrickWish @@ -34,7 +34,7 @@ class BrickWishList(BrickRecordList[BrickWish]): @staticmethod def add(set: str, /) -> None: try: - set = RebrickableSet.parse_number(set) + set = parse_set(set) BrickWish().select_specific(set) except NotFoundException: logger.debug('rebrick.lego.get_set("{set}")'.format( From ed44fb9bab81bed7517451b31b57a0c94c87227f Mon Sep 17 00:00:00 2001 From: Gregoo Date: Sat, 25 Jan 2025 08:51:18 +0100 Subject: [PATCH 07/16] Global cleanup of the code, implementing all the comments for the issue --- bricktracker/config.py | 4 +- bricktracker/instructions.py | 177 ++++++++++++++++----------- bricktracker/views/instructions.py | 54 ++++---- templates/instructions/download.html | 7 +- templates/set/card.html | 4 +- 5 files changed, 138 insertions(+), 108 deletions(-) diff --git a/bricktracker/config.py b/bricktracker/config.py index 36defc6..08db61b 100644 --- a/bricktracker/config.py +++ b/bricktracker/config.py @@ -43,8 +43,8 @@ CONFIG: Final[list[dict[str, Any]]] = [ {'n': 'REBRICKABLE_IMAGE_NIL_MINIFIGURE', 'd': 'https://rebrickable.com/static/img/nil_mf.jpg'}, # noqa: E501 {'n': 'REBRICKABLE_LINK_MINIFIGURE_PATTERN', 'd': 'https://rebrickable.com/minifigs/{number}'}, # noqa: E501 {'n': 'REBRICKABLE_LINK_PART_PATTERN', 'd': 'https://rebrickable.com/parts/{number}/_/{color}'}, # noqa: E501 - {'n': 'REBRICKABLE_LINK_INSTRUCTIONS_PATTERN', 'd': 'https://rebrickable.com/instructions/{number}'}, # 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'}, + {'n': 'REBRICKABLE_LINK_INSTRUCTIONS_PATTERN', 'd': 'https://rebrickable.com/instructions/{path}'}, # noqa: E501 + {'n': 'REBRICKABLE_USER_AGENT', 'd': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'}, # noqa: E501 {'n': 'REBRICKABLE_LINKS', 'e': 'LINKS', 'c': bool}, {'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 diff --git a/bricktracker/instructions.py b/bricktracker/instructions.py index 0c966d3..0dd7f8e 100644 --- a/bricktracker/instructions.py +++ b/bricktracker/instructions.py @@ -1,18 +1,18 @@ from datetime import datetime, timezone import logging import os -from typing import TYPE_CHECKING +from shutil import copyfileobj +from typing import Tuple, TYPE_CHECKING -from flask import current_app, g, url_for, flash +from bs4 import BeautifulSoup +from flask import current_app, g, url_for import humanize +import requests from werkzeug.datastructures import FileStorage from werkzeug.utils import secure_filename -from io import BytesIO -import requests -from bs4 import BeautifulSoup - -from .exceptions import ErrorException +from .exceptions import ErrorException, DownloadException +from .parser import parse_set if TYPE_CHECKING: from .rebrickable_set import RebrickableSet @@ -71,6 +71,34 @@ class BrickInstructions(object): def delete(self, /) -> None: os.remove(self.path()) + # Download an instruction file + def download(self, path: str, /) -> None: + target = self.path(filename=secure_filename(self.filename)) + + if os.path.isfile(target): + raise ErrorException('Cannot download {target} as it already exists'.format( # noqa: E501 + target=self.filename + )) + + url = current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format( # noqa: E501 + path=path + ) + + response = requests.get(url, stream=True) + if response.ok: + with open(target, 'wb') as f: + copyfileobj(response.raw, f) + else: + raise DownloadException('Failed to download {file}. Status code: {code}'.format( # noqa: E501 + file=self.filename, + code=response.status_code + )) + + # Info + logger.info('The instruction file {file} has been downloaded'.format( + file=self.filename + )) + # Display the size in a human format def human_size(self) -> str: return humanize.naturalsize(self.size) @@ -118,73 +146,6 @@ class BrickInstructions(object): )) file.save(target) - - # Info - logger.info('The instruction file {file} has been imported'.format( - file=self.filename - )) - - # Compute the url for the rebrickable instructions page - def url_for_instructions(self, /) -> str: - try: - return current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format( # noqa: E501 - number=self.filename, - ) - except Exception: - pass - - return '' - - def find_instructions(self, set: str, /) -> None: - - headers = { - 'User-Agent': current_app.config['REBRICKABLE_USER_AGENT'] - } - - response = requests.get(BrickInstructions.url_for_instructions(self), headers=headers) - if response.status_code != 200: - raise ErrorException('Failed to load page. Status code: {response.status_code}') - - # Parse the HTML content - soup = BeautifulSoup(response.content, 'html.parser') - - # Collect all tags with "LEGO Building Instructions" in the alt attribute - found_tags = [] - for a_tag in soup.find_all('a', href=True): - img_tag = a_tag.find('img', alt=True) - if img_tag and "LEGO Building Instructions" in img_tag['alt']: - found_tags.append((img_tag['alt'].replace('LEGO Building Instructions for ', ''), a_tag['href'])) # Save alt and href - - return found_tags - - def get_list(self, request_form, /) -> list: - selected_instructions = [] - # Get the list of instructions - for key in request_form: - if key.startswith('instruction-') and request_form.get(key) == 'on': # Checkbox is checked - index = key.split('-')[-1] - alt_text = request_form.get(f'instruction-alt-text-{index}') - href_text = request_form.get(f'instruction-href-text-{index}').replace('/instructions/', '') # Remove the /instructions/ part - selected_instructions.append((href_text,alt_text)) - - return selected_instructions - - def download(self, href: str, /) -> None: - target = self.path(filename=secure_filename(self.filename)) - - if os.path.isfile(target): - raise ErrorException('Cannot download {target} as it already exists'.format( # noqa: E501 - target=self.filename - )) - - url = current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format(number=href) - - response = requests.get(url) - if response.status_code == 200: - # Save the content to the target path - FileStorage(stream=BytesIO(response.content)).save(target) - else: - raise ErrorException(f"Failed to download {self.filename}. Status code: {response.status_code}") # Info logger.info('The instruction file {file} has been imported'.format( @@ -213,3 +174,71 @@ class BrickInstructions(object): return 'file-image-line' else: return 'file-line' + + # Download selected instructions for a set + @staticmethod + def download_instructions(form: dict[str, str], /) -> None: + selected_instructions: list[Tuple[str, str]] = [] + + # Get the list of instructions + for key in form: + if key.startswith('instruction-') and form.get(key) == 'on': + _, _, index = key.partition('-') + alt_text = form.get(f'instruction-alt-text-{index}', '') + href_text = form.get(f'instruction-href-text-{index}', '').removeprefix('/instructions/') # Remove the /instructions/ part # noqa: E501 + selected_instructions.append((href_text, alt_text)) + + # Raise if nothing selected + if not len(selected_instructions): + raise ErrorException('No instruction was selected to download') + + # Loop over selected instructions and download them + for href, filename in selected_instructions: + BrickInstructions(f"{filename}.pdf").download(href) + + # Find the instructions for a set + @staticmethod + def find_instructions(form: dict[str, str], /) -> list[Tuple[str, str]]: + # Grab the set ID + set: str = form.get('add-set', '') + + # Parse it + set = parse_set(set) + + response = requests.get( + current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format( + path=set, + ), + headers={ + 'User-Agent': current_app.config['REBRICKABLE_USER_AGENT'] + } + ) + + if not response.ok: + raise ErrorException('Failed to load the Rebrickable instructions page. Status code: {code}'.format( # noqa: E501 + code=response.status_code + )) + + # Parse the HTML content + soup = BeautifulSoup(response.content, 'html.parser') + + # Collect all tags with "LEGO Building Instructions" in the + # alt attribute + found_tags: list[Tuple[str, str]] = [] + for a_tag in soup.find_all('a', href=True): + img_tag = a_tag.find('img', alt=True) + 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 + if not len(found_tags): + raise ErrorException('No instruction found for set {set}'.format( + set=set + )) + + return found_tags diff --git a/bricktracker/views/instructions.py b/bricktracker/views/instructions.py index 585a981..d347b06 100644 --- a/bricktracker/views/instructions.py +++ b/bricktracker/views/instructions.py @@ -4,8 +4,7 @@ from flask import ( redirect, render_template, request, - url_for, - flash + url_for ) from flask_login import login_required from werkzeug.wrappers.response import Response @@ -14,6 +13,7 @@ from werkzeug.utils import secure_filename from .exceptions import exception_handler from ..instructions import BrickInstructions from ..instructions_list import BrickInstructionsList +from ..parser import parse_set from .upload import upload_helper instructions_page = Blueprint( @@ -134,40 +134,38 @@ def do_upload() -> Response: @login_required @exception_handler(__file__) def download() -> str: + # Grab the set number + try: + set = parse_set(request.args.get('set', '')) + except Exception: + set = '' + return render_template( 'instructions.html', download=True, - error=request.args.get('error') + error=request.args.get('error'), + set=set ) - + + # Show search results -@instructions_page.route('/download/', methods=['POST']) +@instructions_page.route('/download/select', methods=['POST']) +@login_required +@exception_handler(__file__, post_redirect='instructions.download') +def select_download() -> str: + return render_template( + 'instructions.html', + download=True, + instructions=BrickInstructions.find_instructions(request.form) + ) + + +# Download files +@instructions_page.route('/download', methods=['POST']) @login_required @exception_handler(__file__, post_redirect='instructions.download') def do_download() -> Response: - # get set_id from input field - set_id: str = request.form.get('add-set', '') - - # get list of instructions for the set and offer them to download - instructions = BrickInstructions(set_id).find_instructions(set_id) - - return render_template('instructions.html', download=True, instructions=instructions) - -@instructions_page.route('/confirm_download', methods=['POST']) -@login_required -@exception_handler(__file__, post_redirect='instructions.download') -def confirm_download() -> Response: - - # Get list of selected instructions - selected_instructions = BrickInstructions("").get_list(request.form) - - # No instructions selected - if not selected_instructions: - return redirect(url_for('instructions.download')) - - # Loop over selected instructions and download them - for href, filename in selected_instructions: - BrickInstructions(f"{filename}.pdf").download(href) + BrickInstructions.download_instructions(request.form) BrickInstructionsList(force=True) diff --git a/templates/instructions/download.html b/templates/instructions/download.html index 20cb688..9543702 100644 --- a/templates/instructions/download.html +++ b/templates/instructions/download.html @@ -1,7 +1,8 @@
+ {% if error %}{% endif %}
-
+
Download instructions from Rebrickable
@@ -11,7 +12,7 @@
- +
@@ -23,7 +24,7 @@
Select instructions to download
- +
diff --git a/templates/set/card.html b/templates/set/card.html index 1fe7f72..9fcea3d 100644 --- a/templates/set/card.html +++ b/templates/set/card.html @@ -48,9 +48,11 @@ No instructions file found. {% if g.login.is_authenticated() %} Upload an instructions file - Download instruction from Rebrickable {% endif %} {% endif %} + {% if g.login.is_authenticated() %} + Download instructions from Rebrickable + {% endif %}
{{ accordion.footer() }} {{ accordion.table(item.parts(), 'Parts', 'parts-inventory', 'set-details', 'part/table.html', icon='shapes-line')}} From fd38e0a1500033f10e6d0ac19b3ba62964da181b Mon Sep 17 00:00:00 2001 From: Gregoo Date: Sat, 25 Jan 2025 08:55:42 +0100 Subject: [PATCH 08/16] Fix the default value --- .env.sample | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.sample b/.env.sample index 51845fe..7cbc5c0 100644 --- a/.env.sample +++ b/.env.sample @@ -179,7 +179,7 @@ # BK_REBRICKABLE_LINK_PART_PATTERN= # Optional: Pattern of the link to Rebrickable for instructions. Will be passed to Python .format() -# Default: https://rebrickable.com/instructions/{number} +# Default: https://rebrickable.com/instructions/{path} # BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN= # Optional: User-Agent to use when querying Rebrickable outside of the Rebrick python library From 9113d539f0975d5ba78fdbd999097ccca9cabd32 Mon Sep 17 00:00:00 2001 From: Gregoo Date: Sat, 25 Jan 2025 19:43:16 +0100 Subject: [PATCH 09/16] Split the JS socket with a generic part and one dedicated to load Rebrickable sets --- static/scripts/socket/set.js | 244 ++++++++++++++++++++++++++ static/scripts/{ => socket}/socket.js | 207 +--------------------- templates/base.html | 3 +- templates/set/socket.html | 2 +- 4 files changed, 249 insertions(+), 207 deletions(-) create mode 100644 static/scripts/socket/set.js rename static/scripts/{ => socket}/socket.js (54%) diff --git a/static/scripts/socket/set.js b/static/scripts/socket/set.js new file mode 100644 index 0000000..60a2244 --- /dev/null +++ b/static/scripts/socket/set.js @@ -0,0 +1,244 @@ +// Set Socket class +class BrickSetSocket extends BrickSocket { + constructor(id, path, namespace, messages, bulk=false) { + super(id, path, namespace, messages, bulk); + + // Listeners + this.add_listener = undefined; + this.confirm_listener = undefined; + + // Form elements (built based on the initial id) + this.html_button = document.getElementById(id); + this.html_input = document.getElementById(`${id}-set`); + this.html_no_confim = document.getElementById(`${id}-no-confirm`); + + // Card elements + this.html_card = document.getElementById(`${id}-card`); + this.html_card_set = document.getElementById(`${id}-card-set`); + this.html_card_name = document.getElementById(`${id}-card-name`); + this.html_card_image_container = document.getElementById(`${id}-card-image-container`); + this.html_card_image = document.getElementById(`${id}-card-image`); + this.html_card_footer = document.getElementById(`${id}-card-footer`); + this.html_card_confirm = document.getElementById(`${id}-card-confirm`); + this.html_card_dismiss = document.getElementById(`${id}-card-dismiss`); + + if (this.html_button) { + this.add_listener = ((bricksocket) => (e) => { + if (!bricksocket.disabled && bricksocket.socket !== undefined && bricksocket.socket.connected) { + bricksocket.toggle(false); + + // Split and save the list if bulk + if (bricksocket.bulk) { + bricksocket.read_set_list() + } + + if (bricksocket.bulk || (bricksocket.html_no_confim && bricksocket.html_no_confim.checked)) { + bricksocket.import_set(true); + } else { + bricksocket.load_set(); + } + } + })(this); + + this.html_button.addEventListener("click", this.add_listener); + } + + if (this.html_card_dismiss && this.html_card) { + this.html_card_dismiss.addEventListener("click", ((card) => (e) => { + card.classList.add("d-none"); + })(this.html_card)); + } + + // Setup the socket + this.setup(); + } + + // Clear form + clear() { + super.clear(); + + if (this.html_card) { + this.html_card.classList.add("d-none"); + } + + if (this.html_card_footer) { + this.html_card_footer.classList.add("d-none"); + + if (this.html_card_confirm) { + this.html_card_footer.classList.add("d-none"); + } + } + } + + // Upon receiving a complete message + complete(data) { + super.complete(data); + + if (this.bulk) { + // Import the next set + this.import_set(true, undefined, true); + } + } + + // Upon receiving a fail message + fail(data) { + super.fail(data); + + if (this.bulk && this.html_input) { + if (this.set_list_last_set !== undefined) { + this.set_list.unshift(this.set_list_last_set); + this.set_list_last_set = undefined; + } + + this.html_input.value = this.set_list.join(', '); + } + } + + // Import a set + import_set(no_confirm, set, from_complete=false) { + if (this.html_input) { + if (!this.bulk || !from_complete) { + // Reset the progress + if (no_confirm) { + this.clear(); + } else { + this.clear_status(); + } + } + + // Grab from the list if bulk + if (this.bulk) { + set = this.set_list.shift() + + // Abort if nothing left to process + if (set === undefined) { + // Clear the input + this.html_input.value = ""; + + // Settle the form + this.spinner(false); + this.toggle(true); + + return; + } + + // Save the pulled set + this.set_list_last_set = set; + } + + this.spinner(true); + + this.socket.emit(this.messages.IMPORT_SET, { + set: (set !== undefined) ? set : this.html_input.value, + }); + } else { + this.fail("Could not find the input field for the set number"); + } + } + + // Load a set + load_set() { + if (this.html_input) { + // Reset the progress + this.clear() + this.spinner(true); + + this.socket.emit(this.messages.LOAD_SET, { + set: this.html_input.value + }); + } else { + this.fail("Could not find the input field for the set number"); + } + } + + // Bulk: read the input as a list + read_set_list() { + this.set_list = []; + + if (this.html_input) { + const value = this.html_input.value; + this.set_list = value.split(",").map((el) => el.trim()) + } + } + + // Set is loaded + set_loaded(data) { + if (this.html_card) { + this.html_card.classList.remove("d-none"); + + if (this.html_card_set) { + this.html_card_set.textContent = data["set"]; + } + + if (this.html_card_name) { + this.html_card_name.textContent = data["name"]; + } + + if (this.html_card_image_container) { + this.html_card_image_container.setAttribute("style", `background-image: url(${data["image"]})`); + } + + if (this.html_card_image) { + this.html_card_image.setAttribute("src", data["image"]); + this.html_card_image.setAttribute("alt", data["set"]); + } + + if (this.html_card_footer) { + this.html_card_footer.classList.add("d-none"); + + if (!data.download) { + this.html_card_footer.classList.remove("d-none"); + + if (this.html_card_confirm) { + if (this.confirm_listener !== undefined) { + this.html_card_confirm.removeEventListener("click", this.confirm_listener); + } + + this.confirm_listener = ((bricksocket, set) => (e) => { + if (!bricksocket.disabled) { + bricksocket.toggle(false); + bricksocket.import_set(false, set); + } + })(this, data["set"]); + + this.html_card_confirm.addEventListener("click", this.confirm_listener); + } + } + } + } + } + + // Setup the actual socket + setup() { + super.setup(); + + if (this.socket !== undefined) { + // Set loaded + this.socket.on(this.messages.SET_LOADED, ((bricksocket) => (data) => { + bricksocket.set_loaded(data); + })(this)); + } + } + + + // Toggle clicking on the button, or sending events + toggle(enabled) { + super.toggle(enabled); + + if (this.html_button) { + this.html_button.disabled = !enabled; + } + + if (this.html_input) { + this.html_input.disabled = !enabled; + } + + if (this.html_card_confirm) { + this.html_card_confirm.disabled = !enabled; + } + + if (this.html_card_dismiss) { + this.html_card_dismiss.disabled = !enabled; + } + } +} diff --git a/static/scripts/socket.js b/static/scripts/socket/socket.js similarity index 54% rename from static/scripts/socket.js rename to static/scripts/socket/socket.js index 5a24d06..1a34056 100644 --- a/static/scripts/socket.js +++ b/static/scripts/socket/socket.js @@ -5,22 +5,17 @@ class BrickSocket { this.path = path; this.namespace = namespace; this.messages = messages; - this.bulk = bulk; this.disabled = false; this.socket = undefined; - // Listeners - this.add_listener = undefined; - this.confirm_listener = undefined; + // Bulk mode + this.bulk = bulk; // Form elements (built based on the initial id) - this.html_button = document.getElementById(id); this.html_complete = document.getElementById(`${id}-complete`); this.html_count = document.getElementById(`${id}-count`); this.html_fail = document.getElementById(`${id}-fail`); - this.html_input = document.getElementById(`${id}-set`); - this.html_no_confim = document.getElementById(`${id}-no-confirm`); this.html_progress = document.getElementById(`${id}-progress`); this.html_progress_bar = document.getElementById(`${id}-progress-bar`); this.html_progress_message = document.getElementById(`${id}-progress-message`); @@ -28,50 +23,10 @@ class BrickSocket { this.html_status = document.getElementById(`${id}-status`); this.html_status_icon = document.getElementById(`${id}-status-icon`); - // Card elements - this.html_card = document.getElementById(`${id}-card`); - this.html_card_set = document.getElementById(`${id}-card-set`); - this.html_card_name = document.getElementById(`${id}-card-name`); - this.html_card_image_container = document.getElementById(`${id}-card-image-container`); - this.html_card_image = document.getElementById(`${id}-card-image`); - this.html_card_footer = document.getElementById(`${id}-card-footer`); - this.html_card_confirm = document.getElementById(`${id}-card-confirm`); - this.html_card_dismiss = document.getElementById(`${id}-card-dismiss`); - - if (this.html_button) { - this.add_listener = ((bricksocket) => (e) => { - if (!bricksocket.disabled && bricksocket.socket !== undefined && bricksocket.socket.connected) { - bricksocket.toggle(false); - - // Split and save the list if bulk - if (bricksocket.bulk) { - bricksocket.read_set_list() - } - - if (bricksocket.bulk || (bricksocket.html_no_confim && bricksocket.html_no_confim.checked)) { - bricksocket.import_set(true); - } else { - bricksocket.load_set(); - } - } - })(this); - - this.html_button.addEventListener("click", this.add_listener); - } - - if (this.html_card_dismiss && this.html_card) { - this.html_card_dismiss.addEventListener("click", ((card) => (e) => { - card.classList.add("d-none"); - })(this.html_card)); - } - // Socket status window.setInterval(((bricksocket) => () => { bricksocket.status(); })(this), 500); - - // Setup the socket - this.setup(); } // Clear form @@ -89,18 +44,6 @@ class BrickSocket { } this.spinner(false); - - if (this.html_card) { - this.html_card.classList.add("d-none"); - } - - if (this.html_card_footer) { - this.html_card_footer.classList.add("d-none"); - - if (this.html_card_confirm) { - this.html_card_footer.classList.add("d-none"); - } - } } // Clear status message @@ -141,9 +84,6 @@ class BrickSocket { this.html_complete.append(success) } - - // Import the next set - this.import_set(true, undefined, true); } else { this.spinner(false); @@ -188,73 +128,8 @@ class BrickSocket { if (this.html_progress_bar) { this.html_progress_bar.classList.remove("progress-bar-animated"); } - - if (this.bulk && this.html_input) { - if (this.set_list_last_set !== undefined) { - this.set_list.unshift(this.set_list_last_set); - this.set_list_last_set = undefined; - } - - this.html_input.value = this.set_list.join(', '); - } } - // Import a set - import_set(no_confirm, set, from_complete=false) { - if (this.html_input) { - if (!this.bulk || !from_complete) { - // Reset the progress - if (no_confirm) { - this.clear(); - } else { - this.clear_status(); - } - } - - // Grab from the list if bulk - if (this.bulk) { - set = this.set_list.shift() - - // Abort if nothing left to process - if (set === undefined) { - // Clear the input - this.html_input.value = ""; - - // Settle the form - this.spinner(false); - this.toggle(true); - - return; - } - - // Save the pulled set - this.set_list_last_set = set; - } - - this.spinner(true); - - this.socket.emit(this.messages.IMPORT_SET, { - set: (set !== undefined) ? set : this.html_input.value, - }); - } else { - this.fail("Could not find the input field for the set number"); - } - } - - // Load a set - load_set() { - if (this.html_input) { - // Reset the progress - this.clear() - this.spinner(true); - - this.socket.emit(this.messages.LOAD_SET, { - set: this.html_input.value - }); - } else { - this.fail("Could not find the input field for the set number"); - } - } // Update the progress progress(data={}) { @@ -304,63 +179,6 @@ class BrickSocket { } } - // Bulk: read the input as a list - read_set_list() { - this.set_list = []; - - if (this.html_input) { - const value = this.html_input.value; - this.set_list = value.split(",").map((el) => el.trim()) - } - } - - // Set is loaded - set_loaded(data) { - if (this.html_card) { - this.html_card.classList.remove("d-none"); - - if (this.html_card_set) { - this.html_card_set.textContent = data["set"]; - } - - if (this.html_card_name) { - this.html_card_name.textContent = data["name"]; - } - - if (this.html_card_image_container) { - this.html_card_image_container.setAttribute("style", `background-image: url(${data["image"]})`); - } - - if (this.html_card_image) { - this.html_card_image.setAttribute("src", data["image"]); - this.html_card_image.setAttribute("alt", data["set"]); - } - - if (this.html_card_footer) { - this.html_card_footer.classList.add("d-none"); - - if (!data.download) { - this.html_card_footer.classList.remove("d-none"); - - if (this.html_card_confirm) { - if (this.confirm_listener !== undefined) { - this.html_card_confirm.removeEventListener("click", this.confirm_listener); - } - - this.confirm_listener = ((bricksocket, set) => (e) => { - if (!bricksocket.disabled) { - bricksocket.toggle(false); - bricksocket.import_set(false, set); - } - })(this, data["set"]); - - this.html_card_confirm.addEventListener("click", this.confirm_listener); - } - } - } - } - } - // Setup the actual socket setup() { if (this.socket === undefined) { @@ -387,11 +205,6 @@ class BrickSocket { this.socket.on(this.messages.PROGRESS, ((bricksocket) => (data) => { bricksocket.progress(data); })(this)); - - // Set loaded - this.socket.on(this.messages.SET_LOADED, ((bricksocket) => (data) => { - bricksocket.set_loaded(data); - })(this)); } } @@ -434,21 +247,5 @@ class BrickSocket { // Toggle clicking on the button, or sending events toggle(enabled) { this.disabled = !enabled; - - if (this.html_button) { - this.html_button.disabled = !enabled; - } - - if (this.html_input) { - this.html_input.disabled = !enabled; - } - - if (this.html_card_confirm) { - this.html_card_confirm.disabled = !enabled; - } - - if (this.html_card_dismiss) { - this.html_card_dismiss.disabled = !enabled; - } } } diff --git a/templates/base.html b/templates/base.html index 1132f2b..12c4afa 100644 --- a/templates/base.html +++ b/templates/base.html @@ -82,7 +82,8 @@ - + + + From 9b5774555f6abed4033722c400ddd261e9dbe58e Mon Sep 17 00:00:00 2001 From: Gregoo Date: Sat, 25 Jan 2025 22:43:35 +0100 Subject: [PATCH 11/16] Increase the socket status polling interval to 1s --- static/scripts/socket/socket.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/scripts/socket/socket.js b/static/scripts/socket/socket.js index 1a34056..30364ad 100644 --- a/static/scripts/socket/socket.js +++ b/static/scripts/socket/socket.js @@ -26,7 +26,7 @@ class BrickSocket { // Socket status window.setInterval(((bricksocket) => () => { bricksocket.status(); - })(this), 500); + })(this), 1000); } // Clear form From cf9e716d1cd660d6a38bf672d1dafe34754cd36b Mon Sep 17 00:00:00 2001 From: Gregoo Date: Sat, 25 Jan 2025 22:43:54 +0100 Subject: [PATCH 12/16] Remove unused 'ADD_SET' socket message --- bricktracker/socket.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bricktracker/socket.py b/bricktracker/socket.py index 1db6947..c7215ae 100644 --- a/bricktracker/socket.py +++ b/bricktracker/socket.py @@ -15,7 +15,6 @@ logger = logging.getLogger(__name__) # Messages valid through the socket MESSAGES: Final[dict[str, str]] = { - 'ADD_SET': 'add_set', 'COMPLETE': 'complete', 'CONNECT': 'connect', 'DISCONNECT': 'disconnect', From 48ad7b5f02db8007434d06d75608e441f9098208 Mon Sep 17 00:00:00 2001 From: Gregoo Date: Sat, 25 Jan 2025 22:48:10 +0100 Subject: [PATCH 13/16] Trim the url in the progress message to make it more legible --- bricktracker/instructions.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bricktracker/instructions.py b/bricktracker/instructions.py index 5f91836..cc5cee7 100644 --- a/bricktracker/instructions.py +++ b/bricktracker/instructions.py @@ -111,11 +111,14 @@ class BrickInstructions(object): url = current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format( # noqa: E501 path=path ) + trimmed_url = current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format( # noqa: E501 + path=path.partition('/')[0] + ) # Request the file self.socket.progress( message='Requesting {url}'.format( - url=url, + url=trimmed_url, ) ) @@ -133,7 +136,7 @@ class BrickInstructions(object): # Downloading the file self.socket.progress( message='Downloading {url} ({size})'.format( - url=url, + url=trimmed_url, size=self.human_size(), ) ) From acebf6efd69c61c09f912dc1f5c30b9d68548f85 Mon Sep 17 00:00:00 2001 From: Gregoo Date: Sat, 25 Jan 2025 23:05:21 +0100 Subject: [PATCH 14/16] Clear the progress message when clear() --- static/scripts/socket/socket.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/static/scripts/socket/socket.js b/static/scripts/socket/socket.js index 30364ad..2e213db 100644 --- a/static/scripts/socket/socket.js +++ b/static/scripts/socket/socket.js @@ -43,6 +43,8 @@ class BrickSocket { this.html_progress_bar.textContent = ""; } + this.progress_message(""); + this.spinner(false); } From 19750d13657e7a8b261f66dd09fb092f0ad93094 Mon Sep 17 00:00:00 2001 From: Gregoo Date: Sat, 25 Jan 2025 23:05:39 +0100 Subject: [PATCH 15/16] Fix a bug when normalizing total in progress() --- static/scripts/socket/socket.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/scripts/socket/socket.js b/static/scripts/socket/socket.js index 2e213db..cb08889 100644 --- a/static/scripts/socket/socket.js +++ b/static/scripts/socket/socket.js @@ -139,7 +139,7 @@ class BrickSocket { let count = data["count"] // Fix the total if bogus - if (!total || isNaN(total) || total <= 1) { + if (!total || isNaN(total) || total <= 0) { total = 0; } From 2db0c1c2eb8c4984e4757449058f7c948e2b735b Mon Sep 17 00:00:00 2001 From: Gregoo Date: Sat, 25 Jan 2025 23:06:00 +0100 Subject: [PATCH 16/16] Clear the socket when clicking the button --- static/scripts/socket/instructions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/scripts/socket/instructions.js b/static/scripts/socket/instructions.js index 6a1fdc2..271c0c3 100644 --- a/static/scripts/socket/instructions.js +++ b/static/scripts/socket/instructions.js @@ -64,7 +64,7 @@ class BrickInstructionsSocket extends BrickSocket { if (!from_complete) { this.total = this.get_files(true).length; this.current = 0; - this.clear_status(); + this.clear(); } // Find the next checkbox