08-30-2021, 06:06 AM | #16 |
Wizard
Posts: 1,138
Karma: 1954142
Join Date: Aug 2015
Device: Kindle
|
Books added event
Code:
#!/usr/bin/env python # ~*~ coding: utf-8 ~*~ __license__ = 'GPL v3' __copyright__ = '2021, Ahmed Zaki <azaki00.dev@gmail.com>' __docformat__ = 'restructuredtext en' from functools import partial from qt.core import (QApplication, Qt, QTimer, QWidget, QVBoxLayout, QCheckBox, pyqtSignal) from calibre import prints from calibre.constants import DEBUG from calibre.db.listeners import EventType from calibre.utils.date import now from calibre_plugins.action_chains.events.base import ChainEvent import calibre_plugins.action_chains.config as cfg try: load_translations() except NameError: prints("ActionChains::events/books_added.py - exception when loading translations") class ConfigWidget(QWidget): def __init__(self, plugin_action): QWidget.__init__(self) self.plugin_action = plugin_action self.gui = plugin_action.gui self.db = self.gui.current_db self._init_controls() def _init_controls(self): l = self.l = QVBoxLayout() self.setLayout(l) self.select_chk = QCheckBox(_('Select newly added books')) self.select_chk.setChecked(True) l.addWidget(self.select_chk) l.addStretch(1) self.setMinimumSize(300,300) def load_settings(self, settings): if settings: self.select_chk.setChecked(settings['select_books']) def save_settings(self): settings = {} settings['select_books'] = self.select_chk.isChecked() return settings class BooksAddedEvent(ChainEvent): name = 'Books Added' books_added = pyqtSignal(object) timer_interval = 30 def __init__(self, plugin_action): ChainEvent.__init__(self, plugin_action) self.db = plugin_action.gui.current_db self.gui.add_db_listener(self.process_event_in_db) self.book_created_cache = set() self.book_created_cache_last_updated = None QTimer.singleShot(self.timer_interval * 1000, self._on_timeout) def process_event_in_db(self, db, event_type, event_data): if not db.library_id == self.gui.current_db.library_id: return if event_type == EventType.book_created: book_id = event_data[0] self.add_to_book_created_cache(book_id, now()) elif event_type == EventType.books_removed: removed_book_ids = event_data[0] self.book_created_cache = self.book_created_cache.difference(set(removed_book_ids)) def _on_timeout(self): # Make sure no modal widget dialog is present (e.g. add books duplicate dialog). Otherwise, postpone if QApplication.instance().activeModalWidget(): pass # postpone event if another action chains is running elif self.plugin_action.chainStack: pass else: utime = self.book_created_cache_last_updated if utime: elapsed = now() - utime if elapsed.seconds > 20: QTimer.singleShot(0, partial(self.books_added.emit, self.book_created_cache)) QTimer.singleShot(0, self.clean_book_created_cache) # keep the timer runnig QTimer.singleShot(self.timer_interval * 1000, self._on_timeout) def add_to_book_created_cache(self, book_id, timestamp): self.book_created_cache.add(book_id) self.book_created_cache_last_updated = timestamp def clean_book_created_cache(self): self.book_created_cache = set() self.book_created_cache_last_updated = None def get_event_signal(self): return self.books_added def config_widget(self): return ConfigWidget def pre_chains_event_actions(self, event_args, event_opts): if event_opts.get('select_books', False): book_ids = event_args[0] self.gui.library_view.select_rows(book_ids) if DEBUG: prints('Action Chains: Books Added Event: Selecting newly added book_ids: {}') Notes:
Last edited by capink; 01-07-2022 at 07:15 AM. |
08-30-2021, 06:08 AM | #17 |
Wizard
Posts: 1,138
Karma: 1954142
Join Date: Aug 2015
Device: Kindle
|
Save to disk action (non-interactive)
Code:
import os, numbers import copy from qt.core import (QApplication, Qt, QWidget, QGridLayout, QHBoxLayout, QVBoxLayout, QGroupBox, QComboBox, QRadioButton, QCheckBox, QToolButton, QLineEdit) from calibre import prints from calibre.constants import iswindows, isosx, islinux, DEBUG from calibre.gui2 import error_dialog, Dispatcher, choose_dir from calibre.gui2.actions.save_to_disk import SaveToDiskAction from calibre.utils.formatter_functions import formatter_functions from calibre.gui2.dialogs.template_line_editor import TemplateLineEditor from polyglot.builtins import itervalues from calibre_plugins.action_chains.actions.base import ChainAction from calibre_plugins.action_chains.common_utils import DragDropComboBox, get_icon from calibre_plugins.action_chains.templates.dialogs import TemplateBox from calibre_plugins.action_chains.templates import check_template class ModifiedSaveToDiskAction(SaveToDiskAction): def __init__(self, gui): self.gui = gui def save_to_disk(self, single_dir=False, single_format=None, rows=None, write_opf=None, save_cover=None, path=None, opts=None): if rows is None: rows = self.gui.current_view().selectionModel().selectedRows() if not rows or len(rows) == 0: return error_dialog(self.gui, _('Cannot save to disk'), _('No books selected'), show=True) if not path: path = choose_dir(self.gui, 'save to disk dialog', _('Choose destination folder')) if not path: return dpath = os.path.abspath(path).replace('/', os.sep)+os.sep lpath = self.gui.library_view.model().db.library_path.replace('/', os.sep)+os.sep if dpath.startswith(lpath): return error_dialog(self.gui, _('Not allowed'), _('You are trying to save files into the calibre ' 'library. This can cause corruption of your ' 'library. Save to disk is meant to export ' 'files from your calibre library elsewhere.'), show=True) if self.gui.current_view() is self.gui.library_view: from calibre.gui2.save import Saver from calibre.library.save_to_disk import config if opts is None: opts = config().parse() #print('debug1: opts.template: {}, opts.send_template: {}, opts.send_timefmt: {}, opts.timefmt: {}, opts.update_metadata: {}'.format(opts.template, opts.send_template, opts.send_timefmt, opts.timefmt, opts.update_metadata)) if single_format is not None: opts.formats = single_format # Special case for Kindle annotation files if single_format.lower() in ['mbp','pdr','tan']: opts.to_lowercase = False opts.save_cover = False opts.write_opf = False opts.template = opts.send_template opts.single_dir = single_dir if write_opf is not None: opts.write_opf = write_opf if save_cover is not None: opts.save_cover = save_cover book_ids = set(map(self.gui.library_view.model().id, rows)) Saver(book_ids, self.gui.current_db, opts, path, parent=self.gui, pool=self.gui.spare_pool()) else: paths = self.gui.current_view().model().paths(rows) self.gui.device_manager.save_books( Dispatcher(self.books_saved), paths, path) class ConfigWidget(QWidget): def __init__(self, plugin_action): QWidget.__init__(self) self.plugin_action = plugin_action self.gui = plugin_action.gui self.db = self.gui.current_db self._init_controls() def _init_controls(self): l = self.l = QVBoxLayout() self.setLayout(l) self.path_box = QGroupBox(_('&Choose path:')) l.addWidget(self.path_box) path_layout = QVBoxLayout() self.path_box.setLayout(path_layout) self.path_combo = DragDropComboBox(self, drop_mode='file') path_layout.addWidget(self.path_combo) hl1 = QHBoxLayout() path_layout.addLayout(hl1) hl1.addWidget(self.path_combo, 1) self.choose_path_button = QToolButton(self) self.choose_path_button.setToolTip(_('Choose path')) self.choose_path_button.setIcon(get_icon('document_open.png')) self.choose_path_button.clicked.connect(self._choose_path) hl1.addWidget(self.choose_path_button) formats_gb = QGroupBox(_('Formats: '), self) l.addWidget(formats_gb) formats_l = QVBoxLayout() formats_gb.setLayout(formats_l) self.all_fmts_opt = QRadioButton(_('All formats')) self.all_fmts_opt.setChecked(True) formats_l.addWidget(self.all_fmts_opt) self.fmts_list_layout = QHBoxLayout() self.fmts_list_opt = QRadioButton(_('Save only specified formats')) self.fmts_list_edit = QLineEdit() self.fmts_list_edit.setToolTip(_('Comma separated list of formsts you want to save')) self.fmts_list_layout.addWidget(self.fmts_list_opt, 1) self.fmts_list_layout.addWidget(self.fmts_list_edit) formats_l.addLayout(self.fmts_list_layout) template_gb = QGroupBox(_('Template')) template_gb_l = QVBoxLayout() template_gb.setLayout(template_gb_l) self.default_template_opt = QRadioButton(_('Use template define in calibre preferences')) template_gb_l.addWidget(self.default_template_opt) self.default_template_opt.setChecked(True) self.user_template_opt = QRadioButton(_('Define a custom template for this action')) template_gb_l.addWidget(self.user_template_opt) l.addWidget(template_gb) self.user_template_opt.toggled.connect(self._on_template_opt_toggled) self.template_edit = TemplateLineEditor(self) template_gb_l.addWidget(self.template_edit) opts_gb = QGroupBox(_('Options: '), self) l.addWidget(opts_gb) opts_l = QVBoxLayout() opts_gb.setLayout(opts_l) self.single_folder_chk = QCheckBox(_('Save in a single folder')) opts_l.addWidget(self.single_folder_chk) self.save_cover_chk = QCheckBox(_('Save cover')) self.save_cover_chk.setChecked(True) opts_l.addWidget(self.save_cover_chk) self.save_opf_chk = QCheckBox(_('Save opf')) self.save_opf_chk.setChecked(True) opts_l.addWidget(self.save_opf_chk) l.addStretch(1) # self.all_formats = self.gui.library_view.model().db.all_formats() self._on_template_opt_toggled() self.setMinimumSize(400,500) def _on_template_opt_toggled(self): self.template_edit.setEnabled(self.user_template_opt.isChecked()) def _choose_path(self): path = choose_dir(self.gui, 'save to disk dialog', _('Choose destination folder')) if not path: return if iswindows: path = os.path.normpath(path) self.block_events = True existing_index = self.path_combo.findText(path, Qt.MatchExactly) if existing_index >= 0: self.path_combo.setCurrentIndex(existing_index) else: self.path_combo.insertItem(0, path) self.path_combo.setCurrentIndex(0) self.block_events = False def load_settings(self, settings): if settings: if settings['fmt_opt'] == 'all_formats': self.all_fmts_opt.setChecked(True) elif settings['fmt_opt'] == 'selected_formats': self.fmts_list_opt.setChecked(True) fmt = settings['formats'] self.fmts_list_edit.setText(fmt) self.path_combo.setCurrentText(settings['path']) template_opt = settings.get('template_opt', 'default') if template_opt == 'default': self.default_template_opt.setChecked(True) elif template_opt == 'user': self.user_template_opt.setChecked(True) self.template_edit.setText(settings['template']) self.single_folder_chk.setChecked(settings['single_folder']) self.save_cover_chk.setChecked(settings['save_cover']) self.save_opf_chk.setChecked(settings['save_opf']) def save_settings(self): settings = {} settings['path'] = self.path_combo.currentText().strip() if self.fmts_list_opt.isChecked(): settings['fmt_opt'] = 'selected_formats' settings['formats'] = self.fmts_list_edit.text() elif self.all_fmts_opt.isChecked(): settings['fmt_opt'] = 'all_formats' if self.default_template_opt.isChecked(): settings['template_opt'] = 'default' elif self.user_template_opt.isChecked(): settings['template_opt'] = 'user' settings['template'] = self.template_edit.text() settings['single_folder'] = self.single_folder_chk.isChecked() settings['save_cover'] = self.save_cover_chk.isChecked() settings['save_opf'] = self.save_opf_chk.isChecked() return settings class SaveToAction(ChainAction): name = 'Save to disk' def run(self, gui, settings, chain): from calibre.library.save_to_disk import config opts = copy.deepcopy(config().parse()) path = settings['path'] single_dir = settings['single_folder'] if settings['fmt_opt'] == 'selected_formats': opts.formats = settings['formats'].lower() if settings.get('template_opt', 'default') == 'user': opts.template = settings['template'] save_cover = settings['save_cover'] write_opf = settings['save_opf'] action = ModifiedSaveToDiskAction(gui) action.save_to_disk(single_dir=single_dir, write_opf=write_opf, save_cover=save_cover, path=path, opts=opts) def validate(self, settings): if not settings: return (_('Settings Error'), _('You must configure this action before running it')) path = settings['path'] if not path: return (_('No Directory'), _('You must specify a path to valid path')) if not (os.access(path, os.W_OK) and os.path.isdir(path)): return (_('Invalid direcotry'), _('Path is not a writable directory: {}'.format(path))) if not settings.get('fmt_opt'): return (_('No Path'), _('You must choose a format option')) if settings['fmt_opt'] == 'selected_formats': if not settings['formats']: return (_('No format'), _('You must choose a valid format value')) template_opt = settings.get('template_opt', 'default') if template_opt == 'user': template = settings['template'] if not template: return (_('No template'), _('You must specify a template')) else: # only calibre template functions, exclude Action Chains defined functions template_functions = formatter_functions().get_functions() is_template_valid = check_template(template, self.plugin_action, print_error=False, template_functions=template_functions) if is_template_valid is not True: return is_template_valid return True def config_widget(self): return ConfigWidget Last edited by capink; 01-07-2022 at 07:15 AM. Reason: Allow specifying multiple formats to save |
Advert | |
|
08-31-2021, 05:33 PM | #18 |
Chalut o/
Posts: 420
Karma: 145424
Join Date: Dec 2017
Device: Kobo
|
Suggest for the forum :
A button "Copy Code", because it can clearly improve the quality of life. Especially with so many good modules. |
08-31-2021, 06:15 PM | #19 |
null operator (he/him)
Posts: 20,989
Karma: 27620706
Join Date: Mar 2012
Location: Sydney Australia
Device: none
|
|
09-24-2021, 05:47 AM | #20 |
Wizard
Posts: 1,138
Karma: 1954142
Join Date: Aug 2015
Device: Kindle
|
Switch to VL view
This action allows the user to assign View Manager views to virtual libraries. When used with "VL Tab Changed" event, it will automatically apply the corresponding view whenever the user switches VL tabs.
Code:
import copy from functools import partial from qt.core import (QApplication, Qt, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QGroupBox, QAbstractTableModel, QModelIndex, QSizePolicy, QToolButton, QSpacerItem, QIcon, QBrush, pyqtSignal) from calibre import prints from calibre.constants import DEBUG from calibre.gui2 import error_dialog from calibre.gui2.widgets2 import Dialog from calibre_plugins.action_chains.actions.base import ChainAction from calibre_plugins.action_chains.common_utils import get_icon, ViewLog from calibre_plugins.action_chains.database import get_valid_vls from calibre_plugins.action_chains.gui.delegates import ComboDelegate from calibre_plugins.action_chains.gui.models import UP, DOWN from calibre_plugins.action_chains.gui.views import TableView import calibre_plugins.action_chains.config as cfg ALL_BOOKS = '_ALL_BOOKS' KEY_TABS_VIEWS_TABLE_STATE = 'tabsViewsTableStates' def get_vls(db): vls = get_valid_vls(db) vls.insert(0, ALL_BOOKS) return vls def view_manager_views(gui): try: import calibre_plugins.view_manager.config as vm_cfg views = vm_cfg.get_library_config(gui.current_db)[vm_cfg.KEY_VIEWS] return views except: import traceback print(traceback.format_exc()) return [] class TabsModel(QAbstractTableModel): error = pyqtSignal(str, str) def __init__(self, plugin_action, tabs_config=[]): QAbstractTableModel.__init__(self) self.tabs_config = tabs_config self.plugin_action = plugin_action self.gui = self.plugin_action.gui self.db = self.gui.current_db self.col_map = ['tab_name','view_name','errors'] self.editable_columns = ['tab_name','view_name'] #self.hidden_cols = ['errors'] self.hidden_cols = [] self.col_min_width = { 'tab_name': 300, 'view_name': 300 } all_headers = [_('VL'), _('View'), _('errors')] self.headers = all_headers def rowCount(self, parent): if parent and parent.isValid(): return 0 return len(self.tabs_config) def columnCount(self, parent): if parent and parent.isValid(): return 0 return len(self.headers) def headerData(self, section, orientation, role): if role == Qt.DisplayRole and orientation == Qt.Horizontal: return self.headers[section] elif role == Qt.DisplayRole and orientation == Qt.Vertical: return section + 1 return None def data(self, index, role): if not index.isValid(): return None; row, col = index.row(), index.column() if row < 0 or row >= len(self.tabs_config): return None tab_config = self.tabs_config[row] col_name = self.col_map[col] value = tab_config.get(col_name, '') error = tab_config.get('errors', '') if role in [Qt.DisplayRole, Qt.UserRole, Qt.EditRole]: if col_name == 'errors': if error: return error else: return value elif role == Qt.DecorationRole: if col_name == 'errors': if error: return QIcon(get_icon('dialog_error.png')) elif role == Qt.ToolTipRole: if col_name == 'errors': if error: return error elif role == Qt.ForegroundRole: color = None if error: color = Qt.red if color is not None: return QBrush(color) return None def setData(self, index, value, role): done = False row, col = index.row(), index.column() tab_config = self.tabs_config[row] val = str(value).strip() col_name = self.col_map[col] if role == Qt.EditRole: # make sure no duplicate event entries if col_name == 'tab_name': old_name = self.data(index, Qt.DisplayRole) names = self.get_names() if old_name in names: names.remove(old_name) if val in names: msg = _('Duplicate vls') details = _('Name ({}) is used in more than one entry'.format(val)) self.error.emit(msg, details) else: tab_config[col_name] = val else: tab_config[col_name] = val done = True return done def flags(self, index): flags = QAbstractTableModel.flags(self, index) if index.isValid(): tab_config = self.tabs_config[index.row()] col_name = self.col_map[index.column()] if col_name in self.editable_columns: flags |= Qt.ItemIsEditable return flags def insertRows(self, row, count, idx): self.beginInsertRows(QModelIndex(), row, row + count - 1) for i in range(0, count): tab_config = {} tab_config['tab_name'] = '' tab_config['view_name'] = '' self.tabs_config.insert(row + i, tab_config) self.endInsertRows() return True def removeRows(self, row, count, idx): self.beginRemoveRows(QModelIndex(), row, row + count - 1) for i in range(0, count): self.tabs_config.pop(row + i) self.endRemoveRows() return True def move_rows(self, rows, direction=DOWN): srows = sorted(rows, reverse=direction == DOWN) for row in srows: pop = self.tabs_config.pop(row) self.tabs_config.insert(row+direction, pop) self.layoutChanged.emit() def get_names(self): names = [] col = self.col_map.index('tab_name') for row in range(self.rowCount(QModelIndex())): index = self.index(row, col, QModelIndex()) name = self.data(index, Qt.DisplayRole) # empty name belong to separators, dont include if name: names.append(name) return names def validate(self): for tab_config in self.tabs_config: errors = [] tab_name = tab_config['tab_name'] view_name = tab_config['view_name'] if tab_name not in get_vls(self.db): errors.append(_('VL is not available')) if view_name not in view_manager_views(self.gui): errors.append(_('View is not available')) if errors: tab_config['errors'] = ' ::: '.join(errors) class TabsTable(TableView): def __init__(self, parent): TableView.__init__(self, parent) self.plugin_action = parent.plugin_action self.doubleClicked.connect(self._on_double_clicked) self.gui = self.plugin_action.gui self.db = self.gui.current_db self.horizontalHeader().setStretchLastSection(False) #self.setShowGrid(False) def set_model(self, _model): self.setModel(_model) _model.error.connect(lambda *args: error_dialog(self, *args, show=True)) self.col_map = _model.col_map # Hide columns for col_name in _model.hidden_cols: col = self.col_map.index(col_name) self.setColumnHidden(col, True) self.tabs_delegate = ComboDelegate(self, get_vls(self.db)) self.setItemDelegateForColumn(self.col_map.index('tab_name'), self.tabs_delegate) self.views_delegate = ComboDelegate(self, view_manager_views(self.gui)) self.setItemDelegateForColumn(self.col_map.index('view_name'), self.views_delegate) self.resizeColumnsToContents() # Make sure every other column has a minimum width for col_name, width in _model.col_min_width.items(): col = self.col_map.index(col_name) self._set_minimum_column_width(col, width) def _on_double_clicked(self, index): m = self.model() col_name = m.col_map[index.column()] if col_name == 'errors': tab_config = m.tabs_config[index.row()] details = tab_config.get('errors', '') self._view_error_details(details) def _view_error_details(self, details): ViewLog(_('Errors details'), details, self) class ConfigWidget(QWidget): def __init__(self, plugin_action): QWidget.__init__(self) self.plugin_action = plugin_action self.gui = plugin_action.gui self.db = self.gui.current_db #self.tabs_config = tabs_config self._init_controls() def _init_controls(self): self.setWindowTitle(_('Configure views')) self.l = l = QVBoxLayout() self.setLayout(l) settings_l = QGridLayout() l.addLayout(settings_l) _table_gb = QGroupBox(_('Views')) _table_l = QHBoxLayout() _table_gb.setLayout(_table_l) l.addWidget(_table_gb) self._table = TabsTable(self) _table_l.addWidget(self._table) _model = self._model = TabsModel(self.plugin_action) _model.validate() self._table.set_model(_model) self._table.selectionModel().selectionChanged.connect(self._on_table_selection_change) # restore table state state = cfg.plugin_prefs.get(KEY_TABS_VIEWS_TABLE_STATE, None) if state: self._table.apply_state(state) # Add a vertical layout containing the the buttons to move up/down etc. button_layout = QVBoxLayout() _table_l.addLayout(button_layout) move_up_button = self.move_up_button = QToolButton(self) move_up_button.setToolTip(_('Move row up')) move_up_button.setIcon(QIcon(I('arrow-up.png'))) button_layout.addWidget(move_up_button) spacerItem1 = QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding) button_layout.addItem(spacerItem1) add_button = self.add_button = QToolButton(self) add_button.setToolTip(_('Add row')) add_button.setIcon(QIcon(I('plus.png'))) button_layout.addWidget(add_button) spacerItem2 = QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding) button_layout.addItem(spacerItem2) delete_button = self.delete_button = QToolButton(self) delete_button.setToolTip(_('Delete row')) delete_button.setIcon(QIcon(I('minus.png'))) button_layout.addWidget(delete_button) spacerItem4 = QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding) button_layout.addItem(spacerItem4) move_down_button = self.move_down_button = QToolButton(self) move_down_button.setToolTip(_('Move row down')) move_down_button.setIcon(QIcon(I('arrow-down.png'))) button_layout.addWidget(move_down_button) move_up_button.clicked.connect(partial(self._table.move_rows,UP)) move_down_button.clicked.connect(partial(self._table.move_rows,DOWN)) add_button.clicked.connect(self._table.add_row) delete_button.clicked.connect(self._table.delete_rows) self._on_table_selection_change() self.setMinimumSize(400, 300) l.addStretch(1) def _on_table_selection_change(self): sm = self._table.selectionModel() selection_count = len(sm.selectedRows()) self.delete_button.setEnabled(selection_count > 0) self.move_up_button.setEnabled(selection_count > 0) self.move_down_button.setEnabled(selection_count > 0) def save_table_state(self): # save table state cfg.plugin_prefs[KEY_TABS_VIEWS_TABLE_STATE] = self._table.get_state() def load_settings(self, settings): self._model.tabs_config = settings['tabs_config'] self._model.validate() self._model.layoutChanged.emit() def save_settings(self): self.save_table_state() settings = {} tabs_config = self._table.model().tabs_config # remove error keys from event_members for tab_config in tabs_config: try: del tab_config['errors'] except: pass settings['tabs_config'] = tabs_config return settings class SwitchToVLView(ChainAction): name = 'Switch To VL View' def run(self, gui, settings, chain): idx = gui.vl_tabs.currentIndex() vl = str(gui.vl_tabs.tabData(idx) or '').strip() or ALL_BOOKS print('debug1: vl: {}'.format(vl)) if vl: view = self.vl_view_lookup(vl, settings['tabs_config']) print('debug2: view: {}'.format(view)) if view: if not view in view_manager_views(gui): if DEBUG: prints('Action Chains: Switch To VL View: view ({}) is not available'.format(view)) return self.switch_view(gui, view, vl) else: if DEBUG: prints('Action Chains: Switch To VL View: VL Tab ({}) has no configured view'.format(vl)) def vl_view_lookup(self, vl, tabs_config): for tab_config in tabs_config: if vl == tab_config['tab_name']: return tab_config['view_name'] def switch_view(self, gui, view, vl): view_manager = gui.iactions.get('View Manager') if view_manager: import calibre_plugins.view_manager.config as vm_cfg library_config = vm_cfg.get_library_config(gui.current_db) view_info = copy.deepcopy(library_config[vm_cfg.KEY_VIEWS][view]) #### view_info[vm_cfg.KEY_APPLY_VIRTLIB] = False #### selected_ids = gui.library_view.get_selected_ids() # Persist this as the last selected view if library_config.get(vm_cfg.KEY_LAST_VIEW, None) != view: library_config[vm_cfg.KEY_LAST_VIEW] = view vm_cfg.set_library_config(gui.current_db, library_config) # if view_info.get(vm_cfg.KEY_APPLY_VIRTLIB,False): # view_manager.apply_virtlib(view_info[vm_cfg.KEY_VIRTLIB]) if view_info[vm_cfg.KEY_APPLY_RESTRICTION]: view_manager.apply_restriction(view_info[vm_cfg.KEY_RESTRICTION]) if view_info[vm_cfg.KEY_APPLY_SEARCH]: view_manager.apply_search(view_info[vm_cfg.KEY_SEARCH]) view_manager.apply_column_and_sort(view_info) gui.library_view.select_rows(selected_ids) view_manager.current_view = view view_manager.rebuild_menus() else: if DEBUG: prints('Action Chains: Switch To VL View: View Manager Plugin not available') def validate(self, settings): if not settings: return (_('Settings Error'), _('You must configure this action before running it')) return True def config_widget(self): return ConfigWidget Last edited by capink; 01-07-2022 at 07:16 AM. |
Advert | |
|
12-21-2021, 06:30 AM | #21 | |
Wizard
Posts: 1,138
Karma: 1954142
Join Date: Aug 2015
Device: Kindle
|
Refresh current search
Quote:
Code:
from calibre_plugins.action_chains.actions.base import ChainAction class RefreshSearch(ChainAction): name = 'Refresh Current Search' def run(self, gui, settings, chain): gui.search.do_search() |
|
12-21-2021, 06:30 AM | #22 |
Wizard
Posts: 1,138
Karma: 1954142
Join Date: Aug 2015
Device: Kindle
|
Multisort action
Code:
#!/usr/bin/env python # ~*~ coding: utf-8 ~*~ __license__ = 'GPL v3' __copyright__ = '2021, Ahmed Zaki <azaki00.dev@gmail.com>' __docformat__ = 'restructuredtext en' from qt.core import (QApplication, Qt, QWidget, QGridLayout, QHBoxLayout, QVBoxLayout, QLabel, QGroupBox, QToolButton, QPushButton, QScrollArea, QComboBox, QRadioButton, QCheckBox, QSizePolicy) from calibre import prints from calibre.constants import DEBUG from calibre.gui2 import error_dialog from calibre_plugins.action_chains.actions.base import ChainAction from calibre_plugins.action_chains.common_utils import get_icon try: load_translations() except NameError: prints("ActionChains::actions/sort.py - exception when loading translations") pass def get_cols(db): standard = [ 'title', 'authors', 'tags', 'series', 'publisher', 'pubdate', 'rating', 'languages', 'last_modified', 'timestamp', 'comments', 'author_sort', 'sort', 'marked', 'identifiers', 'cover', 'formats' ] custom = sorted([ k for k,v in db.field_metadata.custom_field_metadata().items() if v['datatype'] not in [None,'composite'] ]) return standard + custom class SortControl(QGroupBox): def __init__(self, plugin_action, possible_cols): self.plugin_action = plugin_action self.possible_cols = possible_cols self.gui = plugin_action.gui self.db = self.gui.current_db self._init_controls() def _init_controls(self): QGroupBox.__init__(self) l = QGridLayout() self.setLayout(l) row_idx = 0 remove_label = QLabel('<a href="close">✕</a>') remove_label.setToolTip(_('Remove')) remove_label.linkActivated.connect(self._remove) l.addWidget(remove_label, row_idx, 1, 1, 1, Qt.AlignRight) row_idx += 1 gb1 = QGroupBox('') gb1_l = QVBoxLayout() gb1.setLayout(gb1_l) gb1_text = _('Column:') self.col_combo_box = QComboBox() self.col_combo_box.addItems(self.possible_cols) self.col_combo_box.setCurrentIndex(-1) gb1_l.addWidget(self.col_combo_box) gb1.setTitle(gb1_text) l.addWidget(gb1, row_idx, 0, 1, 1) gb2 = QGroupBox(_('Sort direction'), self) gb2_l = QVBoxLayout() gb2.setLayout(gb2_l) self.button_ascend = QRadioButton(_('Ascending'), self) gb2_l.addWidget(self.button_ascend) self.button_ascend.setChecked(True) self.button_descend = QRadioButton(_('Descending'), self) gb2_l.addWidget(self.button_descend) self.button_descend.setChecked(False) l.addWidget(gb2, row_idx, 1, 1, 1) row_idx += 1 l.setColumnStretch(0, 1) self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum) def apply_sort_filter(self, sort_filter): field, is_ascending = sort_filter self.col_combo_box.setCurrentText(field) self.button_ascend.setChecked(is_ascending) self.button_descend.setChecked(not is_ascending) def _remove(self): self.setParent(None) self.deleteLater() def isComplete(self): '''returns True only if a field and direction are chosen''' if self.col_combo_box.currentText() == '': return False return True def get_sort_filter(self): field = self.col_combo_box.currentText() is_ascending = self.button_ascend.isChecked() return (field, is_ascending) class SortControlsContainer(QWidget): def __init__(self, plugin_action, possible_cols): self.plugin_action = plugin_action self.gui = plugin_action.gui self.db = self.gui.current_db self.possible_cols = possible_cols self._init_controls() def _init_controls(self): QWidget.__init__(self) l = QVBoxLayout() self.setLayout(l) hl1 = QHBoxLayout() clear_button = QPushButton(_('Clear')) clear_button.setToolTip(_('Clear all filters')) clear_button.setIcon(get_icon('clear_left.png')) clear_button.clicked.connect(self.reset) hl1.addWidget(clear_button) hl1.addStretch(1) hl1.addStretch(1) add_button = QPushButton(_('Add Sort Filter')) add_button.setToolTip(_('Add a column to sort by')) add_button.setIcon(get_icon('plus.png')) add_button.clicked.connect(self.add_control) hl1.addWidget(add_button) l.addLayout(hl1) w = QWidget(self) self.controls_layout = QVBoxLayout() self.controls_layout.setSizeConstraint(self.controls_layout.SetMinAndMaxSize) w.setLayout(self.controls_layout) scroll = QScrollArea() scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) scroll.setWidgetResizable(True) scroll.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) scroll.setObjectName('myscrollarea') scroll.setStyleSheet('#myscrollarea {background-color: transparent}') scroll.setWidget(w) l.addWidget(scroll) self._add_control(sort_filter={}) def isComplete(self): '''return True if all controls have fields and algorithms set''' for idx in range(self.controls_layout.count()): control = self.controls_layout.itemAt(idx).widget() if not control.isComplete(): return False return True def _add_control(self, sort_filter=None): control = SortControl(self.plugin_action, self.possible_cols) if sort_filter: control.apply_sort_filter(sort_filter) self.controls_layout.addWidget(control) def add_control(self): if not self.isComplete(): error_dialog( self, _('Incomplete Sort Filter'), _('You must complete the previous sort filter(s) to proceed.'), show=True ) return self._add_control() def reset(self, add_empty_control=True): # remove controls in reverse order for idx in reversed(range(self.controls_layout.count())): control = self.controls_layout.itemAt(idx).widget() control.setParent(None) control.deleteLater() if add_empty_control: self._add_control() def get_sort_filters(self): all_filters = [] for idx in range(self.controls_layout.count()): control = self.controls_layout.itemAt(idx).widget() all_filters.append(control.get_sort_filter()) return all_filters class ConfigWidget(QWidget): def __init__(self, plugin_action): QWidget.__init__(self) self.plugin_action = plugin_action self.gui = plugin_action.gui self.db = self.gui.current_db self.possible_cols = get_cols(self.db) self._init_controls() def _init_controls(self): l = QVBoxLayout() self.setLayout(l) self.container = SortControlsContainer(self.plugin_action, self.possible_cols) l.addWidget(self.container, 1) self.resize(500, 600) self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) def load_settings(self, settings): sort_filters = settings['sort_filters'] self.container.reset(add_empty_control=False) for sort_filter in sort_filters: self.container._add_control(sort_filter) def save_settings(self): settings = {'sort_filters': self.container.get_sort_filters()} return settings class SortAction(ChainAction): name = 'Sort by field' #_is_builtin = True def run(self, gui, settings, chain): sort_filters = settings['sort_filters'] gui.library_view.multisort(sort_filters) def validate(self, settings): gui = self.plugin_action.gui db = gui.current_db if not settings: return (_('Settings Error'), _('You must configure this action before running it')) for sort_filter in settings['sort_filters']: field, is_ascending = sort_filter if not field: return _('No field'), _('You must specify a field for all filters') elif not field in get_cols(db): return _('Field unavailabe'), _('Current library does not have a field called {}'.format(field)) return True def config_widget(self): return ConfigWidget Last edited by capink; 01-07-2022 at 07:17 AM. |
12-28-2021, 05:49 PM | #23 |
Custom User Title
Posts: 9,521
Karma: 64500175
Join Date: Oct 2018
Location: Canada
Device: Kobo Libra H2O, formerly Aura HD
|
Re-calc composite columns
Here is a module I use to recalc the data of a composite column — in my case, an if-then checking current_virtual_library_name() sometimes caches and doesn't change when switching VLs.
Code:
from calibre_plugins.action_chains.actions.base import ChainAction class RefreshAction(ChainAction): name = 'Refresh GUI' def run(self, gui, settings, chain): gui.current_db.data.refresh() gui.library_view.model().resort() Last edited by ownedbycats; 01-29-2022 at 10:28 PM. |
01-04-2022, 05:08 PM | #24 |
Wizard
Posts: 1,138
Karma: 1954142
Join Date: Aug 2015
Device: Kindle
|
Swap title author
Code:
from collections import defaultdict from calibre.ebooks.metadata import ( authors_to_string, authors_to_sort_string, check_isbn, string_to_authors, title_sort ) from calibre_plugins.action_chains.actions.base import ChainAction class SwapTitleAuthors(ChainAction): name = 'Swap title and author' support_scopes = True def __init__(self, plugin_action): ChainAction.__init__(self, plugin_action) self.gui = plugin_action.gui self.db = self.gui.current_db def author_sort_from_authors(self, authors): return self.db.new_api.author_sort_from_authors(authors, key_func=lambda x: x) def book_lang(self, book_id): try: book_lang = self.db.new_api.field_for('languages', book_id)[0] except: book_lang = None return book_lang def swap_title_author(self, book_ids): id_fields_maps = defaultdict(dict) for book_id in book_ids: old_title = self.db.new_api.field_for('title', book_id) old_authors = self.db.new_api.field_for('authors', book_id) new_authors = string_to_authors(old_title) id_fields_maps['authors'][book_id] = new_authors id_fields_maps['author_sort'][book_id] = self.author_sort_from_authors(new_authors) new_title = authors_to_string(old_authors) id_fields_maps['title'][book_id] = new_title id_fields_maps['sort'][book_id] = title_sort(new_title, lang=self.book_lang(book_id)) for field, id_val_map in id_fields_maps.items(): self.db.new_api.set_field(field, id_val_map) def run(self, gui, settings, chain): book_ids = chain.scope().get_book_ids() self.swap_title_author(book_ids) Last edited by capink; 01-04-2022 at 05:11 PM. |
01-29-2022, 09:06 PM | #25 |
Custom User Title
Posts: 9,521
Karma: 64500175
Join Date: Oct 2018
Location: Canada
Device: Kobo Libra H2O, formerly Aura HD
|
Prompt for Action
Modified from 'Prompt for Confirmation' (thanks to capink for assistance) is "Prompt for Action,' to enable selected actions using a yes/no prompt.
Code:
from qt.core import QWidget, QVBoxLayout, QGroupBox, QTextEdit from calibre.gui2 import question_dialog from calibre_plugins.action_chains.actions.base import ChainAction class ConfirmConfigWidget(QWidget): def __init__(self, plugin_action): QWidget.__init__(self) self._init_controls() def _init_controls(self): l = QVBoxLayout() self.setLayout(l) gb = QGroupBox('Confirm message') gb_l = QVBoxLayout() gb.setLayout(gb_l) self.tb = QTextEdit() self.tb.insertPlainText('Do you want to run the next action?') gb_l.addWidget(self.tb) l.addWidget(gb) def load_settings(self, settings): if settings: self.tb.setText(settings['message']) def save_settings(self): settings = {} settings['message'] = self.tb.toPlainText() return settings class ConfirmAction(ChainAction): name = 'Prompt for Action' def config_widget(self): return ConfirmConfigWidget def run(self, gui, settings, chain): message = settings.get('message', 'Do you want to run the next action?') if question_dialog(gui, _('Are you sure?'), message, show_copy_button=False): chain.set_chain_vars({'prompt_action': 'Yes'}) Place the module action somewhere in the chain, obviously before the action you wish to prompt for, and then add the program: globals(prompt_action) text = Yes condition to the actions you wish to prompt for. I've attached a sample chain for demonstration. Last edited by capink; 01-30-2022 at 07:58 PM. Reason: Change import statement to qt.core |
05-10-2022, 05:14 PM | #26 |
Chalut o/
Posts: 420
Karma: 145424
Join Date: Dec 2017
Device: Kobo
|
Bulk Sort Key
The purpose of this module is to update the title and/or author sorting key, like the options in the Bulk Metadata dialog, options that are unavailable via Action Chains (I won't open the huge Bulk Metadata dialog just to check the same 2 options everytime => go on to make a non-interactive module)
Bonus: Normally translated into your language Code:
import os, copy try: from qt.core import (QApplication, Qt, QWidget, QGridLayout, QHBoxLayout, QVBoxLayout, QGroupBox, QCheckBox) except ImportError: from PyQt5.Qt import (QApplication, Qt, QWidget, QGridLayout, QHBoxLayout, QVBoxLayout, QGroupBox, QCheckBox) from calibre import prints from calibre.constants import DEBUG from calibre_plugins.action_chains.actions.base import ChainAction class BulkSortKeyAction(ChainAction): # the name of your action name = 'Bulk Sort Key' support_scopes = True def run(self, gui, settings, chain): cache = gui.current_db.new_api self.ids = chain.scope().get_book_ids() if not self.ids or len(self.ids) == 0: return if settings.get(KEY.SORT_TITLE, DEFAULT[KEY.SORT_TITLE]): from calibre.ebooks.metadata import title_sort lang_map = cache.all_field_for('languages', self.ids) title_map = cache.all_field_for('title', self.ids) def get_sort(book_id): try: lang = lang_map[book_id][0] except (KeyError, IndexError, TypeError, AttributeError): lang = None return title_sort(title_map[book_id], lang=lang) cache.set_field('sort', {bid:get_sort(bid) for bid in self.ids}) if settings.get(KEY.SORT_AUTHOR, DEFAULT[KEY.SORT_AUTHOR]): author_sort_map = cache.author_sort_strings_for_books(self.ids) cache.set_field('author_sort', {book_id: ' & '.join(author_sort_map[book_id]) for book_id in author_sort_map}) def config_widget(self): return ConfigWidget class KEY: SORT_AUTHOR = 'sort_author' SORT_TITLE = 'sort_title' DEFAULT = {} DEFAULT[KEY.SORT_AUTHOR] = True DEFAULT[KEY.SORT_TITLE] = True class ConfigWidget(QWidget): def __init__(self, plugin_action): QWidget.__init__(self) self.plugin_action = plugin_action self.gui = plugin_action.gui self.db = self.gui.current_db self._init_controls() def _init_controls(self): l = self.l = QVBoxLayout() self.setLayout(l) gb_opts = QGroupBox(_("Options"), self) l.addWidget(gb_opts) l_opts = QVBoxLayout() gb_opts.setLayout(l_opts) self.chk_sort_author = QCheckBox(_("A&utomatically set author sort")) self.chk_sort_author.setChecked(DEFAULT[KEY.SORT_AUTHOR]) self.chk_sort_author.setToolTip(_("This will cause the author sort field to be automatically updated\n" " based on the authors field for each selected book. Note that if\n" " you use the control above to set authors in bulk, the author sort\n" " field is updated anyway, regardless of the value of this checkbox.")) l_opts.addWidget(self.chk_sort_author) self.chk_sort_title = QCheckBox(_("Update &title sort")) self.chk_sort_title.setChecked(DEFAULT[KEY.SORT_TITLE]) self.chk_sort_title.setToolTip(_("Update title sort based on the current title. This will be applied only after other changes to title.")) l_opts.addWidget(self.chk_sort_title) l.addStretch(1) self.setMinimumSize(300,100) def load_settings(self, settings): if not settings: settings = copy.deepcopy(DEFAULT) self.chk_sort_author.setChecked(settings.get(KEY.SORT_AUTHOR, DEFAULT[KEY.SORT_AUTHOR])) self.chk_sort_title.setChecked(settings.get(KEY.SORT_TITLE, DEFAULT[KEY.SORT_TITLE])) def save_settings(self): settings = {} settings[KEY.SORT_AUTHOR] = self.chk_sort_author.isChecked() settings[KEY.SORT_TITLE] = self.chk_sort_title.isChecked() return settings Last edited by un_pogaz; 08-20-2022 at 08:03 AM. |
05-15-2022, 01:49 AM | #27 |
Member
Posts: 11
Karma: 10
Join Date: Dec 2021
Device: Kobo Libra 2
|
Thank you for the fix.
Ciao MP |
09-15-2022, 02:30 PM | #28 |
Wizard
Posts: 1,138
Karma: 1954142
Join Date: Aug 2015
Device: Kindle
|
Download metadata action
Code:
#!/usr/bin/env python # ~*~ coding: utf-8 ~*~ __license__ = 'GPL v3' __copyright__ = '2022, Ahmed Zaki <azaki00.dev@gmail.com>' __docformat__ = 'restructuredtext en' from qt.core import (QApplication, Qt, QWidget, QVBoxLayout, QCheckBox, QGroupBox, QRadioButton, QAction) import copy import types from functools import partial from calibre import prints from calibre.constants import DEBUG from calibre.gui2 import Dispatcher, error_dialog from calibre.ptempfile import PersistentTemporaryFile from calibre.gui2.metadata.bulk_download import Job, download from calibre.gui2.actions.edit_metadata import EditMetadataAction from polyglot.builtins import iteritems from calibre_plugins.action_chains.actions.base import ChainAction from calibre_plugins.action_chains.common_utils import responsive_wait, responsive_wait_until def unfinished_job_ids(gui): return set([job.id for job in gui.job_manager.unfinished_jobs()]) class ModifiedEditMetadataAction(EditMetadataAction): name = 'Modified Edit Metadata' action_spec = (_('Modified Edit metadata'), 'edit_input.png', _('Change the title/author/cover etc. of books'), _('')) action_type = 'current' action_add_menu = True def __init__(self, parent, site_customization): EditMetadataAction.__init__(self, parent, site_customization) self.do_genesis() @property def unique_name(self): bn = self.__class__.__name__ return 'Interface Action: %s (%s)'%(bn, self.name) def genesis(self): pass def location_selected(self, loc): pass def library_changed(self): pass def shutting_down(self): pass def metadata_downloaded(self, job): if job.failed: self.gui.job_exception(job, dialog_title=_('Failed to download metadata')) return from calibre.gui2.metadata.bulk_download import get_job_details (aborted, id_map, tdir, log_file, failed_ids, failed_covers, all_failed, det_msg, lm_map) = get_job_details(job) if aborted: return self.cleanup_bulk_download(tdir) if all_failed: num = len(failed_ids | failed_covers) self.cleanup_bulk_download(tdir) return error_dialog(self.gui, _('Download failed'), ngettext( 'Failed to download metadata or cover for the selected book.', 'Failed to download metadata or covers for any of the {} books.', num ).format(num), det_msg=det_msg, show=True) self.gui.status_bar.show_message(_('Metadata download completed'), 3000) msg = '<p>' + ngettext( 'Finished downloading metadata for the selected book.', 'Finished downloading metadata for <b>{} books</b>.', len(id_map)).format(len(id_map)) + ' ' + \ _('Proceed with updating the metadata in your library?') show_copy_button = False checkbox_msg = None if failed_ids or failed_covers: show_copy_button = True num = len(failed_ids.union(failed_covers)) msg += '<p>'+_('Could not download metadata and/or covers for %d of the books. Click' ' "Show details" to see which books.')%num checkbox_msg = _('Show the &failed books in the main book list ' 'after updating metadata') if getattr(job, 'metadata_and_covers', None) == (False, True): # Only covers, remove failed cover downloads from id_map for book_id in failed_covers: if hasattr(id_map, 'discard'): id_map.discard(book_id) payload = (id_map, tdir, log_file, lm_map, failed_ids.union(failed_covers)) if self.do_review: QApplication.setOverrideCursor(Qt.ArrowCursor) try: self.apply_downloaded_metadata(True, payload, self.restrict_to_failed) finally: QApplication.restoreOverrideCursor() else: self.apply_downloaded_metadata(False, payload, self.restrict_to_failed) class ConfigWidget(QWidget): def __init__(self, plugin_action): QWidget.__init__(self) self.plugin_action = plugin_action self.gui = plugin_action.gui self.db = self.gui.current_db self._init_controls() def _init_controls(self): l = self.l = QVBoxLayout() self.setLayout(l) opt_gb = QGroupBox(_('Options')) opt_gb_l = QVBoxLayout() opt_gb.setLayout(opt_gb_l) l.addWidget(opt_gb) self.metadata_opt = QRadioButton(_('Download Metadata')) self.covers_opt = QRadioButton(_('Download Covers')) self.both_opt = QRadioButton(_('Download Both')) self.both_opt.setChecked(True) opt_gb_l.addWidget(self.metadata_opt) opt_gb_l.addWidget(self.covers_opt) opt_gb_l.addWidget(self.both_opt) self.review_chk = QCheckBox(_('Review downloaded metadata before applying them')) self.wait_chk = QCheckBox(_('Wait for metadata download jobs to finish')) self.wait_chk.setToolTip(_('Check this if this action in not the last action in the chain.')) l.addWidget(self.review_chk) l.addWidget(self.wait_chk) l.addStretch(1) self.setMinimumSize(500,300) def load_settings(self, settings): if settings: self.metadata_opt.setChecked(settings.get('download_metadata', False)) self.covers_opt.setChecked(settings.get('download_covers', False)) self.both_opt.setChecked(settings.get('download_both', True)) self.review_chk.setChecked(settings.get('review', True)) self.wait_chk.setChecked(settings.get('wait_jobs', False)) def save_settings(self): settings = {} settings['download_metadata'] = self.metadata_opt.isChecked() settings['download_covers'] = self.covers_opt.isChecked() settings['download_both'] = self.both_opt.isChecked() settings['review'] = self.review_chk.isChecked() settings['wait_jobs'] = self.wait_chk.isChecked() return settings class DownloadMetadata(ChainAction): name = 'Download Metadata' support_scopes = True def run(self, gui, settings, chain): identify = settings.get('download_metadata') or settings.get('download_both', True) covers = settings.get('download_covers') or settings.get('download_both', True) wait_jobs = settings.get('wait_jobs', False) ensure_fields = None edit_metadata = ModifiedEditMetadataAction(gui, '') edit_metadata.do_review = settings.get('review', True) edit_metadata.restrict_to_failed = settings.get('restrict_to_failed', True) callback = Dispatcher(edit_metadata.metadata_downloaded) ids = chain.scope().get_book_ids() if len(ids) == 0: return error_dialog(gui, _('Cannot download metadata'), _('No books selected'), show=True) jobs_before_ids = unfinished_job_ids(gui) tf = PersistentTemporaryFile('_metadata_bulk.log') tf.close() job = Job('metadata bulk download', ngettext( 'Download metadata for one book', 'Download metadata for {} books', len(ids)).format(len(ids)), download, (ids, tf.name, gui.current_db, identify, covers, ensure_fields), {}, callback) job.metadata_and_covers = (identify, covers) job.download_debug_log = tf.name gui.job_manager.run_threaded_job(job) gui.status_bar.show_message(_('Metadata download started'), 3000) if wait_jobs: # wait for jobs spawned by action to kick in responsive_wait(1) # save ids of jobs started after running the action ids_jobs_by_action = unfinished_job_ids(gui).difference(jobs_before_ids) # wait for jobs to finish responsive_wait_until(lambda: ids_jobs_by_action.intersection(unfinished_job_ids(gui)) == set()) def validate(self, settings): #if not settings: #return (_('Settings Error'), _('You must configure this action before running it')) return True def config_widget(self): return ConfigWidget Last edited by capink; 09-29-2022 at 05:52 PM. |
09-22-2022, 11:10 PM | #29 |
Wizard
Posts: 1,138
Karma: 1954142
Join Date: Aug 2015
Device: Kindle
|
This is an action to merge duplicates produced by the Find Duplicates plugin. You can merge the books using one of two options:
Note: Duplicates will be merged into the first book in each group. You can control the order of books in duplicate groups using the Find Duplicates advanced mode (group sorting option) Warning: This action can destroy your library as it merges book irreversibly. You use it at your risk, without any guarantee. You must always backup your library before using the action, and you must examine the results carefully until you are satisfied. If you brick your library without having backed it up, and without having examined the result meticulously, you bear full responsibility. Don't come here to complain or ask for support. You have been warned. Code:
import json from functools import partial from qt.core import (QApplication, Qt, QWidget, QVBoxLayout, QGroupBox, QRadioButton, QCheckBox) from calibre import prints from calibre.constants import DEBUG from calibre.gui2 import choose_files, error_dialog, question_dialog from calibre_plugins.action_chains.common_utils import DoubleProgressDialog, truncate from calibre_plugins.action_chains.actions.base import ChainAction #from calibre.gui2.actions.edit_metadata import EditMetadataAction class MergeConfigWidget(QWidget): def __init__(self, plugin_action): QWidget.__init__(self) self.plugin_action = plugin_action self.gui = plugin_action.gui self.db = self.gui.current_db self._init_controls() def _init_controls(self): l = self.l = QVBoxLayout() self.setLayout(l) groupbox = QGroupBox(_('Duplicate source')) groupbox_layout = QVBoxLayout() groupbox.setLayout(groupbox_layout) l.addWidget(groupbox) from_plugin_opt = self.from_plugin_opt = QRadioButton(_('From Find Duplicates Results')) from_file_opt = self.from_file_opt = QRadioButton(_('From Find Duplicates Exported Json')) groupbox_layout.addWidget(from_plugin_opt) groupbox_layout.addWidget(from_file_opt) from_plugin_opt.setChecked(True) self.confirm_chk = QCheckBox(_('Ask for confirmation before merging')) self.confirm_chk.setChecked(True) l.addWidget(self.confirm_chk) l.addStretch(1) self.setMinimumSize(300,300) def load_settings(self, settings): if settings: if settings['opt'] == 'from_plugin': self.from_plugin_opt.setChecked(True) elif settings['opt'] == 'from_file': self.from_file_opt.setChecked(True) self.confirm_chk.setChecked(settings.get('confirm', True)) def save_settings(self): settings = {} if self.from_plugin_opt.isChecked(): settings['opt'] = 'from_plugin' elif self.from_file_opt: settings['opt'] = 'from_file' settings['confirm'] = self.confirm_chk.isChecked() return settings class MergeDuplicates(ChainAction): name = 'Merge Duplicates' def merge_books(self, dest_id, src_ids): gui = self.plugin_action.gui db = gui.current_db em = gui.iactions['Edit Metadata'] em.add_formats(dest_id, em.formats_for_ids([dest_id]+src_ids)) em.merge_metadata(dest_id, src_ids) #em.delete_books_after_merge(src_ids) # delete books now and will notify db at the end db.new_api.remove_books(src_ids) self.deleted_ids.extend(src_ids) def notify_db_deleted_books(self): gui = self.plugin_action.gui gui.library_view.model().ids_deleted(self.deleted_ids) def pd_callback(self, db, duplicates, pbar): entangled_groups = set() for book_id, group_ids in duplicates['entangled_groups_for_book']: for group_id in group_ids: entangled_groups.add(group_id) pbar.update_overall(len(duplicates['books_for_group'])) self.deleted_ids = [] for group_id, book_ids in duplicates['books_for_group'].items(): if group_id in entangled_groups: msg = _('Group_id () in entangled groups. skipping'.format(group_id)) if DEBUG: prints('Action Chains: '+msg) else: dest_id = book_ids[0] src_ids = book_ids[1:] title = db.new_api.field_for('title', dest_id) title = truncate(title, 30) msg = _('Group ({}): merging into: {}'.format(group_id, title)) self.merge_books(dest_id, src_ids) pbar.update_progress(1, msg) def run(self, gui, settings, chain): if settings.get('confirm', True): message = _('The following action will merge books in your library and data will be permenatly lost. ' 'Are you sure you want to proceed?') if not question_dialog(gui, _('Are you sure?'), message, show_copy_button=False): return if settings['opt'] == 'from_file': filters=[(_('Settings'), ['json'])] json_file = choose_files(gui, 'Choose duplicates json file', _('Select duplicates json file'), filters=filters, select_only_single_file=True) if not json_file: return json_file = json_file[0] with open(json_file) as f: duplicates = json.load(f) if duplicates['library_uuid'] != gui.current_db.library_id: return error_dialog(gui, 'uuid error', 'Library uuid in duplicates json does not match current library uuid.\n' 'Quitting without merging', show=True) elif settings['opt'] == 'from_plugin': duplicates = {} find_duplicates_plugin = gui.iactions.get('Find Duplicates') if not find_duplicates_plugin: return duplicate_finder = find_duplicates_plugin.duplicate_finder # if not hasattr(duplicate_finder, '_groups_for_book_map'): # return if not duplicate_finder.has_results(): return entangled_books = {} for book_id, groups in duplicate_finder._groups_for_book_map.items(): if len(groups) > 1: entangled_books[book_id] = list(groups) duplicates['books_for_group'] = duplicate_finder._books_for_group_map duplicates['entangled_groups_for_book'] = entangled_books callback = partial(self.pd_callback, gui.current_db, duplicates) pd = DoubleProgressDialog(1, callback, gui, window_title=_('Merging ...')) gui.tags_view.blockSignals(True) QApplication.setOverrideCursor(Qt.ArrowCursor) try: pd.exec_() pd.thread = None if pd.error is not None: return error_dialog(gui, _('Failed'), pd.error[0], det_msg=pd.error[1], show=True) finally: self.notify_db_deleted_books() QApplication.restoreOverrideCursor() gui.tags_view.recount() def config_widget(self): return MergeConfigWidget def default_settings(self): return {'opt': 'from_plugin'} Last edited by capink; 09-22-2022 at 11:17 PM. |
09-25-2022, 05:58 AM | #30 | |
want to learn what I want
Posts: 1,252
Karma: 6426810
Join Date: Sep 2020
Device: Calibre E-book viewer
|
Quote:
I'm thinking it could be useful to have different shortcut keys for quick one-click metadata downloads like, one for Amazon, another for Goodreads, another one for BN... and so on. Or perhaps this could be another action script. Last edited by Comfy.n; 09-25-2022 at 06:15 AM. |
|
|
Similar Threads | ||||
Thread | Thread Starter | Forum | Replies | Last Post |
[GUI Plugin] Action Chains | capink | Plugins | 1404 | Yesterday 08:52 PM |
Book Scanning tool chains | tomsem | Workshop | 17 | 12-03-2023 10:19 AM |
Mystery and Crime Thorne, Guy: Chance in Chains (1914); v1 | Pulpmeister | Kindle Books | 0 | 11-25-2018 10:09 PM |
Mystery and Crime Thorne, Guy: Chance in Chains (1914); v1 | Pulpmeister | ePub Books | 0 | 11-25-2018 10:08 PM |
Could this be the last year for the big chains? | Connallmac | News | 66 | 01-07-2011 05:11 PM |