| 
			
			 | 
		#16 | 
| 
			
			
			
			 Wizard 
			
			![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() Posts: 1,216 
				Karma: 1995558 
				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.get('select_books', False))
    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
                # Wait for some seconds to make sure all books are added. If books are added by calibre
                # auto-add, there will not be a modal dialog, so we have to wait a little to make sure
                # auto-add finished adding all the books
                wait_time = 20
                if elapsed.total_seconds() > wait_time:
                    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-20-2025 at 02:12 PM.  | 
| 
		 | 
	
	
	
		
		
		
		
			 
		
		
		
		
		
		
		
			
		
		
		
	 | 
| 
			
			 | 
		#17 | 
| 
			
			
			
			 Wizard 
			
			![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() Posts: 1,216 
				Karma: 1995558 
				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  | 
| 
		 | 
	
	
	
		
		
		
		
			 
		
		
		
		
		
		
		
			
		
		
		
	 | 
| 
			
			 | 
		#18 | 
| 
			
			
			
			 Chalut o/ 
			
			![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() Posts: 486 
				Karma: 678910 
				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.  | 
| 
		 | 
	
	
	
		
		
		
		
			 
		
		
		
		
		
		
		
			
		
		
		
	 | 
| 
			
			 | 
		#19 | 
| 
			
			
			
			 null operator (he/him) 
			
			![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() Posts: 22,018 
				Karma: 30277294 
				Join Date: Mar 2012 
				Location: Sydney Australia 
				
				
				Device: none 
				
				
				 | 
	
	|
| 
		 | 
	
	
	
		
		
		
		
			 
		
		
		
		
		
		
		
			
		
		
		
	 | 
| 
			
			 | 
		#20 | 
| 
			
			
			
			 Wizard 
			
			![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() Posts: 1,216 
				Karma: 1995558 
				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.  | 
| 
		 | 
	
	
	
		
		
		
		
			 
		
		
		
		
		
		
		
			
		
		
		
	 | 
| 
			
			 | 
		#21 | |
| 
			
			
			
			 Wizard 
			
			![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() Posts: 1,216 
				Karma: 1995558 
				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()
 | 
|
| 
		 | 
	
	
	
		
		
		
		
			 
		
		
		
		
		
		
		
			
		
		
		
	 | 
| 
			
			 | 
		#22 | 
| 
			
			
			
			 Wizard 
			
			![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() Posts: 1,216 
				Karma: 1995558 
				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.  | 
| 
		 | 
	
	
	
		
		
		
		
			 
		
		
		
		
		
		
		
			
		
		
		
	 | 
| 
			
			 | 
		#23 | 
| 
			
			
			
			 Custom User Title 
			
			![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() Posts: 11,360 
				Karma: 79528341 
				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.  | 
| 
		 | 
	
	
	
		
		
		
		
			 
		
		
		
		
		
		
		
			
		
		
		
	 | 
| 
			
			 | 
		#24 | 
| 
			
			
			
			 Wizard 
			
			![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() Posts: 1,216 
				Karma: 1995558 
				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.  | 
| 
		 | 
	
	
	
		
		
		
		
			 
		
		
		
		
		
		
		
			
		
		
		
	 | 
| 
			
			 | 
		#25 | 
| 
			
			
			
			 Custom User Title 
			
			![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() Posts: 11,360 
				Karma: 79528341 
				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  | 
| 
		 | 
	
	
	
		
		
		
		
			 
		
		
		
		
		
		
		
			
		
		
		
	 | 
| 
			
			 | 
		#26 | 
| 
			
			
			
			 Chalut o/ 
			
			![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() Posts: 486 
				Karma: 678910 
				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 EDIT: If your have Quality Check 1.14.3 and above installed, you can use directly the same action in "Imported From Other Plugin > Quality Check > Run Quality Check fix" and select "Check and repair book size" 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; 10-05-2025 at 04:18 AM.  | 
| 
		 | 
	
	
	
		
		
		
		
			 
		
		
		
		
		
		
		
			
		
		
		
	 | 
| 
			
			 | 
		#27 | 
| 
			
			
			
			 Member 
			
			![]() Posts: 11 
				Karma: 10 
				Join Date: Dec 2021 
				
				
				
				Device: Kobo Libra 2 
				
				
				 | 
	
	
	
		
		
		
		
		 
			
			Thank you for the fix. 
		
	
		
		
		
		
		
		
		
		
		
		
	
	Ciao MP  | 
| 
		 | 
	
	
	
		
		
		
		
			 
		
		
		
		
		
		
		
			
		
		
		
	 | 
| 
			
			 | 
		#28 | 
| 
			
			
			
			 Wizard 
			
			![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() Posts: 1,216 
				Karma: 1995558 
				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.  | 
| 
		 | 
	
	
	
		
		
		
		
			 
		
		
		
		
		
		
		
			
		
		
		
	 | 
| 
			
			 | 
		#29 | 
| 
			
			
			
			 Wizard 
			
			![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() Posts: 1,216 
				Karma: 1995558 
				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.  | 
| 
		 | 
	
	
	
		
		
		
		
			 
		
		
		
		
		
		
		
			
		
		
		
	 | 
| 
			
			 | 
		#30 | |
| 
			
			
			
			 want to learn what I want 
			
			![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() Posts: 1,682 
				Karma: 7908443 
				Join Date: Sep 2020 
				
				
				
				Device: none 
				
				
				 | 
	
	
	
		
		
			
			 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 | 1556 | 10-26-2025 02:09 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 |