From 053bf75e05b32a1bebe803630402701b0f3f4f40 Mon Sep 17 00:00:00 2001
From: FrederikBaerentsen <frederik+gitea@baerentsen.net>
Date: Wed, 22 Jan 2025 22:41:35 +0100
Subject: [PATCH] Added instructions downloader from Rebrickable.

---
 bricktracker/instructions.py         | 29 ++++++++++-
 bricktracker/views/instructions.py   | 73 +++++++++++++++++++++++++++-
 templates/instructions.html          |  3 ++
 templates/instructions/download.html | 49 +++++++++++++++++++
 templates/set/card.html              |  1 +
 5 files changed, 153 insertions(+), 2 deletions(-)
 create mode 100644 templates/instructions/download.html

diff --git a/bricktracker/instructions.py b/bricktracker/instructions.py
index 6aaa050..9813501 100644
--- a/bricktracker/instructions.py
+++ b/bricktracker/instructions.py
@@ -3,11 +3,13 @@ import logging
 import os
 from typing import TYPE_CHECKING
 
-from flask import current_app, g, url_for
+from flask import current_app, g, url_for, flash
 import humanize
 from werkzeug.datastructures import FileStorage
 from werkzeug.utils import secure_filename
 
+import requests
+
 from .exceptions import ErrorException
 if TYPE_CHECKING:
     from .set import BrickSet
@@ -112,6 +114,31 @@ class BrickInstructions(object):
         logger.info('The instruction file {file} has been imported'.format(
             file=self.filename
         ))
+        
+    def download(self, href: str, /) -> None:
+        target = self.path(secure_filename(self.filename))
+
+        if os.path.isfile(target):
+            raise ErrorException('Cannot upload {target} as it already exists'.format(  # noqa: E501
+                target=self.filename
+            ))
+
+        url = f"https://rebrickable.com/{href}"
+
+        response = requests.get(url)
+        if response.status_code == 200:
+            # Save the file
+            with open(target, 'wb') as file:
+                file.write(response.content)
+            print(f"Downloaded {self.filename} to {target}")
+        else:
+            print(f"Failed to download {self.filename}. Status code: {response.status_code}", 'danger')
+
+
+        # Info
+        logger.info('The instruction file {file} has been imported'.format(
+            file=self.filename
+        ))
 
     # Compute the url for a set instructions file
     def url(self, /) -> str:
diff --git a/bricktracker/views/instructions.py b/bricktracker/views/instructions.py
index 6145914..db3491a 100644
--- a/bricktracker/views/instructions.py
+++ b/bricktracker/views/instructions.py
@@ -4,7 +4,8 @@ from flask import (
     redirect,
     render_template,
     request,
-    url_for
+    url_for,
+    flash
 )
 from flask_login import login_required
 from werkzeug.wrappers.response import Response
@@ -15,6 +16,9 @@ from ..instructions import BrickInstructions
 from ..instructions_list import BrickInstructionsList
 from .upload import upload_helper
 
+import requests
+from bs4 import BeautifulSoup
+
 instructions_page = Blueprint(
     'instructions',
     __name__,
@@ -126,3 +130,70 @@ def do_upload() -> Response:
     BrickInstructionsList(force=True)
 
     return redirect(url_for('instructions.list'))
+
+
+# Download instructions from Rebrickable
+@instructions_page.route('/download/', methods=['GET'])
+@login_required
+@exception_handler(__file__)
+def download() -> str:
+    return render_template(
+        'instructions.html',
+        download=True,
+        error=request.args.get('error')
+    )
+    
+# Actually download an instructions file
+@instructions_page.route('/download/', methods=['POST'])
+@login_required
+@exception_handler(__file__, post_redirect='instructions.download')
+def do_download() -> Response:
+    set_id: str = request.form.get('add-set', '')
+
+    url = f"https://rebrickable.com/instructions/{set_id}"
+    
+    headers = {
+        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
+    }
+    response = requests.get(url, headers=headers)
+    if response.status_code != 200:
+        flash(f"Failed to load page. Status code: {response.status_code}", 'danger')
+        return redirect(url_for('instructions.download'))
+    
+    # Parse the HTML content
+    soup = BeautifulSoup(response.content, 'html.parser')
+    
+    # Collect all <img> tags with "LEGO Building Instructions" in the alt attribute
+    found_tags = []
+    for a_tag in soup.find_all('a', href=True):
+        img_tag = a_tag.find('img', alt=True)
+        if img_tag and "LEGO Building Instructions" in img_tag['alt']:
+            found_tags.append((img_tag['alt'].replace('LEGO Building Instructions for ', ''), a_tag['href']))  # Save alt and href
+        
+    return render_template('instructions.html', download=True, found_tags=found_tags)
+
+@instructions_page.route('/confirm_download', methods=['POST'])
+@login_required
+@exception_handler(__file__, post_redirect='instructions.download')
+def confirm_download() -> Response:
+    selected_instructions = []
+    for key in request.form:
+        if key.startswith('instruction-') and request.form.get(key) == 'on':  # Checkbox is checked
+            index = key.split('-')[-1]
+            alt_text = request.form.get(f'instruction-alt-text-{index}')
+            href_text = request.form.get(f'instruction-href-text-{index}')
+            selected_instructions.append((href_text,alt_text))
+
+    if not selected_instructions:
+        flash("No instructions selected", 'danger')
+        return redirect(url_for('instructions.download'))
+
+    for href, filename in selected_instructions:
+        print(f"Downloading {filename} from {href}")
+        BrickInstructions(f"{filename}.pdf").download(href) 
+
+
+    BrickInstructionsList(force=True)
+
+    #flash("Selected instructions have been downloaded", 'success')
+    return redirect(url_for('instructions.list'))
diff --git a/templates/instructions.html b/templates/instructions.html
index 54bb4c0..28734f3 100644
--- a/templates/instructions.html
+++ b/templates/instructions.html
@@ -5,6 +5,8 @@
 {% block main %}
   {% if upload %}
     {% include 'instructions/upload.html' %}
+  {% elif download %}
+    {% include 'instructions/download.html' %}
   {% elif rename %}
     {% include 'instructions/rename.html' %}
   {% elif delete %}
@@ -14,6 +16,7 @@
       {% if g.login.is_authenticated() %}
         <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 %}
diff --git a/templates/instructions/download.html b/templates/instructions/download.html
new file mode 100644
index 0000000..79b3ccc
--- /dev/null
+++ b/templates/instructions/download.html
@@ -0,0 +1,49 @@
+<div class="container">
+    <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-add-circle-line"></i> Download instructions from Rebrickable</h5>
+                    </div>
+                    <div class="card-body">
+                        <div id="add-fail" class="alert alert-danger d-none" role="alert"></div>
+                        <div id="add-complete" class="alert alert-success d-none" role="alert"></div>
+                        <div class="mb-3">
+                            <label for="add-set" class="form-label">Set number (only one)</label>
+                            <input type="text" class="form-control" id="add-set" name="add-set" placeholder="107-1 or 1642-1 or ..." value="{{ request.args.get('set_num', '') }}">
+                        </div>
+                        <button type="submit" class="btn btn-primary">Search</button>
+                    </div>
+                </div>
+            </form>
+            {% if found_tags %}
+            <div class="card mb-3">
+                <div class="card-header">
+                    <h5 class="mb-0"><i class="ri-add-circle-line"></i> Select instructions to download</h5>
+                </div>
+                <div class="card-body">
+                    <form method="POST" action="{{ url_for('instructions.confirm_download') }}">
+                        <div class="mb-3">
+                            <label class="form-label">Available Instructions</label>
+                            <div class="form-check">
+                                {% for alt_text, href in found_tags %}
+                                <div class="form-check">
+                                    <input class="form-check-input" type="checkbox" name="instruction-{{ loop.index }}" id="instruction-{{ loop.index }}">
+                                    <label class="form-check-label" for="instruction-{{ loop.index }}">
+                                        {{ alt_text }}
+                                    </label>
+                                    <input type="hidden" name="instruction-alt-text-{{ loop.index }}" value="{{ alt_text }}">
+                                    <input type="hidden" name="instruction-href-text-{{ loop.index }}" value="{{ href }}">
+                                </div>
+                                {% endfor %}
+                            </div>
+                        </div>
+                        <button type="submit" class="btn btn-primary">Download Selected</button>
+                    </form>
+                </div>
+            </div>
+            {% endif %}
+        </div>
+    </div>
+</div>
\ No newline at end of file
diff --git a/templates/set/card.html b/templates/set/card.html
index d639516..31900d1 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -50,6 +50,7 @@
             <span class="list-group-item list-group-item-action text-center"><i class="ri-error-warning-line"></i> No instructions file found.</span>
             {% if g.login.is_authenticated() %}
               <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.download', set_num=item.fields.set_num) }}"><i class="ri-download-line"></i> Download instruction from Rebrickable</a>
             {% endif %}
           {% endif %}
         </div>