Compare commits
23 Commits
Author | SHA1 | Date | |
---|---|---|---|
ff1f02b7e3 | |||
53309a9502 | |||
4762028a39 | |||
a9bf5e03f8 | |||
c7b90414d3 | |||
2db0c1c2eb | |||
19750d1365 | |||
acebf6efd6 | |||
48ad7b5f02 | |||
cf9e716d1c | |||
9b5774555f | |||
c4a1a17cfd | |||
9113d539f0 | |||
f48ae99179 | |||
fd38e0a150 | |||
ed44fb9bab | |||
6d3285dbc9 | |||
52f73d5bf9 | |||
6abf4a314f | |||
6320629b07 | |||
4a785df532 | |||
9aa5bd43ec | |||
053bf75e05 |
@ -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/{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
|
||||
# Default: false
|
||||
# Legacy name: LINKS
|
||||
|
@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 1.1.1: PDF Instructions Download
|
||||
|
||||
### Instructions
|
||||
|
||||
- Added buttons for instructions download from Rebrickable
|
||||
|
||||
|
||||
## 1.1.0: Deduped sets, custom checkboxes and database upgrade
|
||||
|
||||
### Database
|
||||
|
@ -18,7 +18,9 @@ A web application for organizing and tracking LEGO sets, parts, and minifigures.
|
||||
|
||||
Use the provided [compose.yaml](compose.yaml) file.
|
||||
|
||||
See [setup](docs/setup.md).
|
||||
See [Quickstart](docs/quickstart.md) to get up and running right away.
|
||||
|
||||
See [Setup](docs/setup.md) for a more setup guide.
|
||||
|
||||
## Usage
|
||||
|
||||
|
@ -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/{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
|
||||
|
@ -1,21 +1,28 @@
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
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
|
||||
import humanize
|
||||
import requests
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from .exceptions import ErrorException
|
||||
from .exceptions import ErrorException, DownloadException
|
||||
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
|
||||
@ -25,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
|
||||
|
||||
@ -67,6 +87,90 @@ class BrickInstructions(object):
|
||||
def delete(self, /) -> None:
|
||||
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
|
||||
def human_size(self) -> str:
|
||||
return humanize.naturalsize(self.size)
|
||||
@ -142,3 +246,44 @@ class BrickInstructions(object):
|
||||
return 'file-image-line'
|
||||
else:
|
||||
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
37
bricktracker/parser.py
Normal 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)
|
@ -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)
|
||||
|
@ -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
|
||||
@ -13,10 +15,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Messages valid through the socket
|
||||
MESSAGES: Final[dict[str, str]] = {
|
||||
'ADD_SET': 'add_set',
|
||||
'COMPLETE': 'complete',
|
||||
'CONNECT': 'connect',
|
||||
'DISCONNECT': 'disconnect',
|
||||
'DOWNLOAD_INSTRUCTIONS': 'download_instructions',
|
||||
'FAIL': 'fail',
|
||||
'IMPORT_SET': 'import_set',
|
||||
'LOAD_SET': 'load_set',
|
||||
@ -84,6 +86,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
|
||||
|
@ -1,4 +1,4 @@
|
||||
from typing import Final
|
||||
|
||||
__version__: Final[str] = '1.1.0'
|
||||
__version__: Final[str] = '1.1.1'
|
||||
__database_version__: Final[int] = 6
|
||||
|
@ -13,6 +13,8 @@ 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 ..socket import MESSAGES
|
||||
from .upload import upload_helper
|
||||
|
||||
instructions_page = Blueprint(
|
||||
@ -126,3 +128,44 @@ 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:
|
||||
# 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
|
||||
)
|
||||
|
@ -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(
|
||||
|
@ -2,7 +2,7 @@ services:
|
||||
bricktracker:
|
||||
container_name: BrickTracker
|
||||
restart: unless-stopped
|
||||
image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.1.0
|
||||
image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.1.1
|
||||
ports:
|
||||
- "3333:3333"
|
||||
volumes:
|
||||
|
@ -2,7 +2,7 @@ services:
|
||||
bricktracker:
|
||||
container_name: BrickTracker
|
||||
restart: unless-stopped
|
||||
image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.1.0
|
||||
image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.1.1
|
||||
ports:
|
||||
- "3333:3333"
|
||||
volumes:
|
||||
|
@ -9,6 +9,7 @@ This page helps you navigate the documentation of BrickTracker.
|
||||
## Installation
|
||||
|
||||
- [Setup](setup.md)
|
||||
- [Variables overview](env.md)
|
||||
|
||||
## Usage
|
||||
|
||||
|
108
docs/env.md
Normal file
108
docs/env.md
Normal file
@ -0,0 +1,108 @@
|
||||
# Environment Variables Reference
|
||||
|
||||
## Essential Variables
|
||||
| Variable | Purpose | Default | Required |
|
||||
|----------|---------|----------|-----------|
|
||||
| `BK_REBRICKABLE_API_KEY` | Rebrickable API key | None | Yes |
|
||||
|
||||
## Common Configuration
|
||||
| Variable | Purpose | Default | Required |
|
||||
|----------|---------|----------|-----------|
|
||||
| `BK_DATABASE_PATH` | SQLite database path | `./app.db` | No |
|
||||
| `BK_PORT` | Server port | `3333` | No |
|
||||
| `BK_HOST` | Server host address | `0.0.0.0` | No |
|
||||
| `BK_DEBUG` | Enable debug mode | `false` | No |
|
||||
| `BK_USE_REMOTE_IMAGES` | Use remote images | `false` | No |
|
||||
| `BK_DEFAULT_TABLE_PER_PAGE` | Items per page | `25` | No |
|
||||
| `BK_TIMEZONE` | Timezone | `Etc/UTC` | No |
|
||||
|
||||
## UI Customization
|
||||
| Variable | Purpose | Default | Required |
|
||||
|----------|---------|----------|-----------|
|
||||
| `BK_HIDE_ADMIN` | Hide admin menu entry | `false` | No |
|
||||
| `BK_HIDE_ADD_SET` | Hide 'Add' menu entry | `false` | No |
|
||||
| `BK_HIDE_ADD_BULK_SET` | Hide bulk add option | `false` | No |
|
||||
| `BK_HIDE_ALL_SETS` | Hide sets menu entry | `false` | No |
|
||||
| `BK_HIDE_ALL_PARTS` | Hide parts menu entry | `false` | No |
|
||||
| `BK_HIDE_ALL_MINIFIGURES` | Hide minifigures menu entry | `false` | No |
|
||||
| `BK_HIDE_ALL_INSTRUCTIONS` | Hide instructions menu entry | `false` | No |
|
||||
| `BK_HIDE_MISSING_PARTS` | Hide missing parts menu entry | `false` | No |
|
||||
| `BK_HIDE_WISHES` | Hide wishlist menu entry | `false` | No |
|
||||
| `BK_INDEPENDENT_ACCORDIONS` | Make accordions independent | `false` | No |
|
||||
|
||||
## Sort Order Configuration
|
||||
| Variable | Purpose | Default | Required |
|
||||
|----------|---------|----------|-----------|
|
||||
| `BK_SETS_DEFAULT_ORDER` | Default set sorting | `"rebrickable_sets"."number" DESC` | No |
|
||||
| `BK_PARTS_DEFAULT_ORDER` | Default part sorting | `"inventory"."name" ASC` | No |
|
||||
| `BK_MINIFIGURES_DEFAULT_ORDER` | Default minifig sorting | `"minifigures"."name" ASC` | No |
|
||||
| `BK_WISHES_DEFAULT_ORDER` | Default wishlist sorting | `"bricktracker_wishes"."rowid" DESC` | No |
|
||||
|
||||
## External Links Configuration
|
||||
| Variable | Purpose | Default | Required |
|
||||
|----------|---------|----------|-----------|
|
||||
| `BK_REBRICKABLE_LINKS` | Show Rebrickable links | `false` | No |
|
||||
| `BK_BRICKLINK_LINKS` | Show BrickLink links | `false` | No |
|
||||
| `BK_BRICKLINK_LINK_PART_PATTERN` | BrickLink part URL pattern | `https://www.bricklink.com/v2/catalog/catalogitem.page?P={number}` | No |
|
||||
| `BK_REBRICKABLE_LINK_PART_PATTERN` | Rebrickable part URL pattern | `https://rebrickable.com/parts/{number}/_/{color}` | No |
|
||||
| `BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN` | Rebrickable minifig URL pattern | `https://rebrickable.com/minifigs/{number}` | No |
|
||||
| `BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN` | Rebrickable instructions URL pattern | `https://rebrickable.com/instructions/{path}` | No |
|
||||
|
||||
## File Storage Configuration
|
||||
| Variable | Purpose | Default | Required |
|
||||
|----------|---------|----------|-----------|
|
||||
| `BK_INSTRUCTIONS_FOLDER` | Instructions storage path | `instructions` | No |
|
||||
| `BK_MINIFIGURES_FOLDER` | Minifigures storage path | `minifigs` | No |
|
||||
| `BK_PARTS_FOLDER` | Parts storage path | `parts` | No |
|
||||
| `BK_SETS_FOLDER` | Sets storage path | `sets` | No |
|
||||
| `BK_INSTRUCTIONS_ALLOWED_EXTENSIONS` | Allowed instruction file types | `.pdf` | No |
|
||||
|
||||
## API and Network Configuration
|
||||
| Variable | Purpose | Default | Required |
|
||||
|----------|---------|----------|-----------|
|
||||
| `BK_DOMAIN_NAME` | CORS origin restriction | None | No |
|
||||
| `BK_REBRICKABLE_PAGE_SIZE` | Items per API call | `100` | No |
|
||||
| `BK_SOCKET_NAMESPACE` | Socket.IO namespace | `bricksocket` | No |
|
||||
| `BK_SOCKET_PATH` | Socket.IO path | `/bricksocket/` | No |
|
||||
| `BK_NO_THREADED_SOCKET` | Disable socket threading | `false` | No |
|
||||
| `BK_REBRICKABLE_USER_AGENT` | Custom 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` | No |
|
||||
|
||||
## External Data Sources
|
||||
| Variable | Purpose | Default | Required |
|
||||
|----------|---------|----------|-----------|
|
||||
| `BK_RETIRED_SETS_FILE_URL` | Retired sets list URL | `https://docs.google.com/spreadsheets/d/1rlYfEXtNKxUOZt2Mfv0H17DvK7bj6Pe0CuYwq6ay8WA/gviz/tq?tqx=out:csv&sheet=Sorted%20by%20Retirement%20Date` | No |
|
||||
| `BK_RETIRED_SETS_PATH` | Local retired sets file path | `./retired_sets.csv` | No |
|
||||
| `BK_THEMES_FILE_URL` | Themes list URL | `https://cdn.rebrickable.com/media/downloads/themes.csv.gz` | No |
|
||||
| `BK_THEMES_PATH` | Local themes file path | `./themes.csv` | No |
|
||||
| `BK_REBRICKABLE_IMAGE_NIL` | Missing image placeholder | `https://rebrickable.com/static/img/nil.png` | No |
|
||||
| `BK_REBRICKABLE_IMAGE_NIL_MINIFIGURE` | Missing minifig placeholder | `https://rebrickable.com/static/img/nil_mf.jpg` | No |
|
||||
|
||||
## Behavior Configuration
|
||||
| Variable | Purpose | Default | Required |
|
||||
|----------|---------|----------|-----------|
|
||||
| `BK_RANDOM` | Shuffle front page lists | `false` | No |
|
||||
| `BK_SKIP_SPARE_PARTS` | Ignore spare parts | `false` | No |
|
||||
| `BK_DATABASE_TIMESTAMP_FORMAT` | Backup timestamp format | `%Y-%m-%d-%H-%M-%S` | No |
|
||||
| `BK_AUTHENTICATION_KEY` | Secret key for auth tokens | None | If using authentication |
|
||||
| `BK_AUTHENTICATION_PASSWORD` | Admin area password | None | No |
|
||||
|
||||
## Sort Order Examples
|
||||
```bash
|
||||
# Sort sets by year ascending
|
||||
BK_SETS_DEFAULT_ORDER="rebrickable_sets"."year" ASC
|
||||
|
||||
# Sort parts by missing count descending
|
||||
BK_PARTS_DEFAULT_ORDER="total_missing" DESC, "inventory"."name" ASC
|
||||
|
||||
# Sort minifigures by ID
|
||||
BK_MINIFIGURES_DEFAULT_ORDER="minifigures"."fig_num" ASC
|
||||
|
||||
# Sort wishlist by set number
|
||||
BK_WISHES_DEFAULT_ORDER="bricktracker_wishes"."set" ASC
|
||||
```
|
||||
|
||||
## File Extensions Example
|
||||
```bash
|
||||
# Allow multiple instruction file types
|
||||
BK_INSTRUCTIONS_ALLOWED_EXTENSIONS=.pdf, .docx, .png
|
||||
```
|
@ -1,7 +1,7 @@
|
||||
# First steps
|
||||
|
||||
> **Note**
|
||||
> The following page is based on version `1.0.0` of BrickTracker.
|
||||
> The following page is based on version `1.1.1` of BrickTracker.
|
||||
|
||||
## Database initialization
|
||||
|
||||
|
90
docs/quickstart.md
Normal file
90
docs/quickstart.md
Normal file
@ -0,0 +1,90 @@
|
||||
# Quickstart
|
||||
|
||||
> **Note**
|
||||
> The following page is based on version `1.1.1` of BrickTracker.
|
||||
|
||||
## Prerequisites
|
||||
- Docker and Docker Compose installed
|
||||
- A Rebrickable API key from https://rebrickable.com/users/profile/
|
||||
- curl or wget (for downloading configuration files)
|
||||
|
||||
## Note on Environment Configuration
|
||||
BrickTracker can be configured using either:
|
||||
- A `.env` file (recommended and shown in this guide)
|
||||
- Environment variables in compose.yaml
|
||||
|
||||
This guide uses the `.env` file approach for better maintainability. The environment variables in the compose.yaml file are kept minimal and only reference the essential paths.
|
||||
|
||||
## Directory Setup
|
||||
|
||||
1. Create the project directory and structure:
|
||||
```bash
|
||||
mkdir -p bricktracker/{data,static/{instructions,minifigures,parts,sets}}
|
||||
cd bricktracker
|
||||
```
|
||||
|
||||
2. Download the sample configuration files:
|
||||
```bash
|
||||
# Get the environment file template
|
||||
curl -o env.sample https://raw.githubusercontent.com/FrederikBaerentsen/BrickTracker/main/env.sample
|
||||
|
||||
# Or with wget:
|
||||
# wget -O env.sample https://raw.githubusercontent.com/FrederikBaerentsen/BrickTracker/main/env.sample
|
||||
```
|
||||
|
||||
## Docker Compose Configuration
|
||||
|
||||
Create `compose.yaml` with this content:
|
||||
```yaml
|
||||
services:
|
||||
bricktracker:
|
||||
container_name: BrickTracker
|
||||
restart: unless-stopped
|
||||
image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.1.1
|
||||
ports:
|
||||
- "3333:3333"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./static/instructions:/app/static/instructions
|
||||
- ./static/minifigures:/app/static/minifigures
|
||||
- ./static/parts:/app/static/parts
|
||||
- ./static/sets:/app/static/sets
|
||||
env_file: ".env"
|
||||
```
|
||||
|
||||
## Starting BrickTracker
|
||||
|
||||
1. Start the application:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
2. Access BrickTracker at `http://localhost:3333`
|
||||
|
||||
Please refer to [Environment Variables Reference](docs/env.md) for a list of available variables.
|
||||
|
||||
3. Read more in [First steps](docs/first-steps.md)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
1. If the application won't start:
|
||||
- Check if port 3333 is available
|
||||
- Check logs with `docker compose logs -f`
|
||||
- Ensure `.env` file is properly formatted
|
||||
|
||||
2. If images aren't appearing:
|
||||
- Verify write permissions on static directories
|
||||
- Ensure network connectivity to Rebrickable
|
||||
|
||||
3. If you can't add sets:
|
||||
- Verify your Rebrickable API key
|
||||
- Check the application logs for API errors
|
||||
|
||||
4. Environment configuration issues:
|
||||
- Make sure `.env` file exists and is readable
|
||||
- Check for any syntax errors in `.env` file
|
||||
- Verify no conflicting environment variables are set in the shell
|
||||
|
||||
For more troubleshooting, take a look at [Common Errors](docs/common-errors.md)
|
||||
|
||||
Please refer to [Setup](docs/setup.md) for more information.
|
@ -1,7 +1,7 @@
|
||||
# Setup
|
||||
|
||||
> **Note**
|
||||
> The following page is based on version `1.0.0` of BrickTracker.
|
||||
> The following page is based on version `1.1.1` of BrickTracker.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@ -53,6 +53,8 @@ services:
|
||||
The [.env.sample](../.env.sample) file provides ample documentation on all the configurable options. Have a look at it.
|
||||
You can make a copy of `.env.sample` as `.env` with your options or create an `.env` file from scratch.
|
||||
|
||||
[Environment Variables Reference](docs/env.md) contains a table of the available variables.
|
||||
|
||||
## Database file
|
||||
|
||||
To accomodate for the original version of BrickTracker, the default database path is `./app.db`.
|
||||
@ -89,6 +91,24 @@ In the original version of BrickTracker they were either shipped with the contai
|
||||
|
||||
You can use the `BK_RETIRED_SET_PATH` and `BK_THEMES_PATH` to relocate them into a volume.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
Updated directory structure showing data volume organization:
|
||||
```
|
||||
bricktracker/
|
||||
├── data/ # Persistent data
|
||||
│ ├── app.db # Database file
|
||||
│ ├── retired_sets.csv # Retired sets data
|
||||
│ └── themes.csv # Themes data
|
||||
├── static/ # Static files
|
||||
│ ├── instructions/ # PDF and other instruction files
|
||||
│ ├── minifigures/ # Minifigure images
|
||||
│ ├── parts/ # Part images
|
||||
│ └── sets/ # Set images
|
||||
├── .env # Environment configuration
|
||||
└── compose.yaml # Docker compose configuration
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
See [authentication](authentication.md)
|
||||
|
@ -7,4 +7,5 @@ humanize
|
||||
jinja2
|
||||
rebrick
|
||||
requests
|
||||
tzdata
|
||||
tzdata
|
||||
bs4
|
||||
|
108
static/scripts/socket/instructions.js
Normal file
108
static/scripts/socket/instructions.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
244
static/scripts/socket/set.js
Normal file
244
static/scripts/socket/set.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
})(this), 1000);
|
||||
}
|
||||
|
||||
// Clear form
|
||||
@ -88,19 +43,9 @@ class BrickSocket {
|
||||
this.html_progress_bar.textContent = "";
|
||||
}
|
||||
|
||||
this.progress_message("");
|
||||
|
||||
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 +86,6 @@ class BrickSocket {
|
||||
|
||||
this.html_complete.append(success)
|
||||
}
|
||||
|
||||
// Import the next set
|
||||
this.import_set(true, undefined, true);
|
||||
} else {
|
||||
this.spinner(false);
|
||||
|
||||
@ -188,73 +130,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={}) {
|
||||
@ -262,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;
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
if (this.socket === undefined) {
|
||||
@ -387,11 +207,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 +249,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;
|
||||
}
|
||||
}
|
||||
}
|
@ -82,7 +82,9 @@
|
||||
<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/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 type="text/javascript">
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
@ -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() %}
|
||||
<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.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>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
63
templates/instructions/download.html
Normal file
63
templates/instructions/download.html
Normal 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>
|
10
templates/instructions/socket.html
Normal file
10
templates/instructions/socket.html
Normal 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>
|
@ -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>
|
||||
{% 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>
|
||||
{{ accordion.footer() }}
|
||||
{{ accordion.table(item.parts(), 'Parts', 'parts-inventory', 'set-details', 'part/table.html', icon='shapes-line')}}
|
||||
|
@ -1,6 +1,6 @@
|
||||
<script type="text/javascript">
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
new BrickSocket('add', '{{ path }}', '{{ namespace }}', {
|
||||
new BrickSetSocket('add', '{{ path }}', '{{ namespace }}', {
|
||||
COMPLETE: '{{ messages['COMPLETE'] }}',
|
||||
FAIL: '{{ messages['FAIL'] }}',
|
||||
IMPORT_SET: '{{ messages['IMPORT_SET'] }}',
|
||||
|
Loading…
Reference in New Issue
Block a user