diff --git a/imgsrc/document-new.svg b/imgsrc/document-new.svg new file mode 100644 index 0000000000..8b9f1a8ab5 --- /dev/null +++ b/imgsrc/document-new.svg @@ -0,0 +1,602 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/resources/images/document-new.png b/resources/images/document-new.png new file mode 100644 index 0000000000..e9b3cb61b9 Binary files /dev/null and b/resources/images/document-new.png differ diff --git a/src/calibre/ebooks/oeb/polish/container.py b/src/calibre/ebooks/oeb/polish/container.py index b5303e09ad..045f95ffea 100644 --- a/src/calibre/ebooks/oeb/polish/container.py +++ b/src/calibre/ebooks/oeb/polish/container.py @@ -158,6 +158,46 @@ class Container(object): # {{{ for name, path in self.name_path_map.iteritems()} } + def add_file(self, name, data, media_type=None): + ''' Add a file to this container. Entries for the file are + automatically created in the OPF manifest and spine + (if the file is a text document) ''' + if self.has_name(name): + raise ValueError('A file with the name %s already exists' % name) + if '..' in name: + raise ValueError('Names are not allowed to have .. in them') + href = self.name_to_href(name, self.opf_name) + all_hrefs = {x.get('href') for x in self.opf_xpath('//opf:manifest/opf:item[@href]')} + if href in all_hrefs: + raise ValueError('An item with the href %s already exists in the manifest' % href) + path = self.name_to_abspath(name) + base = os.path.dirname(path) + if not os.path.exists(base): + os.makedirs(base) + with open(path, 'wb') as f: + f.write(data) + mt = media_type or guess_type(name) + self.name_path_map[name] = path + self.mime_map[name] = mt + if name in self.names_that_need_not_be_manifested: + return + all_ids = {x.get('id') for x in self.opf_xpath('//*[@id]')} + c = 0 + item_id = 'id' + while item_id in all_ids: + c += 1 + item_id = 'id' + '%d'%c + manifest = self.opf_xpath('//opf:manifest')[0] + item = manifest.makeelement(OPF('item'), + id=item_id, href=href) + item.set('media-type', mt) + self.insert_into_xml(manifest, item) + self.dirty(self.opf_name) + if mt in OEB_DOCS: + spine = self.opf_xpath('//opf:spine')[0] + si = manifest.makeelement(OPF('itemref'), idref=item_id) + self.insert_into_xml(spine, si) + def rename(self, current_name, new_name): ''' Renames a file from current_name to new_name. It automatically rebases all links inside the file if the directory the file is in diff --git a/src/calibre/ebooks/oeb/polish/tests/container.py b/src/calibre/ebooks/oeb/polish/tests/container.py index cc836b7b04..6f147ba0a6 100644 --- a/src/calibre/ebooks/oeb/polish/tests/container.py +++ b/src/calibre/ebooks/oeb/polish/tests/container.py @@ -158,3 +158,20 @@ class ContainerTests(BaseTest): # self.run_external_tools(c, gvim=True) + def test_file_add(self): + ' Test adding of files ' + book = get_simple_book() + c = get_container(book) + name = 'folder/added file.html' + c.add_file(name, b'xxx') + self.assertEqual('xxx', c.raw_data(name)) + self.assertIn(name, set(c.manifest_id_map.itervalues())) + self.assertIn(name, {x[0] for x in c.spine_names}) + + name = 'added.css' + c.add_file(name, b'xxx') + self.assertEqual('xxx', c.raw_data(name)) + self.assertIn(name, set(c.manifest_id_map.itervalues())) + self.assertNotIn(name, {x[0] for x in c.spine_names}) + + self.check_links(c) diff --git a/src/calibre/gui2/tweak_book/boss.py b/src/calibre/gui2/tweak_book/boss.py index 5b8cb5ee3f..3004c963b2 100644 --- a/src/calibre/gui2/tweak_book/boss.py +++ b/src/calibre/gui2/tweak_book/boss.py @@ -24,6 +24,7 @@ from calibre.gui2 import error_dialog, choose_files, question_dialog, info_dialo from calibre.gui2.dialogs.confirm_delete import confirm from calibre.gui2.tweak_book import set_current_container, current_container, tprefs, actions, editors from calibre.gui2.tweak_book.undo import GlobalUndoHistory +from calibre.gui2.tweak_book.file_list import NewFileDialog from calibre.gui2.tweak_book.save import SaveManager from calibre.gui2.tweak_book.preview import parse_worker from calibre.gui2.tweak_book.toc import TOCEditor @@ -144,15 +145,52 @@ class Boss(QObject): if not editors: self.gui.preview.clear() + def check_opf_dirtied(self): + c = current_container() + if c.opf_name in editors and editors[c.opf_name].is_modified: + return question_dialog(self.gui, _('Unsaved changes'), _( + 'You have unsaved changes in %s. If you proceed,' + ' you will lose them. Proceed anyway?') % c.opf_name) + return True + def reorder_spine(self, items): - # TODO: If content.opf is dirty in an editor, abort, calling - # file_list.build(current_container) to undo drag and drop + if not self.check_opf_dirtied(): + return self.add_savepoint(_('Re-order text')) c = current_container() c.set_spine(items) self.gui.action_save.setEnabled(True) self.gui.file_list.build(current_container()) # needed as the linear flag may have changed on some items - # TODO: If content.opf is open in an editor, reload it + if c.opf_name in editors: + editors[c.opf_name].replace_data(c.raw_data(c.opf_name)) + + def add_file(self): + if not self.check_opf_dirtied(): + return + d = NewFileDialog(self.gui) + if d.exec_() != d.Accepted: + return + self.add_savepoint(_('Add file %s') % self.gui.elided_text(d.file_name)) + c = current_container() + data = d.file_data + if d.using_template: + data = data.replace(b'%CURSOR%', b'') + try: + c.add_file(d.file_name, data) + except: + self.rewind_savepoint() + raise + self.gui.file_list.build(c) + self.gui.file_list.select_name(d.file_name) + if c.opf_name in editors: + editors[c.opf_name].replace_data(c.raw_data(c.opf_name)) + mt = c.mime_map[d.file_name] + syntax = syntax_from_mime(mt) + if syntax: + if d.using_template: + self.edit_file(d.file_name, syntax, use_template=d.file_data.decode('utf-8')) + else: + self.edit_file(d.file_name, syntax) def edit_toc(self): if not self.check_dirtied(): @@ -454,21 +492,27 @@ class Boss(QObject): _('Saving of the book failed. Click "Show Details"' ' for more information.'), det_msg=tb, show=True) - def init_editor(self, name, editor, data=None): + def init_editor(self, name, editor, data=None, use_template=False): editor.undo_redo_state_changed.connect(self.editor_undo_redo_state_changed) editor.data_changed.connect(self.editor_data_changed) editor.copy_available_state_changed.connect(self.editor_copy_available_state_changed) if data is not None: - editor.data = data + if use_template: + editor.init_from_template(data) + else: + editor.data = data editor.modification_state_changed.connect(self.editor_modification_state_changed) self.gui.central.add_editor(name, editor) - def edit_file(self, name, syntax): + def edit_file(self, name, syntax, use_template=None): editor = editors.get(name, None) if editor is None: editor = editors[name] = editor_from_syntax(syntax, self.gui.editor_tabs) - data = current_container().raw_data(name) - self.init_editor(name, editor, data) + if use_template is None: + data = current_container().raw_data(name) + else: + data = use_template + self.init_editor(name, editor, data, use_template=bool(use_template)) self.show_editor(name) def show_editor(self, name): diff --git a/src/calibre/gui2/tweak_book/editor/text.py b/src/calibre/gui2/tweak_book/editor/text.py index f2b2106fa9..6ae6513a43 100644 --- a/src/calibre/gui2/tweak_book/editor/text.py +++ b/src/calibre/gui2/tweak_book/editor/text.py @@ -115,11 +115,14 @@ class TextEdit(QPlainTextEdit): self.highlight_color = theme_color(theme, 'HighlightRegion', 'bg') # }}} - def load_text(self, text, syntax='html'): + def load_text(self, text, syntax='html', process_template=False): self.highlighter = {'html':HTMLHighlighter, 'css':CSSHighlighter, 'xml':XMLHighlighter}.get(syntax, SyntaxHighlighter)(self) self.highlighter.apply_theme(self.theme) self.highlighter.setDocument(self.document()) self.setPlainText(text) + if process_template and QPlainTextEdit.find(self, '%CURSOR%'): + c = self.textCursor() + c.insertText('') def replace_text(self, text): c = self.textCursor() diff --git a/src/calibre/gui2/tweak_book/editor/widget.py b/src/calibre/gui2/tweak_book/editor/widget.py index 8182dfd478..3e39c74a35 100644 --- a/src/calibre/gui2/tweak_book/editor/widget.py +++ b/src/calibre/gui2/tweak_book/editor/widget.py @@ -48,6 +48,9 @@ class Editor(QMainWindow): self.editor.load_text(val, syntax=self.syntax) return property(fget=fget, fset=fset) + def init_from_template(self, template): + self.editor.load_text(template, syntax=self.syntax, process_template=True) + def get_raw_data(self): return unicode(self.editor.toPlainText()) diff --git a/src/calibre/gui2/tweak_book/file_list.py b/src/calibre/gui2/tweak_book/file_list.py index 5fa4118af9..567f827492 100644 --- a/src/calibre/gui2/tweak_book/file_list.py +++ b/src/calibre/gui2/tweak_book/file_list.py @@ -6,19 +6,22 @@ from __future__ import (unicode_literals, division, absolute_import, __license__ = 'GPL v3' __copyright__ = '2013, Kovid Goyal ' +import os from binascii import hexlify from collections import OrderedDict from PyQt4.Qt import ( QWidget, QTreeWidget, QGridLayout, QSize, Qt, QTreeWidgetItem, QIcon, - QStyledItemDelegate, QStyle, QPixmap, QPainter, pyqtSignal) + QStyledItemDelegate, QStyle, QPixmap, QPainter, pyqtSignal, + QDialogButtonBox, QDialog, QLabel, QLineEdit, QVBoxLayout) -from calibre import human_readable +from calibre import human_readable, sanitize_file_name_unicode from calibre.ebooks.oeb.base import OEB_STYLES, OEB_DOCS from calibre.ebooks.oeb.polish.container import guess_type from calibre.ebooks.oeb.polish.cover import get_cover_page_name, get_raster_cover_name -from calibre.gui2 import error_dialog +from calibre.gui2 import error_dialog, choose_files from calibre.gui2.tweak_book import current_container from calibre.gui2.tweak_book.editor import syntax_from_mime +from calibre.gui2.tweak_book.templates import template_for from calibre.utils.icu import sort_key TOP_ICON_SIZE = 24 @@ -128,6 +131,14 @@ class FileList(QTreeWidget): if name in state['selected']: c.setSelected(True) + def select_name(self, name): + for parent in self.categories.itervalues(): + for c in (parent.child(i) for i in xrange(parent.childCount())): + q = unicode(c.data(0, NAME_ROLE).toString()) + c.setSelected(q == name) + if q == name: + self.scrollToItem(c) + def build(self, container, preserve_state=True): if preserve_state: state = self.get_state() @@ -360,6 +371,86 @@ class FileList(QTreeWidget): ans['selected'][name] = syntax_from_mime(mime) return ans +class NewFileDialog(QDialog): + + def __init__(self, initial_choice='html', parent=None): + QDialog.__init__(self, parent) + self.l = l = QVBoxLayout() + self.setLayout(l) + self.la = la = QLabel(_( + 'Choose a name for the new file')) + self.setWindowTitle(_('Choose file')) + l.addWidget(la) + self.name = n = QLineEdit(self) + n.textChanged.connect(self.update_ok) + l.addWidget(n) + self.err_label = la = QLabel('') + la.setWordWrap(True) + l.addWidget(la) + self.bb = bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + l.addWidget(bb) + bb.accepted.connect(self.accept) + bb.rejected.connect(self.reject) + self.imp_button = b = bb.addButton(_('Import resource file (image/font/etc.)'), bb.ActionRole) + b.setIcon(QIcon(I('view-image.png'))) + b.clicked.connect(self.import_file) + + self.ok_button = bb.button(bb.Ok) + + self.file_data = '' + self.using_template = False + + def show_error(self, msg): + self.err_label.setText('

' + msg) + return False + + def import_file(self): + path = choose_files(self, 'tweak-book-new-resource-file', _('Choose file'), select_only_single_file=True) + if path: + path = path[0] + with open(path, 'rb') as f: + self.file_data = f.read() + name = os.path.basename(path) + self.name.setText(name) + + @property + def name_is_ok(self): + name = unicode(self.name.text()) + if not name or not name.strip(): + return self.show_error('') + ext = name.rpartition('.')[-1] + if not ext or ext == name: + return self.show_error(_('The file name must have an extension')) + norm = name.replace('\\', '/') + parts = name.split('/') + for x in parts: + if sanitize_file_name_unicode(x) != x: + return self.show_error(_('The file name contains invalid characters')) + if current_container().has_name(norm): + return self.show_error(_('This file name already exists in the book')) + self.show_error('') + return True + + def update_ok(self, *args): + self.ok_button.setEnabled(self.name_is_ok) + + def accept(self): + if not self.name_is_ok: + return error_dialog(self, _('No name specified'), _( + 'You must specify a name for the new file, with an extension, for example, chapter1.html'), show=True) + name = unicode(self.name.text()) + name, ext = name.rpartition('.')[0::2] + name = (name + '.' + ext.lower()).replace('\\', '/') + mt = guess_type(name) + if mt in OEB_DOCS: + self.file_data = template_for('html').encode('utf-8') + self.using_template = True + elif mt in OEB_STYLES: + self.file_data = template_for('css').encode('utf-8') + self.using_template = True + self.file_name = name + QDialog.accept(self) + class FileListWidget(QWidget): delete_requested = pyqtSignal(object, object) @@ -375,7 +466,7 @@ class FileListWidget(QWidget): self.layout().setContentsMargins(0, 0, 0, 0) for x in ('delete_requested', 'reorder_spine', 'rename_requested', 'edit_file'): getattr(self.file_list, x).connect(getattr(self, x)) - for x in ('delete_done',): + for x in ('delete_done', 'select_name'): setattr(self, x, getattr(self.file_list, x)) def build(self, container, preserve_state=True): diff --git a/src/calibre/gui2/tweak_book/templates.py b/src/calibre/gui2/tweak_book/templates.py new file mode 100644 index 0000000000..5024819bb5 --- /dev/null +++ b/src/calibre/gui2/tweak_book/templates.py @@ -0,0 +1,44 @@ +#!/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 calibre import prepare_string_for_xml +from calibre.gui2.tweak_book import current_container + +DEFAULT_TEMPLATES = { + 'html': +'''\ + + + + {TITLE} + + + %CURSOR% + + +''', + + + 'css': +'''\ +@charset utf-8; +/* Styles for {TITLE} */ +%CURSOR% +''', + +} + +def template_for(syntax): + mi = current_container().mi + data = { + 'TITLE':mi.title, + 'AUTHOR': ' & '.join(mi.authors), + } + template = DEFAULT_TEMPLATES.get(syntax, '') + return template.format(**{k:prepare_string_for_xml(v, True) for k, v in data.iteritems()}) + diff --git a/src/calibre/gui2/tweak_book/ui.py b/src/calibre/gui2/tweak_book/ui.py index fb51ddd56d..822f6539f2 100644 --- a/src/calibre/gui2/tweak_book/ui.py +++ b/src/calibre/gui2/tweak_book/ui.py @@ -9,7 +9,7 @@ __copyright__ = '2013, Kovid Goyal ' from functools import partial from PyQt4.Qt import ( - QDockWidget, Qt, QLabel, QIcon, QAction, QApplication, QWidget, + QDockWidget, Qt, QLabel, QIcon, QAction, QApplication, QWidget, QFontMetrics, QVBoxLayout, QStackedWidget, QTabWidget, QImage, QPixmap, pyqtSignal) from calibre.constants import __appname__, get_version @@ -22,6 +22,10 @@ from calibre.gui2.tweak_book.keyboard import KeyboardManager from calibre.gui2.tweak_book.preview import Preview from calibre.gui2.tweak_book.search import SearchPanel +def elided_text(font, text, width=200, mode=Qt.ElideMiddle): + fm = QFontMetrics(font) + return unicode(fm.elidedText(text, mode, int(width))) + class Central(QStackedWidget): ' The central widget, hosts the editors ' @@ -146,6 +150,9 @@ class Main(MainWindow): self.keyboard.finalize() self.keyboard.set_mode('other') + def elided_text(self, text, width=200, mode=Qt.ElideMiddle): + return elided_text(self.font(), text, width=width, mode=mode) + @property def editor_tabs(self): return self.central.editor_tabs @@ -165,6 +172,7 @@ class Main(MainWindow): self.addAction(ac) return ac + self.action_new_file = reg('document-new.png', _('&New file'), self.boss.add_file, 'new-file', (), _('Create a new file in the current book')) self.action_open_book = reg('document_open.png', _('Open &book'), self.boss.open_book, 'open-book', 'Ctrl+O', _('Open a new book')) self.action_global_undo = reg('back.png', _('&Revert to before'), self.boss.do_global_undo, 'global-undo', 'Ctrl+Left', _('Revert book to before the last action (Undo)')) @@ -245,6 +253,7 @@ class Main(MainWindow): b = self.menuBar() f = b.addMenu(_('&File')) + f.addAction(self.action_new_file) f.addAction(self.action_open_book) f.addAction(self.action_save) f.addAction(self.action_quit) @@ -302,7 +311,7 @@ class Main(MainWindow): return b a = create(_('Book tool bar'), 'global').addAction - for x in ('open_book', 'global_undo', 'global_redo', 'save', 'create_checkpoint', 'toc'): + for x in ('new_file', 'open_book', 'global_undo', 'global_redo', 'save', 'create_checkpoint', 'toc'): a(getattr(self, 'action_' + x)) a = create(_('Polish book tool bar'), 'polish').addAction