diff --git a/src/calibre/gui2/add_wizard/__init__.py b/src/calibre/gui2/add_wizard/__init__.py new file mode 100644 index 0000000000..f7518db3fc --- /dev/null +++ b/src/calibre/gui2/add_wizard/__init__.py @@ -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 ' +__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_() +# }}} + diff --git a/src/calibre/gui2/add_wizard/scan.ui b/src/calibre/gui2/add_wizard/scan.ui new file mode 100644 index 0000000000..b697ff9894 --- /dev/null +++ b/src/calibre/gui2/add_wizard/scan.ui @@ -0,0 +1,25 @@ + + + WizardPage + + + + 0 + 0 + 400 + 300 + + + + WizardPage + + + Scanning root folder for books + + + This may take a few minutes + + + + + diff --git a/src/calibre/gui2/add_wizard/welcome.ui b/src/calibre/gui2/add_wizard/welcome.ui new file mode 100644 index 0000000000..52fcabb714 --- /dev/null +++ b/src/calibre/gui2/add_wizard/welcome.ui @@ -0,0 +1,134 @@ + + + WizardPage + + + + 0 + 0 + 704 + 468 + + + + WizardPage + + + Choose the location to add books from + + + Select a folder on your hard disk + + + + + + <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> + + + true + + + + + + + &Root folder: + + + opt_root_folder + + + + + + + This folder and its sub-folders will be scanned for books to import into calibre's library + + + + + + + Choose root folder + + + ... + + + + :/images/document_open.svg:/images/document_open.svg + + + + + + + Handle multiple files per book + + + + + + &One book per folder, assumes every ebook file in a folder is the same book in a different format + + + + + + + &Multiple books per folder, assumes every ebook file is a different book + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + diff --git a/src/calibre/library/add_to_library.py b/src/calibre/library/add_to_library.py new file mode 100644 index 0000000000..8451241e3c --- /dev/null +++ b/src/calibre/library/add_to_library.py @@ -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 ' +__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