Dowloads instructions through a socket

This commit is contained in:
Gregoo 2025-01-25 22:42:59 +01:00
parent 9113d539f0
commit c4a1a17cfd
7 changed files with 325 additions and 114 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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