Register Guidelines E-Books Search Today's Posts Mark Forums Read

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

Notices

Reply
 
Thread Tools Search this Thread
Old 08-30-2021, 05:06 AM   #16
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,081
Karma: 1948136
Join Date: Aug 2015
Device: Kindle
Books added event

Code:
#!/usr/bin/env python
# ~*~ coding: utf-8 ~*~

__license__ = 'GPL v3'
__copyright__ = '2021, Ahmed Zaki <azaki00.dev@gmail.com>'
__docformat__ = 'restructuredtext en'

from functools import partial

from qt.core import (QApplication, Qt, QTimer, QWidget, QVBoxLayout, QCheckBox, pyqtSignal)

from calibre import prints
from calibre.constants import DEBUG
from calibre.db.listeners import EventType
from calibre.utils.date import now

from calibre_plugins.action_chains.events.base import ChainEvent
import calibre_plugins.action_chains.config as cfg

try:
    load_translations()
except NameError:
    prints("ActionChains::events/books_added.py - exception when loading translations")

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

    def _init_controls(self):

        l = self.l = QVBoxLayout()
        self.setLayout(l)

        self.select_chk = QCheckBox(_('Select newly added books'))
        self.select_chk.setChecked(True)
        l.addWidget(self.select_chk)
        
        l.addStretch(1)

        self.setMinimumSize(300,300)

    def load_settings(self, settings):
        if settings:
            self.select_chk.setChecked(settings['select_books'])

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

class BooksAddedEvent(ChainEvent):

    name = 'Books Added'
    books_added = pyqtSignal(object)
    timer_interval = 30

    def __init__(self, plugin_action):
        ChainEvent.__init__(self, plugin_action)
        self.db = plugin_action.gui.current_db
        self.gui.add_db_listener(self.process_event_in_db)
        self.book_created_cache = set()
        self.book_created_cache_last_updated = None
        QTimer.singleShot(self.timer_interval * 1000, self._on_timeout)

    def process_event_in_db(self, db, event_type, event_data):
        if not db.library_id == self.gui.current_db.library_id:
            return
        if event_type == EventType.book_created:
            book_id = event_data[0]
            self.add_to_book_created_cache(book_id, now())
        elif event_type == EventType.books_removed:
            removed_book_ids = event_data[0]
            self.book_created_cache = self.book_created_cache.difference(set(removed_book_ids))
        
    def _on_timeout(self):
        # Make sure no modal widget dialog is present (e.g. add books duplicate dialog). Otherwise, postpone
        if QApplication.instance().activeModalWidget():
            pass
        # postpone event if another action chains is running
        elif self.plugin_action.chainStack:
            pass
        else:
            utime = self.book_created_cache_last_updated
            if utime:
                elapsed = now() - utime
                if elapsed.seconds > 20:
                    QTimer.singleShot(0, partial(self.books_added.emit, self.book_created_cache))
                    QTimer.singleShot(0, self.clean_book_created_cache)
        
        # keep the timer runnig
        QTimer.singleShot(self.timer_interval * 1000, self._on_timeout) 

    def add_to_book_created_cache(self, book_id, timestamp):
        self.book_created_cache.add(book_id)
        self.book_created_cache_last_updated = timestamp

    def clean_book_created_cache(self):
        self.book_created_cache = set()
        self.book_created_cache_last_updated = None
    
    def get_event_signal(self):
        return self.books_added

    def config_widget(self):
        return ConfigWidget

    def pre_chains_event_actions(self, event_args, event_opts):
        if event_opts.get('select_books', False):
            book_ids = event_args[0]
            self.gui.library_view.select_rows(book_ids)
            if DEBUG:
                prints('Action Chains: Books Added Event: Selecting newly added book_ids: {}')
After adding this you should have an event called "Books Added" in the dropdown list of the Event Manager. You can attach whatever chains you want to that event.

Notes:
  • The event will automatically select all added books, so that any chains attached to the event can act on them. If whatever reason you don't want this, you can turn it off (Event Manager > Books Added > Event options)
  • There is a lag of several seconds before the event is activated. This is done to ensure calibre's adder (and auto-adder) has added all the books.
  • You can attach one or more chain(s) to the event: Event Manager > Add Event > Books Added > click settings button next to the event.
  • Whenever you change the name of a chain attached to an event, you will need to open the Event Manager to re-attach it.

Last edited by capink; 01-07-2022 at 06:15 AM.
capink is offline   Reply With Quote
Old 08-30-2021, 05:08 AM   #17
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,081
Karma: 1948136
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
Edit: This action has been modified in a way the is not backward compatible with previous iterations. If using the new code, you are advised to re-create the action from scratch.

Last edited by capink; 01-07-2022 at 06:15 AM. Reason: Allow specifying multiple formats to save
capink is offline   Reply With Quote
Advert
Old 08-31-2021, 04:33 PM   #18
un_pogaz
Chalut o/
un_pogaz understands the importance of being earnest.un_pogaz understands the importance of being earnest.un_pogaz understands the importance of being earnest.un_pogaz understands the importance of being earnest.un_pogaz understands the importance of being earnest.un_pogaz understands the importance of being earnest.un_pogaz understands the importance of being earnest.un_pogaz understands the importance of being earnest.un_pogaz understands the importance of being earnest.un_pogaz understands the importance of being earnest.un_pogaz understands the importance of being earnest.
 
un_pogaz's Avatar
 
Posts: 408
Karma: 145324
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.
un_pogaz is offline   Reply With Quote
Old 08-31-2021, 05:15 PM   #19
BetterRed
null operator (he/him)
BetterRed ought to be getting tired of karma fortunes by now.BetterRed ought to be getting tired of karma fortunes by now.BetterRed ought to be getting tired of karma fortunes by now.BetterRed ought to be getting tired of karma fortunes by now.BetterRed ought to be getting tired of karma fortunes by now.BetterRed ought to be getting tired of karma fortunes by now.BetterRed ought to be getting tired of karma fortunes by now.BetterRed ought to be getting tired of karma fortunes by now.BetterRed ought to be getting tired of karma fortunes by now.BetterRed ought to be getting tired of karma fortunes by now.BetterRed ought to be getting tired of karma fortunes by now.
 
Posts: 20,457
Karma: 26645808
Join Date: Mar 2012
Location: Sydney Australia
Device: none
Quote:
Originally Posted by un_pogaz View Post
Suggest for the forum :
A button "Copy Code", because it can clearly improve the quality of life.
Especially with so many good modules.
- or simply attach the scripts as a sipped [sic] .py

BR
BetterRed is online now   Reply With Quote
Old 09-24-2021, 04:47 AM   #20
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,081
Karma: 1948136
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 06:16 AM.
capink is offline   Reply With Quote
Advert
Old 12-21-2021, 05:30 AM   #21
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,081
Karma: 1948136
Join Date: Aug 2015
Device: Kindle
Refresh current search

Quote:
Originally Posted by ownedbycats View Post
Question: Can Selection Modifier be used to re-do the currently existing search, regardless of what it is? Mostly to remove now-invalid entries from the search after changing metadata - resorting doesn't do this.
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()
capink is offline   Reply With Quote
Old 12-21-2021, 05:30 AM   #22
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,081
Karma: 1948136
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 06:17 AM.
capink is offline   Reply With Quote
Old 12-28-2021, 04:49 PM   #23
ownedbycats
Custom User Title
ownedbycats ought to be getting tired of karma fortunes by now.ownedbycats ought to be getting tired of karma fortunes by now.ownedbycats ought to be getting tired of karma fortunes by now.ownedbycats ought to be getting tired of karma fortunes by now.ownedbycats ought to be getting tired of karma fortunes by now.ownedbycats ought to be getting tired of karma fortunes by now.ownedbycats ought to be getting tired of karma fortunes by now.ownedbycats ought to be getting tired of karma fortunes by now.ownedbycats ought to be getting tired of karma fortunes by now.ownedbycats ought to be getting tired of karma fortunes by now.ownedbycats ought to be getting tired of karma fortunes by now.
 
ownedbycats's Avatar
 
Posts: 8,421
Karma: 59666665
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 09:28 PM.
ownedbycats is online now   Reply With Quote
Old 01-04-2022, 04:08 PM   #24
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,081
Karma: 1948136
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 04:11 PM.
capink is offline   Reply With Quote
Old 01-29-2022, 08:06 PM   #25
ownedbycats
Custom User Title
ownedbycats ought to be getting tired of karma fortunes by now.ownedbycats ought to be getting tired of karma fortunes by now.ownedbycats ought to be getting tired of karma fortunes by now.ownedbycats ought to be getting tired of karma fortunes by now.ownedbycats ought to be getting tired of karma fortunes by now.ownedbycats ought to be getting tired of karma fortunes by now.ownedbycats ought to be getting tired of karma fortunes by now.ownedbycats ought to be getting tired of karma fortunes by now.ownedbycats ought to be getting tired of karma fortunes by now.ownedbycats ought to be getting tired of karma fortunes by now.ownedbycats ought to be getting tired of karma fortunes by now.
 
ownedbycats's Avatar
 
Posts: 8,421
Karma: 59666665
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.
Attached Files
File Type: zip ViewBookOnYes.zip (618 Bytes, 146 views)

Last edited by capink; 01-30-2022 at 06:58 PM. Reason: Change import statement to qt.core
ownedbycats is online now   Reply With Quote
Old 05-10-2022, 04:14 PM   #26
un_pogaz
Chalut o/
un_pogaz understands the importance of being earnest.un_pogaz understands the importance of being earnest.un_pogaz understands the importance of being earnest.un_pogaz understands the importance of being earnest.un_pogaz understands the importance of being earnest.un_pogaz understands the importance of being earnest.un_pogaz understands the importance of being earnest.un_pogaz understands the importance of being earnest.un_pogaz understands the importance of being earnest.un_pogaz understands the importance of being earnest.un_pogaz understands the importance of being earnest.
 
un_pogaz's Avatar
 
Posts: 408
Karma: 145324
Join Date: Dec 2017
Device: Kobo
Bulk Sort Key

The purpose of this module is to update the title and/or author sorting key, like the options in the Bulk Metadata dialog, options that are unavailable via Action Chains (I won't open the huge Bulk Metadata dialog just to check the same 2 options everytime => go on to make a non-interactive module)
Bonus: Normally translated into your language

Code:
import os, copy

try:
    from qt.core import (QApplication, Qt, QWidget, QGridLayout, QHBoxLayout, QVBoxLayout,
                            QGroupBox, QCheckBox)
except ImportError:
    from PyQt5.Qt import (QApplication, Qt, QWidget, QGridLayout, QHBoxLayout, QVBoxLayout,
                            QGroupBox, QCheckBox)

from calibre import prints
from calibre.constants import DEBUG

from calibre_plugins.action_chains.actions.base import ChainAction

class BulkSortKeyAction(ChainAction):
    
    # the name of your action
    name = 'Bulk Sort Key'
    support_scopes = True
    
    def run(self, gui, settings, chain):
        
        cache = gui.current_db.new_api
        self.ids = chain.scope().get_book_ids()
        if not self.ids or len(self.ids) == 0:
            return
        
        if settings.get(KEY.SORT_TITLE, DEFAULT[KEY.SORT_TITLE]):
            from calibre.ebooks.metadata import title_sort
            
            lang_map = cache.all_field_for('languages', self.ids)
            title_map = cache.all_field_for('title', self.ids)
            
            def get_sort(book_id):
                try:
                    lang = lang_map[book_id][0]
                except (KeyError, IndexError, TypeError, AttributeError):
                    lang = None
                return title_sort(title_map[book_id], lang=lang)
            
            cache.set_field('sort', {bid:get_sort(bid) for bid in self.ids})
        
        if settings.get(KEY.SORT_AUTHOR, DEFAULT[KEY.SORT_AUTHOR]):
            author_sort_map = cache.author_sort_strings_for_books(self.ids)
            cache.set_field('author_sort', {book_id: ' & '.join(author_sort_map[book_id]) for book_id in author_sort_map})


    def config_widget(self):
        return ConfigWidget

class KEY:
    SORT_AUTHOR = 'sort_author'
    SORT_TITLE = 'sort_title'

DEFAULT = {}
DEFAULT[KEY.SORT_AUTHOR] = True
DEFAULT[KEY.SORT_TITLE] = True

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

    def _init_controls(self):
        l = self.l = QVBoxLayout()
        self.setLayout(l)
        
        gb_opts = QGroupBox(_("Options"), self)
        l.addWidget(gb_opts)
        l_opts = QVBoxLayout()
        gb_opts.setLayout(l_opts)
        
        self.chk_sort_author = QCheckBox(_("A&utomatically set author sort"))
        self.chk_sort_author.setChecked(DEFAULT[KEY.SORT_AUTHOR])
        self.chk_sort_author.setToolTip(_("This will cause the author sort field to be automatically updated\n"
                                            " based on the authors field for each selected book. Note that if\n"
                                            " you use the control above to set authors in bulk, the author sort\n"
                                            " field is updated anyway, regardless of the value of this checkbox."))
        l_opts.addWidget(self.chk_sort_author)
        
        self.chk_sort_title = QCheckBox(_("Update &title sort"))
        self.chk_sort_title.setChecked(DEFAULT[KEY.SORT_TITLE])
        self.chk_sort_title.setToolTip(_("Update title sort based on the current title. This will be applied only after other changes to title."))
        l_opts.addWidget(self.chk_sort_title)
        
        l.addStretch(1)
        
        self.setMinimumSize(300,100)

    def load_settings(self, settings):
        if not settings:
            settings = copy.deepcopy(DEFAULT)
        
        self.chk_sort_author.setChecked(settings.get(KEY.SORT_AUTHOR, DEFAULT[KEY.SORT_AUTHOR]))
        self.chk_sort_title.setChecked(settings.get(KEY.SORT_TITLE, DEFAULT[KEY.SORT_TITLE]))

    def save_settings(self):
        settings = {}
        settings[KEY.SORT_AUTHOR] = self.chk_sort_author.isChecked()
        settings[KEY.SORT_TITLE] = self.chk_sort_title.isChecked()
        return settings
Attached Thumbnails
Click image for larger version

Name:	Capture d’écran 2022-05-10 221043.png
Views:	183
Size:	7.3 KB
ID:	193754  
Attached Files
File Type: py Bulk Sort Key.py (3.9 KB, 99 views)

Last edited by un_pogaz; 08-20-2022 at 07:03 AM.
un_pogaz is offline   Reply With Quote
Old 05-15-2022, 12:49 AM   #27
MikePatch
Member
MikePatch began at the beginning.
 
Posts: 11
Karma: 10
Join Date: Dec 2021
Device: Kobo Libra 2
Thank you for the fix.

Ciao
MP
MikePatch is offline   Reply With Quote
Old 09-15-2022, 01:30 PM   #28
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,081
Karma: 1948136
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 04:52 PM.
capink is offline   Reply With Quote
Old 09-22-2022, 10:10 PM   #29
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,081
Karma: 1948136
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:
  • Merging the duplicates while the results of the Find Duplicates PI are still in display in calibre view.
  • Alternatively, there is an option in the Find Duplicates plugin to export it results to a json file. You can later use this json file to merge the books, when the results of the Find Duplicate plugin have been cleared.

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 10:17 PM.
capink is offline   Reply With Quote
Old 09-25-2022, 04:58 AM   #30
Comfy.n
want to learn what I want
Comfy.n ought to be getting tired of karma fortunes by now.Comfy.n ought to be getting tired of karma fortunes by now.Comfy.n ought to be getting tired of karma fortunes by now.Comfy.n ought to be getting tired of karma fortunes by now.Comfy.n ought to be getting tired of karma fortunes by now.Comfy.n ought to be getting tired of karma fortunes by now.Comfy.n ought to be getting tired of karma fortunes by now.Comfy.n ought to be getting tired of karma fortunes by now.Comfy.n ought to be getting tired of karma fortunes by now.Comfy.n ought to be getting tired of karma fortunes by now.Comfy.n ought to be getting tired of karma fortunes by now.
 
Posts: 945
Karma: 6375800
Join Date: Sep 2020
Device: Calibre E-book viewer
Lightbulb

Quote:
Originally Posted by capink View Post
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)

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
Would it be possible to add to this Download Metadata Action some checkboxes for each metadata provider?

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 05:15 AM.
Comfy.n is online now   Reply With Quote
Reply

Thread Tools Search this Thread
Search this Thread:

Advanced Search

Forum Jump

Similar Threads
Thread Thread Starter Forum Replies Last Post
[GUI Plugin] Action Chains capink Plugins 1307 03-15-2024 09:51 PM
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 04:57 PM.


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