#!/usr/bin/env python
# License: GPLv3 Copyright: 2013, Kovid Goyal <kovid at kovidgoyal.net>
import errno
import os
import shutil
import subprocess
import sys
import tempfile
from functools import partial, wraps
from urllib.parse import urlparse
from qt.core import (
QApplication,
QCheckBox,
QDialog,
QDialogButtonBox,
QGridLayout,
QIcon,
QInputDialog,
QLabel,
QMimeData,
QObject,
QSize,
Qt,
QTimer,
QUrl,
QVBoxLayout,
pyqtSignal,
)
from calibre import prints
from calibre.constants import cache_dir, islinux, ismacos, iswindows
from calibre.ebooks.oeb.base import urlnormalize
from calibre.ebooks.oeb.polish.container import OEB_DOCS, OEB_STYLES, clone_container, guess_type
from calibre.ebooks.oeb.polish.container import get_container as _gc
from calibre.ebooks.oeb.polish.cover import mark_as_cover, mark_as_titlepage, set_cover
from calibre.ebooks.oeb.polish.css import filter_css, rename_class
from calibre.ebooks.oeb.polish.main import SUPPORTED, tweak_polish
from calibre.ebooks.oeb.polish.pretty import fix_all_html, pretty_all
from calibre.ebooks.oeb.polish.replace import get_recommended_folders, rationalize_folders, rename_files, replace_file
from calibre.ebooks.oeb.polish.split import AbortError, merge, multisplit, split
from calibre.ebooks.oeb.polish.toc import create_inline_toc, mark_as_nav, remove_names_from_toc
from calibre.ebooks.oeb.polish.utils import link_stylesheets
from calibre.ebooks.oeb.polish.utils import setup_css_parser_serialization as scs
from calibre.gui2 import (
add_to_recent_docs,
choose_dir,
choose_files,
choose_save_file,
error_dialog,
info_dialog,
open_url,
qapplication_or_fail,
question_dialog,
sanitize_env_vars,
warning_dialog,
)
from calibre.gui2.dialogs.confirm_delete import confirm
from calibre.gui2.tweak_book import actions, current_container, dictionaries, editor_name, editors, set_book_locale, set_current_container, tprefs
from calibre.gui2.tweak_book.completion.worker import completion_worker
from calibre.gui2.tweak_book.editor import editor_from_syntax, syntax_from_mime
from calibre.gui2.tweak_book.editor.insert_resource import NewBook, get_resource_data
from calibre.gui2.tweak_book.file_list import FILE_COPY_MIME, NewFileDialog
from calibre.gui2.tweak_book.preferences import Preferences
from calibre.gui2.tweak_book.preview import parse_worker
from calibre.gui2.tweak_book.save import SaveManager, find_first_existing_ancestor, save_container
from calibre.gui2.tweak_book.search import run_search, validate_search_request
from calibre.gui2.tweak_book.spell import find_next as find_next_word
from calibre.gui2.tweak_book.spell import find_next_error
from calibre.gui2.tweak_book.toc import TOCEditor
from calibre.gui2.tweak_book.undo import GlobalUndoHistory
from calibre.gui2.tweak_book.widgets import (
AddCover,
FilterCSS,
ImportForeign,
InsertLink,
InsertSemantics,
InsertTag,
MultiSplit,
QuickOpen,
RationalizeFolders,
)
from calibre.gui2.widgets import BusyCursor
from calibre.ptempfile import PersistentTemporaryDirectory, TemporaryDirectory
from calibre.startup import connect_lambda
from calibre.utils.config import JSONConfig
from calibre.utils.icu import numeric_sort_key
from calibre.utils.imghdr import identify
from calibre.utils.ipc.launch import exe_path, macos_edit_book_bundle_path
from calibre.utils.localization import _, ngettext
from calibre.utils.tdir_in_cache import tdir_in_cache
from polyglot.builtins import as_bytes
_diff_dialogs = []
last_used_transform_rules = []
last_used_html_transform_rules = []
def get_container(*args, **kwargs):
kwargs['tweak_mode'] = True
container = _gc(*args, **kwargs)
return container
def setup_css_parser_serialization():
scs(tprefs['editor_tab_stop_width'])
def in_thread_job(func):
@wraps(func)
def ans(*args, **kwargs):
with BusyCursor():
return func(*args, **kwargs)
return ans
_boss: Boss | None = None
def get_boss() -> Boss:
assert _boss is not None
return _boss
def open_path_in_new_editor_instance(path: str):
import subprocess
from calibre.gui2 import sanitize_env_vars
with sanitize_env_vars():
if ismacos:
from calibre.utils.ipc.launch import macos_edit_book_bundle_path
bundle = os.path.dirname(os.path.dirname(macos_edit_book_bundle_path().rstrip('/')))
subprocess.Popen(['open', '-n', '-a', bundle, path])
else:
subprocess.Popen([sys.executable, path])
[Doku]
class Boss(QObject):
handle_completion_result_signal = pyqtSignal(object)
def __init__(self, parent, notify=None):
QObject.__init__(self, parent)
self.global_undo = GlobalUndoHistory()
self.file_was_readonly = False
self.container_count = 0
self.tdir = None
self.save_manager = SaveManager(parent, notify)
self.save_manager.report_error.connect(self.report_save_error)
self.save_manager.check_for_completion.connect(self.check_terminal_save)
self.doing_terminal_save = False
self.ignore_preview_to_editor_sync = False
setup_css_parser_serialization()
global _boss
_boss = self
self.gui = parent
completion_worker().result_callback = self.handle_completion_result_signal.emit
self.handle_completion_result_signal.connect(self.handle_completion_result, Qt.ConnectionType.QueuedConnection)
self.completion_request_count = 0
self.editor_cache = JSONConfig('editor-cache', base_path=cache_dir())
d = self.editor_cache.defaults
d['edit_book_state'] = {}
d['edit_book_state_order'] = []
def __call__(self, gui):
self.gui = gui
gui.message_popup.undo_requested.connect(self.do_global_undo)
fl = gui.file_list
fl.delete_requested.connect(self.delete_requested)
fl.reorder_spine.connect(self.reorder_spine)
fl.rename_requested.connect(self.rename_requested)
fl.bulk_rename_requested.connect(self.bulk_rename_requested)
fl.edit_file.connect(self.edit_file_requested)
fl.merge_requested.connect(self.merge_requested)
fl.mark_requested.connect(self.mark_requested)
fl.export_requested.connect(self.export_requested)
fl.replace_requested.connect(self.replace_requested)
fl.link_stylesheets_requested.connect(self.link_stylesheets_requested)
fl.initiate_file_copy.connect(self.copy_files_to_clipboard)
fl.initiate_file_paste.connect(self.paste_files_from_clipboard)
fl.open_file_with.connect(self.open_file_with)
self.gui.central.current_editor_changed.connect(self.apply_current_editor_state)
self.gui.central.close_requested.connect(self.editor_close_requested)
self.gui.central.search_panel.search_triggered.connect(self.search)
self.gui.text_search.find_text.connect(self.find_text)
self.gui.preview.sync_requested.connect(self.sync_editor_to_preview)
self.gui.preview.split_start_requested.connect(self.split_start_requested)
self.gui.preview.split_requested.connect(self.split_requested)
self.gui.preview.link_clicked.connect(self.link_clicked)
self.gui.preview.open_file_with.connect(self.open_file_with)
self.gui.preview.edit_file.connect(self.edit_file_requested)
self.gui.check_book.item_activated.connect(self.check_item_activated)
self.gui.check_book.check_requested.connect(self.check_requested)
self.gui.check_book.fix_requested.connect(self.fix_requested)
self.gui.toc_view.navigate_requested.connect(self.link_clicked)
self.gui.toc_view.refresh_requested.connect(self.commit_all_editors_to_container)
self.gui.image_browser.image_activated.connect(self.image_activated)
self.gui.checkpoints.revert_requested.connect(self.revert_requested)
self.gui.checkpoints.compare_requested.connect(self.compare_requested)
self.gui.saved_searches.run_saved_searches.connect(self.run_saved_searches)
self.gui.saved_searches.copy_search_to_search_panel.connect(self.gui.central.search_panel.paste_saved_search)
self.gui.central.search_panel.save_search.connect(self.save_search)
self.gui.central.search_panel.show_saved_searches.connect(self.show_saved_searches)
self.gui.spell_check.find_word.connect(self.find_word)
self.gui.spell_check.refresh_requested.connect(self.commit_all_editors_to_container)
self.gui.spell_check.word_replaced.connect(self.word_replaced)
self.gui.spell_check.word_ignored.connect(self.word_ignored)
self.gui.spell_check.change_requested.connect(self.word_change_requested)
self.gui.live_css.goto_declaration.connect(self.goto_style_declaration)
self.gui.manage_fonts.container_changed.connect(self.apply_container_update_to_gui)
self.gui.manage_fonts.embed_all_fonts.connect(self.manage_fonts_embed)
self.gui.manage_fonts.subset_all_fonts.connect(self.manage_fonts_subset)
self.gui.reports.edit_requested.connect(self.reports_edit_requested)
self.gui.reports.refresh_starting.connect(self.commit_all_editors_to_container)
self.gui.reports.delete_requested.connect(self.delete_requested)
@property
def currently_editing(self):
"Return the name of the file being edited currently or None if no file is being edited"
return editor_name(self.gui.central.current_editor)
def preferences(self):
orig_spell = tprefs['inline_spell_check']
orig_size = tprefs['toolbar_icon_size']
p = Preferences(self.gui)
ret = p.exec()
if p.dictionaries_changed:
dictionaries.clear_caches()
dictionaries.initialize(force=True) # Reread user dictionaries
if p.toolbars_changed:
self.gui.populate_toolbars()
for ed in editors.values():
if hasattr(ed, 'populate_toolbars'):
ed.populate_toolbars()
if orig_size != tprefs['toolbar_icon_size']:
for ed in editors.values():
if hasattr(ed, 'bars'):
for bar in ed.bars:
bar.setIconSize(QSize(tprefs['toolbar_icon_size'], tprefs['toolbar_icon_size']))
if ret == QDialog.DialogCode.Accepted:
setup_css_parser_serialization()
self.gui.apply_settings()
self.refresh_file_list()
self.gui.preview.start_refresh_timer()
if ret == QDialog.DialogCode.Accepted or p.dictionaries_changed:
for ed in editors.values():
ed.apply_settings(dictionaries_changed=p.dictionaries_changed)
if orig_spell != tprefs['inline_spell_check']:
from calibre.gui2.tweak_book.editor.syntax.html import refresh_spell_check_status
refresh_spell_check_status()
for ed in editors.values():
try:
ed.editor.highlighter.rehighlight()
except AttributeError:
pass
def mark_requested(self, name, action):
self.commit_dirty_opf()
c = current_container()
if action == 'cover':
mark_as_cover(current_container(), name)
elif action.startswith('titlepage:'):
action, move_to_start = action.partition(':')[0::2]
move_to_start = move_to_start == 'True'
mark_as_titlepage(current_container(), name, move_to_start=move_to_start)
elif action == 'nav':
mark_as_nav(current_container(), name)
if c.opf_name in editors:
editors[c.opf_name].replace_data(c.raw_data(c.opf_name))
self.gui.file_list.build(c)
self.set_modified()
def mkdtemp(self, prefix=''):
self.container_count += 1
return tempfile.mkdtemp(prefix=f'{prefix}{self.container_count:05}-', dir=self.tdir)
def _check_before_open(self):
if self.gui.action_save.isEnabled():
if not question_dialog(
self.gui,
_('Unsaved changes'),
_('The current book has unsaved changes. If you open a new book, they will be lost. Are you sure you want to proceed?'),
):
return
if self.save_manager.has_tasks:
return info_dialog(
self.gui,
_('Cannot open'),
_('The current book is being saved, you cannot open a new book until the saving is completed'),
show=True,
)
return True
def new_book(self):
if not self._check_before_open():
return
d = NewBook(self.gui)
if d.exec() == QDialog.DialogCode.Accepted:
fmt = d.fmt.lower()
path = choose_save_file(
self.gui,
'edit-book-new-book',
_('Choose file location'),
filters=[(fmt.upper(), (fmt,))],
all_files=False,
)
if path is not None:
if not path.lower().endswith('.' + fmt):
path = path + '.' + fmt
from calibre.ebooks.oeb.polish.create import create_book
create_book(d.mi, path, fmt=fmt)
self.open_book(path=path)
def import_book(self, path=None):
if not self._check_before_open():
return
d = ImportForeign(self.gui)
if hasattr(path, 'rstrip'):
d.set_src(os.path.abspath(path))
if d.exec() == QDialog.DialogCode.Accepted:
for name in tuple(editors):
self.close_editor(name)
from calibre.ebooks.oeb.polish.import_book import import_book_as_epub
src, dest = d.data
self._clear_notify_data = True
def func(src, dest, tdir):
import_book_as_epub(src, dest)
return get_container(dest, tdir=tdir)
self.gui.blocking_job(
'import_book',
_('Importing book, please wait...'),
self.book_opened,
func,
src,
dest,
tdir=self.mkdtemp(),
)
[Doku]
def open_book(self, path=None, edit_file=None, clear_notify_data=True, open_folder=False, search_text=None):
"""
Open the e-book at ``path`` for editing. Will show an error if the e-book is not in a supported format or the current book has unsaved changes.
:param edit_file: The name of a file inside the newly opened book to start editing. Can also be a list of names.
"""
if isinstance(path, (list, tuple)) and path:
# Can happen from an file_event_hook on OS X when drag and dropping
# onto the icon in the dock or using open -a
extra_paths = path[1:]
path = path[0]
for x in extra_paths:
open_path_in_new_editor_instance(x)
if not self._check_before_open():
return
if not hasattr(path, 'rpartition'):
if open_folder:
path = choose_dir(self.gui, 'open-book-folder-for-tweaking', _('Choose book folder'))
if path:
path = [path]
else:
path = choose_files(
self.gui,
'open-book-for-tweaking',
_('Choose book'),
[(_('Books'), [x.lower() for x in SUPPORTED])],
all_files=False,
select_only_single_file=True,
)
if not path:
return
path = path[0]
if not os.path.exists(path):
return error_dialog(self.gui, _('File not found'), _('The file %s does not exist.') % path, show=True)
isdir = os.path.isdir(path)
ext = path.rpartition('.')[-1].upper()
if ext not in SUPPORTED and not isdir:
from calibre.ebooks.oeb.polish.import_book import IMPORTABLE
if ext.lower() in IMPORTABLE:
return self.import_book(path)
return error_dialog(
self.gui,
_('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,
)
self.file_was_readonly = not os.access(path, os.W_OK)
if self.file_was_readonly:
warning_dialog(
self.gui,
_('Read-only file'),
_('The file {} is read-only. Saving changes to it will either fail or cause its permissions to be reset.').format(path),
show=True,
)
with self.editor_cache:
self.save_book_edit_state()
for name in tuple(editors):
self.close_editor(name)
self.gui.preview.clear()
self.gui.live_css.clear()
self.container_count = -1
if self.tdir:
shutil.rmtree(self.tdir, ignore_errors=True)
# We use the cache dir rather than the temporary dir to try and prevent
# temp file cleaners from nuking ebooks. See https://bugs.launchpad.net/bugs/1740460
self.tdir = tdir_in_cache('ee')
self._edit_file_on_open = edit_file
self._search_text_on_open = search_text
self._clear_notify_data = clear_notify_data
self.gui.blocking_job('open_book', _('Opening book, please wait...'), self.book_opened, get_container, path, tdir=self.mkdtemp())
def book_opened(self, job):
ef = getattr(self, '_edit_file_on_open', None)
cn = getattr(self, '_clear_notify_data', True)
st = getattr(self, '_search_text_on_open', None)
self._edit_file_on_open = self._search_text_on_open = None
if job.traceback is not None:
self.gui.update_status_bar_default_message()
if 'DRMError:' in job.traceback:
from calibre.gui2.dialogs.drm_error import DRMErrorMessage
return DRMErrorMessage(self.gui).exec()
if 'ObfuscationKeyMissing:' in job.traceback:
return error_dialog(
self.gui,
_('Failed to open book'),
_(
'Failed to open book, it has obfuscated fonts, but the obfuscation key is missing from the OPF.'
' Do an EPUB to EPUB conversion before trying to edit this book.'
),
show=True,
)
return error_dialog(
self.gui,
_('Failed to open book'),
_('Failed to open book, click "Show details" for more information.'),
det_msg=job.traceback,
show=True,
)
if cn:
self.save_manager.clear_notify_data()
self.gui.check_book.clear_at_startup()
self.gui.spell_check.clear_caches()
dictionaries.clear_ignored(), dictionaries.clear_caches()
parse_worker.clear()
container = job.result
set_current_container(container)
completion_worker().clear_caches()
with BusyCursor():
self.current_metadata = self.gui.current_metadata = container.mi
lang = container.opf_xpath('//dc:language/text()') or [self.current_metadata.language]
set_book_locale(lang[0])
self.global_undo.open_book(container)
self.gui.update_window_title()
self.gui.file_list.current_edited_name = None
self.gui.file_list.build(container, preserve_state=False)
self.gui.action_save.setEnabled(False)
self.update_global_history_actions()
recent_books = list(tprefs.get('recent-books', []))
path = os.path.abspath(container.path_to_ebook)
if path in recent_books:
recent_books.remove(path)
self.gui.update_status_bar_default_message(path)
recent_books.insert(0, path)
tprefs['recent-books'] = recent_books[:10]
self.gui.update_recent_books()
if iswindows:
try:
add_to_recent_docs(path)
except Exception:
import traceback
traceback.print_exc()
if ef:
if isinstance(ef, str):
ef = [ef]
for i in ef:
self.gui.file_list.request_edit(i)
elif tprefs['restore_book_state']:
self.restore_book_edit_state()
self.gui.toc_view.update_if_visible()
self.add_savepoint(_('Start of editing session'))
if st:
self.find_initial_text(st)
def update_editors_from_container(self, container=None, names=None):
c = container or current_container()
for name, ed in tuple(editors.items()):
if c.has_name(name):
if names is None or name in names:
ed.replace_data(c.raw_data(name))
ed.is_synced_to_container = True
else:
self.close_editor(name)
def refresh_file_list(self):
container = current_container()
self.gui.file_list.build(container)
completion_worker().clear_caches('names')
[Doku]
def apply_container_update_to_gui(self, mark_as_modified=True):
"""
Update all the components of the user interface to reflect the latest data in the current book container.
:param mark_as_modified: If True, the book will be marked as modified, so the user will be prompted to save it
when quitting.
"""
self.refresh_file_list()
self.update_global_history_actions()
self.update_editors_from_container()
if mark_as_modified:
self.set_modified()
self.gui.toc_view.update_if_visible()
completion_worker().clear_caches()
self.gui.preview.start_refresh_timer()
@in_thread_job
def delete_requested(self, spine_items, other_items):
self.add_savepoint(_('Before: Delete files'))
self.commit_dirty_opf()
c = current_container()
c.remove_from_spine(spine_items)
for name in other_items:
c.remove_item(name)
self.set_modified()
self.gui.file_list.delete_done(spine_items, other_items)
spine_names = [x for x, remove in spine_items if remove]
completion_worker().clear_caches('names')
items = spine_names + list(other_items)
for name in items:
if name in editors:
self.close_editor(name)
if not editors:
self.gui.preview.clear()
self.gui.live_css.clear()
changed = remove_names_from_toc(current_container(), spine_names + list(other_items))
if changed:
self.gui.toc_view.update_if_visible()
for toc in changed:
if toc and toc in editors:
editors[toc].replace_data(c.raw_data(toc))
if c.opf_name in editors:
editors[c.opf_name].replace_data(c.raw_data(c.opf_name))
self.gui.message_popup(ngettext('One file deleted', '{} files deleted', len(items)).format(len(items)))
def commit_dirty_opf(self):
c = current_container()
if c.opf_name in editors and not editors[c.opf_name].is_synced_to_container:
self.commit_editor_to_container(c.opf_name)
self.gui.update_window_title()
def reorder_spine(self, items):
if not self.ensure_book():
return
self.add_savepoint(_('Before: Re-order text'))
c = current_container()
c.set_spine(items)
self.set_modified()
self.gui.file_list.build(current_container()) # needed as the linear flag may have changed on some items
if c.opf_name in editors:
editors[c.opf_name].replace_data(c.raw_data(c.opf_name))
completion_worker().clear_caches('names')
def add_file(self):
if not self.ensure_book(_('You must first open a book to edit, before trying to create new files in it.')):
return
self.commit_dirty_opf()
d = NewFileDialog(self.gui)
if d.exec() != QDialog.DialogCode.Accepted:
return
added_name = self.do_add_file(d.file_name, d.file_data, using_template=d.using_template, edit_file=True)
if d.file_name.rpartition('.')[2].lower() in ('ttf', 'otf', 'woff'):
from calibre.gui2.tweak_book.manage_fonts import show_font_face_rule_for_font_file
show_font_face_rule_for_font_file(d.file_data, added_name, self.gui)
def do_add_file(self, file_name, data, using_template=False, edit_file=False):
self.add_savepoint(_('Before: Add file %s') % self.gui.elided_text(file_name))
c = current_container()
adata = data.replace(b'%CURSOR%', b'') if using_template else data
spine_index = c.index_in_spine(self.currently_editing or '')
if spine_index is not None:
spine_index += 1
try:
added_name = c.add_file(file_name, adata, spine_index=spine_index)
except Exception:
self.rewind_savepoint()
raise
self.gui.file_list.build(c)
self.gui.file_list.select_name(file_name)
if c.opf_name in editors:
editors[c.opf_name].replace_data(c.raw_data(c.opf_name))
mt = c.mime_map[file_name]
syntax = syntax_from_mime(file_name, mt)
if syntax and edit_file:
if using_template:
self.edit_file(file_name, syntax, use_template=data.decode('utf-8'))
else:
self.edit_file(file_name, syntax)
self.set_modified()
completion_worker().clear_caches('names')
return added_name
def add_files(self):
if not self.ensure_book(_('You must first open a book to edit, before trying to create new files in it.')):
return
files = choose_files(self.gui, 'tweak-book-bulk-import-files', _('Choose files'))
if files:
folder_map = get_recommended_folders(current_container(), files)
files = {x: ('/'.join((folder, os.path.basename(x))) if folder else os.path.basename(x)) for x, folder in folder_map.items()}
self.add_savepoint(_('Before: Add files'))
c = current_container()
added_fonts = set()
for path in sorted(files, key=numeric_sort_key):
name = files[path]
i = 0
while c.exists(name) or c.manifest_has_name(name) or c.has_name_case_insensitive(name):
i += 1
name, ext = name.rpartition('.')[0::2]
name = f'{name}_{i}.{ext}'
try:
with open(path, 'rb') as f:
c.add_file(name, f.read())
except Exception:
self.rewind_savepoint()
raise
if name.rpartition('.')[2].lower() in ('ttf', 'otf', 'woff'):
added_fonts.add(name)
self.gui.file_list.build(c)
if c.opf_name in editors:
editors[c.opf_name].replace_data(c.raw_data(c.opf_name))
self.set_modified()
completion_worker().clear_caches('names')
if added_fonts:
from calibre.gui2.tweak_book.manage_fonts import show_font_face_rule_for_font_files
show_font_face_rule_for_font_files(c, added_fonts, self.gui)
def add_cover(self):
if not self.ensure_book():
return
d = AddCover(current_container(), self.gui)
d.import_requested.connect(self.do_add_file)
try:
if d.exec() == QDialog.DialogCode.Accepted and d.file_name is not None:
report = []
with BusyCursor():
self.add_savepoint(_('Before: Add cover'))
set_cover(
current_container(),
d.file_name,
report.append,
options={'existing_image': True, 'keep_aspect': tprefs['add_cover_preserve_aspect_ratio']},
)
self.apply_container_update_to_gui()
finally:
d.import_requested.disconnect()
def ensure_book(self, msg=None):
msg = msg or _('No book is currently open. You must first open a book.')
if current_container() is None:
error_dialog(self.gui, _('No book open'), msg, show=True)
return False
return True
def edit_toc(self):
if not self.ensure_book(_('You must open a book before trying to edit the Table of Contents.')):
return
self.add_savepoint(_('Before: Edit Table of Contents'))
self.__current_toc_editor = d = TOCEditor(title=self.current_metadata.title, parent=self.gui)
d.finished.connect(self.toc_edit_finished)
# Using d.exec() causes showing the webview to hide the dialog
d.open()
def toc_edit_finished(self, retcode: int):
self.__current_toc_editor = None
if retcode != QDialog.DialogCode.Accepted:
self.rewind_savepoint()
return
with BusyCursor():
self.set_modified()
self.update_editors_from_container()
self.gui.toc_view.update_if_visible()
self.gui.file_list.build(current_container())
def insert_inline_toc(self):
if not self.ensure_book():
return
self.commit_all_editors_to_container()
self.add_savepoint(_('Before: Insert inline Table of Contents'))
name = create_inline_toc(current_container())
if name is None:
self.rewind_savepoint()
return error_dialog(
self.gui,
_('No Table of Contents'),
_(
'Cannot create an inline Table of Contents as this book has no existing'
' Table of Contents. You must first create a Table of Contents using the'
' Edit Table of Contents tool.'
),
show=True,
)
self.apply_container_update_to_gui()
self.edit_file(name, 'html')
def polish(self, action, name, parent=None):
if not self.ensure_book():
return
from calibre.gui2.tweak_book.polish import get_customization, show_report
customization = get_customization(action, name, parent or self.gui)
if customization is None:
return
with BusyCursor():
self.add_savepoint(_('Before: %s') % name)
try:
report, changed = tweak_polish(current_container(), {action: True}, customization=customization)
except Exception:
self.rewind_savepoint()
raise
if changed:
self.apply_container_update_to_gui()
self.gui.update_window_title()
if not changed:
self.rewind_savepoint()
show_report(changed, self.current_metadata.title, report, parent or self.gui, self.show_current_diff)
def transform_html(self):
global last_used_html_transform_rules
if not self.ensure_book(_('You must first open a book in order to transform styles.')):
return
from calibre.ebooks.html_transform_rules import transform_container
from calibre.gui2.html_transform_rules import RulesDialog
d = RulesDialog(self.gui)
d.rules = last_used_html_transform_rules
d.transform_scope = tprefs['html_transform_scope']
ret = d.exec()
last_used_html_transform_rules = d.rules
scope = d.transform_scope
tprefs.set('html_transform_scope', scope)
if ret != QDialog.DialogCode.Accepted:
return
mime_map = current_container().mime_map
names = ()
if scope == 'current':
if not self.currently_editing or mime_map.get(self.currently_editing) not in OEB_DOCS:
return error_dialog(self.gui, _('No HTML file'), _('Not currently editing an HTML file'), show=True)
names = (self.currently_editing,)
elif scope == 'open':
names = tuple(name for name in editors if mime_map.get(name) in OEB_DOCS)
if not names:
return error_dialog(self.gui, _('No HTML files'), _('Not currently editing any HTML files'), show=True)
elif scope == 'selected':
names = tuple(name for name in self.gui.file_list.file_list.selected_names if mime_map.get(name) in OEB_DOCS)
if not names:
return error_dialog(
self.gui,
_('No HTML files'),
_('No HTML files are currently selected in the File browser'),
show=True,
)
with BusyCursor():
self.add_savepoint(_('Before HTML transformation'))
try:
changed = transform_container(current_container(), last_used_html_transform_rules, names)
except Exception:
self.rewind_savepoint()
raise
if changed:
self.apply_container_update_to_gui()
if not changed:
self.rewind_savepoint()
return info_dialog(self.gui, _('No changes'), _('No HTML was changed.'), show=True)
self.show_current_diff()
def transform_styles(self):
global last_used_transform_rules
if not self.ensure_book(_('You must first open a book in order to transform styles.')):
return
from calibre.ebooks.css_transform_rules import transform_container
from calibre.gui2.css_transform_rules import RulesDialog
d = RulesDialog(self.gui)
d.rules = last_used_transform_rules
ret = d.exec()
last_used_transform_rules = d.rules
if ret != QDialog.DialogCode.Accepted:
return
with BusyCursor():
self.add_savepoint(_('Before style transformation'))
try:
changed = transform_container(current_container(), last_used_transform_rules)
except Exception:
self.rewind_savepoint()
raise
if changed:
self.apply_container_update_to_gui()
if not changed:
self.rewind_savepoint()
info_dialog(self.gui, _('No changes'), _('No styles were changed.'), show=True)
return
self.show_current_diff()
def get_external_resources(self):
if not self.ensure_book(_('You must first open a book in order to transform styles.')):
return
from calibre.gui2.tweak_book.download import DownloadResources
with BusyCursor():
self.add_savepoint(_('Before: Get external resources'))
try:
d = DownloadResources(self.gui)
d.exec()
except Exception:
self.rewind_savepoint()
raise
if d.resources_replaced:
self.apply_container_update_to_gui()
if d.show_diff:
self.show_current_diff()
else:
self.rewind_savepoint()
def manage_fonts(self):
if not self.ensure_book(_('No book is currently open. You must first open a book to manage fonts.')):
return
self.commit_all_editors_to_container()
self.gui.manage_fonts.display()
def manage_fonts_embed(self):
self.polish('embed', _('Embed all fonts'), parent=self.gui.manage_fonts)
self.gui.manage_fonts.refresh()
def manage_fonts_subset(self):
self.polish('subset', _('Subset all fonts'), parent=self.gui.manage_fonts)
# Renaming {{{
def rationalize_folders(self):
if not self.ensure_book():
return
c = current_container()
if not c.SUPPORTS_FILENAMES:
return error_dialog(
self.gui,
_('Not supported'),
_('The %s format does not support file and folder names internally, therefore arranging files into folders is not allowed.')
% c.book_type.upper(),
show=True,
)
d = RationalizeFolders(self.gui)
if d.exec() != QDialog.DialogCode.Accepted:
return
self.commit_all_editors_to_container()
name_map = rationalize_folders(c, d.folder_map)
if not name_map:
confirm(
_('The files in this book are already arranged into folders'),
'already-arranged-into-folders',
self.gui,
pixmap='dialog_information.png',
title=_('Nothing to do'),
show_cancel_button=False,
config_set=tprefs,
confirm_msg=_('Show this message &again'),
)
return
self.add_savepoint(_('Before: Arrange into folders'))
self.gui.blocking_job(
'rationalize_folders',
_('Renaming and updating links...'),
partial(self.rename_done, name_map),
rename_files,
current_container(),
name_map,
)
def rename_requested(self, oldname, newname):
self.commit_all_editors_to_container()
if guess_type(oldname) != guess_type(newname):
args = os.path.splitext(oldname) + os.path.splitext(newname)
if not confirm(
_('You are changing the file type of {0}<b>{1}</b> to {2}<b>{3}</b>. Doing so can cause problems, are you sure?').format(*args),
'confirm-filetype-change',
parent=self.gui,
title=_('Are you sure?'),
config_set=tprefs,
):
return
if urlnormalize(newname) != newname:
if not confirm(
_(
'The name you have chosen {0} contains special characters, internally'
' it will look like: {1}Try to use only the English alphabet [a-z], numbers [0-9],'
' hyphens and underscores for file names. Other characters can cause problems for '
' different e-book viewers. Are you sure you want to proceed?'
).format(f'<pre>{newname}</pre>', f'<pre>{urlnormalize(newname)}</pre>'),
'confirm-urlunsafe-change',
parent=self.gui,
title=_('Are you sure?'),
config_set=tprefs,
):
return
self.add_savepoint(_('Before: Rename %s') % oldname)
name_map = {oldname: newname}
self.gui.blocking_job(
'rename_file',
_('Renaming and updating links...'),
partial(self.rename_done, name_map, from_filelist=self.gui.file_list.current_name),
rename_files,
current_container(),
name_map,
)
def bulk_rename_requested(self, name_map):
self.add_savepoint(_('Before: Bulk rename'))
self.gui.blocking_job(
'bulk_rename_files',
_('Renaming and updating links...'),
partial(self.rename_done, name_map, from_filelist=self.gui.file_list.current_name),
rename_files,
current_container(),
name_map,
)
def rename_done(self, name_map, job, from_filelist=None):
if job.traceback is not None:
self.gui.file_list.restore_temp_names()
return error_dialog(
self.gui,
_('Failed to rename files'),
_('Failed to rename files, click "Show details" for more information.'),
det_msg=job.traceback,
show=True,
)
self.gui.file_list.build(current_container())
self.set_modified()
for oldname, newname in name_map.items():
if oldname in editors:
editors[newname] = ed = editors.pop(oldname)
ed.change_document_name(newname)
self.gui.central.rename_editor(editors[newname], newname)
if self.gui.preview.current_name == oldname:
self.gui.preview.current_name = newname
self.apply_container_update_to_gui()
if from_filelist:
self.gui.file_list.select_names(frozenset(name_map.values()), current_name=name_map.get(from_filelist))
self.gui.file_list.file_list.setFocus(Qt.FocusReason.PopupFocusReason)
# }}}
# Global history {{{
def do_global_undo(self, *a):
container = self.global_undo.undo()
if container is not None:
set_current_container(container)
self.apply_container_update_to_gui()
def do_global_redo(self):
container = self.global_undo.redo()
if container is not None:
set_current_container(container)
self.apply_container_update_to_gui()
def update_global_history_actions(self):
gu = self.global_undo
for x, text in (('undo', _('&Revert to')), ('redo', _('&Revert to'))):
ac = getattr(self.gui, f'action_global_{x}')
ac.setEnabled(getattr(gu, 'can_' + x))
ac.setText(text + ' "%s"' % (getattr(gu, x + '_msg') or '...'))
[Doku]
def add_savepoint(self, msg):
"Create a restore checkpoint with the name specified as ``msg``"
self.commit_all_editors_to_container()
nc = clone_container(current_container(), self.mkdtemp())
self.global_undo.add_savepoint(nc, msg)
set_current_container(nc)
self.update_global_history_actions()
[Doku]
def rewind_savepoint(self):
"Undo the previous creation of a restore checkpoint, useful if you create a checkpoint, then abort the operation with no changes"
container = self.global_undo.rewind_savepoint()
if container is not None:
set_current_container(container)
self.update_global_history_actions()
def create_diff_dialog(self, revert_msg=_('&Revert changes'), show_open_in_editor=True):
global _diff_dialogs
from calibre.gui2.tweak_book.diff.main import Diff
def line_activated(name, lnum, right):
if right:
self.edit_file_requested(name, None, guess_type(name))
if name in editors:
editor = editors[name]
editor.go_to_line(lnum)
editor.setFocus(Qt.FocusReason.OtherFocusReason)
self.gui.raise_and_focus()
d = Diff(revert_button_msg=revert_msg, show_open_in_editor=show_open_in_editor)
[x.break_cycles() for x in _diff_dialogs if not x.isVisible()]
_diff_dialogs = [x for x in _diff_dialogs if x.isVisible()] + [d]
(
d.show(),
d.raise_and_focus(),
d.setFocus(Qt.FocusReason.OtherFocusReason),
d.setWindowModality(Qt.WindowModality.NonModal),
)
if show_open_in_editor:
d.line_activated.connect(line_activated)
return d
[Doku]
def show_current_diff(self, allow_revert=True, to_container=None):
"""
Show the changes to the book from its last checkpointed state
:param allow_revert: If True the diff dialog will have a button to allow the user to revert all changes
:param to_container: A container object to compare the current container to. If None, the previously checkpointed container is used
"""
self.commit_all_editors_to_container()
k = {} if allow_revert else {'revert_msg': None}
d = self.create_diff_dialog(**k)
previous_container = self.global_undo.previous_container
connect_lambda(d.revert_requested, self, lambda self: self.revert_requested(previous_container))
other = to_container or self.global_undo.previous_container
d.container_diff(
other,
self.global_undo.current_container,
names=(
self.global_undo.label_for_container(other),
self.global_undo.label_for_container(self.global_undo.current_container),
),
)
def ask_to_show_current_diff(self, name, title, msg, allow_revert=True, to_container=None):
if tprefs.get('skip_ask_to_show_current_diff_for_' + name):
return
d = QDialog(self.gui)
k = QVBoxLayout(d)
d.setWindowTitle(title)
k.addWidget(QLabel(msg))
cb = QCheckBox(_('Show this popup again'))
k.addWidget(cb)
cb.setChecked(True)
connect_lambda(cb.toggled, d, lambda d, checked: tprefs.set('skip_ask_to_show_current_diff_for_' + name, not checked))
bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, d)
k.addWidget(bb)
bb.accepted.connect(d.accept)
bb.rejected.connect(d.reject)
b = bb.addButton(_('See what &changed'), QDialogButtonBox.ButtonRole.AcceptRole)
assert b is not None
b.setIcon(QIcon.ic('diff.png')), b.setAutoDefault(False)
close_btn = bb.button(QDialogButtonBox.StandardButton.Close)
assert close_btn is not None
close_btn.setDefault(True)
if d.exec() == QDialog.DialogCode.Accepted:
self.show_current_diff(allow_revert=allow_revert, to_container=to_container)
def compare_book(self):
if not self.ensure_book():
return
self.commit_all_editors_to_container()
c = current_container()
path = choose_files(
self.gui,
'select-book-for-comparison',
_('Choose book'),
filters=[(_('%s books') % c.book_type.upper(), (c.book_type,))],
select_only_single_file=True,
all_files=False,
)
if path and path[0]:
with TemporaryDirectory('_compare') as tdir:
other = _gc(path[0], tdir=tdir, tweak_mode=True)
d = self.create_diff_dialog(revert_msg=None)
d.container_diff(other, c, names=(_('Other book'), _('Current book')))
def revert_requested(self, container):
self.commit_all_editors_to_container()
nc = self.global_undo.revert_to(container)
set_current_container(nc)
self.apply_container_update_to_gui()
def compare_requested(self, container):
self.show_current_diff(to_container=container)
# }}}
[Doku]
def set_modified(self):
"Mark the book as having been modified"
self.gui.action_save.setEnabled(True)
def request_completion(self, name, completion_type, completion_data, query=None):
if completion_type is None:
completion_worker().clear_caches(completion_data)
return
request_id = (self.completion_request_count, name)
self.completion_request_count += 1
completion_worker().queue_completion(request_id, completion_type, completion_data, query)
return request_id[0]
def handle_completion_result(self, result):
name = result.request_id[1]
editor = editors.get(name)
if editor is not None:
editor.handle_completion_result(result)
def fix_html(self, current):
if current:
ed = self.gui.central.current_editor
if hasattr(ed, 'fix_html'):
ed.fix_html()
else:
with BusyCursor():
self.add_savepoint(_('Before: Fix HTML'))
fix_all_html(current_container())
self.update_editors_from_container()
self.set_modified()
self.ask_to_show_current_diff('html-fix', _('Fixing done'), _('All HTML files fixed'))
def pretty_print(self, current):
if current:
ed = self.gui.central.current_editor
ed.pretty_print(editor_name(ed))
else:
with BusyCursor():
self.add_savepoint(_('Before: Beautify files'))
pretty_all(current_container())
self.update_editors_from_container()
self.set_modified()
QApplication.alert(self.gui)
self.ask_to_show_current_diff('beautify', _('Beautified'), _('All files beautified'))
def mark_selected_text(self):
ed = self.gui.central.current_editor
if ed is not None:
ed.mark_selected_text()
if ed.has_marked_text:
self.gui.central.search_panel.set_where('selected-text')
else:
self.gui.central.search_panel.unset_marked()
def editor_action(self, action):
ed = self.gui.central.current_editor
edname = editor_name(ed)
if hasattr(ed, 'action_triggered'):
if action and action[0] == 'insert_resource':
rtype = action[1]
if rtype == 'image' and ed.syntax not in {'css', 'html'}:
return error_dialog(
self.gui,
_('Not supported'),
_('Inserting images is only supported for HTML and CSS files.'),
show=True,
)
rdata = get_resource_data(rtype, self.gui)
if rdata is None:
return
if rtype == 'image':
chosen_name, chosen_image_is_external, fullpage, preserve_ar = rdata
if chosen_image_is_external:
with open(chosen_image_is_external[1], 'rb') as f:
current_container().add_file(chosen_image_is_external[0], f.read())
self.refresh_file_list()
chosen_name = chosen_image_is_external[0]
href = current_container().name_to_href(chosen_name, edname)
fmt, width, height = identify(current_container().raw_data(chosen_name, decode=False))
ed.insert_image(href, fullpage=fullpage, preserve_aspect_ratio=preserve_ar, width=width, height=height)
elif action[0] == 'insert_hyperlink':
self.commit_all_editors_to_container()
d = InsertLink(current_container(), edname, initial_text=ed.get_smart_selection(), parent=self.gui)
if d.exec() == QDialog.DialogCode.Accepted:
ed.insert_hyperlink(d.href, d.text, template=d.rendered_template)
elif action[0] == 'insert_tag':
d = InsertTag(parent=self.gui)
if d.exec() == QDialog.DialogCode.Accepted:
ed.insert_tag(d.tag)
else:
ed.action_triggered(action)
def rename_class(self, class_name):
self.commit_all_editors_to_container()
text, ok = QInputDialog.getText(self.gui, _('New class name'), _('Rename the class {} to?').format(class_name))
if ok:
self.add_savepoint(_('Before: Rename {}').format(class_name))
with BusyCursor():
changed = rename_class(current_container(), class_name, text.strip())
if changed:
self.apply_container_update_to_gui()
self.show_current_diff()
else:
self.rewind_savepoint()
return info_dialog(self.gui, _('No matches'), _('No class {} found to change').format(class_name), show=True)
def set_semantics(self):
if not self.ensure_book():
return
self.commit_all_editors_to_container()
c = current_container()
if c.book_type == 'azw3':
return error_dialog(self.gui, _('Not supported'), _('Semantics are not supported for the AZW3 format.'), show=True)
d = InsertSemantics(c, parent=self.gui)
if d.exec() == QDialog.DialogCode.Accepted and d.changes:
self.add_savepoint(_('Before: Set Semantics'))
d.apply_changes(current_container())
self.apply_container_update_to_gui()
def filter_css(self):
self.commit_all_editors_to_container()
c = current_container()
ed = self.gui.central.current_editor
current_name = editor_name(ed)
if current_name and c.mime_map[current_name] not in OEB_DOCS | OEB_STYLES:
current_name = None
d = FilterCSS(current_name=current_name, parent=self.gui)
if d.exec() == QDialog.DialogCode.Accepted and d.filtered_properties:
self.add_savepoint(_('Before: Filter style information'))
with BusyCursor():
changed = filter_css(current_container(), d.filtered_properties, names=d.filter_names)
if changed:
self.apply_container_update_to_gui()
self.show_current_diff()
else:
self.rewind_savepoint()
return info_dialog(self.gui, _('No matches'), _('No matching style rules were found'), show=True)
def show_find(self):
self.gui.central.show_find()
ed = self.gui.central.current_editor
if ed is not None and hasattr(ed, 'selected_text'):
text = ed.selected_text
if text and text.strip():
self.gui.central.pre_fill_search(text)
def show_text_search(self):
self.gui.text_search_dock.show()
self.gui.text_search.find.setFocus(Qt.FocusReason.OtherFocusReason)
def search_action_triggered(self, action, overrides=None):
ss = self.gui.saved_searches.isVisible()
trigger_saved_search = ss and (not self.gui.central.search_panel.isVisible() or self.gui.saved_searches.has_focus())
if trigger_saved_search:
return self.gui.saved_searches.trigger_action(action, overrides=overrides)
self.search(action, overrides)
def run_saved_searches(self, searches, action):
ed = self.gui.central.current_editor
name = editor_name(ed)
searchable_names = self.gui.file_list.searchable_names
if not searches or not validate_search_request(name, searchable_names, getattr(ed, 'has_marked_text', False), searches[0], self.gui):
return
ret = run_search(
searches,
action,
ed,
name,
searchable_names,
self.gui,
self.show_editor,
self.edit_file,
self.show_current_diff,
self.add_savepoint,
self.rewind_savepoint,
self.set_modified,
)
if ret is True:
ed = self.gui.central.current_editor
if getattr(ed, 'has_line_numbers', False):
getattr(ed, 'editor').setFocus(Qt.FocusReason.OtherFocusReason)
else:
self.gui.saved_searches.setFocus(Qt.FocusReason.OtherFocusReason)
else:
self.gui.saved_searches.setFocus(Qt.FocusReason.OtherFocusReason)
def search(self, action, overrides=None):
# Run a search/replace
sp = self.gui.central.search_panel
# Ensure the search panel is visible
sp.setVisible(True)
ed = self.gui.central.current_editor
name = editor_name(ed)
state = sp.state
if overrides:
state.update(overrides)
searchable_names = self.gui.file_list.searchable_names
if not validate_search_request(name, searchable_names, getattr(ed, 'has_marked_text', False), state, self.gui):
return
ret = run_search(
state,
action,
ed,
name,
searchable_names,
self.gui,
self.show_editor,
self.edit_file,
self.show_current_diff,
self.add_savepoint,
self.rewind_savepoint,
self.set_modified,
)
if ret is True:
ed = self.gui.central.current_editor
if getattr(ed, 'has_line_numbers', False):
getattr(ed, 'editor').setFocus(Qt.FocusReason.OtherFocusReason)
else:
self.gui.saved_searches.setFocus(Qt.FocusReason.OtherFocusReason)
else:
self.gui.saved_searches.setFocus(Qt.FocusReason.OtherFocusReason)
def find_text(self, state):
from calibre.gui2.tweak_book.text_search import run_text_search
searchable_names = self.gui.file_list.searchable_names
ed = self.gui.central.current_editor
name = editor_name(ed)
if not validate_search_request(name, searchable_names, getattr(ed, 'has_marked_text', False), state, self.gui):
return
ret = run_text_search(state, ed, name, searchable_names, self.gui, self.show_editor, self.edit_file)
if ret is True:
ed = self.gui.central.current_editor
if getattr(ed, 'has_line_numbers', False):
getattr(ed, 'editor').setFocus(Qt.FocusReason.OtherFocusReason)
def find_initial_text(self, text):
from calibre.gui2.tweak_book.search import get_search_regex
from calibre.gui2.tweak_book.text_search import file_matches_pattern
search = {'find': text, 'mode': 'normal', 'case_sensitive': True, 'direction': 'down'}
pat = get_search_regex(search)
searchable_names = set(self.gui.file_list.searchable_names['text'])
for name, ed in editors.items():
searchable_names.discard(name)
if ed.find_text(pat, complete=True):
self.show_editor(name)
return
for name in searchable_names:
if file_matches_pattern(name, pat):
self.edit_file(name)
if editors[name].find_text(pat, complete=True):
return
def find_word(self, word, locations):
# Go to a word from the spell check dialog
ed = self.gui.central.current_editor
name = editor_name(ed)
find_next_word(word, locations, ed, name, self.gui, self.show_editor, self.edit_file)
def next_spell_error(self):
# Go to the next spelling error
ed = self.gui.central.current_editor
name = editor_name(ed)
find_next_error(ed, name, self.gui, self.show_editor, self.edit_file, self.close_editor)
def word_change_requested(self, w, new_word):
if self.commit_all_editors_to_container():
self.gui.spell_check.change_word_after_update(w, new_word)
else:
self.gui.spell_check.do_change_word(w, new_word)
def word_replaced(self, changed_names):
self.set_modified()
self.update_editors_from_container(names=set(changed_names))
def word_ignored(self, word, locale):
if tprefs['inline_spell_check']:
for ed in editors.values():
try:
ed.editor.recheck_word(word, locale)
except AttributeError:
pass
def editor_link_clicked(self, url):
ed = self.gui.central.current_editor
name = editor_name(ed)
if url.startswith('#'):
target = name
else:
target = current_container().href_to_name(url, name)
frag = url.partition('#')[-1]
if current_container().has_name(target):
self.link_clicked(target, frag, show_anchor_not_found=True)
else:
try:
purl = urlparse(url)
except ValueError:
return
if purl.scheme not in {'', 'file'}:
open_url(QUrl(url))
else:
error_dialog(self.gui, _('Not found'), _('No file with the name %s was found in the book') % target, show=True)
def editor_class_clicked(self, class_data):
from calibre.gui2.tweak_book.jump_to_class import NoMatchingRuleFound, NoMatchingTagFound, find_first_matching_rule
ed = self.gui.central.current_editor
name = editor_name(ed)
try:
res = find_first_matching_rule(current_container(), name, ed.get_raw_data(), class_data)
except NoMatchingTagFound, NoMatchingRuleFound:
res = None
if res is not None and res.file_name and res.rule_address:
editor = self.open_editor_for_name(res.file_name)
if editor:
editor.goto_css_rule(res.rule_address, sourceline_address=res.style_tag_address)
else:
error_dialog(
self.gui,
_('No matches found'),
_('No style rules that match the class {} were found').format(class_data['class']),
show=True,
)
def save_search(self):
state = self.gui.central.search_panel.state
self.show_saved_searches()
self.gui.saved_searches.add_predefined_search(state)
def show_saved_searches(self):
self.gui.saved_searches_dock.show()
saved_searches = show_saved_searches
def create_checkpoint(self):
text, ok = QInputDialog.getText(
self.gui,
_('Choose name'),
_('Choose a name for the checkpoint.\nYou can later restore the book to this checkpoint via the\n"Revert to..." entries in the Edit menu.'),
)
if ok:
self.add_savepoint(text)
def commit_editor_to_container(self, name, container=None):
container = container or current_container()
ed = editors[name]
with container.open(name, 'wb') as f:
f.write(ed.data)
if name == container.opf_name:
container.refresh_mime_map()
lang = container.opf_xpath('//dc:language/text()') or [self.current_metadata.language]
set_book_locale(lang[0])
if container is current_container():
ed.is_synced_to_container = True
if name == container.opf_name:
self.gui.file_list.build(container)
[Doku]
def commit_all_editors_to_container(self):
"""Commit any changes that the user has made to files open in editors to
the container. You should call this method before performing any
actions on the current container"""
changed = False
with BusyCursor():
for name, ed in editors.items():
if not ed.is_synced_to_container:
self.commit_editor_to_container(name)
ed.is_synced_to_container = True
changed = True
return changed
[Doku]
def save_book(self):
"Save the book. Saving is performed in the background"
self.gui.update_window_title()
c = current_container()
for name, ed in editors.items():
if ed.is_modified or not ed.is_synced_to_container:
self.commit_editor_to_container(name, c)
ed.is_modified = False
path_to_ebook = os.path.abspath(c.path_to_ebook)
destdir = os.path.dirname(path_to_ebook)
if not c.is_dir and not os.path.exists(destdir):
info_dialog(
self.gui,
_('Path does not exist'),
_('The file you are editing (%s) no longer exists. You have to choose a new save location.') % path_to_ebook,
show_copy_button=False,
show=True,
)
fmt = path_to_ebook.rpartition('.')[-1].lower()
start_dir = find_first_existing_ancestor(path_to_ebook)
path = choose_save_file(
self.gui,
'choose-new-save-location',
_('Choose file location'),
initial_path=os.path.join(start_dir, os.path.basename(path_to_ebook)),
filters=[(fmt.upper(), (fmt,))],
all_files=False,
)
if path is not None:
if not path.lower().endswith('.' + fmt):
path = path + '.' + fmt
path = os.path.abspath(path)
c.path_to_ebook = path
self.global_undo.update_path_to_ebook(path)
else:
return
if os.path.exists(c.path_to_ebook) and not os.access(c.path_to_ebook, os.W_OK):
if not question_dialog(
self.gui,
_('File is read-only'),
_('The file at {} is read-only. The editor will try to reset its permissions before saving. Proceed with saving?').format(c.path_to_ebook),
override_icon='dialog_warning.png',
yes_text=_('&Save'),
no_text=_('&Cancel'),
yes_icon='save.png',
):
return
self.gui.action_save.setEnabled(False)
tdir = self.mkdtemp(prefix='save-')
container = clone_container(c, tdir)
self.save_manager.schedule(tdir, container)
def _save_copy(self, post_action=None):
self.gui.update_window_title()
c = current_container()
if c.is_dir:
return error_dialog(self.gui, _('Cannot save a copy'), _('Saving a copy of a folder based book is not supported'), show=True)
ext = c.path_to_ebook.rpartition('.')[-1]
path = choose_save_file(
self.gui,
'tweak_book_save_copy',
_('Choose path'),
initial_filename=self.current_metadata.title + '.' + ext,
filters=[(_('Book (%s)') % ext.upper(), [ext.lower()])],
all_files=False,
)
if not path:
return
if '.' not in os.path.basename(path):
path += '.' + ext.lower()
tdir = self.mkdtemp(prefix='save-copy-')
container = clone_container(c, tdir)
for name, ed in editors.items():
if ed.is_modified or not ed.is_synced_to_container:
self.commit_editor_to_container(name, container)
def do_save(c, path, tdir):
save_container(c, path)
shutil.rmtree(tdir, ignore_errors=True)
return path
self.gui.blocking_job(
'save_copy',
_('Saving copy, please wait...'),
partial(self.copy_saved, post_action=post_action),
do_save,
container,
path,
tdir,
)
def save_copy(self):
self._save_copy()
def copy_saved(self, job, post_action=None):
if job.traceback is not None:
return error_dialog(
self.gui,
_('Failed to save copy'),
_('Failed to save copy, click "Show details" for more information.'),
det_msg=job.traceback,
show=True,
)
msg = _('Copy saved to %s') % job.result
if post_action is None:
info_dialog(self.gui, _('Copy saved'), msg, show=True)
elif post_action == 'replace':
msg = _('Editing saved copy at: %s') % job.result
self.open_book(job.result, edit_file=self.currently_editing)
elif post_action == 'edit':
if ismacos:
cmd = [
'open',
'-F',
'-n',
'-a',
os.path.dirname(os.path.dirname(os.path.dirname(macos_edit_book_bundle_path()))),
'--args',
]
else:
cmd = [exe_path('ebook-edit')]
if islinux:
cmd.append('--detach')
cmd.append(job.result)
ce = self.currently_editing
if ce:
cmd.append(ce)
with sanitize_env_vars():
subprocess.Popen(cmd)
self.gui.show_status_message(msg, 5)
def report_save_error(self, tb):
if self.doing_terminal_save:
prints(tb, file=sys.stderr)
self.abort_terminal_save()
self.set_modified()
error_dialog(
self.gui,
_('Could not save'),
_(
'Saving of the book failed. Click "Show details"'
' for more information. You can try to save a copy'
' to a different location, via File->Save a copy'
),
det_msg=tb,
show=True,
)
def go_to_line_number(self):
ed = self.gui.central.current_editor
if ed is None or not ed.has_line_numbers:
return
num, ok = QInputDialog.getInt(self.gui, _('Enter line number'), ('Line number:'), ed.current_line, 1, max(100000, ed.number_of_lines))
if ok:
ed.current_line = num
def split_start_requested(self):
self.commit_all_editors_to_container()
self.gui.preview.do_start_split()
@in_thread_job
def split_requested(self, name, loc, totals):
self.add_savepoint(_('Before: Split %s') % self.gui.elided_text(name))
try:
bottom_name = split(current_container(), name, loc, totals=totals)
except AbortError:
self.rewind_savepoint()
raise
self.apply_container_update_to_gui()
self.edit_file(bottom_name, 'html')
def multisplit(self):
ed = self.gui.central.current_editor
if ed.syntax != 'html':
return
name = editor_name(ed)
if name is None:
return
d = MultiSplit(self.gui)
if d.exec() == QDialog.DialogCode.Accepted:
with BusyCursor():
self.add_savepoint(_('Before: Split %s') % self.gui.elided_text(name))
try:
multisplit(current_container(), name, d.xpath)
except AbortError:
self.rewind_savepoint()
raise
self.apply_container_update_to_gui()
def open_editor_for_name(self, name):
if name in editors:
editor = editors[name]
self.gui.central.show_editor(editor)
else:
try:
mt = current_container().mime_map[name]
except KeyError:
error_dialog(
self.gui,
_('Does not exist'),
_(
'The file %s does not exist. If you were trying to click an item in'
' the Table of Contents, you may'
' need to refresh it by right-clicking and choosing "Refresh".'
)
% name,
show=True,
)
return None
syntax = syntax_from_mime(name, mt)
if not syntax:
error_dialog(
self.gui,
_('Unsupported file format'),
_('Editing files of type %s is not supported') % mt,
show=True,
)
return None
editor = self.edit_file(name, syntax)
return editor
def link_clicked(self, name, anchor, show_anchor_not_found=False):
if not name:
return
editor = self.open_editor_for_name(name)
if anchor and hasattr(editor, 'go_to_anchor'):
if editor.go_to_anchor(anchor):
self.gui.preview.pending_go_to_anchor = anchor
elif show_anchor_not_found:
error_dialog(self.gui, _('Not found'), _('The anchor %s was not found in this file') % anchor, show=True)
@in_thread_job
def check_item_activated(self, item):
is_mult = item.has_multiple_locations and getattr(item, 'current_location_index', None) is not None
name = item.all_locations[item.current_location_index][0] if is_mult else item.name
editor = None
if name in editors:
editor = editors[name]
self.gui.central.show_editor(editor)
else:
try:
editor = self.edit_file_requested(name, None, current_container().mime_map[name])
except KeyError:
error_dialog(
self.gui,
_('File deleted'),
_('The file {} has already been deleted, re-run Check Book to update the results.').format(name),
show=True,
)
if getattr(editor, 'has_line_numbers', False):
assert editor is not None
if is_mult:
editor.go_to_line(*(item.all_locations[item.current_location_index][1:3]))
else:
editor.go_to_line(item.line or 0, item.col or 0)
editor.set_focus()
@in_thread_job
def check_requested(self, *args):
if current_container() is None:
return
self.commit_all_editors_to_container()
c = self.gui.check_book
c.parent().show()
c.parent().raise_and_focus()
c.run_checks(current_container())
def spell_check_requested(self):
if current_container() is None:
return
self.commit_all_editors_to_container()
self.add_savepoint(_('Before: Spell Check'))
self.gui.spell_check.show()
@in_thread_job
def fix_requested(self, errors):
self.add_savepoint(_('Before: Auto-fix errors'))
c = self.gui.check_book
c.parent().show()
c.parent().raise_and_focus()
changed = c.fix_errors(current_container(), errors)
if changed:
self.apply_container_update_to_gui()
self.set_modified()
else:
self.rewind_savepoint()
@in_thread_job
def merge_requested(self, category, names, master):
self.add_savepoint(_('Before: Merge files into %s') % self.gui.elided_text(master))
try:
merge(current_container(), category, names, master)
except AbortError:
self.rewind_savepoint()
raise
self.apply_container_update_to_gui()
if master in editors:
self.show_editor(master)
self.gui.file_list.merge_completed(master)
self.gui.message_popup(_('{} files merged').format(len(names)))
@in_thread_job
def link_stylesheets_requested(self, names, sheets, remove):
self.add_savepoint(_('Before: Link stylesheets'))
changed_names = link_stylesheets(current_container(), names, sheets, remove)
if changed_names:
self.update_editors_from_container(names=changed_names)
self.set_modified()
@in_thread_job
def export_requested(self, name_or_names, path):
if isinstance(name_or_names, (str, bytes)):
return self.export_file(name_or_names, path)
for name in name_or_names:
dest = os.path.abspath(os.path.join(path, name))
if '/' in name or os.sep in name:
try:
os.makedirs(os.path.dirname(dest))
except OSError as err:
if err.errno != errno.EEXIST:
raise
self.export_file(name, dest)
def open_file_with(self, file_name, fmt, entry):
if file_name in editors and not editors[file_name].is_synced_to_container:
self.commit_editor_to_container(file_name)
with current_container().open(file_name) as src:
tdir = PersistentTemporaryDirectory(suffix='-ee-ow')
with open(os.path.join(tdir, os.path.basename(file_name)), 'wb') as dest:
shutil.copyfileobj(src, dest)
from calibre.gui2.open_with import run_program
run_program(entry, dest.name, self)
if question_dialog(
self.gui,
_('File opened'),
_('When you are done editing {0} click "Import" to update the file in the book or "Discard" to lose any changes.').format(file_name),
yes_text=_('Import'),
no_text=_('Discard'),
):
self.add_savepoint(_('Before: Replace %s') % file_name)
with open(dest.name, 'rb') as src, current_container().open(file_name, 'wb') as cdest:
shutil.copyfileobj(src, cdest)
self.apply_container_update_to_gui()
try:
shutil.rmtree(tdir)
except Exception:
pass
@in_thread_job
def copy_files_to_clipboard(self, names):
names = tuple(names)
for name in names:
if name in editors and not editors[name].is_synced_to_container:
self.commit_editor_to_container(name)
container = current_container()
md = QMimeData()
url_map = {name: container.get_file_path_for_processing(name, allow_modification=False) for name in names}
from calibre.gui2.dnd import set_urls_from_local_file_paths
set_urls_from_local_file_paths(md, *url_map.values())
import json
md.setData(
FILE_COPY_MIME,
as_bytes(json.dumps({name: (url_map[name], container.mime_map.get(name)) for name in names})),
)
_clipboard = qapplication_or_fail().clipboard()
assert _clipboard is not None
_clipboard.setMimeData(md)
@in_thread_job
def paste_files_from_clipboard(self):
_clipboard = qapplication_or_fail().clipboard()
assert _clipboard is not None
md = _clipboard.mimeData()
assert md is not None
if md.hasUrls() and md.hasFormat(FILE_COPY_MIME):
import json
self.commit_all_editors_to_container()
name_map = json.loads(bytes(md.data(FILE_COPY_MIME)))
container = current_container()
for name, (path, mt) in name_map.items():
with open(path, 'rb') as f:
container.add_file(name, f.read(), media_type=mt, modify_name_if_needed=True)
self.apply_container_update_to_gui()
def export_file(self, name, path):
if name in editors and not editors[name].is_synced_to_container:
self.commit_editor_to_container(name)
with current_container().open(name, 'rb') as src, open(path, 'wb') as dest:
shutil.copyfileobj(src, dest)
@in_thread_job
def replace_requested(self, name, path, basename, force_mt):
self.add_savepoint(_('Before: Replace %s') % name)
replace_file(current_container(), name, path, basename, force_mt)
self.apply_container_update_to_gui()
def browse_images(self):
self.gui.image_browser.refresh()
self.gui.image_browser.show()
self.gui.image_browser.raise_and_focus()
def show_reports(self):
if not self.ensure_book(_('You must first open a book in order to see the report.')):
return
self.gui.reports.refresh()
self.gui.reports.show()
self.gui.reports.raise_and_focus()
def reports_edit_requested(self, name):
mt = current_container().mime_map.get(name, guess_type(name))
self.edit_file_requested(name, None, mt)
def image_activated(self, name):
mt = current_container().mime_map.get(name, guess_type(name))
self.edit_file_requested(name, None, mt)
def check_external_links(self):
if self.ensure_book(_('You must first open a book in order to check links.')):
self.commit_all_editors_to_container()
self.gui.check_external_links.show()
def embed_tts(self):
if not self.ensure_book(_('You must first open a book in order to add Text-to-speech narration.')):
return
self.commit_all_editors_to_container()
from calibre.gui2.tweak_book.tts import TTSEmbed
self.add_savepoint(_('Before: adding narration'))
d = TTSEmbed(current_container(), self.gui)
if d.exec() == QDialog.DialogCode.Accepted:
self.apply_container_update_to_gui()
self.show_current_diff()
else:
self.rewind_savepoint()
def compress_images(self):
if not self.ensure_book(_('You must first open a book in order to compress images.')):
return
from calibre.gui2.tweak_book.polish import CompressImages, CompressImagesProgress, show_report
d = CompressImages(self.gui)
if d.exec() == QDialog.DialogCode.Accepted:
with BusyCursor():
self.add_savepoint(_('Before: compress images'))
d = CompressImagesProgress(
names=d.names,
jpeg_quality=d.jpeg_quality,
webp_quality=d.webp_quality,
compress_png=d.compress_png,
png_to_format=d.png_to_format,
gif_to_format=d.gif_to_format,
parent=self.gui,
)
if d.exec() != QDialog.DialogCode.Accepted:
self.rewind_savepoint()
return
changed, report = d.compress_result
if changed is None and report:
self.rewind_savepoint()
return error_dialog(
self.gui,
_('Unexpected error'),
_('Failed to compress images, click "Show details" for more information'),
det_msg=report,
show=True,
)
if changed:
self.apply_container_update_to_gui()
else:
self.rewind_savepoint()
show_report(changed, self.current_metadata.title, report, self.gui, self.show_current_diff)
def sync_editor_to_preview(self, name, sourceline_address):
editor = self.edit_file(name, 'html')
self.ignore_preview_to_editor_sync = True
try:
editor.goto_sourceline(*sourceline_address)
finally:
self.ignore_preview_to_editor_sync = False
def do_sync_preview_to_editor(self, wait_for_highlight_to_finish=False):
if self.ignore_preview_to_editor_sync:
return
ed = self.gui.central.current_editor
if ed is not None:
name = editor_name(ed)
if name is not None and getattr(ed, 'syntax', None) == 'html':
hl = getattr(ed, 'highlighter', None)
if wait_for_highlight_to_finish:
if getattr(hl, 'is_working', False):
QTimer.singleShot(75, self.sync_preview_to_editor_on_highlight_finish)
return
ct = ed.current_tag()
self.gui.preview.sync_to_editor(name, ct)
if hl is not None and hl.is_working:
QTimer.singleShot(75, self.sync_preview_to_editor_on_highlight_finish)
[Doku]
def sync_preview_to_editor(self):
"Sync the position of the preview panel to the current cursor position in the current editor"
self.do_sync_preview_to_editor()
def sync_preview_to_editor_on_highlight_finish(self):
self.do_sync_preview_to_editor(wait_for_highlight_to_finish=True)
def show_partial_cfi_in_editor(self, name, cfi):
editor = self.edit_file(name, 'html')
if not editor or not editor.has_line_numbers:
return False
from calibre.ebooks.epub.cfi.parse import decode_cfi
from calibre.ebooks.oeb.polish.parsing import parse
root = parse(
editor.get_raw_data(),
decoder=lambda x: x.decode('utf-8'),
line_numbers=True,
linenumber_attribute='data-lnum',
)
node = decode_cfi(root, cfi)
def barename(x):
return x.tag.partition('}')[-1]
if node is not None:
lnum = node.get('data-lnum')
if lnum:
tags_before = []
for tag in root.xpath(f'//*[@data-lnum="{lnum}"]'):
tags_before.append(barename(tag))
if tag is node:
break
else:
tags_before.append(barename(node))
lnum = int(lnum)
return editor.goto_sourceline(lnum, tags_before, attribute='id' if node.get('id') else None)
return False
def goto_style_declaration(self, data):
name = data['name']
editor = self.edit_file(name, syntax=data['syntax'])
self.gui.live_css.navigate_to_declaration(data, editor)
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)
editor.cursor_position_changed.connect(self.sync_preview_to_editor)
editor.cursor_position_changed.connect(self.update_cursor_position)
if hasattr(editor, 'word_ignored'):
editor.word_ignored.connect(self.word_ignored)
if hasattr(editor, 'link_clicked'):
editor.link_clicked.connect(self.editor_link_clicked)
if hasattr(editor, 'class_clicked'):
editor.class_clicked.connect(self.editor_class_clicked)
if hasattr(editor, 'rename_class'):
editor.rename_class.connect(self.rename_class)
if getattr(editor, 'syntax', None) == 'html':
editor.smart_highlighting_updated.connect(self.gui.live_css.sync_to_editor)
if hasattr(editor, 'set_request_completion'):
editor.set_request_completion(partial(self.request_completion, name), name)
if data is not None:
if use_template:
editor.init_from_template(data)
else:
editor.data = data
editor.is_synced_to_container = True
editor.modification_state_changed.connect(self.editor_modification_state_changed)
self.gui.central.add_editor(name, editor)
[Doku]
def edit_file(self, name, syntax=None, use_template=None):
"""Open the file specified by name in an editor
:param syntax: The media type of the file, for example, ``'text/html'``. If not specified it is guessed from the file extension.
:param use_template: A template to initialize the opened editor with
"""
editor = editors.get(name, None)
if editor is None:
syntax = syntax or syntax_from_mime(name, guess_type(name))
if use_template is None:
data = current_container().raw_data(name)
if isinstance(data, bytes) and syntax in {'html', 'css', 'text', 'xml', 'javascript'}:
try:
data = data.decode('utf-8')
except UnicodeDecodeError:
return error_dialog(
self.gui,
_('Cannot decode'),
_('Cannot edit %s as it appears to be in an unknown character encoding') % name,
show=True,
)
else:
data = use_template
editor = editors[name] = editor_from_syntax(syntax, self.gui.editor_tabs)
self.init_editor(name, editor, data, use_template=bool(use_template))
if tprefs['pretty_print_on_open']:
editor.pretty_print(name)
self.show_editor(name)
return editor
[Doku]
def show_editor(self, name):
"Show the editor that is editing the file specified by ``name``"
self.gui.central.show_editor(editors[name])
editors[name].set_focus()
def edit_file_requested(self, name, syntax=None, mime=None):
if name in editors:
self.gui.central.show_editor(editors[name])
return editors[name]
mime = mime or current_container().mime_map.get(name, guess_type(name))
syntax = syntax or syntax_from_mime(name, mime)
if not syntax:
return error_dialog(self.gui, _('Unsupported file format'), _('Editing files of type %s is not supported') % mime, show=True)
return self.edit_file(name, syntax)
def edit_next_file(self, backwards=False):
self.gui.file_list.edit_next_file(self.currently_editing, backwards)
def quick_open(self):
if not self.ensure_book(_('No book is currently open. You must first open a book to edit.')):
return
c = current_container()
files = [name for name, mime in c.mime_map.items() if c.exists(name) and syntax_from_mime(name, mime) is not None]
d = QuickOpen(files, parent=self.gui)
if d.exec() == QDialog.DialogCode.Accepted and d.selected_result is not None:
self.edit_file_requested(d.selected_result, None, c.mime_map[d.selected_result])
# Editor basic controls {{{
def do_editor_undo(self):
ed = self.gui.central.current_editor
if ed is not None:
ed.undo()
def do_editor_redo(self):
ed = self.gui.central.current_editor
if ed is not None:
ed.redo()
def do_editor_copy(self):
ed = self.gui.central.current_editor
if ed is not None:
ed.copy()
def do_editor_cut(self):
ed = self.gui.central.current_editor
if ed is not None:
ed.cut()
def do_editor_paste(self):
ed = self.gui.central.current_editor
if ed is not None:
ed.paste()
def toggle_line_wrapping_in_all_editors(self):
tprefs['editor_line_wrap'] ^= True
yes = tprefs['editor_line_wrap']
for ed in editors.values():
if getattr(ed, 'editor', None) and hasattr(ed.editor, 'apply_line_wrap_mode'):
ed.editor.apply_line_wrap_mode(yes)
def editor_data_changed(self, editor):
self.gui.preview.start_refresh_timer()
for name, ed in editors.items():
if ed is editor:
self.gui.toc_view.start_refresh_timer(name)
break
def editor_undo_redo_state_changed(self, *args):
self.apply_current_editor_state()
def editor_copy_available_state_changed(self, *args):
self.apply_current_editor_state()
def editor_modification_state_changed(self, is_modified):
self.apply_current_editor_state()
if is_modified:
self.set_modified()
# }}}
def apply_current_editor_state(self):
ed = self.gui.central.current_editor
self.gui.cursor_position_widget.update_position()
if ed is not None:
actions['editor-undo'].setEnabled(ed.undo_available)
actions['editor-redo'].setEnabled(ed.redo_available)
actions['editor-copy'].setEnabled(ed.copy_available)
actions['editor-cut'].setEnabled(ed.cut_available)
actions['go-to-line-number'].setEnabled(ed.has_line_numbers)
actions['fix-html-current'].setEnabled(ed.syntax == 'html')
name = editor_name(ed)
mime = current_container().mime_map.get(name)
if name is not None and (getattr(ed, 'syntax', None) == 'html' or mime == 'image/svg+xml'):
if self.gui.preview.show(name):
# The file being displayed by the preview has changed.
# Set the preview's position to the current cursor
# position in the editor, in case the editors' cursor
# position has not changed, since the last time it was
# focused. This is not inefficient since multiple requests
# to sync are de-bounced with a 100 msec wait.
self.sync_preview_to_editor()
if name is not None:
self.gui.file_list.mark_name_as_current(name)
if ed.has_line_numbers:
self.gui.cursor_position_widget.update_position(*ed.cursor_position)
else:
actions['go-to-line-number'].setEnabled(False)
self.gui.file_list.clear_currently_edited_name()
def update_cursor_position(self):
ed = self.gui.central.current_editor
if getattr(ed, 'has_line_numbers', False):
self.gui.cursor_position_widget.update_position(*ed.cursor_position)
else:
self.gui.cursor_position_widget.update_position()
def editor_close_requested(self, editor):
name = editor_name(editor)
if not name:
return
if not editor.is_synced_to_container:
self.commit_editor_to_container(name)
self.close_editor(name)
[Doku]
def close_editor(self, name):
"Close the editor that is editing the file specified by ``name``"
editor = editors.pop(name)
self.gui.central.close_editor(editor)
editor.break_cycles()
if not editors or getattr(self.gui.central.current_editor, 'syntax', None) != 'html':
self.gui.preview.clear()
self.gui.live_css.clear()
def insert_character(self):
self.gui.insert_char.show()
def manage_snippets(self):
from calibre.gui2.tweak_book.editor.snippets import UserSnippets
UserSnippets(self.gui).exec()
def merge_files(self):
self.gui.file_list.merge_files()
# Shutdown {{{
def quit(self):
if self.doing_terminal_save:
return False
if self.save_manager.has_tasks:
if question_dialog(
self.gui,
_('Are you sure?'),
_('The current book is being saved in the background. Quitting now will <b>abort the save process</b>! Finish saving first?'),
yes_text=_('Finish &saving first'),
no_text=_('&Quit immediately'),
):
if self.save_manager.has_tasks:
self.start_terminal_save_indicator()
return False
if not self.confirm_quit():
return False
self.shutdown()
qapplication_or_fail().exit()
return True
def confirm_quit(self):
if self.gui.action_save.isEnabled():
d = QDialog(self.gui)
l = QGridLayout(d)
d.setLayout(l)
d.setWindowTitle(_('Unsaved changes'))
i = QLabel('')
i.setMaximumSize(QSize(64, 64))
i.setPixmap(QIcon.ic('dialog_warning.png').pixmap(i.maximumSize()))
l.addWidget(i, 0, 0)
m = QLabel(_('There are unsaved changes, if you quit without saving, you will lose them.'))
m.setWordWrap(True)
l.addWidget(m, 0, 1)
bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel)
bb.rejected.connect(d.reject)
bb.accepted.connect(d.accept)
l.addWidget(bb, 1, 0, 1, 2)
do_save: list[bool | None] = [None]
def endit(x):
do_save[0] = x
d.accept()
b = bb.addButton(_('&Save and Quit'), QDialogButtonBox.ButtonRole.ActionRole)
assert b is not None
b.setIcon(QIcon.ic('save.png'))
b.clicked.connect(lambda: endit(True))
b = bb.addButton(_('&Quit without saving'), QDialogButtonBox.ButtonRole.ActionRole)
assert b is not None
b.clicked.connect(lambda: endit(False))
d.resize(d.sizeHint())
if d.exec() != QDialog.DialogCode.Accepted or do_save[0] is None:
return False
if do_save[0]:
self.gui.action_save.trigger()
self.start_terminal_save_indicator()
return False
return True
def start_terminal_save_indicator(self):
self.save_state()
self.gui.blocking_job.set_msg(_('Saving, please wait...'))
self.gui.blocking_job.start()
self.doing_terminal_save = True
def abort_terminal_save(self):
self.doing_terminal_save = False
self.gui.blocking_job.stop()
def check_terminal_save(self):
if self.doing_terminal_save and not self.save_manager.has_tasks: # terminal save could have been aborted
self.shutdown()
qapplication_or_fail().exit()
def shutdown(self):
self.save_state()
completion_worker().shutdown()
self.save_manager.check_for_completion.disconnect()
self.gui.preview.stop_refresh_timer()
self.gui.live_css.stop_update_timer()
[x.reject() for x in _diff_dialogs]
del _diff_dialogs[:]
self.save_manager.shutdown()
parse_worker.shutdown()
self.save_manager.wait(0.1)
def save_state(self):
with self.editor_cache:
self.save_book_edit_state()
with tprefs:
self.gui.save_state()
def save_book_edit_state(self):
c = current_container()
if c and c.path_to_ebook:
tprefs = self.editor_cache
mem = tprefs['edit_book_state']
order = tprefs['edit_book_state_order']
extra = len(order) - 99
if extra > 0:
order = [k for k in order[extra:] if k in mem]
mem = {k: mem[k] for k in order}
mem[c.path_to_ebook] = {
'editors': {name: ed.current_editing_state for name, ed in editors.items()},
'currently_editing': self.currently_editing,
'tab_order': self.gui.central.tab_order,
}
try:
order.remove(c.path_to_ebook)
except ValueError:
pass
order.append(c.path_to_ebook)
tprefs['edit_book_state'] = mem
tprefs['edit_book_state_order'] = order
def restore_book_edit_state(self):
c = current_container()
if c and c.path_to_ebook:
tprefs = self.editor_cache
state = tprefs['edit_book_state'].get(c.path_to_ebook)
if state is not None:
opened = set()
eds = state.get('editors', {})
for name in state.get('tab_order', ()):
if c.has_name(name):
try:
editor = self.edit_file_requested(name)
if editor is not None:
opened.add(name)
es = eds.get(name)
if es is not None:
editor.current_editing_state = es
except Exception:
import traceback
traceback.print_exc()
ce = state.get('currently_editing')
if ce in opened:
self.show_editor(ce)
# }}}