mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Beginnings of add books wizard
This commit is contained in:
parent
855ed54782
commit
2047bfbe9b
174
src/calibre/gui2/add_wizard/__init__.py
Normal file
174
src/calibre/gui2/add_wizard/__init__.py
Normal file
@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python
|
||||
# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
|
||||
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
|
||||
__docformat__ = 'restructuredtext en'
|
||||
|
||||
import os
|
||||
|
||||
from PyQt4.Qt import QWizard, QWizardPage, QIcon, QPixmap, Qt, QThread, \
|
||||
pyqtSignal
|
||||
|
||||
from calibre.gui2 import error_dialog, choose_dir, gprefs
|
||||
from calibre.constants import filesystem_encoding
|
||||
from calibre.library.add_to_library import find_folders_under, \
|
||||
find_books_in_folder, hash_merge_format_collections
|
||||
|
||||
class WizardPage(QWizardPage): # {{{
|
||||
|
||||
def __init__(self, db, parent):
|
||||
QWizardPage.__init__(self, parent)
|
||||
self.db = db
|
||||
self.register = parent.register
|
||||
self.setupUi(self)
|
||||
|
||||
self.do_init()
|
||||
|
||||
def do_init(self):
|
||||
pass
|
||||
|
||||
# }}}
|
||||
|
||||
# Scan root folder Page {{{
|
||||
|
||||
from calibre.gui2.add_wizard.scan_ui import Ui_WizardPage as ScanWidget
|
||||
|
||||
class RecursiveFinder(QThread):
|
||||
|
||||
activity_changed = pyqtSignal(object, object) # description and total count
|
||||
activity_iterated = pyqtSignal(object, object) # item desc, progress number
|
||||
|
||||
def __init__(self, parent=None):
|
||||
QThread.__init__(self, parent)
|
||||
self.canceled = False
|
||||
self.cancel_callback = lambda : self.canceled
|
||||
self.folders = set([])
|
||||
self.books = []
|
||||
|
||||
def cancel(self, *args):
|
||||
self.canceled = True
|
||||
|
||||
def set_params(self, root, db, one_per_folder):
|
||||
self.root, self.db = root, db
|
||||
self.one_per_folder = one_per_folder
|
||||
|
||||
def run(self):
|
||||
self.activity_changed.emit(_('Searching for sub-folders'), 0)
|
||||
self.folders = find_folders_under(self.root, self.db,
|
||||
cancel_callback=self.cancel_callback)
|
||||
if self.canceled:
|
||||
return
|
||||
self.activity_changed.emit(_('Searching for books'), len(self.folders))
|
||||
for i, folder in enumerate(self.folders):
|
||||
if self.canceled:
|
||||
break
|
||||
books_in_folder = find_books_in_folder(folder, self.one_per_folder,
|
||||
cancel_callback=self.cancel_callback)
|
||||
if self.canceled:
|
||||
break
|
||||
self.books.extend(books_in_folder)
|
||||
self.activity_iterated.emit(folder, i)
|
||||
|
||||
self.activity_changed.emit(
|
||||
_('Looking for duplicates based on file hash'), 0)
|
||||
|
||||
self.books = hash_merge_format_collections(self.books,
|
||||
cancel_callback=self.cancel_callback)
|
||||
|
||||
|
||||
|
||||
class ScanPage(WizardPage, ScanWidget):
|
||||
|
||||
ID = 2
|
||||
|
||||
# }}}
|
||||
|
||||
# Welcome Page {{{
|
||||
|
||||
from calibre.gui2.add_wizard.welcome_ui import Ui_WizardPage as WelcomeWidget
|
||||
|
||||
class WelcomePage(WizardPage, WelcomeWidget):
|
||||
|
||||
ID = 1
|
||||
|
||||
def do_init(self):
|
||||
# Root folder must be filled
|
||||
self.registerField('root_folder*', self.opt_root_folder)
|
||||
|
||||
self.register['root_folder'] = self.get_root_folder
|
||||
self.register['one_per_folder'] = self.get_one_per_folder
|
||||
|
||||
self.button_choose_root_folder.clicked.connect(self.choose_root_folder)
|
||||
|
||||
def choose_root_folder(self, *args):
|
||||
x = self.get_root_folder()
|
||||
if x is None:
|
||||
x = '~'
|
||||
x = choose_dir(self, 'add wizard choose root folder',
|
||||
_('Choose root folder'), default_dir=x)
|
||||
if x is not None:
|
||||
self.opt_root_folder.setText(os.path.abspath(x))
|
||||
|
||||
def initializePage(self):
|
||||
opf = gprefs.get('add wizard one per folder', True)
|
||||
self.opt_one_per_folder.setChecked(opf)
|
||||
self.opt_many_per_folder.setChecked(not opf)
|
||||
add_dir = gprefs.get('add wizard root folder', None)
|
||||
if add_dir is not None:
|
||||
self.opt_root_folder.setText(add_dir)
|
||||
|
||||
def get_root_folder(self):
|
||||
x = unicode(self.opt_root_folder.text()).strip()
|
||||
if not x:
|
||||
return None
|
||||
return os.path.abspath(x.encode(filesystem_encoding))
|
||||
|
||||
def get_one_per_folder(self):
|
||||
return self.opt_one_per_folder.isChecked()
|
||||
|
||||
def validatePage(self):
|
||||
x = self.get_root_folder()
|
||||
xu = x.decode(filesystem_encoding)
|
||||
if x and os.access(x, os.R_OK) and os.path.isdir(x):
|
||||
gprefs['add wizard root folder'] = xu
|
||||
gprefs['add wizard one per folder'] = self.get_one_per_folder()
|
||||
return True
|
||||
error_dialog(self, _('Invalid root folder'),
|
||||
xu + _('is not a valid root folder'), show=True)
|
||||
return False
|
||||
|
||||
# }}}
|
||||
|
||||
class Wizard(QWizard): # {{{
|
||||
|
||||
def __init__(self, db, parent=None):
|
||||
QWizard.__init__(self, parent)
|
||||
self.setModal(True)
|
||||
self.setWindowTitle(_('Add books to calibre'))
|
||||
self.setWindowIcon(QIcon(I('add_book.svg')))
|
||||
self.setPixmap(self.LogoPixmap, QPixmap(P('content_server/calibre.png')).scaledToHeight(80,
|
||||
Qt.SmoothTransformation))
|
||||
self.setPixmap(self.WatermarkPixmap,
|
||||
QPixmap(I('welcome_wizard.svg')))
|
||||
|
||||
self.register = {}
|
||||
|
||||
for attr, cls in [
|
||||
('welcome_page', WelcomePage),
|
||||
('scan_page', ScanPage),
|
||||
]:
|
||||
setattr(self, attr, cls(db, self))
|
||||
self.setPage(getattr(cls, 'ID'), getattr(self, attr))
|
||||
|
||||
# }}}
|
||||
|
||||
# Test Wizard {{{
|
||||
if __name__ == '__main__':
|
||||
from PyQt4.Qt import QApplication
|
||||
from calibre.library import db
|
||||
app = QApplication([])
|
||||
w = Wizard(db())
|
||||
w.exec_()
|
||||
# }}}
|
||||
|
25
src/calibre/gui2/add_wizard/scan.ui
Normal file
25
src/calibre/gui2/add_wizard/scan.ui
Normal file
@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>WizardPage</class>
|
||||
<widget class="QWizardPage" name="WizardPage">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>400</width>
|
||||
<height>300</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>WizardPage</string>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Scanning root folder for books</string>
|
||||
</property>
|
||||
<property name="subTitle">
|
||||
<string>This may take a few minutes</string>
|
||||
</property>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
134
src/calibre/gui2/add_wizard/welcome.ui
Normal file
134
src/calibre/gui2/add_wizard/welcome.ui
Normal file
@ -0,0 +1,134 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>WizardPage</class>
|
||||
<widget class="QWizardPage" name="WizardPage">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>704</width>
|
||||
<height>468</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>WizardPage</string>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Choose the location to add books from</string>
|
||||
</property>
|
||||
<property name="subTitle">
|
||||
<string>Select a folder on your hard disk</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0" colspan="3">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string><p>calibre can scan your computer for existing books automatically. These books will then be <b>copied</b> into the calibre library. This wizard will help you customize the scanning and import process for your existing book collection.</p>
|
||||
<p>Choose a root folder. Books will be searched for only inside this folder and any sub-folders.</p>
|
||||
<p>Make sure that the folder you chose for your calibre library <b>is not</b> under the root folder you choose.</p></string>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
<string>&Root folder:</string>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>opt_root_folder</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QLineEdit" name="opt_root_folder">
|
||||
<property name="toolTip">
|
||||
<string>This folder and its sub-folders will be scanned for books to import into calibre's library</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QToolButton" name="button_choose_root_folder">
|
||||
<property name="toolTip">
|
||||
<string>Choose root folder</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
<property name="icon">
|
||||
<iconset resource="../../../../resources/images.qrc">
|
||||
<normaloff>:/images/document_open.svg</normaloff>:/images/document_open.svg</iconset>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0" colspan="3">
|
||||
<widget class="QGroupBox" name="groupBox">
|
||||
<property name="title">
|
||||
<string>Handle multiple files per book</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QRadioButton" name="opt_one_per_folder">
|
||||
<property name="text">
|
||||
<string>&One book per folder, assumes every ebook file in a folder is the same book in a different format</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QRadioButton" name="opt_many_per_folder">
|
||||
<property name="text">
|
||||
<string>&Multiple books per folder, assumes every ebook file is a different book</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="0">
|
||||
<spacer name="verticalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<spacer name="verticalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<spacer name="verticalSpacer_3">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources>
|
||||
<include location="../../../../resources/images.qrc"/>
|
||||
</resources>
|
||||
<connections/>
|
||||
</ui>
|
178
src/calibre/library/add_to_library.py
Normal file
178
src/calibre/library/add_to_library.py
Normal file
@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python
|
||||
# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
|
||||
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
|
||||
__docformat__ = 'restructuredtext en'
|
||||
|
||||
import os
|
||||
from hashlib import sha1
|
||||
|
||||
from calibre.constants import filesystem_encoding
|
||||
from calibre.ebooks import BOOK_EXTENSIONS
|
||||
|
||||
def find_folders_under(root, db, add_root=True, # {{{
|
||||
follow_links=False, cancel_callback=lambda : False):
|
||||
'''
|
||||
Find all folders under the specified root path, ignoring any folders under
|
||||
the library path of db
|
||||
|
||||
root must be a bytestring in filesystem_encoding
|
||||
|
||||
If follow_links is True, follow symbolic links. WARNING; this can lead to
|
||||
infinite recursion.
|
||||
|
||||
cancel_callback must be a no argument callable that returns True to cancel
|
||||
the search
|
||||
'''
|
||||
assert not isinstance(root, unicode) # root must be in filesystem encoding
|
||||
lp = db.library_path
|
||||
if isinstance(lp, unicode):
|
||||
try:
|
||||
lp = lp.encode(filesystem_encoding)
|
||||
except:
|
||||
lp = None
|
||||
if lp:
|
||||
lp = os.path.abspath(lp)
|
||||
|
||||
root = os.path.abspath(root)
|
||||
|
||||
ans = set([])
|
||||
for dirpath, dirnames, __ in os.walk(root, topdown=True, followlinks=follow_links):
|
||||
if cancel_callback():
|
||||
break
|
||||
for x in list(dirnames):
|
||||
path = os.path.join(dirpath, x)
|
||||
if lp and path.startswith(lp):
|
||||
dirnames.remove(x)
|
||||
if lp and dirpath.startswith(lp):
|
||||
continue
|
||||
ans.add(dirpath)
|
||||
|
||||
if not add_root:
|
||||
ans.remove(root)
|
||||
|
||||
return ans
|
||||
|
||||
# }}}
|
||||
|
||||
class FormatCollection(object): # {{{
|
||||
|
||||
def __init__(self, parent_folder, formats):
|
||||
self.path_map = {}
|
||||
for x in set(formats):
|
||||
fmt = os.path.splitext(x)[1].lower()
|
||||
if fmt:
|
||||
fmt = fmt[1:]
|
||||
self.path_map[fmt] = x
|
||||
self.parent_folder = None
|
||||
self.hash_map = {}
|
||||
for fmt, path in self.format_map.items():
|
||||
self.hash_map[fmt] = self.hash_of_file(path)
|
||||
|
||||
def hash_of_file(self, path):
|
||||
with open(path, 'rb') as f:
|
||||
return sha1(f.read()).digest()
|
||||
|
||||
@property
|
||||
def hashes(self):
|
||||
return frozenset(self.formats.values())
|
||||
|
||||
@property
|
||||
def is_empty(self):
|
||||
return len(self) == 0
|
||||
|
||||
def __iter__(self):
|
||||
for x in self.path_map:
|
||||
yield x
|
||||
|
||||
def __len__(self):
|
||||
return len(self.path_map)
|
||||
|
||||
def remove(self, fmt):
|
||||
self.hash_map.pop(fmt, None)
|
||||
self.path_map.pop(fmt, None)
|
||||
|
||||
def matches(self, other):
|
||||
if not self.hashes.intersection(other.hashes):
|
||||
return False
|
||||
for fmt in self:
|
||||
if self.hash_map[fmt] != other.hash_map.get(fmt, False):
|
||||
return False
|
||||
return True
|
||||
|
||||
def merge(self, other):
|
||||
for fmt in list(other):
|
||||
self.path_map[fmt] = other.path_map[fmt]
|
||||
self.hash_map[fmt] = other.hash_map[fmt]
|
||||
other.remove(fmt)
|
||||
|
||||
# }}}
|
||||
|
||||
def books_in_folder(folder, one_per_folder, # {{{
|
||||
cancel_callback=lambda : False):
|
||||
assert not isinstance(folder, unicode)
|
||||
|
||||
dirpath = os.path.abspath(folder)
|
||||
if one_per_folder:
|
||||
formats = set([])
|
||||
for path in os.listdir(dirpath):
|
||||
if cancel_callback():
|
||||
return []
|
||||
path = os.path.abspath(os.path.join(dirpath, path))
|
||||
if os.path.isdir(path) or not os.access(path, os.R_OK):
|
||||
continue
|
||||
ext = os.path.splitext(path)[1]
|
||||
if not ext:
|
||||
continue
|
||||
ext = ext[1:].lower()
|
||||
if ext not in BOOK_EXTENSIONS and ext != 'opf':
|
||||
continue
|
||||
formats.add(path)
|
||||
return [FormatCollection(folder, formats)]
|
||||
else:
|
||||
books = {}
|
||||
for path in os.listdir(dirpath):
|
||||
if cancel_callback():
|
||||
return
|
||||
path = os.path.abspath(os.path.join(dirpath, path))
|
||||
if os.path.isdir(path) or not os.access(path, os.R_OK):
|
||||
continue
|
||||
ext = os.path.splitext(path)[1]
|
||||
if not ext:
|
||||
continue
|
||||
ext = ext[1:].lower()
|
||||
if ext not in BOOK_EXTENSIONS:
|
||||
continue
|
||||
|
||||
key = os.path.splitext(path)[0]
|
||||
if not books.has_key(key):
|
||||
books[key] = set([])
|
||||
books[key].add(path)
|
||||
|
||||
return [FormatCollection(folder, x) for x in books.values() if x]
|
||||
|
||||
# }}}
|
||||
|
||||
def hash_merge_format_collections(collections, cancel_callback=lambda:False):
|
||||
ans = []
|
||||
|
||||
collections = list(collections)
|
||||
l = len(collections)
|
||||
for i in range(l):
|
||||
if cancel_callback():
|
||||
return collections
|
||||
one = collections[i]
|
||||
if one.is_empty:
|
||||
continue
|
||||
for j in range(i+1, l):
|
||||
if cancel_callback():
|
||||
return collections
|
||||
two = collections[j]
|
||||
if two.is_empty:
|
||||
continue
|
||||
if one.matches(two):
|
||||
one.merge(two)
|
||||
ans.append(one)
|
||||
|
||||
return ans
|
Loading…
x
Reference in New Issue
Block a user