diff --git a/bricktracker/instructions.py b/bricktracker/instructions.py index 0dd7f8e..5f91836 100644 --- a/bricktracker/instructions.py +++ b/bricktracker/instructions.py @@ -2,6 +2,7 @@ from datetime import datetime, timezone import logging import os from shutil import copyfileobj +import traceback from typing import Tuple, TYPE_CHECKING from bs4 import BeautifulSoup @@ -12,14 +13,16 @@ from werkzeug.datastructures import FileStorage from werkzeug.utils import secure_filename from .exceptions import ErrorException, DownloadException -from .parser import parse_set if TYPE_CHECKING: from .rebrickable_set import RebrickableSet + from .socket import BrickSocket logger = logging.getLogger(__name__) class BrickInstructions(object): + socket: 'BrickSocket' + allowed: bool rebrickable: 'RebrickableSet | None' extension: str @@ -29,9 +32,22 @@ class BrickInstructions(object): name: str size: int - def __init__(self, file: os.DirEntry | str, /): + def __init__( + self, + file: os.DirEntry | str, + /, + *, + socket: 'BrickSocket | None' = None, + ): + # Save the socket + if socket is not None: + self.socket = socket + if isinstance(file, str): self.filename = file + + if self.filename == '': + raise ErrorException('An instruction filename cannot be empty') else: self.filename = file.name @@ -73,31 +89,84 @@ class BrickInstructions(object): # Download an instruction file def download(self, path: str, /) -> None: - target = self.path(filename=secure_filename(self.filename)) + try: + # Just to make sure that the progress is initiated + self.socket.progress( + message='Downloading {file}'.format( + file=self.filename, + ) + ) - if os.path.isfile(target): - raise ErrorException('Cannot download {target} as it already exists'.format( # noqa: E501 - target=self.filename - )) + target = self.path(filename=secure_filename(self.filename)) - url = current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format( # noqa: E501 - path=path - ) + # Skipping rather than failing here + if os.path.isfile(target): + self.socket.complete( + message='File {file} already exists, skipped'.format( + file=self.filename, + ) + ) - 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 - )) + else: + url = current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format( # noqa: E501 + path=path + ) - # Info - logger.info('The instruction file {file} has been downloaded'.format( - file=self.filename - )) + # Request the file + self.socket.progress( + message='Requesting {url}'.format( + url=url, + ) + ) + + response = requests.get(url, stream=True) + if response.ok: + + # 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=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( + message='File {file} downloaded ({size})'.format( # noqa: E501 + file=self.filename, + size=self.human_size() + ) + ) + + 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()) # Display the size in a human format def human_size(self) -> str: @@ -175,36 +244,9 @@ class BrickInstructions(object): 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) - + def find_instructions(set: str, /) -> list[Tuple[str, str]]: response = requests.get( current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format( path=set, diff --git a/bricktracker/socket.py b/bricktracker/socket.py index 4351592..1db6947 100644 --- a/bricktracker/socket.py +++ b/bricktracker/socket.py @@ -5,6 +5,8 @@ from flask import copy_current_request_context, Flask, request from flask_socketio import SocketIO from .configuration_list import BrickConfigurationList +from .instructions import BrickInstructions +from .instructions_list import BrickInstructionsList from .login import LoginManager from .set import BrickSet from .sql import close as sql_close @@ -17,6 +19,7 @@ MESSAGES: Final[dict[str, str]] = { 'COMPLETE': 'complete', 'CONNECT': 'connect', 'DISCONNECT': 'disconnect', + 'DOWNLOAD_INSTRUCTIONS': 'download_instructions', 'FAIL': 'fail', 'IMPORT_SET': 'import_set', 'LOAD_SET': 'load_set', @@ -84,6 +87,41 @@ class BrickSocket(object): def disconnect() -> None: self.disconnected() + @self.socket.on(MESSAGES['DOWNLOAD_INSTRUCTIONS'], namespace=self.namespace) # noqa: E501 + def download_instructions(data: dict[str, Any], /) -> None: + # Needs to be authenticated + if LoginManager.is_not_authenticated(): + self.fail(message='You need to be authenticated') + return + + instructions = BrickInstructions( + '{name}.pdf'.format(name=data.get('alt', '')), + socket=self + ) + + path = data.get('href', '').removeprefix('/instructions/') + + # Update the progress + try: + self.progress_total = int(data.get('total', 0)) + self.progress_count = int(data.get('current', 0)) + except Exception: + pass + + # Start it in a thread if requested + if self.threaded: + @copy_current_request_context + def do_download() -> None: + instructions.download(path) + + BrickInstructionsList(force=True) + + self.socket.start_background_task(do_download) + else: + instructions.download(path) + + BrickInstructionsList(force=True) + @self.socket.on(MESSAGES['IMPORT_SET'], namespace=self.namespace) def import_set(data: dict[str, Any], /) -> None: # Needs to be authenticated diff --git a/bricktracker/views/instructions.py b/bricktracker/views/instructions.py index d347b06..2c2138a 100644 --- a/bricktracker/views/instructions.py +++ b/bricktracker/views/instructions.py @@ -14,6 +14,7 @@ from .exceptions import exception_handler from ..instructions import BrickInstructions from ..instructions_list import BrickInstructionsList from ..parser import parse_set +from ..socket import MESSAGES from .upload import upload_helper instructions_page = Blueprint( @@ -149,24 +150,22 @@ def download() -> str: # Show search results -@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: - BrickInstructions.download_instructions(request.form) +def do_download() -> str: + # Grab the set number + try: + set = parse_set(request.form.get('download-set', '')) + except Exception: + set = '' - BrickInstructionsList(force=True) - - return redirect(url_for('instructions.list')) + return render_template( + 'instructions.html', + download=True, + instructions=BrickInstructions.find_instructions(set), + set=set, + path=current_app.config['SOCKET_PATH'], + namespace=current_app.config['SOCKET_NAMESPACE'], + messages=MESSAGES + ) diff --git a/static/scripts/socket/instructions.js b/static/scripts/socket/instructions.js new file mode 100644 index 0000000..6a1fdc2 --- /dev/null +++ b/static/scripts/socket/instructions.js @@ -0,0 +1,108 @@ +// Instructions Socket class +class BrickInstructionsSocket extends BrickSocket { + constructor(id, path, namespace, messages) { + super(id, path, namespace, messages, true); + + // Listeners + this.download_listener = undefined; + + // 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.download_listener = ((bricksocket) => (e) => { + if (!bricksocket.disabled && bricksocket.socket !== undefined && bricksocket.socket.connected) { + bricksocket.toggle(false); + + bricksocket.download_instructions(); + } + })(this); + + this.html_button.addEventListener("click", this.download_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(); + } + + // Upon receiving a complete message + complete(data) { + super.complete(data); + + // Uncheck current file + this.file.checked = false; + + // Download the next file + this.download_instructions(true); + } + + // 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 an instructions file + download_instructions(from_complete=false) { + if (this.html_files) { + if (!from_complete) { + this.total = this.get_files(true).length; + this.current = 0; + this.clear_status(); + } + + // Find the next checkbox + this.file = this.get_files(true).shift(); + + // Abort if nothing left to process + if (this.file === undefined) { + // Settle the form + this.spinner(false); + this.toggle(true); + + return; + } + + this.spinner(true); + + this.current++; + this.socket.emit(this.messages.DOWNLOAD_INSTRUCTIONS, { + alt: this.file.dataset.downloadAlt, + href: this.file.dataset.downloadHref, + total: this.total, + current: this.current, + }); + } else { + this.fail("Could not find the list of files 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; + } + } +} diff --git a/templates/base.html b/templates/base.html index 12c4afa..6d89d3d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -83,6 +83,7 @@ <script src="{{ url_for('static', filename='scripts/grid.js') }}"></script> <script src="{{ url_for('static', filename='scripts/set.js') }}"></script> <script src="{{ url_for('static', filename='scripts/socket/socket.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/table.js') }}"></script> <script type="text/javascript"> diff --git a/templates/instructions/download.html b/templates/instructions/download.html index 9543702..c896769 100644 --- a/templates/instructions/download.html +++ b/templates/instructions/download.html @@ -1,50 +1,63 @@ <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.select_download') }}"> - <div class="card mb-3"> - <div class="card-header"> - <h5 class="mb-0"><i class="ri-add-circle-line"></i> Download instructions from Rebrickable</h5> - </div> - <div class="card-body"> - <div id="add-fail" class="alert alert-danger d-none" role="alert"></div> - <div id="add-complete" class="alert alert-success d-none" role="alert"></div> - <div class="mb-3"> - <label for="add-set" class="form-label">Set number (only one)</label> - <input type="text" class="form-control" id="add-set" name="add-set" placeholder="107-1 or 1642-1 or ..." value="{{ set }}"> - </div> - <button type="submit" class="btn btn-primary">Search</button> - </div> - </div> - </form> - {% if instructions %} - <div class="card mb-3"> - <div class="card-header"> - <h5 class="mb-0"><i class="ri-add-circle-line"></i> Select instructions to download</h5> - </div> - <div class="card-body"> - <form method="POST" action="{{ url_for('instructions.do_download') }}"> - <div class="mb-3"> - <label class="form-label">Available Instructions</label> - <div class="form-check"> - {% for alt_text, href in instructions %} - <div class="form-check"> - <input class="form-check-input" type="checkbox" name="instruction-{{ loop.index }}" id="instruction-{{ loop.index }}"> - <label class="form-check-label" for="instruction-{{ loop.index }}"> - {{ alt_text }} - </label> - <input type="hidden" name="instruction-alt-text-{{ loop.index }}" value="{{ alt_text }}"> - <input type="hidden" name="instruction-href-text-{{ loop.index }}" value="{{ href }}"> - </div> - {% endfor %} - </div> - </div> - <button type="submit" class="btn btn-primary">Download Selected</button> - </form> - </div> + {% 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> - {% endif %} + </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 instructions %} + <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="download-fail" class="alert alert-danger d-none" role="alert"></div> + <div id="download-complete"></div> + <h5 class="border-bottom">Available Instructions</h5> + <div id="download-files"> + {% for alt_text, href in instructions %} + <div class="form-check"> + <input class="form-check-input" type="checkbox" id="instruction-{{ loop.index }}" data-download-href="{{ href }}" data-download-alt="{{ alt_text }}" autocomplete="off"> + <label class="form-check-label" for="instruction-{{ loop.index }}">{{ alt_text }}</label> + </div> + {% endfor %} + </div> + </div> + <hr> + <div class="mb-3"> + <p> + Progress <span id="download-count"></span> + <span id="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="download-progress" class="progress" role="progressbar" aria-label="Download an instructions file progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"> + <div id="download-progress-bar" class="progress-bar" style="width: 0%"></div> + </div> + <p id="download-progress-message" class="text-center d-none"></p> + </div> + </div> + <div class="card-footer text-end"> + <span id="download-status-icon" class="me-1"></span><span id="download-status" class="me-1"></span><button id="download" type="button" class="btn btn-primary"><i class="ri-download-line"></i> Download selected files</button> + </div> + </div> + {% include 'instructions/socket.html' %} + {% endif %} </div> -</div> \ No newline at end of file + </div> +</div> diff --git a/templates/instructions/socket.html b/templates/instructions/socket.html new file mode 100644 index 0000000..3bd73f4 --- /dev/null +++ b/templates/instructions/socket.html @@ -0,0 +1,10 @@ +<script type="text/javascript"> + document.addEventListener("DOMContentLoaded", () => { + new BrickInstructionsSocket('download', '{{ path }}', '{{ namespace }}', { + COMPLETE: '{{ messages['COMPLETE'] }}', + DOWNLOAD_INSTRUCTIONS: '{{ messages['DOWNLOAD_INSTRUCTIONS'] }}', + FAIL: '{{ messages['FAIL'] }}', + PROGRESS: '{{ messages['PROGRESS'] }}', + }); + }); +</script>