Register Guidelines E-Books Today's Posts Search

Go Back   MobileRead Forums > E-Book Software > Calibre > Plugins

Notices

Reply
 
Thread Tools Search this Thread
Old 08-30-2021, 04:51 AM   #1
capink
Wizard
capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.
 
Posts: 1,085
Karma: 1948136
Join Date: Aug 2015
Device: Kindle
Action Chains Resources

This thread will host custom action chain resources (actions, events and chains). This post will be updated with links to the actions and events posted in the thread. Also there is a link in the Action Chains thread that redirect to here.

Note: Actions and events posted here, are added to the plugin by copy/pasting the code into the plugin's module editor: Action Chains > Manage Modules > add > copy/paste

Note: Chains posted here can be added this way: Action Chains > Add/Modify chains > right click chain table > import > select the chain's zip file
  1. Here is a list of custom actions posted on this thread:
  2. Here is a list of custom events posted on this thread:
  3. Here is a list of chains:
  4. Here is a list of useful templates:

Last edited by chaley; 03-11-2024 at 02:41 PM. Reason: Add new bulk export and import notes
capink is offline   Reply With Quote
Old 08-30-2021, 04:51 AM   #2
capink
Wizard
capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.
 
Posts: 1,085
Karma: 1948136
Join Date: Aug 2015
Device: Kindle
reserved.
capink is offline   Reply With Quote
Advert
Old 08-30-2021, 04:52 AM   #3
capink
Wizard
capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.
 
Posts: 1,085
Karma: 1948136
Join Date: Aug 2015
Device: Kindle
reserved2.
capink is offline   Reply With Quote
Old 08-30-2021, 04:52 AM   #4
capink
Wizard
capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.
 
Posts: 1,085
Karma: 1948136
Join Date: Aug 2015
Device: Kindle
reserved3.
capink is offline   Reply With Quote
Old 08-30-2021, 04:52 AM   #5
capink
Wizard
capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.
 
Posts: 1,085
Karma: 1948136
Join Date: Aug 2015
Device: Kindle
reserved4.
capink is offline   Reply With Quote
Advert
Old 08-30-2021, 04:53 AM   #6
capink
Wizard
capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.
 
Posts: 1,085
Karma: 1948136
Join Date: Aug 2015
Device: Kindle
Action to prompt for confirmation

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('Are you sure you want to proceed?')

        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 = 'Confirm'

    def config_widget(self):
        return ConfirmConfigWidget

    def run(self, gui, settings, chain):
        message = settings.get('message', 'Are you sure you want to proceed?')
        if not question_dialog(gui, _('Are you sure?'), message, show_copy_button=False):
            raise chain.UserInterrupt

Last edited by capink; 01-07-2022 at 06:09 AM.
capink is offline   Reply With Quote
Old 08-30-2021, 04:54 AM   #7
capink
Wizard
capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.
 
Posts: 1,085
Karma: 1948136
Join Date: Aug 2015
Device: Kindle
Re-sort booklist action

Code:
from calibre_plugins.action_chains.actions.base import ChainAction

class ResortAction(ChainAction):

    name = 'Re-sort'

    def run(self, gui, settings, chain):
        gui.current_view().resort()
capink is offline   Reply With Quote
Old 08-30-2021, 04:56 AM   #8
capink
Wizard
capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.
 
Posts: 1,085
Karma: 1948136
Join Date: Aug 2015
Device: Kindle
Action to launch tag editor

Code:
from qt.core import (QApplication, Qt, QWidget, QVBoxLayout, QHBoxLayout,
                     QGroupBox, QComboBox, QPushButton, QRadioButton)

from calibre.gui2.dialogs.tag_editor import TagEditor

from calibre_plugins.action_chains.actions.base import ChainAction
from calibre_plugins.action_chains.templates import check_template, TEMPLATE_ERROR
from calibre_plugins.action_chains.templates.dialogs import TemplateBox

def get_possible_cols(db):
    fm = db.field_metadata.custom_field_metadata()
    cols = [ col for col, cmeta in fm.items() if cmeta['is_multiple'] ]
    cols = [ col for col in cols if fm[col]['datatype'] not in ['composite',None] ]
    cols.insert(0, 'tags')
    return cols

class ItemEditorConfig(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 = self.get_possible_cols()
        self.template = ''
        self._init_controls()

    def _init_controls(self):
        self.blockSignals(True)
        l = self.l = QVBoxLayout()
        self.setLayout(l)

        col_box = QGroupBox(_('Choose column:'))
        l.addWidget(col_box)
        col_box_layout = QVBoxLayout()
        col_box.setLayout(col_box_layout)

        name_layout = QHBoxLayout()
        col_box_layout.addLayout(name_layout)     
        name_opt = self.name_opt = QRadioButton(_('By column name'))
        name_layout.addWidget(name_opt)
        name_opt.setChecked(True)
        
        self.col_combobox = QComboBox()
        self.col_combobox.addItems(self.possible_cols)
        self.col_combobox.setCurrentText('tags')
        name_layout.addWidget(self.col_combobox)

        highlighted_opt = self.highlighted_opt = QRadioButton(_('Currently highlighted column'))
        col_box_layout.addWidget(highlighted_opt)

        template_layout = QHBoxLayout()
        col_box_layout.addLayout(template_layout)     
        template_opt = self.template_opt = QRadioButton(_('By template'))
        template_layout.addWidget(template_opt)
        
        self.template_button = QPushButton(_('Add template'))
        self.template_button.clicked.connect(self._on_template_button_clicked)
        template_layout.addWidget(self.template_button)

        l.addStretch(1)
        self.setMinimumSize(400,200)
        self.blockSignals(False)

    def get_possible_cols(self):
        return get_possible_cols(self.db)

    def _on_template_button_clicked(self):
        d = TemplateBox(self, self.plugin_action, template_text=self.template)
        if d.exec_() == d.Accepted:
            self.template = d.template
            self.template_button.setText(_('Edit template'))

    def load_settings(self, settings):
        if settings:
            if settings['col_opt'] == 'name':
                self.name_opt.setChecked(True)
                self.col_combobox.setCurrentText(settings['col_name'])
            elif settings['col_opt'] == 'highlighted':
                self.highlighted_opt.setChecked(True)
            elif settings['col_opt'] == 'template':
                self.template_opt.setChecked(True)
                self.template = settings['template']
                if self.template:
                    self.template_button.setText('Edit template')

    def save_settings(self):
        settings = {}
        if self.name_opt.isChecked():
            settings['col_opt'] = 'name'
            settings['col_name'] = self.col_combobox.currentText()
        elif self.highlighted_opt.isChecked():
            settings['col_opt'] = 'highlighted'
        else:
            settings['col_opt'] = 'template'
            settings['template'] = self.template
        return settings

class ItemEditorAction(ChainAction):

    name = 'Item Editor'

    def run(self, gui, settings, chain):
        db = gui.current_db
        rows = gui.current_view().selectionModel().selectedRows()
        book_ids = [ gui.library_view.model().db.id(row.row()) for row in rows ]

        if settings['col_opt'] == 'name':
            col_name = settings['col_name']
        elif settings['col_opt'] == 'highlighted':
            index = gui.library_view.currentIndex()
            column_map = gui.library_view.model().column_map
            col_name = column_map[index.column()]
        else:
            col_name = chain.evaluate_template(settings['template'])

        if col_name not in get_possible_cols(db):
            return

        if col_name == 'tags':
            key = None
        else:
            key = col_name
        
        if len(book_ids) == 0:
            return
        elif len(book_ids) == 1:
            book_id = book_ids[0]
        else:
            book_id = None

        d = TagEditor(gui, db, book_id, key)
        QApplication.setOverrideCursor(Qt.ArrowCursor)
        try:
            d.exec_()
        finally:
            QApplication.restoreOverrideCursor()
        if d.result() == d.Accepted:
            val = d.tags
            id_map = {book_id: val for book_id in book_ids}
            db.new_api.set_field(col_name, id_map)

        del d

    def config_widget(self):
        return ItemEditorConfig

    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'))
        if settings['col_opt'] == 'name':
            col_name = settings['col_name']
            if not col_name in get_possible_cols(db):
                return (_('Column Error'), _('Cannot find a column with name "{}" in current library'.format(col_name)))
        elif settings['col_opt'] == 'template':
            if not settings.get('template'):
                return (_('Empty Template Error'), _('Template for column name cannot be empty'))
            is_template_valid = check_template(settings['template'], self.plugin_action, print_error=False)
            if is_template_valid is not True:
                return is_template_valid
        return True
Warning: If you apply this action on multiple selected books, you will lose all preexisting tags on these books, as they will be overwritten by the new ones.

Note: you will need to configure this action before running it to choose the column.

Last edited by capink; 01-07-2022 at 06:10 AM.
capink is offline   Reply With Quote
Old 08-30-2021, 04:58 AM   #9
capink
Wizard
capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.
 
Posts: 1,085
Karma: 1948136
Join Date: Aug 2015
Device: Kindle
Set covers from book(s) action

Note: This action is now available as part of the builtin Single Field Edit Action.

Code:
from calibre.ebooks.metadata.meta import metadata_from_formats
from calibre_plugins.action_chains.actions.base import ChainAction

class CoverFromBook(ChainAction):

    name = 'Cover from book'
    support_scopes = True

    def run(self, gui, settings, chain):
        db = gui.current_db.new_api
        book_ids = chain.scope().get_book_ids()
        for book_id in book_ids:
            fmts = db.formats(book_id, verify_formats=False)
            paths = list(filter(None, [db.format_abspath(book_id, fmt) for fmt in fmts]))
            mi = metadata_from_formats(paths)
            if mi.cover_data:
                cdata = mi.cover_data[-1]
            if cdata is not None:
                db.set_cover({book_id: cdata})

Last edited by capink; 09-24-2021 at 04:17 AM.
capink is offline   Reply With Quote
Old 08-30-2021, 04:59 AM   #10
capink
Wizard
capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.
 
Posts: 1,085
Karma: 1948136
Join Date: Aug 2015
Device: Kindle
Generate cover action

Note: This action is now available as part of the builtin Single Field Edit Action.

Code:
from calibre.ebooks.covers import generate_cover
from calibre.gui2.covers import CoverSettingsWidget
from calibre_plugins.action_chains.actions.base import ChainAction

class ConfigWidget(CoverSettingsWidget):
    def __init__(self, plugin_action):
        CoverSettingsWidget.__init__(self)
        self.plugin_action = plugin_action
        self.gui = plugin_action.gui
        self.db = self.gui.current_db

    def load_settings(self, settings):
        self.apply_prefs(settings)

    def save_settings(self):
        return self.current_prefs

class GenerateCover(ChainAction):

    name = 'Generate Cover'
    support_scopes = True

    def run(self, gui, settings, chain):
        db = gui.current_db
        book_ids = chain.scope().get_book_ids()

        for book_id in book_ids:
            mi = db.get_metadata(book_id, index_is_id=True)
            cdata = generate_cover(mi, settings)
            db.new_api.set_cover({book_id:cdata})

    def config_widget(self):
        return ConfigWidget

Last edited by capink; 09-24-2021 at 04:17 AM.
capink is offline   Reply With Quote
Old 08-30-2021, 05:00 AM   #11
capink
Wizard
capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.
 
Posts: 1,085
Karma: 1948136
Join Date: Aug 2015
Device: Kindle
Trim cover action

Note: This action is now available as part of the builtin Single Field Edit Action.

Code:
from functools import partial

from qt.core import (QApplication, Qt)

from calibre.gui2 import error_dialog

from calibre_plugins.action_chains.common_utils import DoubleProgressDialog
from calibre_plugins.action_chains.actions.base import ChainAction

class TrimCover(ChainAction):

    name = 'Trim Cover'
    support_scopes = True

    def trim_covers(self, db, book_ids, pbar):

        from calibre.utils.img import (
            image_from_data, image_to_data, remove_borders_from_image
        )
        
        pbar.update_overall(len(book_ids))
        
        for book_id in book_ids:
            cdata = db.new_api.cover(book_id)
            if cdata:
                img = image_from_data(cdata)
                nimg = remove_borders_from_image(img)
                if nimg is not img:
                    cdata = image_to_data(nimg)
                    db.new_api.set_cover({book_id:cdata})
            msg = _('Trimming cover for book_id: {}'.format(book_id))
            pbar.update_progress(1, msg)

    def run(self, gui, settings, chain):
        db = gui.current_db
        book_ids = chain.scope().get_book_ids()

        callback = partial(self.trim_covers, db, book_ids)
        pd = DoubleProgressDialog(1, callback, gui, window_title=_('Trimming ...'))

        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:
            QApplication.restoreOverrideCursor()
            gui.tags_view.recount()

Last edited by capink; 01-07-2022 at 06:11 AM.
capink is offline   Reply With Quote
Old 08-30-2021, 05:01 AM   #12
capink
Wizard
capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.
 
Posts: 1,085
Karma: 1948136
Join Date: Aug 2015
Device: Kindle
Remove formats action

Note: This action is now available as part of the builtin Single Field Edit Action.

Code:
from qt.core import (Qt, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QFrame,
                     QGroupBox, QCheckBox, QLineEdit, QRadioButton)

from calibre.gui2.widgets2 import Dialog
from calibre.ebooks.metadata.meta import metadata_from_formats

from calibre_plugins.action_chains.actions.base import ChainAction

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)
        self.setLayout(l)

        line = QFrame(self)
        line.setFrameShape(QFrame.HLine)
        line.setFrameShadow(QFrame.Sunken)
        l.addWidget(line)
                
        group_box = QGroupBox(_('Formats options'))
        group_box_l = QVBoxLayout()
        group_box.setLayout(group_box_l)
        l.addWidget(group_box)
        
        self.all_opt = QRadioButton(_('Remove all formats'))
        self.all_opt.setChecked(True)
        group_box_l.addWidget(self.all_opt)

        self.include_opt = QRadioButton(_('Remove only specified formats (comma separated list)'))
        self.include_edit = QLineEdit()
        group_box_l.addWidget(self.include_opt)
        group_box_l.addWidget(self.include_edit)

        self.exclude_opt = QRadioButton(_('Remove all formats except spcefied (comma separated list)'))
        self.exclude_edit = QLineEdit()
        group_box_l.addWidget(self.exclude_opt)
        group_box_l.addWidget(self.exclude_edit)

        l.addStretch(1)   

    def load_settings(self, settings):
        if settings:
            if settings['opt'] == 'all':
                self.all_opt.setChecked(True)
            elif settings['opt'] == 'include':
                self.include_opt.setChecked(True)
                self.include_edit.setText(settings['include'])
            elif settings['opt'] == 'exclude':
                self.exclude_opt.setChecked(True)
                self.exclude_edit.setText(settings['exclude'])

    def save_settings(self):
        settings = {}
        if self.all_opt.isChecked():
            settings['opt'] = 'all'
        elif self.include_opt.isChecked():
            settings['opt'] = 'include'
            settings['include'] = self.include_edit.text()
        elif self.exclude_opt.isChecked():
            settings['opt'] = 'exclude'
            settings['exclude'] = self.exclude_edit.text()
        return settings

class RemoveFormats(ChainAction):

    name = 'Remove Formats'
    support_scopes = True      

    def run(self, gui, settings, chain):
        db = gui.current_db
        book_ids = chain.scope().get_book_ids()

        for book_id in book_ids:
            fmts_to_delete = set()
            fmts_string = db.formats(book_id, index_is_id=True)
            if fmts_string:
                available_fmts = [ fmt.strip().upper() for fmt in fmts_string.split(',') ]
            else:
                available_fmts = []
            if settings['remove_opt'] == 'all':
                if available_fmts:
                    fmts_to_delete = available_fmts
            elif settings['remove_opt'] == 'include':
                fmts_to_delete = [ fmt.strip().upper() for fmt in settings.get('include', '').split(',') ]
            elif settings['remove_opt'] == 'exclude':
                fmts_to_keep = set([ fmt.strip().upper() for fmt in settings.get('exclude', '').split(',') ])
                fmts_to_delete = set(available_fmts).difference(fmts_to_keep)
            for fmt in fmts_to_delete:
                fmt = fmt.upper()
                if fmt in available_fmts:
                    db.remove_format(book_id, fmt, index_is_id=True, notify=False)

    def default_settings(self):
        return {'opt': 'all'}

    def config_widget(self):
        return ConfigWidget

Last edited by capink; 01-07-2022 at 06:12 AM.
capink is offline   Reply With Quote
Old 08-30-2021, 05:02 AM   #13
capink
Wizard
capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.
 
Posts: 1,085
Karma: 1948136
Join Date: Aug 2015
Device: Kindle
Add format from a predefined path

Note: This action is now available as part of the builtin Single Field Edit Action. (by choosing the template option and typing the pre-defined format in the template editor).

Code:
import subprocess
import os

from qt.core import (QApplication, Qt, QWidget, QHBoxLayout, QVBoxLayout,
                     QGroupBox, QToolButton)

from calibre import prints
from calibre.constants import DEBUG, iswindows
from calibre.gui2 import error_dialog, choose_files

from calibre_plugins.action_chains.actions.base import ChainAction
from calibre_plugins.action_chains.common_utils import DragDropComboBox, get_icon

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.template = ''
        self._init_controls()

    def _init_controls(self):

        l = QVBoxLayout()
        self.setLayout(l)

        self.format_box = QGroupBox(_('&Choose format:'))
        l.addWidget(self.format_box)
        format_layout = QVBoxLayout()
        self.format_box.setLayout(format_layout)
        self.format_combo = DragDropComboBox(self, drop_mode='file')
        format_layout.addWidget(self.format_combo)
        hl1 = QHBoxLayout()
        format_layout.addLayout(hl1)
        hl1.addWidget(self.format_combo, 1)
        self.choose_format_button = QToolButton(self)
        self.choose_format_button.setToolTip(_('Choose format'))
        self.choose_format_button.setIcon(get_icon('document_open.png'))
        self.choose_format_button.clicked.connect(self._choose_file)
        hl1.addWidget(self.choose_format_button)

    def _choose_file(self):
        files = choose_files(None, _('Select format dialog'), _('Select a format'),
                             all_files=True, select_only_single_file=True)
        if not files:
            return
        format_path = files[0]
        if iswindows:
            format_path = os.path.normpath(format_path)

        self.block_events = True
        existing_index = self.format_combo.findText(format_path, Qt.MatchExactly)
        if existing_index >= 0:
            self.format_combo.setCurrentIndex(existing_index)
        else:
            self.format_combo.insertItem(0, format_path)
            self.format_combo.setCurrentIndex(0)
        self.block_events = False

    def load_settings(self, settings):
        if settings:
            self.format_combo.setCurrentText(settings['path_to_format'])

    def save_settings(self):
        settings = {}
        settings['path_to_format'] = self.format_combo.currentText().strip()
        return settings


class AddFormatAction(ChainAction):

    name = 'Add Format'
    support_scopes = True

    def run(self, gui, settings, chain):
        book_ids = chain.scope().get_book_ids()
        
        db = gui.current_db
        
        path = settings['path_to_format']
        try:
            fmt = os.path.splitext(path)[-1].lower().replace('.', '').upper()
        except:
            fmt = ''
        
        for book_id in book_ids:
            db.new_api.add_format(book_id, fmt, path)

    def validate(self, settings):
        if not settings:
            return (_('Settings Error'), _('You must configure this action before running it'))
        if not settings['path_to_format']:
            return (_('No Format'), _('You must specify a path to valid format'))
        return True

    def config_widget(self):
        return ConfigWidget

Last edited by capink; 01-30-2022 at 10:38 AM.
capink is offline   Reply With Quote
Old 08-30-2021, 05:03 AM   #14
capink
Wizard
capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.
 
Posts: 1,085
Karma: 1948136
Join Date: Aug 2015
Device: Kindle
Add books action

Here is a new custom "Add Books" action. It is based on calibre's add books action and can be configured to run in one of the following modes:
  • Add books from single directory.
  • Add from folders and sub-folders (single book per directory).
  • Add from folders and sub-folders (multiple books per directory).

When running the the first option, you can make it add books from a predefined directory, which makes it suitable to create your auto-add chain (if used with the timer event). Note however, that unlike calibre's auto-add, this will launch a progress dialog that will block the gui until all books are added.

Also the action has an option to select the newly added books, so that other actions in the chain can work on them.

Code:
import os
from functools import partial
import copy
import shutil

from qt.core import (Qt, QApplication, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
                     QRadioButton, QCheckBox, QIcon, QGroupBox, QLabel, QComboBox, QToolButton)

from calibre import prints
from calibre.constants import DEBUG
from calibre.gui2 import choose_dir, choose_files, gprefs
from calibre.gui2.add import Adder
from calibre.gui2.auto_add import allowed_formats
from calibre.gui2.actions.add import AddAction, get_filters
from calibre.utils.date import now

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.database import get_books_modified_since

class ModifiedAdder(Adder):
    def __init__(self, source, single_book_per_directory=True, db=None, parent=None, callback=None, pool=None, list_of_archives=False, duplicate_opt='runtime'):
        self.duplicate_opt = duplicate_opt
        Adder.__init__(self, source, single_book_per_directory, db, parent, callback, pool, list_of_archives)
        self.block_thread()

    def block_thread(self):
        while not self.abort_scan:
            QApplication.processEvents()
        return

    def process_duplicates(self):
        if self.duplicate_opt == 'runtime':
            return Adder.process_duplicates(self)
        elif self.duplicate_opt == 'add':
            duplicates = copy.copy(self.duplicates)
            if duplicates:
                self.do_one = self.process_duplicate
                self.duplicates_to_process = iter(duplicates)
                self.pd.title = _('Adding duplicates')
                self.pd.msg = ''
                self.pd.max, self.pd.value = len(duplicates), 0
                self.do_one_signal.emit()
                return
        elif self.duplicate_opt == 'remove':
            duplicates = ()
        self.finish()

class ModifiedAddAction(AddAction):

    def __init__(self, gui):
        self.gui = gui
        self.allowed = allowed_formats()
        self.duplicate_opt = 'runtime'

    def do_add_recursive(self, root, single, list_of_archives=False):
        from calibre.gui2.add import Adder
        adder = ModifiedAdder(root, single_book_per_directory=single, db=self.gui.current_db, list_of_archives=list_of_archives,
              callback=self._files_added, parent=self.gui, pool=self.gui.spare_pool(), duplicate_opt=self.duplicate_opt)

    def _add_books(self, paths, to_device, on_card=None):
        if on_card is None:
            on_card = 'carda' if self.gui.stack.currentIndex() == 2 else \
                      'cardb' if self.gui.stack.currentIndex() == 3 else None
        if not paths:
            return
        ModifiedAdder(paths, db=None if to_device else self.gui.current_db,
              parent=self.gui, callback=partial(self._files_added, on_card=on_card), pool=self.gui.spare_pool(), duplicate_opt=self.duplicate_opt)

    def add_books(self, books, *args):
        '''
        Add books from the local filesystem to either the library or the device.
        '''
        filters = get_filters()
        to_device = self.gui.stack.currentIndex() != 0
        if to_device:
            fmts = self.gui.device_manager.device.settings().format_map
            filters = [(_('Supported books'), fmts)]

        if not books:
            return
        self._add_books(books, to_device)

    def add_recursive(self, single, root):
        if not root:
            return
        lp = os.path.normcase(os.path.abspath(self.gui.current_db.library_path))
        if lp.startswith(os.path.normcase(os.path.abspath(root)) + os.pathsep):
            return error_dialog(self.gui, _('Cannot add'), _(
                'Cannot add books from the folder: %s as it contains the currently opened calibre library') % root, show=True)
        self.do_add_recursive(root, single)

    def is_filename_allowed(self, filename):
        ext = os.path.splitext(filename)[1][1:].lower()
        allowed = ext in self.allowed
        return allowed

    def books_from_path(self, path):
        files = [os.path.join(path, x) for x in os.listdir(path) if
                    # Firefox creates 0 byte placeholder files when downloading
                    os.stat(os.path.join(path, x)).st_size > 0 and
                    # Must be a file
                    os.path.isfile(os.path.join(path, x)) and
                    # Must have read and write permissions
                    os.access(os.path.join(path, x), os.R_OK|os.W_OK) and
                    # Must be a known ebook file type
                    self.is_filename_allowed(x)
                ]
        return files

    def clean_path(self, path):
        files = [os.path.join(path, x) for x in os.listdir(path) if
                    os.path.isfile(os.path.join(path, x)) and
                    os.access(os.path.join(path, x), os.R_OK|os.W_OK)]
        dirs = [os.path.join(path, x) for x in os.listdir(path) if
                    os.path.isdir(os.path.join(path, x)) and
                    os.access(os.path.join(path, x), os.R_OK|os.W_OK)]
        for f in files:
            os.remove(f)
        for d in dirs:
            shutil.rmtree(d)


class ConfigWidget(QWidget):

    def __init__(self, plugin_action):
        QWidget.__init__(self)
        self.gui = plugin_action.gui
        self.db = self.gui.current_db
        self._init_controls()

    def _init_controls(self):

        l = QVBoxLayout()
        self.setLayout(l)
        
        actions_group_box = QGroupBox('Actions')
        actions_group_box_l = QVBoxLayout()
        actions_group_box.setLayout(actions_group_box_l)
        l.addWidget(actions_group_box)
        
        add_books_opt = self.add_books_opt = QRadioButton('Add books from a single directory')
        self.add_books_opt.setChecked(True)
        actions_group_box_l.addWidget(self.add_books_opt)

        recursive_single_opt = self.recursive_single_opt = QRadioButton('Add from folders and sub-folders (Single book per directory)')
        actions_group_box_l.addWidget(self.recursive_single_opt)

        recursive_multiple_opt = self.recursive_multiple_opt = QRadioButton('Add from folders and sub-folders (Multiple books per directory)')
        actions_group_box_l.addWidget(self.recursive_multiple_opt)

        for opt in [add_books_opt, recursive_single_opt, recursive_multiple_opt]:
            opt.toggled.connect(self._action_opt_toggled)

        location_group_box = QGroupBox(_('Books location'))
        location_group_box_l = QVBoxLayout()
        location_group_box.setLayout(location_group_box_l)
        l.addWidget(location_group_box)

        self.runtime_opt = QRadioButton('Ask at runtime')
        self.runtime_opt.setChecked(True)
        location_group_box_l.addWidget(self.runtime_opt)
        self.predefined_opt = QRadioButton('Add from location defined below')
        location_group_box_l.addWidget(self.predefined_opt)
        hl1 = QHBoxLayout()
        location_group_box_l.addLayout(hl1)
        self.file_combo = DragDropComboBox(self, drop_mode='file')        
        hl1.addWidget(self.file_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)        

        duplicate_group_box = QGroupBox(_('What to do with duplicate books'))
        duplicate_group_box_l = QVBoxLayout()
        duplicate_group_box.setLayout(duplicate_group_box_l)
        l.addWidget(duplicate_group_box)

        self.duplicate_options = {
            'runtime': _('Ask at runtime'),
            'add': _('Add duplicates'),
            'remove': _('Remove duplicates')
        }
        self.duplicates_combo = QComboBox()
        self.duplicates_combo.addItems(list(self.duplicate_options.values()))
        duplicate_group_box_l.addWidget(self.duplicates_combo)

        options_group_box = QGroupBox(_('Options'))
        options_group_box_l = QVBoxLayout()
        options_group_box.setLayout(options_group_box_l)
        l.addWidget(options_group_box)
        
        self.delete_books_chk = QCheckBox(_('Delete books after adding'))
        options_group_box_l.addWidget(self.delete_books_chk)
#        self.warning_1 = QLabel(_('<b>Warning: </b> This will delete all files and directories in directory'))
#        self.warning_1.setWordWrap(True)
#        options_group_box_l.addWidget(self.warning_1)
        self.select_added_chk = QCheckBox(_('Select added books'))
        options_group_box_l.addWidget(self.select_added_chk)
#        self.hide_progress_chk = QCheckBox('When adding from predefined path, hide progress bar')
#        options_group_box_l.addWidget(self.hide_progress_chk)

        l.addStretch(1)

        self.setMinimumSize(400,500)
        
        self._action_opt_toggled()

    def _action_opt_toggled(self):
        self.delete_books_chk.setEnabled(self.add_books_opt.isChecked())
        if not self.delete_books_chk.isEnabled():
            self.delete_books_chk.setChecked(False)
            
        self.predefined_opt.setEnabled(self.add_books_opt.isChecked())
        if not self.predefined_opt.isEnabled():
            self.runtime_opt.setChecked(True)

    def _choose_path(self):

        root = choose_dir(self.gui, 'recursive book import root dir dialog',
          _('Select root folder'))

        if not root:
            return

        self.block_events = True
        existing_index = self.file_combo.findText(root, Qt.MatchExactly)
        if existing_index >= 0:
            self.file_combo.setCurrentIndex(existing_index)
        else:
            self.file_combo.insertItem(0, root)
            self.file_combo.setCurrentIndex(0)
        self.block_events = False

    def load_settings(self, settings):
        if settings:
            if settings['action'] == 'add_books':
                self.add_books_opt.setChecked(True)
            else:
                if settings['is_single']:
                    self.recursive_single_opt.setChecked(True)
                else:
                    self.recursive_multiple_opt.setChecked(True)
            if settings['path_opt'] == 'runtime':
                self.runtime_opt.setChecked(True)
            else:
                self.predefined_opt.setChecked(True)
            self.file_combo.populate_items(settings.get('file_list', []), settings['path_to_books'])
            self.duplicates_combo.setCurrentText(self.duplicate_options[settings['duplicate_opt']])
            self.delete_books_chk.setChecked(settings['delete_books'])
            self.select_added_chk.setChecked(settings['select_added_books'])
#            self.hide_progress_chk.setChecked(settings['hide_progress'])
            self._action_opt_toggled()

    def save_settings(self):
        settings = {}
        if self.add_books_opt.isChecked():
            settings['action'] = 'add_books'
        elif self.recursive_single_opt.isChecked():
            settings['action'] = 'recursive_add'
            settings['is_single'] = True
        elif self.recursive_multiple_opt.isChecked():
            settings['action'] = 'recursive_add'
            settings['is_single'] = False
        if self.runtime_opt.isChecked():
            settings['path_opt'] = 'runtime'
        else:
            settings['path_opt'] = 'predefined'
        settings['path_to_books'] = self.file_combo.currentText()
        settings['file_list'] = self.file_combo.get_items_list()
        settings['duplicate_opt'] = {v: k for k, v in self.duplicate_options.items()}[self.duplicates_combo.currentText()]
        settings['delete_books'] = self.delete_books_chk.isChecked()
        settings['select_added_books'] = self.select_added_chk.isChecked()
#        settings['hide_progress'] = self.hide_progress_chk.isChecked()
        return settings

class ChainsAddAction(ChainAction):

    name = 'Add Books'

    def __init__(self, plugin_action):
        ChainAction.__init__(self, plugin_action)
        #self.add_action = ModifiedAddAction(plugin_action.gui)

    def run(self, gui, settings, chain):
        add_action = ModifiedAddAction(self.plugin_action.gui)
        start_time = now()
        action = settings['action']
        path_opt = settings['path_opt']
        path_to_books = settings.get('path_to_books', '')
        duplicate_opt = settings['duplicate_opt']
        add_action.duplicate_opt = duplicate_opt
#        if path_opt == 'predefined' and settings['hide_progress']:
#            add_action.hide_progress_dialog = True
        if action == 'add_books':
            if path_opt == 'runtime':
                filters = get_filters()
                books = choose_files(gui, 'add books dialog dir',
                        _('Select books'), filters=filters)
            else:
                books = add_action.books_from_path(path_to_books)
            add_action.add_books(books)
            if settings['delete_books']:
                for book in books:
                    os.remove(book)
        elif action == 'recursive_add':
            is_single = settings['is_single']
            if path_opt == 'runtime':
                root = choose_dir(gui, 'recursive book import root dir dialog',
                  _('Select root folder'))
            else:
                root = path_to_books
            add_action.add_recursive(is_single, root)
#            if settings['delete_books']:
#                add_action.clean_path(path_to_books)
        if settings['select_added_books']:
            added_ids = get_books_modified_since(gui.current_db, start_time)
            gui.library_view.select_rows(added_ids)

    def validate(self, settings):
        if not settings:
            return (_('Settings Error'), _('You must configure this action before running it'))
        if settings['path_opt'] == 'predefined':
            path_to_books = settings['path_to_books']
            if not path_to_books:
                return (_('Path Error'), _('You must choose a path to add books from'))
            if not os.path.isdir(path_to_books):
                return (_('Path Error'), _('Path entered ({}) is not a valid directory'.format(path_to_books)))
            if not os.access(path_to_books, os.R_OK|os.W_OK):
                return (_('Path Error'), _('Path entered ({}) does not have read/write access'.format(path_to_books)))
            if os.path.normpath(path_to_books) == os.path.normpath(gprefs['auto_add_path']):
                return (_('Path Error'), _('Predefined path ({}) cannot be the same as calibre auto add path'.format(path_to_books)))
        return True

    def config_widget(self):
        return ConfigWidget

Last edited by capink; 01-07-2022 at 06:13 AM.
capink is offline   Reply With Quote
Old 08-30-2021, 05:04 AM   #15
capink
Wizard
capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.capink ought to be getting tired of karma fortunes by now.
 
Posts: 1,085
Karma: 1948136
Join Date: Aug 2015
Device: Kindle
Convert books action

Note: A more sophisticated version of this is available as a builtin action

Here is a minimalistic "Convert Books" action based on calibre's auto-convert. It is non interactive, and follows the same settings as calibre's auto-convert.

Code:
from qt.core import (Qt, QApplication, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
                     QCheckBox, QIcon, QGroupBox)

from calibre import prints
from calibre.constants import DEBUG
from calibre.utils.date import now

from calibre_plugins.action_chains.actions.base import ChainAction
from calibre_plugins.action_chains.actions.calibre_actions import unfinished_job_ids, responsive_wait, responsive_wait_until

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 = QVBoxLayout()
        self.setLayout(l)

        job_wait_chk = self.job_wait_chk = QCheckBox(_('Wait until all convert jobs finish.'))
        l.addWidget(job_wait_chk)
        job_wait_chk.setChecked(True)

        l.addStretch(1)

    def load_settings(self, settings):
        if settings:
            self.job_wait_chk.setChecked(settings['wait_jobs'])

    def save_settings(self):
        settings = {}
        settings['wait_jobs'] = self.job_wait_chk.isChecked()
        return settings

class ChainsConvertAction(ChainAction):

    name = 'Convert Books (Minimal Version)'
    support_scopes = True

    def run(self, gui, settings, chain):
        db = gui.current_db
        book_ids = chain.scope().get_book_ids()
        start_time = now()

        jobs_before_ids = unfinished_job_ids(gui)

        gui.iactions['Convert Books'].auto_convert_auto_add(book_ids)

        wait_jobs = settings.get('wait_jobs', True)

        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 config_widget(self):
        return ConfigWidget

Last edited by capink; 01-15-2023 at 09:56 PM.
capink is offline   Reply With Quote
Reply


Forum Jump

Similar Threads
Thread Thread Starter Forum Replies Last Post
[GUI Plugin] Action Chains capink Plugins 1315 04-11-2024 07:40 AM
Book Scanning tool chains tomsem Workshop 17 12-03-2023 09:19 AM
Mystery and Crime Thorne, Guy: Chance in Chains (1914); v1 Pulpmeister Kindle Books 0 11-25-2018 09:09 PM
Mystery and Crime Thorne, Guy: Chance in Chains (1914); v1 Pulpmeister ePub Books 0 11-25-2018 09:08 PM
Could this be the last year for the big chains? Connallmac News 66 01-07-2011 04:11 PM


All times are GMT -4. The time now is 11:49 PM.


MobileRead.com is a privately owned, operated and funded community.