mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Implement #5829 (Capability to delete books off device from custom search that includes ondevice:"=True"). Make destination directory for Boox customizable
This commit is contained in:
commit
7f7f53061b
@ -81,9 +81,6 @@ class HANLINV3(USBMS):
|
|||||||
return drives
|
return drives
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class HANLINV5(HANLINV3):
|
class HANLINV5(HANLINV3):
|
||||||
name = 'Hanlin V5 driver'
|
name = 'Hanlin V5 driver'
|
||||||
gui_name = 'Hanlin V5'
|
gui_name = 'Hanlin V5'
|
||||||
@ -120,8 +117,22 @@ class BOOX(HANLINV3):
|
|||||||
MAIN_MEMORY_VOLUME_LABEL = 'BOOX Internal Memory'
|
MAIN_MEMORY_VOLUME_LABEL = 'BOOX Internal Memory'
|
||||||
STORAGE_CARD_VOLUME_LABEL = 'BOOX Storage Card'
|
STORAGE_CARD_VOLUME_LABEL = 'BOOX Storage Card'
|
||||||
|
|
||||||
EBOOK_DIR_MAIN = 'MyBooks'
|
EBOOK_DIR_MAIN = ['MyBooks']
|
||||||
EBOOK_DIR_CARD_A = 'MyBooks'
|
EXTRA_CUSTOMIZATION_MESSAGE = _('Comma separated list of directories to '
|
||||||
|
'send e-books to on the device. The first one that exists will '
|
||||||
|
'be used.')
|
||||||
|
EXTRA_CUSTOMIZATION_DEFAULT = ', '.join(EBOOK_DIR_MAIN)
|
||||||
|
|
||||||
|
# EBOOK_DIR_CARD_A = 'MyBooks' ## Am quite sure we need this.
|
||||||
|
|
||||||
|
def post_open_callback(self):
|
||||||
|
opts = self.settings()
|
||||||
|
dirs = opts.extra_customization
|
||||||
|
if not dirs:
|
||||||
|
dirs = self.EBOOK_DIR_MAIN
|
||||||
|
else:
|
||||||
|
dirs = [x.strip() for x in dirs.split(',')]
|
||||||
|
self.EBOOK_DIR_MAIN = dirs
|
||||||
|
|
||||||
def windows_sort_drives(self, drives):
|
def windows_sort_drives(self, drives):
|
||||||
return drives
|
return drives
|
||||||
|
@ -28,6 +28,7 @@ from calibre.constants import preferred_encoding, filesystem_encoding, \
|
|||||||
from calibre.gui2.dialogs.choose_format import ChooseFormatDialog
|
from calibre.gui2.dialogs.choose_format import ChooseFormatDialog
|
||||||
from calibre.ebooks import BOOK_EXTENSIONS
|
from calibre.ebooks import BOOK_EXTENSIONS
|
||||||
from calibre.gui2.dialogs.confirm_delete import confirm
|
from calibre.gui2.dialogs.confirm_delete import confirm
|
||||||
|
from calibre.gui2.dialogs.delete_matching_from_device import DeleteMatchingFromDeviceDialog
|
||||||
|
|
||||||
class AnnotationsAction(object): # {{{
|
class AnnotationsAction(object): # {{{
|
||||||
|
|
||||||
@ -471,6 +472,45 @@ class DeleteAction(object): # {{{
|
|||||||
if ids:
|
if ids:
|
||||||
self.tags_view.recount()
|
self.tags_view.recount()
|
||||||
|
|
||||||
|
def remove_matching_books_from_device(self, *args):
|
||||||
|
if not self.device_manager.is_device_connected:
|
||||||
|
d = error_dialog(self, _('Cannot delete books'),
|
||||||
|
_('No device is connected'))
|
||||||
|
d.exec_()
|
||||||
|
return
|
||||||
|
ids = self._get_selected_ids()
|
||||||
|
if not ids:
|
||||||
|
#For some reason the delete dialog reports no selection, so
|
||||||
|
#we need to do it here
|
||||||
|
return
|
||||||
|
to_delete = {}
|
||||||
|
some_to_delete = False
|
||||||
|
for model,name in ((self.memory_view.model(), _('Main memory')),
|
||||||
|
(self.card_a_view.model(), _('Storage Card A')),
|
||||||
|
(self.card_b_view.model(), _('Storage Card B'))):
|
||||||
|
to_delete[name] = (model, model.paths_for_db_ids(ids))
|
||||||
|
if len(to_delete[name][1]) > 0:
|
||||||
|
some_to_delete = True
|
||||||
|
if not some_to_delete:
|
||||||
|
d = error_dialog(self, _('No books to delete'),
|
||||||
|
_('None of the selected books are on the device'))
|
||||||
|
d.exec_()
|
||||||
|
return
|
||||||
|
d = DeleteMatchingFromDeviceDialog(self, to_delete)
|
||||||
|
if d.exec_():
|
||||||
|
paths = {}
|
||||||
|
ids = {}
|
||||||
|
for (model, id, path) in d.result:
|
||||||
|
if model not in paths:
|
||||||
|
paths[model] = []
|
||||||
|
ids[model] = []
|
||||||
|
paths[model].append(path)
|
||||||
|
ids[model].append(id)
|
||||||
|
for model in paths:
|
||||||
|
job = self.remove_paths(paths[model])
|
||||||
|
self.delete_memory[job] = (paths[model], model)
|
||||||
|
model.mark_for_deletion(job, ids[model], rows_are_ids=True)
|
||||||
|
self.status_bar.show_message(_('Deleting books from device.'), 1000)
|
||||||
|
|
||||||
def delete_covers(self, *args):
|
def delete_covers(self, *args):
|
||||||
ids = self._get_selected_ids()
|
ids = self._get_selected_ids()
|
||||||
|
@ -1347,7 +1347,7 @@ class DeviceMixin(object): # {{{
|
|||||||
if reset:
|
if reset:
|
||||||
# First build a cache of the library, so the search isn't On**2
|
# First build a cache of the library, so the search isn't On**2
|
||||||
self.db_book_title_cache = {}
|
self.db_book_title_cache = {}
|
||||||
self.db_book_uuid_cache = set()
|
self.db_book_uuid_cache = {}
|
||||||
db = self.library_view.model().db
|
db = self.library_view.model().db
|
||||||
for id in db.data.iterallids():
|
for id in db.data.iterallids():
|
||||||
mi = db.get_metadata(id, index_is_id=True)
|
mi = db.get_metadata(id, index_is_id=True)
|
||||||
@ -1364,7 +1364,7 @@ class DeviceMixin(object): # {{{
|
|||||||
aus = re.sub('(?u)\W|[_]', '', aus)
|
aus = re.sub('(?u)\W|[_]', '', aus)
|
||||||
self.db_book_title_cache[title]['author_sort'][aus] = mi
|
self.db_book_title_cache[title]['author_sort'][aus] = mi
|
||||||
self.db_book_title_cache[title]['db_ids'][mi.application_id] = mi
|
self.db_book_title_cache[title]['db_ids'][mi.application_id] = mi
|
||||||
self.db_book_uuid_cache.add(mi.uuid)
|
self.db_book_uuid_cache[mi.uuid] = mi.application_id
|
||||||
|
|
||||||
# Now iterate through all the books on the device, setting the
|
# Now iterate through all the books on the device, setting the
|
||||||
# in_library field Fastest and most accurate key is the uuid. Second is
|
# in_library field Fastest and most accurate key is the uuid. Second is
|
||||||
@ -1376,11 +1376,13 @@ class DeviceMixin(object): # {{{
|
|||||||
for book in booklist:
|
for book in booklist:
|
||||||
if getattr(book, 'uuid', None) in self.db_book_uuid_cache:
|
if getattr(book, 'uuid', None) in self.db_book_uuid_cache:
|
||||||
book.in_library = True
|
book.in_library = True
|
||||||
|
# ensure that the correct application_id is set
|
||||||
|
book.application_id = self.db_book_uuid_cache[book.uuid]
|
||||||
continue
|
continue
|
||||||
|
|
||||||
book_title = book.title.lower() if book.title else ''
|
book_title = book.title.lower() if book.title else ''
|
||||||
book_title = re.sub('(?u)\W|[_]', '', book_title)
|
book_title = re.sub('(?u)\W|[_]', '', book_title)
|
||||||
book.in_library = False
|
book.in_library = None
|
||||||
d = self.db_book_title_cache.get(book_title, None)
|
d = self.db_book_title_cache.get(book_title, None)
|
||||||
if d is not None:
|
if d is not None:
|
||||||
if getattr(book, 'application_id', None) in d['db_ids']:
|
if getattr(book, 'application_id', None) in d['db_ids']:
|
||||||
|
67
src/calibre/gui2/dialogs/delete_matching_from_device.py
Normal file
67
src/calibre/gui2/dialogs/delete_matching_from_device.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
|
||||||
|
__docformat__ = 'restructuredtext en'
|
||||||
|
__license__ = 'GPL v3'
|
||||||
|
|
||||||
|
from PyQt4.Qt import Qt, QDialog, QTableWidgetItem, QAbstractItemView, QIcon
|
||||||
|
|
||||||
|
from calibre.ebooks.metadata import authors_to_string
|
||||||
|
from calibre.gui2.dialogs.delete_matching_from_device_ui import Ui_DeleteMatchingFromDeviceDialog
|
||||||
|
|
||||||
|
class tableItem(QTableWidgetItem):
|
||||||
|
|
||||||
|
def __init__(self, text):
|
||||||
|
QTableWidgetItem.__init__(self, text)
|
||||||
|
self.setFlags(Qt.ItemIsEnabled)
|
||||||
|
|
||||||
|
def __ge__(self, other):
|
||||||
|
return unicode(self.text()).lower() >= unicode(other.text()).lower()
|
||||||
|
|
||||||
|
def __lt__(self, other):
|
||||||
|
return unicode(self.text()).lower() < unicode(other.text()).lower()
|
||||||
|
|
||||||
|
class DeleteMatchingFromDeviceDialog(QDialog, Ui_DeleteMatchingFromDeviceDialog):
|
||||||
|
|
||||||
|
def __init__(self, parent, items):
|
||||||
|
QDialog.__init__(self, parent)
|
||||||
|
Ui_DeleteMatchingFromDeviceDialog.__init__(self)
|
||||||
|
self.setupUi(self)
|
||||||
|
|
||||||
|
self.buttonBox.accepted.connect(self.accepted)
|
||||||
|
self.table.cellClicked.connect(self.cell_clicked)
|
||||||
|
self.table.setSelectionMode(QAbstractItemView.NoSelection)
|
||||||
|
self.table.setColumnCount(5)
|
||||||
|
self.table.setHorizontalHeaderLabels(['', _('Location'), _('Title'),
|
||||||
|
_('Author'), _('Format')])
|
||||||
|
del_icon = QIcon(I('list_remove.svg'))
|
||||||
|
rows = 0
|
||||||
|
for card in items:
|
||||||
|
rows += len(items[card][1])
|
||||||
|
self.table.setRowCount(rows)
|
||||||
|
row = 0
|
||||||
|
for card in items:
|
||||||
|
(model,books) = items[card]
|
||||||
|
for (id,book) in books:
|
||||||
|
item = QTableWidgetItem(del_icon, '')
|
||||||
|
item.setData(Qt.UserRole, (model, id, book.path))
|
||||||
|
self.table.setItem(row, 0, item)
|
||||||
|
self.table.setItem(row, 1, tableItem(card))
|
||||||
|
self.table.setItem(row, 2, tableItem(book.title))
|
||||||
|
self.table.setItem(row, 3, tableItem(authors_to_string(book.authors)))
|
||||||
|
self.table.setItem(row, 4, tableItem(book.path.rpartition('.')[2]))
|
||||||
|
row += 1
|
||||||
|
self.table.resizeColumnsToContents()
|
||||||
|
self.table.setSortingEnabled(True)
|
||||||
|
self.table.sortByColumn(2, Qt.AscendingOrder)
|
||||||
|
|
||||||
|
def accepted(self):
|
||||||
|
self.result = []
|
||||||
|
for row in range(self.table.rowCount()):
|
||||||
|
(model, id, path) = self.table.item(row, 0).data(Qt.UserRole).toPyObject()
|
||||||
|
path = unicode(path)
|
||||||
|
self.result.append((model, id, path))
|
||||||
|
return
|
||||||
|
|
||||||
|
def cell_clicked(self, row, col):
|
||||||
|
if col == 0:
|
||||||
|
self.table.removeRow(row)
|
86
src/calibre/gui2/dialogs/delete_matching_from_device.ui
Normal file
86
src/calibre/gui2/dialogs/delete_matching_from_device.ui
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ui version="4.0">
|
||||||
|
<class>DeleteMatchingFromDeviceDialog</class>
|
||||||
|
<widget class="QDialog" name="DeleteMatchingFromDeviceDialog">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>730</width>
|
||||||
|
<height>342</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="MinimumExpanding">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>Delete from device</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout">
|
||||||
|
<item>
|
||||||
|
<widget class="QTableWidget" name="table">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="columnCount">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QDialogButtonBox" name="buttonBox">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="standardButtons">
|
||||||
|
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
|
||||||
|
</property>
|
||||||
|
<property name="centerButtons">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<resources/>
|
||||||
|
<connections>
|
||||||
|
<connection>
|
||||||
|
<sender>buttonBox</sender>
|
||||||
|
<signal>accepted()</signal>
|
||||||
|
<receiver>DeleteMatchingFromDeviceDialog</receiver>
|
||||||
|
<slot>accept()</slot>
|
||||||
|
<hints>
|
||||||
|
<hint type="sourcelabel">
|
||||||
|
<x>229</x>
|
||||||
|
<y>211</y>
|
||||||
|
</hint>
|
||||||
|
<hint type="destinationlabel">
|
||||||
|
<x>157</x>
|
||||||
|
<y>234</y>
|
||||||
|
</hint>
|
||||||
|
</hints>
|
||||||
|
</connection>
|
||||||
|
<connection>
|
||||||
|
<sender>buttonBox</sender>
|
||||||
|
<signal>rejected()</signal>
|
||||||
|
<receiver>DeleteMatchingFromDeviceDialog</receiver>
|
||||||
|
<slot>reject()</slot>
|
||||||
|
<hints>
|
||||||
|
<hint type="sourcelabel">
|
||||||
|
<x>297</x>
|
||||||
|
<y>217</y>
|
||||||
|
</hint>
|
||||||
|
<hint type="destinationlabel">
|
||||||
|
<x>286</x>
|
||||||
|
<y>234</y>
|
||||||
|
</hint>
|
||||||
|
</hints>
|
||||||
|
</connection>
|
||||||
|
</connections>
|
||||||
|
</ui>
|
@ -131,6 +131,9 @@ class ToolbarMixin(object): # {{{
|
|||||||
self.delete_all_but_selected_formats)
|
self.delete_all_but_selected_formats)
|
||||||
self.delete_menu.addAction(
|
self.delete_menu.addAction(
|
||||||
_('Remove covers from selected books'), self.delete_covers)
|
_('Remove covers from selected books'), self.delete_covers)
|
||||||
|
self.delete_menu.addAction(
|
||||||
|
_('Remove matching books from device'),
|
||||||
|
self.remove_matching_books_from_device)
|
||||||
self.action_del.setMenu(self.delete_menu)
|
self.action_del.setMenu(self.delete_menu)
|
||||||
|
|
||||||
self.action_open_containing_folder.setShortcut(Qt.Key_O)
|
self.action_open_containing_folder.setShortcut(Qt.Key_O)
|
||||||
|
@ -769,6 +769,7 @@ class OnDeviceSearch(SearchQueryParser): # {{{
|
|||||||
'format',
|
'format',
|
||||||
'formats',
|
'formats',
|
||||||
'title',
|
'title',
|
||||||
|
'inlibrary'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -807,12 +808,21 @@ class OnDeviceSearch(SearchQueryParser): # {{{
|
|||||||
'author': lambda x: ' & '.join(getattr(x, 'authors')).lower(),
|
'author': lambda x: ' & '.join(getattr(x, 'authors')).lower(),
|
||||||
'collections':lambda x: ','.join(getattr(x, 'device_collections')).lower(),
|
'collections':lambda x: ','.join(getattr(x, 'device_collections')).lower(),
|
||||||
'format':lambda x: os.path.splitext(x.path)[1].lower(),
|
'format':lambda x: os.path.splitext(x.path)[1].lower(),
|
||||||
|
'inlibrary':lambda x : getattr(x, 'in_library')
|
||||||
}
|
}
|
||||||
for x in ('author', 'format'):
|
for x in ('author', 'format'):
|
||||||
q[x+'s'] = q[x]
|
q[x+'s'] = q[x]
|
||||||
for index, row in enumerate(self.model.db):
|
for index, row in enumerate(self.model.db):
|
||||||
for locvalue in locations:
|
for locvalue in locations:
|
||||||
accessor = q[locvalue]
|
accessor = q[locvalue]
|
||||||
|
if query == 'true':
|
||||||
|
if accessor(row) is not None:
|
||||||
|
matches.add(index)
|
||||||
|
continue
|
||||||
|
if query == 'false':
|
||||||
|
if accessor(row) is None:
|
||||||
|
matches.add(index)
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
### Can't separate authors because comma is used for name sep and author sep
|
### Can't separate authors because comma is used for name sep and author sep
|
||||||
### Exact match might not get what you want. For that reason, turn author
|
### Exact match might not get what you want. For that reason, turn author
|
||||||
@ -862,7 +872,11 @@ class DeviceBooksModel(BooksModel): # {{{
|
|||||||
self.editable = True
|
self.editable = True
|
||||||
self.book_in_library = None
|
self.book_in_library = None
|
||||||
|
|
||||||
def mark_for_deletion(self, job, rows):
|
def mark_for_deletion(self, job, rows, rows_are_ids=False):
|
||||||
|
if rows_are_ids:
|
||||||
|
self.marked_for_deletion[job] = rows
|
||||||
|
self.reset()
|
||||||
|
else:
|
||||||
self.marked_for_deletion[job] = self.indices(rows)
|
self.marked_for_deletion[job] = self.indices(rows)
|
||||||
for row in rows:
|
for row in rows:
|
||||||
indices = self.row_indices(row)
|
indices = self.row_indices(row)
|
||||||
@ -888,13 +902,13 @@ class DeviceBooksModel(BooksModel): # {{{
|
|||||||
ans.extend(v)
|
ans.extend(v)
|
||||||
return ans
|
return ans
|
||||||
|
|
||||||
def clear_ondevice(self, db_ids):
|
def clear_ondevice(self, db_ids, to_what=None):
|
||||||
for data in self.db:
|
for data in self.db:
|
||||||
if data is None:
|
if data is None:
|
||||||
continue
|
continue
|
||||||
app_id = getattr(data, 'application_id', None)
|
app_id = getattr(data, 'application_id', None)
|
||||||
if app_id is not None and app_id in db_ids:
|
if app_id is not None and app_id in db_ids:
|
||||||
data.in_library = False
|
data.in_library = to_what
|
||||||
self.reset()
|
self.reset()
|
||||||
|
|
||||||
def flags(self, index):
|
def flags(self, index):
|
||||||
@ -1049,6 +1063,13 @@ class DeviceBooksModel(BooksModel): # {{{
|
|||||||
def paths(self, rows):
|
def paths(self, rows):
|
||||||
return [self.db[self.map[r.row()]].path for r in rows ]
|
return [self.db[self.map[r.row()]].path for r in rows ]
|
||||||
|
|
||||||
|
def paths_for_db_ids(self, db_ids):
|
||||||
|
res = []
|
||||||
|
for r,b in enumerate(self.db):
|
||||||
|
if b.application_id in db_ids:
|
||||||
|
res.append((r,b))
|
||||||
|
return res
|
||||||
|
|
||||||
def indices(self, rows):
|
def indices(self, rows):
|
||||||
'''
|
'''
|
||||||
Return indices into underlying database from rows
|
Return indices into underlying database from rows
|
||||||
@ -1089,6 +1110,8 @@ class DeviceBooksModel(BooksModel): # {{{
|
|||||||
elif role == Qt.DecorationRole and cname == 'inlibrary':
|
elif role == Qt.DecorationRole and cname == 'inlibrary':
|
||||||
if self.db[self.map[row]].in_library:
|
if self.db[self.map[row]].in_library:
|
||||||
return QVariant(self.bool_yes_icon)
|
return QVariant(self.bool_yes_icon)
|
||||||
|
elif self.db[self.map[row]].in_library is not None:
|
||||||
|
return QVariant(self.bool_no_icon)
|
||||||
elif role == Qt.TextAlignmentRole:
|
elif role == Qt.TextAlignmentRole:
|
||||||
cname = self.column_map[index.column()]
|
cname = self.column_map[index.column()]
|
||||||
ans = Qt.AlignVCenter | ALIGNMENT_MAP[self.alignment_map.get(cname,
|
ans = Qt.AlignVCenter | ALIGNMENT_MAP[self.alignment_map.get(cname,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user