From efb83eb6fcd1b175e70281fc0776ca2e53422ad0 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 8 Oct 2013 21:51:16 +0530 Subject: [PATCH] Start work on new Tweak Book tool --- src/calibre/ebooks/oeb/polish/container.py | 6 + src/calibre/gui2/tweak_book/__init__.py | 17 +++ src/calibre/gui2/tweak_book/file_list.py | 163 +++++++++++++++++++++ src/calibre/gui2/tweak_book/job.py | 84 +++++++++++ src/calibre/gui2/tweak_book/main.py | 48 ++++++ src/calibre/gui2/tweak_book/ui.py | 85 +++++++++++ src/calibre/gui2/tweak_book/undo.py | 23 +++ 7 files changed, 426 insertions(+) create mode 100644 src/calibre/gui2/tweak_book/__init__.py create mode 100644 src/calibre/gui2/tweak_book/file_list.py create mode 100644 src/calibre/gui2/tweak_book/job.py create mode 100644 src/calibre/gui2/tweak_book/main.py create mode 100644 src/calibre/gui2/tweak_book/ui.py create mode 100644 src/calibre/gui2/tweak_book/undo.py diff --git a/src/calibre/ebooks/oeb/polish/container.py b/src/calibre/ebooks/oeb/polish/container.py index 4151773c10..de8afe124f 100644 --- a/src/calibre/ebooks/oeb/polish/container.py +++ b/src/calibre/ebooks/oeb/polish/container.py @@ -495,6 +495,12 @@ class Container(object): # {{{ with open(dest, 'wb') as f: f.write(data) + def filesize(self, name): + if name in self.dirtied: + self.commit_item(name) + path = self.name_to_abspath(name) + return os.path.getsize(path) + def open(self, name, mode='rb'): ''' Open the file pointed to by name for direct read/write. Note that this will commit the file if it is dirtied and remove it from the parse diff --git a/src/calibre/gui2/tweak_book/__init__.py b/src/calibre/gui2/tweak_book/__init__.py new file mode 100644 index 0000000000..72e61e5ae1 --- /dev/null +++ b/src/calibre/gui2/tweak_book/__init__.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2013, Kovid Goyal ' + + +_current_container = None + +def current_container(): + return _current_container + +def set_current_container(container): + global _current_container + _current_container = container diff --git a/src/calibre/gui2/tweak_book/file_list.py b/src/calibre/gui2/tweak_book/file_list.py new file mode 100644 index 0000000000..02fb7367e8 --- /dev/null +++ b/src/calibre/gui2/tweak_book/file_list.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2013, Kovid Goyal ' + +from PyQt4.Qt import ( + QWidget, QTreeWidget, QGridLayout, QSize, Qt, QTreeWidgetItem, QIcon, + QStyledItemDelegate, QStyle) + +from calibre import guess_type, human_readable +from calibre.ebooks.oeb.base import OEB_STYLES +from calibre.gui2.tweak_book import current_container + +ICON_SIZE = 24 +NAME_ROLE = Qt.UserRole + +class ItemDelegate(QStyledItemDelegate): + + def sizeHint(self, option, index): + ans = QStyledItemDelegate.sizeHint(self, option, index) + top_level = not index.parent().isValid() + ans += QSize(0, 20 if top_level else 10) + return ans + + def paint(self, painter, option, index): + top_level = not index.parent().isValid() + hover = option.state & QStyle.State_MouseOver + if hover: + if top_level: + suffix = '\xa0(%d)' % index.model().rowCount(index) + else: + suffix = '\xa0' + human_readable(current_container().filesize(unicode(index.data(NAME_ROLE).toString()))) + br = painter.boundingRect(option.rect, Qt.AlignRight|Qt.AlignVCenter, suffix) + if top_level and index.row() > 0: + option.rect.adjust(0, 5, 0, 0) + painter.drawLine(option.rect.topLeft(), option.rect.topRight()) + option.rect.adjust(0, 1, 0, 0) + if hover: + option.rect.adjust(0, 0, -br.width(), 0) + QStyledItemDelegate.paint(self, painter, option, index) + if hover: + option.rect.adjust(0, 0, br.width(), 0) + painter.drawText(option.rect, Qt.AlignRight|Qt.AlignVCenter, suffix) + +class FileList(QTreeWidget): + + def __init__(self, parent=None): + QTreeWidget.__init__(self, parent) + self.delegate = ItemDelegate(self) + self.setTextElideMode(Qt.ElideMiddle) + self.setItemDelegate(self.delegate) + self.setIconSize(QSize(ICON_SIZE, ICON_SIZE)) + self.header().close() + self.setDragEnabled(True) + self.setSelectionMode(self.ExtendedSelection) + self.viewport().setAcceptDrops(True) + self.setDropIndicatorShown(True) + self.setDragDropMode(self.InternalMove) + self.setAutoScroll(True) + self.setAutoScrollMargin(ICON_SIZE*2) + self.setDefaultDropAction(Qt.MoveAction) + self.setAutoExpandDelay(1000) + self.setAnimated(True) + self.setMouseTracking(True) + self.in_drop_event = False + self.setContextMenuPolicy(Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self.show_context_menu) + self.root = self.invisibleRootItem() + + def build(self, container): + self.clear() + self.root = self.invisibleRootItem() + self.root.setFlags(Qt.ItemIsDragEnabled) + self.categories = {} + for category, text, icon in ( + ('text', _('Text'), 'keyboard-prefs.png'), + ('styles', _('Styles'), 'lookfeel.png'), + ('images', _('Images'), 'view-image.png'), + ('fonts', _('Fonts'), 'font.png'), + ('misc', _('Miscellaneous'), 'mimetypes/dir.png'), + ): + self.categories[category] = i = QTreeWidgetItem(self.root, 0) + i.setText(0, text) + i.setIcon(0, QIcon(I(icon))) + f = i.font(0) + f.setBold(True) + i.setFont(0, f) + i.setData(0, NAME_ROLE, category) + flags = Qt.ItemIsEnabled + if category == 'text': + flags |= Qt.ItemIsDropEnabled + i.setFlags(flags) + + processed, seen = set(), {} + + def get_display_name(name, item): + parts = name.split('/') + text = parts[-1] + while text in seen and parts: + text = parts.pop() + '/' + text + seen[text] = item + return text + + for name, linear in container.spine_names: + processed.add(name) + i = QTreeWidgetItem(self.categories['text'], 1) + prefix = '' if linear else '[nl] ' + if not linear: + i.setIcon(self.non_linear_icon) + i.setText(0, prefix + get_display_name(name, i)) + i.setStatusTip(0, _('Full path: ') + name) + i.setFlags(Qt.ItemIsEnabled | Qt.ItemIsDragEnabled | Qt.ItemIsSelectable) + i.setData(0, NAME_ROLE, name) + + font_types = {guess_type('a.'+x)[0] for x in ('ttf', 'otf', 'woff')} + + def get_category(mt): + category = 'misc' + if mt.startswith('image/'): + category = 'images' + elif mt in font_types: + category = 'fonts' + elif mt in OEB_STYLES: + category = 'styles' + return category + + all_files = list(container.manifest_type_map.iteritems()) + all_files.append((guess_type('a.opf')[0], [container.opf_name])) + + for name in container.name_path_map: + if name in processed: + continue + processed.add(name) + imt = container.mime_map.get(name, guess_type(name)[0]) + icat = get_category(imt) + i = QTreeWidgetItem(self.categories[icat], 1) + i.setText(0, get_display_name(name, i)) + i.setStatusTip(0, _('Full path: ') + name) + i.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + i.setData(0, NAME_ROLE, name) + + for c in self.categories.itervalues(): + self.expandItem(c) + + def show_context_menu(self, point): + pass + +class FileListWidget(QWidget): + + def __init__(self, parent=None): + QWidget.__init__(self, parent) + self.setLayout(QGridLayout(self)) + self.file_list = FileList(self) + self.layout().addWidget(self.file_list) + self.layout().setContentsMargins(0, 0, 0, 0) + + def build(self, container): + self.file_list.build(container) + + diff --git a/src/calibre/gui2/tweak_book/job.py b/src/calibre/gui2/tweak_book/job.py new file mode 100644 index 0000000000..9024f67857 --- /dev/null +++ b/src/calibre/gui2/tweak_book/job.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2013, Kovid Goyal ' + +import time +from threading import Thread +from functools import partial + +from PyQt4.Qt import (QWidget, QVBoxLayout, QLabel, Qt, QPainter, QBrush, QColor) + +from calibre.gui2 import Dispatcher +from calibre.gui2.progress_indicator import ProgressIndicator + +class LongJob(Thread): + + daemon = True + + def __init__(self, name, user_text, callback, function, *args, **kwargs): + Thread.__init__(self, name=name) + self.user_text = user_text + self.function = function + self.args, self.kwargs = args, kwargs + self.result = self.traceback = None + self.time_taken = None + self.callback = callback + + def run(self): + st = time.time() + try: + self.result = self.function(*self.args, **self.kwargs) + except: + import traceback + self.traceback = traceback.format_exc() + self.time_taken = time.time() - st + try: + self.callback(self) + finally: + pass + +class BlockingJob(QWidget): + + def __init__(self, parent): + QWidget.__init__(self, parent) + l = QVBoxLayout() + self.setLayout(l) + l.addStretch(10) + self.pi = ProgressIndicator(self, 128) + l.addWidget(self.pi, alignment=Qt.AlignHCenter) + self.msg = QLabel('') + l.addSpacing(10) + l.addWidget(self.msg, alignment=Qt.AlignHCenter) + l.addStretch(10) + self.setVisible(False) + + def start(self): + self.setGeometry(0, 0, self.parent().width(), self.parent().height()) + self.setVisible(True) + self.raise_() + self.pi.startAnimation() + + def stop(self): + self.pi.stopAnimation() + self.setVisible(False) + + def job_done(self, callback, job): + del job.callback + self.stop() + callback(job) + + def paintEvent(self, ev): + p = QPainter(self) + p.fillRect(ev.region().boundingRect(), QBrush(QColor(200, 200, 200, 160), Qt.SolidPattern)) + p.end() + QWidget.paintEvent(self, ev) + + def __call__(self, name, user_text, callback, function, *args, **kwargs): + self.msg.setText('

%s

' % user_text) + job = LongJob(name, user_text, Dispatcher(partial(self.job_done, callback)), function, *args, **kwargs) + job.start() + self.start() diff --git a/src/calibre/gui2/tweak_book/main.py b/src/calibre/gui2/tweak_book/main.py new file mode 100644 index 0000000000..57abb2b786 --- /dev/null +++ b/src/calibre/gui2/tweak_book/main.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2013, Kovid Goyal ' + +import sys, os + +from PyQt4.Qt import QIcon + +from calibre.constants import islinux +from calibre.gui2 import Application, ORG_NAME, APP_UID +from calibre.ptempfile import reset_base_dir +from calibre.utils.config import OptionParser +from calibre.gui2.tweak_book.ui import Main + +def option_parser(): + return OptionParser('''\ +%prog [opts] [path_to_ebook] + +Launch the calibre tweak book tool. +''') + +def main(args=sys.argv): + # Ensure we can continue to function if GUI is closed + os.environ.pop('CALIBRE_WORKER_TEMP_DIR', None) + reset_base_dir() + + parser = option_parser() + opts, args = parser.parse_args(args) + override = 'calibre-tweak-book' if islinux else None + app = Application(args, override_program_name=override) + app.load_builtin_fonts() + app.setWindowIcon(QIcon(I('tweak.png'))) + Application.setOrganizationName(ORG_NAME) + Application.setApplicationName(APP_UID) + main = Main(opts) + sys.excepthook = main.unhandled_exception + main.show() + if len(args) > 1: + main.open_book(args[1]) + app.exec_() + +if __name__ == '__main__': + main() + diff --git a/src/calibre/gui2/tweak_book/ui.py b/src/calibre/gui2/tweak_book/ui.py new file mode 100644 index 0000000000..1f6a2055a4 --- /dev/null +++ b/src/calibre/gui2/tweak_book/ui.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2013, Kovid Goyal ' + +import shutil, tempfile + +from PyQt4.Qt import QDockWidget, Qt, QLabel, QIcon + +from calibre.ebooks.oeb.polish.container import get_container +from calibre.ebooks.oeb.polish.main import SUPPORTED +from calibre.ptempfile import PersistentTemporaryDirectory +from calibre.gui2 import error_dialog +from calibre.gui2.main_window import MainWindow +from calibre.gui2.tweak_book import set_current_container, current_container +from calibre.gui2.tweak_book.file_list import FileListWidget +from calibre.gui2.tweak_book.job import BlockingJob +from calibre.gui2.tweak_book.undo import GlobalUndoHistory + +def load_book(path_to_ebook, base_tdir): + tdir = tempfile.mkdtemp(dir=base_tdir) + return get_container(path_to_ebook, tdir=tdir) + +class Main(MainWindow): + + APP_NAME = _('Tweak Book') + + def __init__(self, opts): + MainWindow.__init__(self, opts, disable_automatic_gc=True) + self.setWindowTitle(self.APP_NAME) + self.setWindowIcon(QIcon(I('tweak.png'))) + self.opts = opts + self.tdir = None + self.path_to_ebook = None + self.container = None + self.global_undo = GlobalUndoHistory() + self.blocking_job = BlockingJob(self) + + self.file_list_dock = d = QDockWidget(_('&Files Browser'), self) + d.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea) + self.file_list = FileListWidget(d) + d.setWidget(self.file_list) + self.addDockWidget(Qt.LeftDockWidgetArea, d) + + self.status_bar = self.statusBar() + self.l = QLabel('Placeholder') + + self.setCentralWidget(self.l) + + def resizeEvent(self, ev): + self.blocking_job.resize(ev.size()) + return super(Main, self).resizeEvent(ev) + + def open_book(self, path): + ext = path.rpartition('.')[-1].upper() + if ext not in SUPPORTED: + return error_dialog(self, _('Unsupported format'), + _('Tweaking is only supported for books in the %s formats.' + ' Convert your book to one of these formats first.') % _(' and ').join(sorted(SUPPORTED)), + show=True) + + # TODO: Handle already open, dirtied book + + if self.tdir: + shutil.rmtree(self.tdir, ignore_errors=True) + self.tdir = PersistentTemporaryDirectory() + self.blocking_job('open_book', _('Opening book, please wait...'), self.book_opened, load_book, path, self.tdir) + + def book_opened(self, job): + if job.traceback is not None: + return error_dialog(self, _('Failed to open book'), + _('Failed to open book, click Show details for more information.'), + det_msg=job.traceback, show=True) + container = job.result + set_current_container(container) + self.current_metadata = container.mi + self.global_undo.open_book(container) + self.update_window_title() + self.file_list.build(container) + + def update_window_title(self): + self.setWindowTitle(self.current_metadata.title + ' [%s] - %s' %(current_container().book_type.upper(), self.APP_NAME)) diff --git a/src/calibre/gui2/tweak_book/undo.py b/src/calibre/gui2/tweak_book/undo.py new file mode 100644 index 0000000000..c221c0327c --- /dev/null +++ b/src/calibre/gui2/tweak_book/undo.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2013, Kovid Goyal ' + + +class State(object): + + def __init__(self, container): + self.container = container + self.operation = None + +class GlobalUndoHistory(object): + + def __init__(self): + self.states = [] + + def open_book(self, container): + self.states = [State(container)] +