diff --git a/src/calibre/db/backend.py b/src/calibre/db/backend.py index 17402cdf14..ac15e31c67 100644 --- a/src/calibre/db/backend.py +++ b/src/calibre/db/backend.py @@ -48,7 +48,7 @@ from calibre.utils.date import EPOCH, parse_date, utcfromtimestamp, utcnow from calibre.utils.filenames import ( ascii_filename, atomic_rename, copyfile_using_links, copytree_using_links, hardlink_file, is_case_sensitive, is_fat_filesystem, make_long_path_useable, - remove_dir_if_empty, samefile, + remove_dir_if_empty, samefile, get_long_path_name ) from calibre.utils.formatter_functions import ( compile_user_template_functions, formatter_functions, load_user_template_functions, @@ -1498,6 +1498,12 @@ class DB: if os.path.exists(fmt_path): return fmt_path + def is_path_inside_book_dir(self, path, book_relpath, sub_path): + book_path = os.path.abspath(os.path.join(self.library_path, book_relpath, sub_path)) + book_path = os.path.normcase(get_long_path_name(book_path)).rstrip(os.sep) + path = os.path.normcase(get_long_path_name(os.path.abspath(path))).rstrip(os.sep) + return path.startswith(book_path + os.sep) + def apply_to_format(self, book_id, path, fname, fmt, func, missing_value=None): path = self.format_abspath(book_id, fmt, fname, path) if path is None: diff --git a/src/calibre/db/cache.py b/src/calibre/db/cache.py index b546a6145c..2ccf6181d4 100644 --- a/src/calibre/db/cache.py +++ b/src/calibre/db/cache.py @@ -3075,6 +3075,14 @@ class Cache: def reindex_annotations(self): self.backend.reindex_annotations() + @read_api + def are_paths_inside_book_dir(self, book_id, paths, sub_path=''): + try: + path = self._field_for('path', book_id).replace('/', os.sep) + except: + return set() + return {x for x in paths if self.backend.is_path_inside_book_dir(x, path, sub_path)} + @write_api def add_extra_files(self, book_id, map_of_relpath_to_stream_or_path, replace=True, auto_rename=False): ' Add extra data files ' diff --git a/src/calibre/gui2/dialogs/data_files_manager.py b/src/calibre/gui2/dialogs/data_files_manager.py new file mode 100644 index 0000000000..b0489f483f --- /dev/null +++ b/src/calibre/gui2/dialogs/data_files_manager.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python +# License: GPLv3 Copyright: 2023, Kovid Goyal + + +import os +from contextlib import contextmanager +from datetime import datetime +from qt.core import ( + QAbstractItemView, QAbstractListModel, QDialogButtonBox, QIcon, QItemSelectionModel, + QLabel, QListView, QPushButton, Qt, QVBoxLayout, QSize +) + +from calibre import human_readable +from calibre.db.constants import DATA_DIR_NAME, DATA_FILE_PATTERN +from calibre.gui2 import choose_files, error_dialog, file_icon_provider, question_dialog +from calibre.gui2.widgets2 import Dialog +from calibre.utils.icu import primary_sort_key + + +class Files(QAbstractListModel): + + def __init__(self, db, book_id, parent=None): + self.db = db + self.book_id = book_id + super().__init__(parent=parent) + self.fi = file_icon_provider() + self.files = sorted(db.list_extra_files(self.book_id, pattern=DATA_FILE_PATTERN), key=self.file_sort_key) + + def refresh(self): + self.modelAboutToBeReset.emit() + self.files = sorted(self.db.list_extra_files(self.book_id, pattern=DATA_FILE_PATTERN), key=self.file_sort_key) + self.modelReset.emit() + + def file_sort_key(self, ef): + return primary_sort_key(ef.relpath) + + def rowCount(self, parent=None): + return len(self.files) + + def file_display_name(self, rownum): + ef = self.files[rownum] + name = ef.relpath.split('/', 1)[1] + return name.replace('/', os.sep) + + def item_at(self, rownum): + return self.files[rownum] + + def data(self, index, role): + row = index.row() + if row >= len(self.files): + return None + if role == Qt.ItemDataRole.DisplayRole: + name = self.file_display_name(row) + e = self.item_at(row) + date = datetime.fromtimestamp(e.stat_result.st_mtime) + l2 = human_readable(e.stat_result.st_size) + date.strftime(' [%Y/%m/%d]') + return name + '\n' + l2 + if role == Qt.ItemDataRole.DecorationRole: + ef = self.files[row] + fmt = ef.relpath.rpartition('.')[-1].lower() + return self.fi.icon_from_ext(fmt) + return None + + def flags(self, index): + return Qt.ItemFlag.ItemIsSelectable|Qt.ItemFlag.ItemIsEnabled + + +class DataFilesManager(Dialog): + + def __init__(self, db, book_id, parent=None): + self.db = db.new_api + self.book_title = title = self.db.field_for('title', book_id) or _('Unknown') + self.book_id = book_id + super().__init__(_('Manage data files for {}').format(title), 'manage-data-files-xx', + parent=parent, default_buttons=QDialogButtonBox.StandardButton.Close) + + def sizeHint(self): + return QSize(400, 500) + + def setup_ui(self): + self.l = l = QVBoxLayout(self) + + self.fview = v = QListView(self) + l.addWidget(v) + self.files = Files(self.db.new_api, self.book_id, parent=v) + v.setModel(self.files) + v.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + if self.files.rowCount(): + v.setCurrentIndex(self.files.index(0)) + v.selectionModel().currentChanged.connect(self.current_changed) + + self.current_label = la = QLabel(self) + la.setWordWrap(True) + l.addWidget(la) + + l.addWidget(self.bb) + self.add_button = b = QPushButton(QIcon.ic('plus.png'), _('&Add files'), self) + b.clicked.connect(self.add_files) + self.bb.addButton(b, QDialogButtonBox.ButtonRole.ActionRole) + + self.current_changed() + self.resize(self.sizeHint()) + + def current_changed(self): + idx = self.fview.currentIndex() + txt = '' + if idx.isValid(): + txt = self.files.file_display_name(idx.row()) + self.current_label.setText(txt) + + @property + def current_item(self): + ci = self.fview.currentIndex() + try: + return self.files.item_at(ci.row()) + except Exception: + return None + + @contextmanager + def preserve_state(self): + selected = set() + vs = self.fview.verticalScrollBar() + pos = vs.value() + for idx in self.fview.selectionModel().selectedRows(): + e = self.files.item_at(idx.row()) + selected.add(e.relpath) + current = self.current_item + try: + yield + finally: + sm = self.fview.selectionModel() + for i in range(self.files.rowCount()): + e = self.files.item_at(i) + flags = QItemSelectionModel.SelectionFlag.NoUpdate + if current is not None and e.relpath == current.relpath: + flags |= QItemSelectionModel.SelectionFlag.Current + if e.relpath in selected: + flags |= QItemSelectionModel.SelectionFlag.Select + if flags != QItemSelectionModel.SelectionFlag.NoUpdate: + sm.select(self.files.index(i), flags) + self.current_changed() + vs.setValue(pos) + + def add_files(self): + files = choose_files(self, 'choose-data-files-to-add', _('Choose files to add')) + if not files: + return + q = self.db.are_paths_inside_book_dir(self.book_id, files, DATA_DIR_NAME) + if q: + return error_dialog( + self, _('Cannot add'), _( + 'Cannot add these data files to the book because they are already in the book\'s data files folder' + ), show=True, det_msg='\n'.join(q)) + + m = {f'{DATA_DIR_NAME}/{os.path.basename(x)}': x for x in files} + added = self.db.add_extra_files(self.book_id, m, replace=False, auto_rename=False) + collisions = set(m) - set(added) + if collisions: + if question_dialog(self, _('Replace existing files?'), _( + 'The following files already exist as data files in the book. Replace them?' + ) + '\n' + '\n'.join(x.partition('/')[2] for x in collisions)): + self.db.add_extra_files(self.book_id, m, replace=True, auto_rename=False) + with self.preserve_state(): + self.files.refresh() + + +if __name__ == '__main__': + from calibre.gui2 import Application + from calibre.library import db as di + app = Application([]) + dfm = DataFilesManager(di(os.path.expanduser('~/test library')), 1893) + dfm.exec()