Instructions downloader #54

Merged
FrederikBaerentsen merged 19 commits from instructions into master 2025-01-26 19:17:42 +01:00
18 changed files with 723 additions and 253 deletions

View File

@ -178,6 +178,14 @@
# Default: https://rebrickable.com/parts/{number}/_/{color} # Default: https://rebrickable.com/parts/{number}/_/{color}
# BK_REBRICKABLE_LINK_PART_PATTERN= # 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/{path}
# 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 # Optional: Display Rebrickable links wherever applicable
# Default: false # Default: false
# Legacy name: LINKS # Legacy name: LINKS

View File

@ -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_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_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_PART_PATTERN', 'd': 'https://rebrickable.com/parts/{number}/_/{color}'}, # noqa: E501
{'n': 'REBRICKABLE_LINK_INSTRUCTIONS_PATTERN', 'd': 'https://rebrickable.com/instructions/{path}'}, # noqa: E501
{'n': 'REBRICKABLE_USER_AGENT', 'd': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'}, # noqa: E501
{'n': 'REBRICKABLE_LINKS', 'e': 'LINKS', 'c': bool}, {'n': 'REBRICKABLE_LINKS', 'e': 'LINKS', 'c': bool},
{'n': 'REBRICKABLE_PAGE_SIZE', 'd': 100, 'c': int}, {'n': 'REBRICKABLE_PAGE_SIZE', 'd': 100, 'c': int},
{'n': 'RETIRED_SETS_FILE_URL', 'd': 'https://docs.google.com/spreadsheets/d/1rlYfEXtNKxUOZt2Mfv0H17DvK7bj6Pe0CuYwq6ay8WA/gviz/tq?tqx=out:csv&sheet=Sorted%20by%20Retirement%20Date'}, # noqa: E501 {'n': 'RETIRED_SETS_FILE_URL', 'd': 'https://docs.google.com/spreadsheets/d/1rlYfEXtNKxUOZt2Mfv0H17DvK7bj6Pe0CuYwq6ay8WA/gviz/tq?tqx=out:csv&sheet=Sorted%20by%20Retirement%20Date'}, # noqa: E501

View File

@ -1,21 +1,28 @@
from datetime import datetime, timezone from datetime import datetime, timezone
import logging import logging
import os import os
from typing import TYPE_CHECKING from shutil import copyfileobj
import traceback
from typing import Tuple, TYPE_CHECKING
from bs4 import BeautifulSoup
from flask import current_app, g, url_for from flask import current_app, g, url_for
import humanize import humanize
import requests
from werkzeug.datastructures import FileStorage from werkzeug.datastructures import FileStorage
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from .exceptions import ErrorException from .exceptions import ErrorException, DownloadException
if TYPE_CHECKING: if TYPE_CHECKING:
from .rebrickable_set import RebrickableSet from .rebrickable_set import RebrickableSet
from .socket import BrickSocket
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class BrickInstructions(object): class BrickInstructions(object):
socket: 'BrickSocket'
allowed: bool allowed: bool
rebrickable: 'RebrickableSet | None' rebrickable: 'RebrickableSet | None'
extension: str extension: str
@ -25,9 +32,22 @@ class BrickInstructions(object):
name: str name: str
size: int 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): if isinstance(file, str):
self.filename = file self.filename = file
if self.filename == '':
raise ErrorException('An instruction filename cannot be empty')
else: else:
self.filename = file.name self.filename = file.name
@ -67,6 +87,90 @@ class BrickInstructions(object):
def delete(self, /) -> None: def delete(self, /) -> None:
os.remove(self.path()) os.remove(self.path())
# Download an instruction file
def download(self, path: str, /) -> None:
try:
# Just to make sure that the progress is initiated
self.socket.progress(
message='Downloading {file}'.format(
file=self.filename,
)
)
target = self.path(filename=secure_filename(self.filename))
# Skipping rather than failing here
if os.path.isfile(target):
self.socket.complete(
message='File {file} already exists, skipped'.format(
file=self.filename,
)
)
else:
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=trimmed_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=trimmed_url,
size=self.human_size(),
)
)
with open(target, 'wb') as f:
copyfileobj(response.raw, f)
else:
raise DownloadException('failed to download: {code}'.format( # noqa: E501
code=response.status_code
))
# Info
logger.info('The instruction file {file} has been downloaded'.format( # noqa: E501
file=self.filename
))
# Complete
self.socket.complete(
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 # Display the size in a human format
def human_size(self) -> str: def human_size(self) -> str:
return humanize.naturalsize(self.size) return humanize.naturalsize(self.size)
@ -142,3 +246,44 @@ class BrickInstructions(object):
return 'file-image-line' return 'file-image-line'
else: else:
return 'file-line' return 'file-line'
# Find the instructions for a set
@staticmethod
def find_instructions(set: str, /) -> list[Tuple[str, str]]:
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 <img> 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

37
bricktracker/parser.py Normal file
View File

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

View File

@ -7,6 +7,7 @@ from flask import current_app
from .exceptions import ErrorException, NotFoundException from .exceptions import ErrorException, NotFoundException
from .instructions import BrickInstructions from .instructions import BrickInstructions
from .parser import parse_set
from .rebrickable import Rebrickable from .rebrickable import Rebrickable
from .rebrickable_image import RebrickableImage from .rebrickable_image import RebrickableImage
from .record import BrickRecord from .record import BrickRecord
@ -98,7 +99,7 @@ class RebrickableSet(BrickRecord):
try: try:
self.socket.auto_progress(message='Parsing set number') 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( self.socket.auto_progress(
message='Set {set}: loading from Rebrickable'.format( message='Set {set}: loading from Rebrickable'.format(
@ -187,39 +188,3 @@ class RebrickableSet(BrickRecord):
'url': str(data['set_url']), 'url': str(data['set_url']),
'last_modified': str(data['last_modified_dt']), '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)

View File

@ -5,6 +5,8 @@ from flask import copy_current_request_context, Flask, request
from flask_socketio import SocketIO from flask_socketio import SocketIO
from .configuration_list import BrickConfigurationList from .configuration_list import BrickConfigurationList
from .instructions import BrickInstructions
from .instructions_list import BrickInstructionsList
from .login import LoginManager from .login import LoginManager
from .set import BrickSet from .set import BrickSet
from .sql import close as sql_close from .sql import close as sql_close
@ -13,10 +15,10 @@ logger = logging.getLogger(__name__)
# Messages valid through the socket # Messages valid through the socket
MESSAGES: Final[dict[str, str]] = { MESSAGES: Final[dict[str, str]] = {
'ADD_SET': 'add_set',
'COMPLETE': 'complete', 'COMPLETE': 'complete',
'CONNECT': 'connect', 'CONNECT': 'connect',
'DISCONNECT': 'disconnect', 'DISCONNECT': 'disconnect',
'DOWNLOAD_INSTRUCTIONS': 'download_instructions',
'FAIL': 'fail', 'FAIL': 'fail',
'IMPORT_SET': 'import_set', 'IMPORT_SET': 'import_set',
'LOAD_SET': 'load_set', 'LOAD_SET': 'load_set',
@ -84,6 +86,41 @@ class BrickSocket(object):
def disconnect() -> None: def disconnect() -> None:
self.disconnected() 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) @self.socket.on(MESSAGES['IMPORT_SET'], namespace=self.namespace)
def import_set(data: dict[str, Any], /) -> None: def import_set(data: dict[str, Any], /) -> None:
# Needs to be authenticated # Needs to be authenticated

View File

@ -13,6 +13,8 @@ from werkzeug.utils import secure_filename
from .exceptions import exception_handler from .exceptions import exception_handler
from ..instructions import BrickInstructions from ..instructions import BrickInstructions
from ..instructions_list import BrickInstructionsList from ..instructions_list import BrickInstructionsList
from ..parser import parse_set
from ..socket import MESSAGES
from .upload import upload_helper from .upload import upload_helper
instructions_page = Blueprint( instructions_page = Blueprint(
@ -126,3 +128,44 @@ def do_upload() -> Response:
BrickInstructionsList(force=True) BrickInstructionsList(force=True)
return redirect(url_for('instructions.list')) return redirect(url_for('instructions.list'))
# Download instructions from Rebrickable
@instructions_page.route('/download/', methods=['GET'])
@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'),
set=set
)
# Show search results
@instructions_page.route('/download', methods=['POST'])
@login_required
@exception_handler(__file__, post_redirect='instructions.download')
def do_download() -> str:
# Grab the set number
try:
set = parse_set(request.form.get('download-set', ''))
except Exception:
set = ''
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

@ -4,9 +4,9 @@ from typing import Self
from flask import current_app from flask import current_app
from .exceptions import NotFoundException from .exceptions import NotFoundException
from .parser import parse_set
from .rebrickable import Rebrickable from .rebrickable import Rebrickable
from .rebrickable_image import RebrickableImage from .rebrickable_image import RebrickableImage
from .rebrickable_set import RebrickableSet
from .record_list import BrickRecordList from .record_list import BrickRecordList
from .wish import BrickWish from .wish import BrickWish
@ -34,7 +34,7 @@ class BrickWishList(BrickRecordList[BrickWish]):
@staticmethod @staticmethod
def add(set: str, /) -> None: def add(set: str, /) -> None:
try: try:
set = RebrickableSet.parse_number(set) set = parse_set(set)
BrickWish().select_specific(set) BrickWish().select_specific(set)
except NotFoundException: except NotFoundException:
logger.debug('rebrick.lego.get_set("{set}")'.format( logger.debug('rebrick.lego.get_set("{set}")'.format(

View File

@ -8,3 +8,4 @@ jinja2
rebrick rebrick
requests requests
tzdata tzdata
bs4

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();
}
// 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

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

View File

@ -5,22 +5,17 @@ class BrickSocket {
this.path = path; this.path = path;
this.namespace = namespace; this.namespace = namespace;
this.messages = messages; this.messages = messages;
this.bulk = bulk;
this.disabled = false; this.disabled = false;
this.socket = undefined; this.socket = undefined;
// Listeners // Bulk mode
this.add_listener = undefined; this.bulk = bulk;
this.confirm_listener = undefined;
// Form elements (built based on the initial id) // Form elements (built based on the initial id)
this.html_button = document.getElementById(id);
this.html_complete = document.getElementById(`${id}-complete`); this.html_complete = document.getElementById(`${id}-complete`);
this.html_count = document.getElementById(`${id}-count`); this.html_count = document.getElementById(`${id}-count`);
this.html_fail = document.getElementById(`${id}-fail`); 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 = document.getElementById(`${id}-progress`);
this.html_progress_bar = document.getElementById(`${id}-progress-bar`); this.html_progress_bar = document.getElementById(`${id}-progress-bar`);
this.html_progress_message = document.getElementById(`${id}-progress-message`); 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 = document.getElementById(`${id}-status`);
this.html_status_icon = document.getElementById(`${id}-status-icon`); 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 // Socket status
window.setInterval(((bricksocket) => () => { window.setInterval(((bricksocket) => () => {
bricksocket.status(); bricksocket.status();
})(this), 500); })(this), 1000);
// Setup the socket
this.setup();
} }
// Clear form // Clear form
@ -88,19 +43,9 @@ class BrickSocket {
this.html_progress_bar.textContent = ""; this.html_progress_bar.textContent = "";
} }
this.progress_message("");
this.spinner(false); 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 // Clear status message
@ -141,9 +86,6 @@ class BrickSocket {
this.html_complete.append(success) this.html_complete.append(success)
} }
// Import the next set
this.import_set(true, undefined, true);
} else { } else {
this.spinner(false); this.spinner(false);
@ -188,73 +130,8 @@ class BrickSocket {
if (this.html_progress_bar) { if (this.html_progress_bar) {
this.html_progress_bar.classList.remove("progress-bar-animated"); 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 // Update the progress
progress(data={}) { progress(data={}) {
@ -262,7 +139,7 @@ class BrickSocket {
let count = data["count"] let count = data["count"]
// Fix the total if bogus // Fix the total if bogus
if (!total || isNaN(total) || total <= 1) { if (!total || isNaN(total) || total <= 0) {
total = 0; total = 0;
} }
@ -304,63 +181,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 the actual socket
setup() { setup() {
if (this.socket === undefined) { if (this.socket === undefined) {
@ -387,11 +207,6 @@ class BrickSocket {
this.socket.on(this.messages.PROGRESS, ((bricksocket) => (data) => { this.socket.on(this.messages.PROGRESS, ((bricksocket) => (data) => {
bricksocket.progress(data); bricksocket.progress(data);
})(this)); })(this));
// Set loaded
this.socket.on(this.messages.SET_LOADED, ((bricksocket) => (data) => {
bricksocket.set_loaded(data);
})(this));
} }
} }
@ -434,21 +249,5 @@ class BrickSocket {
// Toggle clicking on the button, or sending events // Toggle clicking on the button, or sending events
toggle(enabled) { toggle(enabled) {
this.disabled = !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;
}
} }
} }

View File

@ -82,7 +82,9 @@
<script src="{{ url_for('static', filename='scripts/changer.js') }}"></script> <script src="{{ url_for('static', filename='scripts/changer.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/grid.js') }}"></script> <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/set.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/socket.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 src="{{ url_for('static', filename='scripts/table.js') }}"></script>
<script type="text/javascript"> <script type="text/javascript">
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {

View File

@ -5,6 +5,8 @@
{% block main %} {% block main %}
{% if upload %} {% if upload %}
{% include 'instructions/upload.html' %} {% include 'instructions/upload.html' %}
{% elif download %}
{% include 'instructions/download.html' %}
{% elif rename %} {% elif rename %}
{% include 'instructions/rename.html' %} {% include 'instructions/rename.html' %}
{% elif delete %} {% elif delete %}
@ -14,6 +16,7 @@
{% if g.login.is_authenticated() %} {% if g.login.is_authenticated() %}
<p class="border-bottom pb-2 px-2 text-center"> <p class="border-bottom pb-2 px-2 text-center">
<a class="btn btn-primary" href="{{ url_for('instructions.upload') }}"><i class="ri-upload-line"></i> Upload an instructions file</a> <a class="btn btn-primary" href="{{ url_for('instructions.upload') }}"><i class="ri-upload-line"></i> Upload an instructions file</a>
<a class="btn btn-primary" href="{{ url_for('instructions.download') }}"><i class="ri-download-line"></i> Download instructions from Rebrickable</a>
<a href="{{ url_for('admin.admin', open_instructions=true) }}" class="btn btn-light border" role="button"><i class="ri-refresh-line"></i> Refresh the instructions cache</a> <a href="{{ url_for('admin.admin', open_instructions=true) }}" class="btn btn-light border" role="button"><i class="ri-refresh-line"></i> Refresh the instructions cache</a>
</p> </p>
{% endif %} {% endif %}

View File

@ -0,0 +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.do_download') }}">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0"><i class="ri-download-line"></i> Download instructions from Rebrickable</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label for="download-set" class="form-label">Set number (only one)</label>
<input type="text" class="form-control" id="download-set" name="download-set" placeholder="107-1 or 1642-1 or ..." value="{{ set }}">
</div>
</div>
<div class="card-footer text-end">
<button type="submit" class="btn btn-primary"><i class="ri-search-line"></i> Search</button>
</div>
</div>
</form>
{% if 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>

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>

View File

@ -50,6 +50,9 @@
<a class="list-group-item list-group-item-action" href="{{ url_for('instructions.upload') }}"><i class="ri-upload-line"></i> Upload an instructions file</a> <a class="list-group-item list-group-item-action" href="{{ url_for('instructions.upload') }}"><i class="ri-upload-line"></i> Upload an instructions file</a>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if g.login.is_authenticated() %}
<a class="list-group-item list-group-item-action" href="{{ url_for('instructions.download', set=item.fields.set) }}"><i class="ri-download-line"></i> Download instructions from Rebrickable</a>
{% endif %}
</div> </div>
{{ accordion.footer() }} {{ accordion.footer() }}
{{ accordion.table(item.parts(), 'Parts', 'parts-inventory', 'set-details', 'part/table.html', icon='shapes-line')}} {{ accordion.table(item.parts(), 'Parts', 'parts-inventory', 'set-details', 'part/table.html', icon='shapes-line')}}

View File

@ -1,6 +1,6 @@
<script type="text/javascript"> <script type="text/javascript">
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
new BrickSocket('add', '{{ path }}', '{{ namespace }}', { new BrickSetSocket('add', '{{ path }}', '{{ namespace }}', {
COMPLETE: '{{ messages['COMPLETE'] }}', COMPLETE: '{{ messages['COMPLETE'] }}',
FAIL: '{{ messages['FAIL'] }}', FAIL: '{{ messages['FAIL'] }}',
IMPORT_SET: '{{ messages['IMPORT_SET'] }}', IMPORT_SET: '{{ messages['IMPORT_SET'] }}',