diff --git a/README.md b/README.md
index ba65254..ea030a4 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,19 @@
# Archive.org Ripper
-This script lets you download books page-by-page from [archive.org](https://archive.org) in the event that there is no PDF link. Any book with a <14 day loan period is like this, as you can see:
+This program lets you download books page-by-page from [archive.org](https://archive.org) in the event that there is no PDF link. Any book with a <14 day loan period is like this, as you can see:

-The script needs your login credentials to borrow the book, then it will run on its own using your session.
+The program needs your login credentials to borrow the book, then it will run on its own using your session.
Do not use this program in an illegal manner. Thanks!
+## Setup
+
+Go to the Releases page if you want to download the GUI version, packaged into an executable. This is the most user-friendly option.
+
+For a command-line interface, just clone this repo, create a virtual environment, run `pip install -r requirements.txt`, and then `python ripper.py` with optional arguments.
+
## Screenshots

@@ -17,6 +23,4 @@ Do not use this program in an illegal manner. Thanks!
- Searching for books instead of inputting id directly
-- GUI
-
- Option to convert to pdf or epub instead of saving each page individually
diff --git a/archiveripper/.gitignore b/archiveripper/.gitignore
new file mode 100644
index 0000000..fab7372
--- /dev/null
+++ b/archiveripper/.gitignore
@@ -0,0 +1,73 @@
+# This file is used to ignore files which are generated
+# ----------------------------------------------------------------------------
+
+*~
+*.autosave
+*.a
+*.core
+*.moc
+*.o
+*.obj
+*.orig
+*.rej
+*.so
+*.so.*
+*_pch.h.cpp
+*_resource.rc
+*.qm
+.#*
+*.*#
+core
+!core/
+tags
+.DS_Store
+.directory
+*.debug
+Makefile*
+*.prl
+*.app
+moc_*.cpp
+ui_*.h
+qrc_*.cpp
+Thumbs.db
+*.res
+*.rc
+/.qmake.cache
+/.qmake.stash
+
+# qtcreator generated files
+*.pro.user*
+
+# xemacs temporary files
+*.flc
+
+# Vim temporary files
+.*.swp
+
+# Visual Studio generated files
+*.ib_pdb_index
+*.idb
+*.ilk
+*.pdb
+*.sln
+*.suo
+*.vcproj
+*vcproj.*.*.user
+*.ncb
+*.sdf
+*.opensdf
+*.vcxproj
+*vcxproj.*
+
+# MinGW generated files
+*.Debug
+*.Release
+
+# Python byte code
+*.pyc
+
+# Binaries
+# --------
+*.dll
+*.exe
+
diff --git a/archiveripper/__main__.py b/archiveripper/__main__.py
new file mode 100644
index 0000000..4f2e8ec
--- /dev/null
+++ b/archiveripper/__main__.py
@@ -0,0 +1,10 @@
+# This directory contains the GUI version of archiveripper
+
+import sys
+from PySide6.QtWidgets import QApplication
+from .ripper import ArchiveRipper
+
+app = QApplication(sys.argv)
+widget = ArchiveRipper()
+widget.show()
+sys.exit(app.exec())
diff --git a/api.py b/archiveripper/api.py
similarity index 100%
rename from api.py
rename to archiveripper/api.py
diff --git a/archiveripper/ripper.py b/archiveripper/ripper.py
new file mode 100644
index 0000000..f41b3b0
--- /dev/null
+++ b/archiveripper/ripper.py
@@ -0,0 +1,79 @@
+from PySide6.QtCore import Slot
+from PySide6.QtWidgets import QDialog, QMainWindow, QMessageBox
+
+from .api import ArchiveReaderClient
+from .ui.ui_ArchiveRipper import Ui_ArchiveRipper
+from .ui.ui_CredentialsDialog import Ui_CredentialsDialog
+from .ui.ui_NewRipDialog import Ui_NewRipDialog
+
+
+class ArchiveRipper(QMainWindow):
+
+ def __init__(self):
+ super(ArchiveRipper, self).__init__()
+ self.ui = Ui_ArchiveRipper()
+ self.ui.setupUi(self)
+
+ # dialogs
+ self.new_rip_dialog = QDialog()
+ self.new_rip_dialog.ui = Ui_NewRipDialog()
+ self.new_rip_dialog.ui.setupUi(self.new_rip_dialog)
+ self.new_rip_dialog.accepted.connect(self.new_rip)
+
+ self.credentials_dialog = QDialog()
+ self.credentials_dialog.ui = Ui_CredentialsDialog()
+ self.credentials_dialog.ui.setupUi(self.credentials_dialog)
+ self.credentials_dialog.accepted.connect(self.update_credentials)
+
+
+ # signals
+ self.ui.actionNew_Rip.triggered.connect(self.new_rip_dialog.exec)
+ self.ui.actionExit.triggered.connect(self.close)
+
+ # api client
+ self.client = ArchiveReaderClient()
+
+ # application state
+ self.credentials = {
+ "email": None,
+ "password": None
+ }
+ self.book_url = None
+ self.dest_path = None
+ self.start_page = None
+ self.end_page = None
+
+ @Slot()
+ def new_rip(self):
+ book_url = self.new_rip_dialog.ui.bookUrl.text()
+ self.book_url = book_url[book_url.rfind("/") + 1:]
+ self.dest_path = self.new_rip_dialog.ui.filePath.text()
+
+ self.verify_login()
+ msgbox = QMessageBox()
+ msgbox.setWindowTitle("New Rip Status")
+ try:
+ self.client.schedule_loan_book(self.book_url)
+ msgbox.setText("Borrowed book successfully")
+ msgbox.setIcon(QMessageBox.Information)
+ except Exception as e:
+ msgbox.setText(f"Failed to borrow book!\n{e}")
+ msgbox.setIcon(QMessageBox.Critical)
+ finally:
+ msgbox.exec()
+
+ def verify_login(self):
+ if not self.credentials["email"] or not self.credentials["password"]:
+ self.credentials_dialog.exec()
+ try:
+ self.client.login(self.credentials["email"], self.credentials["password"])
+ except Exception as e:
+ msgbox = QMessageBox()
+ msgbox.setText(f"Failed to log in!\n{e}")
+ msgbox.setIcon(QMessageBox.Critical)
+ msgbox.exec()
+
+ @Slot()
+ def update_credentials(self):
+ self.credentials["email"] = self.credentials_dialog.ui.email.text()
+ self.credentials["password"] = self.credentials_dialog.ui.password.text()
diff --git a/archiveripper/ui/ui_ArchiveRipper.py b/archiveripper/ui/ui_ArchiveRipper.py
new file mode 100644
index 0000000..afa4c23
--- /dev/null
+++ b/archiveripper/ui/ui_ArchiveRipper.py
@@ -0,0 +1,90 @@
+# -*- coding: utf-8 -*-
+
+################################################################################
+## Form generated from reading UI file 'ArchiveRipper.ui'
+##
+## Created by: Qt User Interface Compiler version 6.1.3
+##
+## WARNING! All changes made in this file will be lost when recompiling UI file!
+################################################################################
+
+from PySide6.QtCore import * # type: ignore
+from PySide6.QtGui import * # type: ignore
+from PySide6.QtWidgets import * # type: ignore
+
+
+class Ui_ArchiveRipper(object):
+ def setupUi(self, ArchiveRipper):
+ if not ArchiveRipper.objectName():
+ ArchiveRipper.setObjectName(u"ArchiveRipper")
+ ArchiveRipper.resize(640, 480)
+ self.actionExit = QAction(ArchiveRipper)
+ self.actionExit.setObjectName(u"actionExit")
+ self.actionPreferences = QAction(ArchiveRipper)
+ self.actionPreferences.setObjectName(u"actionPreferences")
+ self.actionAbout = QAction(ArchiveRipper)
+ self.actionAbout.setObjectName(u"actionAbout")
+ self.actionNew_Rip = QAction(ArchiveRipper)
+ self.actionNew_Rip.setObjectName(u"actionNew_Rip")
+ self.actionOpen_Rip = QAction(ArchiveRipper)
+ self.actionOpen_Rip.setObjectName(u"actionOpen_Rip")
+ self.actionExport = QAction(ArchiveRipper)
+ self.actionExport.setObjectName(u"actionExport")
+ self.actionSave = QAction(ArchiveRipper)
+ self.actionSave.setObjectName(u"actionSave")
+ self.actionSave_As = QAction(ArchiveRipper)
+ self.actionSave_As.setObjectName(u"actionSave_As")
+ self.centralwidget = QWidget(ArchiveRipper)
+ self.centralwidget.setObjectName(u"centralwidget")
+ ArchiveRipper.setCentralWidget(self.centralwidget)
+ self.menubar = QMenuBar(ArchiveRipper)
+ self.menubar.setObjectName(u"menubar")
+ self.menubar.setGeometry(QRect(0, 0, 640, 21))
+ self.menuFile = QMenu(self.menubar)
+ self.menuFile.setObjectName(u"menuFile")
+ self.menuHelp = QMenu(self.menubar)
+ self.menuHelp.setObjectName(u"menuHelp")
+ ArchiveRipper.setMenuBar(self.menubar)
+ self.statusbar = QStatusBar(ArchiveRipper)
+ self.statusbar.setObjectName(u"statusbar")
+ ArchiveRipper.setStatusBar(self.statusbar)
+
+ self.menubar.addAction(self.menuFile.menuAction())
+ self.menubar.addAction(self.menuHelp.menuAction())
+ self.menuFile.addAction(self.actionNew_Rip)
+ self.menuFile.addAction(self.actionOpen_Rip)
+ self.menuFile.addSeparator()
+ self.menuFile.addAction(self.actionSave)
+ self.menuFile.addAction(self.actionSave_As)
+ self.menuFile.addAction(self.actionExport)
+ self.menuFile.addSeparator()
+ self.menuFile.addAction(self.actionPreferences)
+ self.menuFile.addSeparator()
+ self.menuFile.addAction(self.actionExit)
+ self.menuHelp.addAction(self.actionAbout)
+
+ self.retranslateUi(ArchiveRipper)
+
+ QMetaObject.connectSlotsByName(ArchiveRipper)
+ # setupUi
+
+ def retranslateUi(self, ArchiveRipper):
+ ArchiveRipper.setWindowTitle(QCoreApplication.translate("ArchiveRipper", u"ArchiveRipper", None))
+ self.actionExit.setText(QCoreApplication.translate("ArchiveRipper", u"Exit", None))
+#if QT_CONFIG(shortcut)
+ self.actionExit.setShortcut(QCoreApplication.translate("ArchiveRipper", u"Ctrl+Q", None))
+#endif // QT_CONFIG(shortcut)
+ self.actionPreferences.setText(QCoreApplication.translate("ArchiveRipper", u"Preferences...", None))
+#if QT_CONFIG(shortcut)
+ self.actionPreferences.setShortcut(QCoreApplication.translate("ArchiveRipper", u"Ctrl+,", None))
+#endif // QT_CONFIG(shortcut)
+ self.actionAbout.setText(QCoreApplication.translate("ArchiveRipper", u"About", None))
+ self.actionNew_Rip.setText(QCoreApplication.translate("ArchiveRipper", u"New Rip...", None))
+ self.actionOpen_Rip.setText(QCoreApplication.translate("ArchiveRipper", u"Open Rip...", None))
+ self.actionExport.setText(QCoreApplication.translate("ArchiveRipper", u"Export...", None))
+ self.actionSave.setText(QCoreApplication.translate("ArchiveRipper", u"Save", None))
+ self.actionSave_As.setText(QCoreApplication.translate("ArchiveRipper", u"Save As...", None))
+ self.menuFile.setTitle(QCoreApplication.translate("ArchiveRipper", u"File", None))
+ self.menuHelp.setTitle(QCoreApplication.translate("ArchiveRipper", u"Help", None))
+ # retranslateUi
+
diff --git a/archiveripper/ui/ui_CredentialsDialog.py b/archiveripper/ui/ui_CredentialsDialog.py
new file mode 100644
index 0000000..e1fc51e
--- /dev/null
+++ b/archiveripper/ui/ui_CredentialsDialog.py
@@ -0,0 +1,69 @@
+# -*- coding: utf-8 -*-
+
+################################################################################
+## Form generated from reading UI file 'CredentialsDialog.ui'
+##
+## Created by: Qt User Interface Compiler version 6.1.3
+##
+## WARNING! All changes made in this file will be lost when recompiling UI file!
+################################################################################
+
+from PySide6.QtCore import * # type: ignore
+from PySide6.QtGui import * # type: ignore
+from PySide6.QtWidgets import * # type: ignore
+
+
+class Ui_CredentialsDialog(object):
+ def setupUi(self, CredentialsDialog):
+ if not CredentialsDialog.objectName():
+ CredentialsDialog.setObjectName(u"CredentialsDialog")
+ CredentialsDialog.resize(400, 100)
+ self.verticalLayout = QVBoxLayout(CredentialsDialog)
+ self.verticalLayout.setObjectName(u"verticalLayout")
+ self.formLayout = QFormLayout()
+ self.formLayout.setObjectName(u"formLayout")
+ self.email = QLineEdit(CredentialsDialog)
+ self.email.setObjectName(u"email")
+
+ self.formLayout.setWidget(0, QFormLayout.FieldRole, self.email)
+
+ self.label = QLabel(CredentialsDialog)
+ self.label.setObjectName(u"label")
+
+ self.formLayout.setWidget(0, QFormLayout.LabelRole, self.label)
+
+ self.label_2 = QLabel(CredentialsDialog)
+ self.label_2.setObjectName(u"label_2")
+
+ self.formLayout.setWidget(1, QFormLayout.LabelRole, self.label_2)
+
+ self.password = QLineEdit(CredentialsDialog)
+ self.password.setObjectName(u"password")
+ self.password.setEchoMode(QLineEdit.Password)
+
+ self.formLayout.setWidget(1, QFormLayout.FieldRole, self.password)
+
+
+ self.verticalLayout.addLayout(self.formLayout)
+
+ self.buttonBox = QDialogButtonBox(CredentialsDialog)
+ self.buttonBox.setObjectName(u"buttonBox")
+ self.buttonBox.setOrientation(Qt.Horizontal)
+ self.buttonBox.setStandardButtons(QDialogButtonBox.Cancel|QDialogButtonBox.Ok)
+
+ self.verticalLayout.addWidget(self.buttonBox)
+
+
+ self.retranslateUi(CredentialsDialog)
+ self.buttonBox.accepted.connect(CredentialsDialog.accept)
+ self.buttonBox.rejected.connect(CredentialsDialog.reject)
+
+ QMetaObject.connectSlotsByName(CredentialsDialog)
+ # setupUi
+
+ def retranslateUi(self, CredentialsDialog):
+ CredentialsDialog.setWindowTitle(QCoreApplication.translate("CredentialsDialog", u"Enter Archive.org Credentials", None))
+ self.label.setText(QCoreApplication.translate("CredentialsDialog", u"Archive.org email:", None))
+ self.label_2.setText(QCoreApplication.translate("CredentialsDialog", u"Password:", None))
+ # retranslateUi
+
diff --git a/archiveripper/ui/ui_NewRipDialog.py b/archiveripper/ui/ui_NewRipDialog.py
new file mode 100644
index 0000000..980192f
--- /dev/null
+++ b/archiveripper/ui/ui_NewRipDialog.py
@@ -0,0 +1,86 @@
+# -*- coding: utf-8 -*-
+
+################################################################################
+## Form generated from reading UI file 'NewRipDialog.ui'
+##
+## Created by: Qt User Interface Compiler version 6.1.3
+##
+## WARNING! All changes made in this file will be lost when recompiling UI file!
+################################################################################
+
+from PySide6.QtCore import * # type: ignore
+from PySide6.QtGui import * # type: ignore
+from PySide6.QtWidgets import * # type: ignore
+
+
+class Ui_NewRipDialog(object):
+ def setupUi(self, NewRipDialog):
+ if not NewRipDialog.objectName():
+ NewRipDialog.setObjectName(u"NewRipDialog")
+ NewRipDialog.resize(400, 137)
+ self.verticalLayout = QVBoxLayout(NewRipDialog)
+ self.verticalLayout.setObjectName(u"verticalLayout")
+ self.formLayout = QFormLayout()
+ self.formLayout.setObjectName(u"formLayout")
+ self.label = QLabel(NewRipDialog)
+ self.label.setObjectName(u"label")
+
+ self.formLayout.setWidget(0, QFormLayout.LabelRole, self.label)
+
+ self.label_2 = QLabel(NewRipDialog)
+ self.label_2.setObjectName(u"label_2")
+
+ self.formLayout.setWidget(1, QFormLayout.LabelRole, self.label_2)
+
+ self.label_3 = QLabel(NewRipDialog)
+ self.label_3.setObjectName(u"label_3")
+
+ self.formLayout.setWidget(3, QFormLayout.LabelRole, self.label_3)
+
+ self.bookUrl = QLineEdit(NewRipDialog)
+ self.bookUrl.setObjectName(u"bookUrl")
+
+ self.formLayout.setWidget(0, QFormLayout.FieldRole, self.bookUrl)
+
+ self.horizontalLayout = QHBoxLayout()
+ self.horizontalLayout.setObjectName(u"horizontalLayout")
+ self.filePath = QLineEdit(NewRipDialog)
+ self.filePath.setObjectName(u"filePath")
+
+ self.horizontalLayout.addWidget(self.filePath)
+
+ self.fileBrowseBtn = QPushButton(NewRipDialog)
+ self.fileBrowseBtn.setObjectName(u"fileBrowseBtn")
+
+ self.horizontalLayout.addWidget(self.fileBrowseBtn)
+
+
+ self.formLayout.setLayout(3, QFormLayout.FieldRole, self.horizontalLayout)
+
+
+ self.verticalLayout.addLayout(self.formLayout)
+
+ self.buttonBox = QDialogButtonBox(NewRipDialog)
+ self.buttonBox.setObjectName(u"buttonBox")
+ self.buttonBox.setOrientation(Qt.Horizontal)
+ self.buttonBox.setStandardButtons(QDialogButtonBox.Cancel|QDialogButtonBox.Ok)
+
+ self.verticalLayout.addWidget(self.buttonBox)
+
+
+ self.retranslateUi(NewRipDialog)
+ self.buttonBox.accepted.connect(NewRipDialog.accept)
+ self.buttonBox.rejected.connect(NewRipDialog.reject)
+
+ QMetaObject.connectSlotsByName(NewRipDialog)
+ # setupUi
+
+ def retranslateUi(self, NewRipDialog):
+ NewRipDialog.setWindowTitle(QCoreApplication.translate("NewRipDialog", u"New Rip", None))
+ self.label.setText(QCoreApplication.translate("NewRipDialog", u"Paste the book's URL here:", None))
+ self.label_2.setText("")
+ self.label_3.setText(QCoreApplication.translate("NewRipDialog", u"Save pages to:", None))
+ self.bookUrl.setPlaceholderText(QCoreApplication.translate("NewRipDialog", u"https://archive.org/details/ ...", None))
+ self.fileBrowseBtn.setText(QCoreApplication.translate("NewRipDialog", u"Browse...", None))
+ # retranslateUi
+
diff --git a/requirements.txt b/requirements.txt
index 989b995..f4323c6 100644
Binary files a/requirements.txt and b/requirements.txt differ
diff --git a/ripper.py b/ripper.py
index ca324f5..bbe85de 100644
--- a/ripper.py
+++ b/ripper.py
@@ -1,8 +1,9 @@
# ripper.py
# Copyright (c) 2020 James Shiffer
-# This file contains the main application logic.
+# This file contains the command-line version of archiveripper.
-import argparse, api, getpass, logging, os, sys
+import argparse, getpass, logging, os
+from archiveripper import api
def main():
client = api.ArchiveReaderClient()
diff --git a/ui/ArchiveRipper.ui b/ui/ArchiveRipper.ui
new file mode 100644
index 0000000..65d87f4
--- /dev/null
+++ b/ui/ArchiveRipper.ui
@@ -0,0 +1,100 @@
+
+
+ ArchiveRipper
+
+
+
+ 0
+ 0
+ 640
+ 480
+
+
+
+ ArchiveRipper
+
+
+
+
+
+
+ Exit
+
+
+ Ctrl+Q
+
+
+
+
+ Preferences...
+
+
+ Ctrl+,
+
+
+
+
+ About
+
+
+
+
+ New Rip...
+
+
+
+
+ Open Rip...
+
+
+
+
+ Export...
+
+
+
+
+ Save
+
+
+
+
+ Save As...
+
+
+
+
+
+
diff --git a/ui/CredentialsDialog.ui b/ui/CredentialsDialog.ui
new file mode 100644
index 0000000..3291324
--- /dev/null
+++ b/ui/CredentialsDialog.ui
@@ -0,0 +1,92 @@
+
+
+ CredentialsDialog
+
+
+
+ 0
+ 0
+ 400
+ 100
+
+
+
+ Enter Archive.org Credentials
+
+
+ -
+
+
-
+
+
+ -
+
+
+ Archive.org email:
+
+
+
+ -
+
+
+ Password:
+
+
+
+ -
+
+
+ QLineEdit::Password
+
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QDialogButtonBox::Cancel|QDialogButtonBox::Ok
+
+
+
+
+
+
+
+
+ buttonBox
+ accepted()
+ CredentialsDialog
+ accept()
+
+
+ 248
+ 254
+
+
+ 157
+ 274
+
+
+
+
+ buttonBox
+ rejected()
+ CredentialsDialog
+ reject()
+
+
+ 316
+ 260
+
+
+ 286
+ 274
+
+
+
+
+
diff --git a/ui/NewRipDialog.ui b/ui/NewRipDialog.ui
new file mode 100644
index 0000000..3f4011e
--- /dev/null
+++ b/ui/NewRipDialog.ui
@@ -0,0 +1,110 @@
+
+
+ NewRipDialog
+
+
+
+ 0
+ 0
+ 400
+ 137
+
+
+
+ New Rip
+
+
+ -
+
+
-
+
+
+ Paste the book's URL here:
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+ Save pages to:
+
+
+
+ -
+
+
+ https://archive.org/details/ ...
+
+
+
+ -
+
+
-
+
+
+ -
+
+
+ Browse...
+
+
+
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QDialogButtonBox::Cancel|QDialogButtonBox::Ok
+
+
+
+
+
+
+
+
+ buttonBox
+ accepted()
+ NewRipDialog
+ accept()
+
+
+ 248
+ 254
+
+
+ 157
+ 274
+
+
+
+
+ buttonBox
+ rejected()
+ NewRipDialog
+ reject()
+
+
+ 316
+ 260
+
+
+ 286
+ 274
+
+
+
+
+
diff --git a/ui/README.md b/ui/README.md
new file mode 100644
index 0000000..12d1b32
--- /dev/null
+++ b/ui/README.md
@@ -0,0 +1,23 @@
+This folder contains Qt .ui files which need to be recompiled into Python files whenever they are changed.
+
+Hopefully, your editor has a way to automate this for you. In VS Code, for example, all you have to do is download the Qt for Python extension and add the following setting:
+
+```json
+"qtForPython.uic.args": [
+ "-o \"${workspaceFolder}${pathSeparator}archiveripper${pathSeparator}ui${pathSeparator}ui_${fileBasenameNoExtension}.py\""
+]
+```
+
+If you need to do it by hand, though, you just need to run this command for each file you change:
+
+```sh
+pyside6-uic $FILENAME.ui > ../archiveripper/ui/ui_$FILENAME.py
+```
+
+And if you receive the error
+
+```
+ValueError: source code string cannot contain null bytes
+```
+
+trying to import the compiled .py scripts, this is just because they were encoded as little-endian UTF-16 for some stupid reason. You just need to change them to UTF-8.