mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2024-07-30 21:27:34 +00:00
Revert "Add duplicate entry handling (Fix #179)"
This reverts commit 491ebb6714
.
This commit is contained in:
parent
6357fea8db
commit
66ec0913b6
7 changed files with 325 additions and 279 deletions
|
@ -86,7 +86,7 @@ class Entry:
|
|||
return self.__str__()
|
||||
|
||||
def __eq__(self, __value: object) -> bool:
|
||||
# __value = cast(Self, object)
|
||||
__value = cast(Self, object)
|
||||
if os.name == "nt":
|
||||
return (
|
||||
int(self.id) == int(__value.id) #type: ignore
|
||||
|
@ -328,7 +328,7 @@ class Library:
|
|||
|
||||
def __init__(self) -> None:
|
||||
# Library Info =========================================================
|
||||
self.library_dir: Path = None
|
||||
self.library_dir: str = None
|
||||
|
||||
# Entries ==============================================================
|
||||
# List of every Entry object.
|
||||
|
@ -439,7 +439,7 @@ class Library:
|
|||
{"id": 30, "name": "Comments", "type": "text_box"},
|
||||
]
|
||||
|
||||
def create_library(self, path: Path) -> int:
|
||||
def create_library(self, path) -> int:
|
||||
"""
|
||||
Creates a TagStudio library in the given directory.\n
|
||||
Return Codes:\n
|
||||
|
@ -447,6 +447,8 @@ class Library:
|
|||
2: File creation error
|
||||
"""
|
||||
|
||||
path = os.path.normpath(path).rstrip("\\")
|
||||
|
||||
# If '.TagStudio' is included in the path, trim the path up to it.
|
||||
if TS_FOLDER_NAME in str(path):
|
||||
# TODO: Native Path method instead of this casting.
|
||||
|
@ -467,12 +469,12 @@ class Library:
|
|||
def verify_ts_folders(self) -> None:
|
||||
"""Verifies/creates folders required by TagStudio."""
|
||||
|
||||
full_ts_path = Path() / self.library_dir / TS_FOLDER_NAME
|
||||
full_backup_path = (
|
||||
Path() / self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME
|
||||
full_ts_path = os.path.normpath(f"{self.library_dir}/{TS_FOLDER_NAME}")
|
||||
full_backup_path = os.path.normpath(
|
||||
f"{self.library_dir}/{TS_FOLDER_NAME}/{BACKUP_FOLDER_NAME}"
|
||||
)
|
||||
full_collage_path = (
|
||||
Path() / self.library_dir / TS_FOLDER_NAME / COLLAGE_FOLDER_NAME
|
||||
full_collage_path = os.path.normpath(
|
||||
f"{self.library_dir}/{TS_FOLDER_NAME}/{COLLAGE_FOLDER_NAME}"
|
||||
)
|
||||
|
||||
if not os.path.isdir(full_ts_path):
|
||||
|
@ -521,7 +523,7 @@ class Library:
|
|||
encoding="utf-8",
|
||||
) as file:
|
||||
json_dump: JsonLibary = ujson.load(file)
|
||||
self.library_dir = Path(path)
|
||||
self.library_dir = str(path)
|
||||
self.verify_ts_folders()
|
||||
major, minor, patch = json_dump["ts-version"].split(".")
|
||||
|
||||
|
@ -677,7 +679,6 @@ class Library:
|
|||
)
|
||||
self.entries.append(e)
|
||||
self._map_entry_id_to_index(e, -1)
|
||||
|
||||
end_time = time.time()
|
||||
logging.info(
|
||||
f"[LIBRARY] Entries loaded in {(end_time - start_time):.3f} seconds"
|
||||
|
@ -739,9 +740,19 @@ class Library:
|
|||
"""Maps a full filepath to its corresponding Entry's ID."""
|
||||
self.filename_to_entry_id_map.clear()
|
||||
for entry in self.entries:
|
||||
self.filename_to_entry_id_map[Path() / entry.path / entry.filename] = (
|
||||
entry.id
|
||||
)
|
||||
if os.name == "nt":
|
||||
# print(str(os.path.normpath(
|
||||
# f'{entry.path}/{entry.filename}')).lower().lstrip('\\').lstrip('/'))
|
||||
self.filename_to_entry_id_map[
|
||||
str(os.path.normpath(f"{entry.path}/{entry.filename}"))
|
||||
.lower()
|
||||
.lstrip("\\")
|
||||
.lstrip("/")
|
||||
] = entry.id
|
||||
else:
|
||||
self.filename_to_entry_id_map[
|
||||
str(os.path.normpath(f"{entry.path}/{entry.filename}")).lstrip("/")
|
||||
] = entry.id
|
||||
|
||||
# def _map_filenames_to_entry_ids(self):
|
||||
# """Maps the file paths of entries to their index in the library list."""
|
||||
|
@ -891,19 +902,23 @@ class Library:
|
|||
# - Files without library entries
|
||||
# for type in TYPES:
|
||||
start_time = time.time()
|
||||
for f in self.library_dir.glob("**/*"):
|
||||
for f in glob.glob(self.library_dir + "/**/*", recursive=True):
|
||||
# p = Path(os.path.normpath(f))
|
||||
if (
|
||||
"$RECYCLE.BIN" not in f.parts
|
||||
and TS_FOLDER_NAME not in f.parts
|
||||
and "tagstudio_thumbs" not in f.parts
|
||||
and not f.is_dir()
|
||||
"$RECYCLE.BIN" not in f
|
||||
and TS_FOLDER_NAME not in f
|
||||
and "tagstudio_thumbs" not in f
|
||||
and not os.path.isdir(f)
|
||||
):
|
||||
if f.suffix not in self.ignored_extensions:
|
||||
if os.path.splitext(f)[1][1:].lower() not in self.ignored_extensions:
|
||||
self.dir_file_count += 1
|
||||
file = f.relative_to(self.library_dir)
|
||||
file = str(os.path.relpath(f, self.library_dir))
|
||||
|
||||
try:
|
||||
_ = self.filename_to_entry_id_map[file]
|
||||
if os.name == "nt":
|
||||
_ = self.filename_to_entry_id_map[file.lower()]
|
||||
else:
|
||||
_ = self.filename_to_entry_id_map[file]
|
||||
except KeyError:
|
||||
# print(file)
|
||||
self.files_not_in_library.append(file)
|
||||
|
@ -922,7 +937,9 @@ class Library:
|
|||
try:
|
||||
self.files_not_in_library = sorted(
|
||||
self.files_not_in_library,
|
||||
key=lambda t: -os.stat((Path() / self.library_dir / t)).st_ctime,
|
||||
key=lambda t: -os.stat(
|
||||
os.path.normpath(self.library_dir + "/" + t)
|
||||
).st_ctime,
|
||||
)
|
||||
except (FileExistsError, FileNotFoundError):
|
||||
print(
|
||||
|
@ -953,7 +970,12 @@ class Library:
|
|||
# Step [1/2]:
|
||||
# Remove this Entry from the Entries list.
|
||||
entry = self.get_entry(entry_id)
|
||||
path = Path() / entry.path / entry.filename
|
||||
path = (
|
||||
str(os.path.normpath(f"{entry.path}/{entry.filename}"))
|
||||
.lstrip("\\")
|
||||
.lstrip("/")
|
||||
)
|
||||
path = path.lower() if os.name == "nt" else path
|
||||
# logging.info(f'Removing path: {path}')
|
||||
del self.filename_to_entry_id_map[path]
|
||||
|
||||
|
@ -979,34 +1001,51 @@ class Library:
|
|||
`dupe_entries = tuple(int, list[int])`
|
||||
"""
|
||||
|
||||
# self.dupe_entries.clear()
|
||||
# known_files: set = set()
|
||||
# for entry in self.entries:
|
||||
# full_path = os.path.normpath(f'{self.library_dir}/{entry.path}/{entry.filename}')
|
||||
# if full_path in known_files:
|
||||
# self.dupe_entries.append(full_path)
|
||||
# else:
|
||||
# known_files.add(full_path)
|
||||
|
||||
self.dupe_entries.clear()
|
||||
registered: dict = {} # string: list[int]
|
||||
|
||||
# Registered: filename : list[ALL entry IDs pointing to this filename]
|
||||
# Dupe Entries: primary ID : list of [every OTHER entry ID pointing]
|
||||
|
||||
for i, e in enumerate(self.entries):
|
||||
file: Path = Path() / e.path / e.filename
|
||||
# If this unique filepath has not been marked as checked,
|
||||
if not registered.get(file, None):
|
||||
# Register the filepath as having been checked, and include
|
||||
# its entry ID as the first entry in the corresponding list.
|
||||
registered[file] = [e.id]
|
||||
# Else if the filepath is already been seen in another entry,
|
||||
else:
|
||||
# Add this new entry ID to the list of entry ID(s) pointing to
|
||||
# the same file.
|
||||
registered[file].append(e.id)
|
||||
yield i - 1 # The -1 waits for the next step to finish
|
||||
|
||||
for k, v in registered.items():
|
||||
if len(v) > 1:
|
||||
self.dupe_entries.append((v[0], v[1:]))
|
||||
# logging.info(f"DUPLICATE FOUND: {(v[0], v[1:])}")
|
||||
# for id in v:
|
||||
# logging.info(f"\t{(Path()/self.get_entry(id).path/self.get_entry(id).filename)}")
|
||||
|
||||
yield len(self.entries)
|
||||
checked = set()
|
||||
remaining: list[Entry] = list(self.entries)
|
||||
for p, entry_p in enumerate(self.entries, start=0):
|
||||
if p not in checked:
|
||||
matched: list[int] = []
|
||||
for c, entry_c in enumerate(remaining, start=0):
|
||||
if os.name == "nt":
|
||||
if (
|
||||
entry_p.path.lower() == entry_c.path.lower()
|
||||
and entry_p.filename.lower() == entry_c.filename.lower()
|
||||
and c != p
|
||||
):
|
||||
matched.append(c)
|
||||
checked.add(c)
|
||||
else:
|
||||
if (
|
||||
entry_p.path == entry_c.path
|
||||
and entry_p.filename == entry_c.filename
|
||||
and c != p
|
||||
):
|
||||
matched.append(c)
|
||||
checked.add(c)
|
||||
if matched:
|
||||
self.dupe_entries.append((p, matched))
|
||||
sys.stdout.write(
|
||||
f"\r[LIBRARY] Entry [{p}/{len(self.entries)-1}]: Has Duplicate(s): {matched}"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
else:
|
||||
sys.stdout.write(
|
||||
f"\r[LIBRARY] Entry [{p}/{len(self.entries)-1}]: Has No Duplicates"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
checked.add(p)
|
||||
print("")
|
||||
|
||||
def merge_dupe_entries(self):
|
||||
"""
|
||||
|
@ -1016,36 +1055,35 @@ class Library:
|
|||
`dupe_entries = tuple(int, list[int])`
|
||||
"""
|
||||
|
||||
logging.info("[LIBRARY] Mirroring Duplicate Entries...")
|
||||
id_to_entry_map: dict = {}
|
||||
|
||||
print("[LIBRARY] Mirroring Duplicate Entries...")
|
||||
for dupe in self.dupe_entries:
|
||||
# Store the id to entry relationship as the library one is about to
|
||||
# be destroyed.
|
||||
# NOTE: This is not a good solution, but will be upended by the
|
||||
# database migration soon anyways.
|
||||
for id in dupe[1]:
|
||||
id_to_entry_map[id] = self.get_entry(id)
|
||||
self.mirror_entry_fields([dupe[0]] + dupe[1])
|
||||
|
||||
logging.info(
|
||||
# print('Consolidating Entries...')
|
||||
# for dupe in self.dupe_entries:
|
||||
# for index in dupe[1]:
|
||||
# print(f'Consolidating Duplicate: {(self.entries[index].path + os.pathsep + self.entries[index].filename)}')
|
||||
# self.entries.remove(self.entries[index])
|
||||
# self._map_filenames_to_entry_indices()
|
||||
|
||||
print(
|
||||
"[LIBRARY] Consolidating Entries... (This may take a while for larger libraries)"
|
||||
)
|
||||
for i, dupe in enumerate(self.dupe_entries):
|
||||
for id in dupe[1]:
|
||||
# NOTE: Instead of using self.remove_entry(id), I'm bypassing it
|
||||
# because it's currently inefficient in how it needs to remap
|
||||
# every ID to every list index. I'm recreating the steps it
|
||||
# takes but in a batch-friendly way here.
|
||||
# NOTE: Couldn't use get_entry(id) because that relies on the
|
||||
# entry's index in the list, which is currently being messed up.
|
||||
logging.info(f"[LIBRARY] Removing Unneeded Entry {id}")
|
||||
self.entries.remove(id_to_entry_map[id])
|
||||
yield i - 1 # The -1 waits for the next step to finish
|
||||
|
||||
self._entry_id_to_index_map.clear()
|
||||
for i, e in enumerate(self.entries, start=0):
|
||||
self._map_entry_id_to_index(e, i)
|
||||
unique: list[Entry] = []
|
||||
for i, e in enumerate(self.entries):
|
||||
if e not in unique:
|
||||
unique.append(e)
|
||||
# print(f'[{i}/{len(self.entries)}] Appending: {(e.path + os.pathsep + e.filename)[0:32]}...')
|
||||
sys.stdout.write(
|
||||
f"\r[LIBRARY] [{i}/{len(self.entries)}] Appending Unique Entry..."
|
||||
)
|
||||
else:
|
||||
sys.stdout.write(
|
||||
f"\r[LIBRARY] [{i}/{len(self.entries)}] Consolidating Duplicate: {(e.path + os.pathsep + e.filename)[0:]}..."
|
||||
)
|
||||
print("")
|
||||
# [unique.append(x) for x in self.entries if x not in unique]
|
||||
self.entries = unique
|
||||
self._map_filenames_to_entry_ids()
|
||||
|
||||
def refresh_dupe_files(self, results_filepath):
|
||||
|
@ -1055,9 +1093,9 @@ class Library:
|
|||
by a DupeGuru results file.
|
||||
"""
|
||||
full_results_path = (
|
||||
Path() / self.library_dir / results_filepath
|
||||
os.path.normpath(f"{self.library_dir}/{results_filepath}")
|
||||
if self.library_dir not in results_filepath
|
||||
else Path(results_filepath)
|
||||
else os.path.normpath(f"{results_filepath}")
|
||||
)
|
||||
if os.path.exists(full_results_path):
|
||||
self.dupe_files.clear()
|
||||
|
@ -1083,15 +1121,26 @@ class Library:
|
|||
)
|
||||
for match in matches:
|
||||
# print(f'MATCHED ({match[2]}%): \n {files[match[0]]} \n-> {files[match[1]]}')
|
||||
file_1 = Path() / files[match[0]] / self.library_dir
|
||||
file_2 = Path() / files[match[1]] / self.library_dir
|
||||
if (
|
||||
file_1 in self.filename_to_entry_id_map.keys()
|
||||
and file_2 in self.filename_to_entry_id_map.keys()
|
||||
):
|
||||
self.dupe_files.append(
|
||||
(files[match[0]], files[match[1]], match[2])
|
||||
)
|
||||
if os.name == "nt":
|
||||
file_1 = str(os.path.relpath(files[match[0]], self.library_dir))
|
||||
file_2 = str(os.path.relpath(files[match[1]], self.library_dir))
|
||||
if (
|
||||
file_1.lower() in self.filename_to_entry_id_map.keys()
|
||||
and file_2.lower() in self.filename_to_entry_id_map.keys()
|
||||
):
|
||||
self.dupe_files.append(
|
||||
(files[match[0]], files[match[1]], match[2])
|
||||
)
|
||||
else:
|
||||
if (
|
||||
file_1 in self.filename_to_entry_id_map.keys()
|
||||
and file_2 in self.filename_to_entry_id_map.keys()
|
||||
):
|
||||
self.dupe_files.append(
|
||||
(files[match[0]], files[match[1]], match[2])
|
||||
)
|
||||
# self.dupe_files.append((files[match[0]], files[match[1]], match[2]))
|
||||
|
||||
print("")
|
||||
|
||||
for dupe in self.dupe_files:
|
||||
|
@ -1167,16 +1216,19 @@ class Library:
|
|||
print(f"[LIBRARY] Fixed {self.get_entry(id).filename}")
|
||||
# (int, str)
|
||||
|
||||
# Consolidate new matches with existing unlinked entries.
|
||||
self.refresh_dupe_entries()
|
||||
if self.dupe_entries:
|
||||
self.merge_dupe_entries()
|
||||
|
||||
# Remap filenames to entry IDs.
|
||||
self._map_filenames_to_entry_ids()
|
||||
# TODO - the type here doesnt match but I cant reproduce calling this
|
||||
self.remove_missing_matches(fixed_indices)
|
||||
|
||||
# for i in fixed_indices:
|
||||
# # print(json_dump[i])
|
||||
# del self.missing_matches[i]
|
||||
|
||||
# with open(matched_json_filepath, "w") as outfile:
|
||||
# outfile.flush()
|
||||
# json.dump({}, outfile, indent=4)
|
||||
# print(f'Re-saved to disk at {matched_json_filepath}')
|
||||
|
||||
def _match_missing_file(self, file: str) -> list[str]:
|
||||
"""
|
||||
Tries to find missing entry files within the library directory.
|
||||
|
@ -1204,7 +1256,7 @@ class Library:
|
|||
# matches[file].append(new_path)
|
||||
|
||||
print(
|
||||
f"[LIBRARY] MATCH: {file} \n\t-> {Path()/self.library_dir/new_path/tail}\n"
|
||||
f'[LIBRARY] MATCH: {file} \n\t-> {os.path.normpath(self.library_dir + "/" + new_path + "/" + tail)}\n'
|
||||
)
|
||||
|
||||
if not matches:
|
||||
|
@ -1293,12 +1345,20 @@ class Library:
|
|||
return None
|
||||
|
||||
# @deprecated('Use new Entry ID system.')
|
||||
def get_entry_id_from_filepath(self, filename: Path):
|
||||
def get_entry_id_from_filepath(self, filename):
|
||||
"""Returns an Entry ID given the full filepath it points to."""
|
||||
try:
|
||||
if self.entries:
|
||||
if os.name == "nt":
|
||||
return self.filename_to_entry_id_map[
|
||||
str(
|
||||
os.path.normpath(
|
||||
os.path.relpath(filename, self.library_dir)
|
||||
)
|
||||
).lower()
|
||||
]
|
||||
return self.filename_to_entry_id_map[
|
||||
Path(filename).relative_to(self.library_dir)
|
||||
str(os.path.normpath(os.path.relpath(filename, self.library_dir)))
|
||||
]
|
||||
except:
|
||||
return -1
|
||||
|
|
|
@ -32,7 +32,7 @@ class DeleteUnlinkedEntriesModal(QWidget):
|
|||
super().__init__()
|
||||
self.lib = library
|
||||
self.driver = driver
|
||||
self.setWindowTitle("Delete Unlinked Entries")
|
||||
self.setWindowTitle(f"Delete Unlinked Entries")
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setMinimumSize(500, 400)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
|
@ -81,6 +81,20 @@ class DeleteUnlinkedEntriesModal(QWidget):
|
|||
self.model.appendRow(QStandardItem(i))
|
||||
|
||||
def delete_entries(self):
|
||||
# pb = QProgressDialog('', None, 0, len(self.lib.missing_files))
|
||||
# # pb.setMaximum(len(self.lib.missing_files))
|
||||
# pb.setFixedSize(432, 112)
|
||||
# pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
||||
# pb.setWindowTitle('Deleting Entries')
|
||||
# pb.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
# pb.show()
|
||||
|
||||
# r = CustomRunnable(lambda: self.lib.ref(pb))
|
||||
# r.done.connect(lambda: self.done.emit())
|
||||
# # r.done.connect(lambda: self.model.clear())
|
||||
# QThreadPool.globalInstance().start(r)
|
||||
# # r.run()
|
||||
|
||||
iterator = FunctionIterator(self.lib.remove_missing_files)
|
||||
|
||||
pw = ProgressWidget(
|
||||
|
@ -105,3 +119,23 @@ class DeleteUnlinkedEntriesModal(QWidget):
|
|||
r = CustomRunnable(lambda: iterator.run())
|
||||
QThreadPool.globalInstance().start(r)
|
||||
r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.done.emit()))
|
||||
|
||||
# def delete_entries_runnable(self):
|
||||
# deleted = []
|
||||
# for i, missing in enumerate(self.lib.missing_files):
|
||||
# # pb.setValue(i)
|
||||
# # pb.setLabelText(f'Deleting {i}/{len(self.lib.missing_files)} Unlinked Entries')
|
||||
# try:
|
||||
# id = self.lib.get_entry_id_from_filepath(missing)
|
||||
# logging.info(f'Removing Entry ID {id}:\n\t{missing}')
|
||||
# self.lib.remove_entry(id)
|
||||
# self.driver.purge_item_from_navigation(ItemType.ENTRY, id)
|
||||
# deleted.append(missing)
|
||||
# except KeyError:
|
||||
# logging.info(
|
||||
# f'{ERROR} \"{id}\" was reported as missing, but is not in the file_to_entry_id map.')
|
||||
# yield i
|
||||
# for d in deleted:
|
||||
# self.lib.missing_files.remove(d)
|
||||
# # self.driver.filter_items('')
|
||||
# # self.done.emit()
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
import logging
|
||||
import typing
|
||||
|
||||
from PySide6.QtCore import Qt, QThreadPool
|
||||
from PySide6.QtCore import QThread, Qt, QThreadPool
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
|
||||
|
||||
from src.core.library import Library
|
||||
|
@ -14,7 +14,6 @@ from src.qt.helpers.function_iterator import FunctionIterator
|
|||
from src.qt.helpers.custom_runnable import CustomRunnable
|
||||
from src.qt.modals.delete_unlinked import DeleteUnlinkedEntriesModal
|
||||
from src.qt.modals.relink_unlinked import RelinkUnlinkedEntries
|
||||
from src.qt.modals.merge_dupe_entries import MergeDuplicateEntries
|
||||
from src.qt.widgets.progress import ProgressWidget
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
|
@ -22,79 +21,65 @@ if typing.TYPE_CHECKING:
|
|||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
ERROR = "[ERROR]"
|
||||
WARNING = "[WARNING]"
|
||||
INFO = "[INFO]"
|
||||
ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
|
||||
class FixUnlinkedEntriesModal(QWidget):
|
||||
# done = Signal(int)
|
||||
def __init__(self, library: "Library", driver: "QtDriver"):
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.driver = driver
|
||||
self.missing_count = -1
|
||||
self.dupe_count = -1
|
||||
self.setWindowTitle("Fix Unlinked Entries")
|
||||
self.count = -1
|
||||
self.setWindowTitle(f"Fix Unlinked Entries")
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setMinimumSize(400, 300)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
self.unlinked_desc_widget = QLabel()
|
||||
self.unlinked_desc_widget.setObjectName("unlinkedDescriptionLabel")
|
||||
self.unlinked_desc_widget.setWordWrap(True)
|
||||
self.unlinked_desc_widget.setStyleSheet("text-align:left;")
|
||||
self.unlinked_desc_widget.setText(
|
||||
"""Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked. Unlinked entries may be automatically relinked via searching your directories, manually relinked by the user, or deleted if desired."""
|
||||
self.desc_widget = QLabel()
|
||||
self.desc_widget.setObjectName("descriptionLabel")
|
||||
self.desc_widget.setWordWrap(True)
|
||||
self.desc_widget.setStyleSheet(
|
||||
# 'background:blue;'
|
||||
"text-align:left;"
|
||||
# 'font-weight:bold;'
|
||||
# 'font-size:14px;'
|
||||
# 'padding-top: 6px'
|
||||
""
|
||||
)
|
||||
self.desc_widget.setText("""Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked.
|
||||
Unlinked entries may be automatically relinked via searching your directories, manually relinked by the user, or deleted if desired.""")
|
||||
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.dupe_desc_widget = QLabel()
|
||||
self.dupe_desc_widget.setObjectName("dupeDescriptionLabel")
|
||||
self.dupe_desc_widget.setWordWrap(True)
|
||||
self.dupe_desc_widget.setStyleSheet("text-align:left;")
|
||||
self.dupe_desc_widget.setText(
|
||||
"""Duplicate entries are defined as multiple entries which point to the same file on disk. Merging these will combine the tags and metadata from all duplicates into a single consolidated entry. These are not to be confused with "duplicate files", which are duplicates of your files themselves outside of TagStudio."""
|
||||
self.missing_count = QLabel()
|
||||
self.missing_count.setObjectName("missingCountLabel")
|
||||
self.missing_count.setStyleSheet(
|
||||
# 'background:blue;'
|
||||
# 'text-align:center;'
|
||||
"font-weight:bold;"
|
||||
"font-size:14px;"
|
||||
# 'padding-top: 6px'
|
||||
""
|
||||
)
|
||||
self.missing_count.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
# self.missing_count.setText('Missing Files: N/A')
|
||||
|
||||
self.missing_count_label = QLabel()
|
||||
self.missing_count_label.setObjectName("missingCountLabel")
|
||||
self.missing_count_label.setStyleSheet("font-weight:bold;" "font-size:14px;")
|
||||
self.missing_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.dupe_count_label = QLabel()
|
||||
self.dupe_count_label.setObjectName("dupeCountLabel")
|
||||
self.dupe_count_label.setStyleSheet("font-weight:bold;" "font-size:14px;")
|
||||
self.dupe_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.refresh_unlinked_button = QPushButton()
|
||||
self.refresh_unlinked_button.setText("&Refresh All")
|
||||
self.refresh_unlinked_button.clicked.connect(
|
||||
lambda: self.refresh_missing_files()
|
||||
)
|
||||
|
||||
self.merge_class = MergeDuplicateEntries(self.lib, self.driver)
|
||||
self.relink_class = RelinkUnlinkedEntries(self.lib, self.driver)
|
||||
self.refresh_button = QPushButton()
|
||||
self.refresh_button.setText("&Refresh")
|
||||
self.refresh_button.clicked.connect(lambda: self.refresh_missing_files())
|
||||
|
||||
self.search_button = QPushButton()
|
||||
self.search_button.setText("&Search && Relink")
|
||||
self.relink_class.done.connect(
|
||||
lambda: self.refresh_and_repair_dupe_entries(self.merge_class)
|
||||
)
|
||||
self.relink_class = RelinkUnlinkedEntries(self.lib, self.driver)
|
||||
self.relink_class.done.connect(lambda: self.refresh_missing_files())
|
||||
self.relink_class.done.connect(lambda: self.driver.update_thumbs())
|
||||
self.search_button.clicked.connect(lambda: self.relink_class.repair_entries())
|
||||
|
||||
self.refresh_dupe_button = QPushButton()
|
||||
self.refresh_dupe_button.setText("Refresh Duplicate Entries")
|
||||
self.refresh_dupe_button.clicked.connect(lambda: self.refresh_dupe_entries())
|
||||
|
||||
self.merge_dupe_button = QPushButton()
|
||||
self.merge_dupe_button.setText("&Merge Duplicate Entries")
|
||||
self.merge_class.done.connect(lambda: self.set_dupe_count(-1))
|
||||
self.merge_class.done.connect(lambda: self.set_missing_count(-1))
|
||||
self.merge_class.done.connect(lambda: self.driver.filter_items())
|
||||
self.merge_dupe_button.clicked.connect(lambda: self.merge_class.merge_entries())
|
||||
|
||||
self.manual_button = QPushButton()
|
||||
self.manual_button.setText("&Manual Relink")
|
||||
|
||||
|
@ -107,6 +92,14 @@ class FixUnlinkedEntriesModal(QWidget):
|
|||
self.delete_button.setText("De&lete Unlinked Entries")
|
||||
self.delete_button.clicked.connect(lambda: self.delete_modal.show())
|
||||
|
||||
# self.combo_box = QComboBox()
|
||||
# self.combo_box.setEditable(False)
|
||||
# # self.combo_box.setMaxVisibleItems(5)
|
||||
# self.combo_box.setStyleSheet('combobox-popup:0;')
|
||||
# self.combo_box.view().setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
# for df in self.lib.default_fields:
|
||||
# self.combo_box.addItem(f'{df["name"]} ({df["type"].replace("_", " ").title()})')
|
||||
|
||||
self.button_container = QWidget()
|
||||
self.button_layout = QHBoxLayout(self.button_container)
|
||||
self.button_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
@ -114,39 +107,50 @@ class FixUnlinkedEntriesModal(QWidget):
|
|||
|
||||
self.done_button = QPushButton()
|
||||
self.done_button.setText("&Done")
|
||||
# self.save_button.setAutoDefault(True)
|
||||
self.done_button.setDefault(True)
|
||||
self.done_button.clicked.connect(self.hide)
|
||||
# self.done_button.clicked.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
|
||||
# self.save_button.clicked.connect(lambda: save_callback(widget.get_content()))
|
||||
self.button_layout.addWidget(self.done_button)
|
||||
|
||||
self.root_layout.addWidget(self.missing_count_label)
|
||||
self.root_layout.addWidget(self.unlinked_desc_widget)
|
||||
self.root_layout.addWidget(self.refresh_unlinked_button)
|
||||
# self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
|
||||
|
||||
# self.done.connect(lambda x: callback(x))
|
||||
|
||||
self.root_layout.addWidget(self.desc_widget)
|
||||
self.root_layout.addWidget(self.missing_count)
|
||||
self.root_layout.addWidget(self.refresh_button)
|
||||
self.root_layout.addWidget(self.search_button)
|
||||
self.manual_button.setHidden(True)
|
||||
self.root_layout.addWidget(self.manual_button)
|
||||
self.root_layout.addWidget(self.delete_button)
|
||||
# self.root_layout.setStretch(1,2)
|
||||
self.root_layout.addStretch(1)
|
||||
self.root_layout.addWidget(self.dupe_count_label)
|
||||
self.root_layout.addWidget(self.dupe_desc_widget)
|
||||
self.root_layout.addWidget(self.refresh_dupe_button)
|
||||
self.root_layout.addWidget(self.merge_dupe_button)
|
||||
self.root_layout.addStretch(2)
|
||||
self.root_layout.addWidget(self.button_container)
|
||||
|
||||
self.set_missing_count(self.missing_count)
|
||||
self.set_dupe_count(self.dupe_count)
|
||||
self.set_missing_count(self.count)
|
||||
|
||||
def refresh_missing_files(self):
|
||||
logging.info(f"Start RMF: {QThread.currentThread()}")
|
||||
# pb = QProgressDialog(f'Scanning Library for Unlinked Entries...', None, 0,len(self.lib.entries))
|
||||
# pb.setFixedSize(432, 112)
|
||||
# pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
||||
# pb.setWindowTitle('Scanning Library')
|
||||
# pb.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
# pb.show()
|
||||
|
||||
iterator = FunctionIterator(self.lib.refresh_missing_files)
|
||||
pw = ProgressWidget(
|
||||
window_title="Scanning Library",
|
||||
label_text="Scanning Library for Unlinked Entries...",
|
||||
label_text=f"Scanning Library for Unlinked Entries...",
|
||||
cancel_button_text=None,
|
||||
minimum=0,
|
||||
maximum=len(self.lib.entries),
|
||||
)
|
||||
pw.show()
|
||||
iterator.value.connect(lambda v: pw.update_progress(v + 1))
|
||||
# rmf.value.connect(lambda v: pw.update_label(f'Progress: {v}'))
|
||||
r = CustomRunnable(lambda: iterator.run())
|
||||
QThreadPool.globalInstance().start(r)
|
||||
r.done.connect(
|
||||
|
@ -155,76 +159,30 @@ class FixUnlinkedEntriesModal(QWidget):
|
|||
pw.deleteLater(),
|
||||
self.set_missing_count(len(self.lib.missing_files)),
|
||||
self.delete_modal.refresh_list(),
|
||||
self.refresh_dupe_entries(),
|
||||
)
|
||||
)
|
||||
|
||||
def refresh_dupe_entries(self):
|
||||
iterator = FunctionIterator(self.lib.refresh_dupe_entries)
|
||||
pw = ProgressWidget(
|
||||
window_title="Scanning Library",
|
||||
label_text="Scanning Library for Duplicate Entries...",
|
||||
cancel_button_text=None,
|
||||
minimum=0,
|
||||
maximum=len(self.lib.entries),
|
||||
)
|
||||
pw.show()
|
||||
iterator.value.connect(lambda v: pw.update_progress(v + 1))
|
||||
r = CustomRunnable(lambda: iterator.run())
|
||||
QThreadPool.globalInstance().start(r)
|
||||
r.done.connect(
|
||||
lambda: (
|
||||
pw.hide(),
|
||||
pw.deleteLater(),
|
||||
self.set_dupe_count(len(self.lib.dupe_entries)),
|
||||
)
|
||||
)
|
||||
# r = CustomRunnable(lambda: self.lib.refresh_missing_files(lambda v: self.update_scan_value(pb, v)))
|
||||
# r.done.connect(lambda: (pb.hide(), pb.deleteLater(), self.set_missing_count(len(self.lib.missing_files)), self.delete_modal.refresh_list()))
|
||||
# QThreadPool.globalInstance().start(r)
|
||||
# # r.run()
|
||||
# pass
|
||||
|
||||
def refresh_and_repair_dupe_entries(self, merge_class: MergeDuplicateEntries):
|
||||
iterator = FunctionIterator(self.lib.refresh_dupe_entries)
|
||||
pw = ProgressWidget(
|
||||
window_title="Scanning Library",
|
||||
label_text="Scanning Library for Duplicate Entries...",
|
||||
cancel_button_text=None,
|
||||
minimum=0,
|
||||
maximum=len(self.lib.entries),
|
||||
)
|
||||
pw.show()
|
||||
iterator.value.connect(lambda v: pw.update_progress(v + 1))
|
||||
r = CustomRunnable(lambda: iterator.run())
|
||||
QThreadPool.globalInstance().start(r)
|
||||
r.done.connect(
|
||||
lambda: (
|
||||
pw.hide(),
|
||||
pw.deleteLater(),
|
||||
self.set_dupe_count(len(self.lib.dupe_entries)),
|
||||
merge_class.merge_entries(),
|
||||
)
|
||||
)
|
||||
# def update_scan_value(self, pb:QProgressDialog, value=int):
|
||||
# # pb.setLabelText(f'Scanning Library for Unlinked Entries ({value}/{len(self.lib.entries)})...')
|
||||
# pb.setValue(value)
|
||||
|
||||
def set_missing_count(self, count: int):
|
||||
self.missing_count = count
|
||||
if self.missing_count < 0:
|
||||
self.count = count
|
||||
if self.count < 0:
|
||||
self.search_button.setDisabled(True)
|
||||
self.delete_button.setDisabled(True)
|
||||
self.missing_count_label.setText("Unlinked Entries: N/A")
|
||||
elif self.missing_count == 0:
|
||||
self.missing_count.setText(f"Unlinked Entries: N/A")
|
||||
elif self.count == 0:
|
||||
self.search_button.setDisabled(True)
|
||||
self.delete_button.setDisabled(True)
|
||||
self.missing_count_label.setText(f"Unlinked Entries: {count}")
|
||||
self.missing_count.setText(f"Unlinked Entries: {count}")
|
||||
else:
|
||||
self.search_button.setDisabled(False)
|
||||
self.delete_button.setDisabled(False)
|
||||
self.missing_count_label.setText(f"Unlinked Entries: {count}")
|
||||
|
||||
def set_dupe_count(self, count: int):
|
||||
self.dupe_count = count
|
||||
if self.dupe_count < 0:
|
||||
self.dupe_count_label.setText("Duplicate Entries: N/A")
|
||||
self.merge_dupe_button.setDisabled(True)
|
||||
elif self.dupe_count == 0:
|
||||
self.dupe_count_label.setText(f"Duplicate Entries: {count}")
|
||||
self.merge_dupe_button.setDisabled(True)
|
||||
else:
|
||||
self.dupe_count_label.setText(f"Duplicate Entries: {count}")
|
||||
self.merge_dupe_button.setDisabled(False)
|
||||
self.missing_count.setText(f"Unlinked Entries: {count}")
|
||||
|
|
|
@ -1,46 +0,0 @@
|
|||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import typing
|
||||
|
||||
from PySide6.QtCore import QObject, Signal, QThreadPool
|
||||
|
||||
from src.core.library import Library
|
||||
from src.qt.helpers.function_iterator import FunctionIterator
|
||||
from src.qt.helpers.custom_runnable import CustomRunnable
|
||||
from src.qt.widgets.progress import ProgressWidget
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
class MergeDuplicateEntries(QObject):
|
||||
done = Signal()
|
||||
|
||||
def __init__(self, library: "Library", driver: "QtDriver"):
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.driver = driver
|
||||
|
||||
def merge_entries(self):
|
||||
iterator = FunctionIterator(self.lib.merge_dupe_entries)
|
||||
|
||||
pw = ProgressWidget(
|
||||
window_title="Merging Duplicate Entries",
|
||||
label_text="",
|
||||
cancel_button_text=None,
|
||||
minimum=0,
|
||||
maximum=len(self.lib.dupe_entries),
|
||||
)
|
||||
pw.show()
|
||||
|
||||
iterator.value.connect(lambda x: pw.update_progress(x))
|
||||
iterator.value.connect(
|
||||
lambda: (pw.update_label("Merging Duplicate Entries..."))
|
||||
)
|
||||
|
||||
r = CustomRunnable(lambda: iterator.run())
|
||||
r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.done.emit()))
|
||||
QThreadPool.globalInstance().start(r)
|
|
@ -26,6 +26,20 @@ class RelinkUnlinkedEntries(QObject):
|
|||
self.fixed = 0
|
||||
|
||||
def repair_entries(self):
|
||||
# pb = QProgressDialog('', None, 0, len(self.lib.missing_files))
|
||||
# # pb.setMaximum(len(self.lib.missing_files))
|
||||
# pb.setFixedSize(432, 112)
|
||||
# pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
||||
# pb.setWindowTitle('Relinking Entries')
|
||||
# pb.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
# pb.show()
|
||||
|
||||
# r = CustomRunnable(lambda: self.repair_entries_runnable(pb))
|
||||
# r.done.connect(lambda: self.done.emit())
|
||||
# # r.done.connect(lambda: self.model.clear())
|
||||
# QThreadPool.globalInstance().start(r)
|
||||
# # r.run()
|
||||
|
||||
iterator = FunctionIterator(self.lib.fix_missing_files)
|
||||
|
||||
pw = ProgressWidget(
|
||||
|
@ -35,7 +49,6 @@ class RelinkUnlinkedEntries(QObject):
|
|||
minimum=0,
|
||||
maximum=len(self.lib.missing_files),
|
||||
)
|
||||
|
||||
pw.show()
|
||||
|
||||
iterator.value.connect(lambda x: pw.update_progress(x[0] + 1))
|
||||
|
@ -47,6 +60,7 @@ class RelinkUnlinkedEntries(QObject):
|
|||
),
|
||||
)
|
||||
)
|
||||
# iterator.value.connect(lambda x: self.driver.purge_item_from_navigation(ItemType.ENTRY, x[1]))
|
||||
|
||||
r = CustomRunnable(lambda: iterator.run())
|
||||
r.done.connect(
|
||||
|
@ -59,3 +73,27 @@ class RelinkUnlinkedEntries(QObject):
|
|||
|
||||
def reset_fixed(self):
|
||||
self.fixed = 0
|
||||
|
||||
# def repair_entries_runnable(self, pb: QProgressDialog):
|
||||
# fixed = 0
|
||||
# for i in self.lib.fix_missing_files():
|
||||
# if i[1]:
|
||||
# fixed += 1
|
||||
# pb.setValue(i[0])
|
||||
# pb.setLabelText(f'Attempting to Relink {i[0]+1}/{len(self.lib.missing_files)} Entries, {fixed} Successfully Relinked')
|
||||
|
||||
# for i, missing in enumerate(self.lib.missing_files):
|
||||
# pb.setValue(i)
|
||||
# pb.setLabelText(f'Relinking {i}/{len(self.lib.missing_files)} Unlinked Entries')
|
||||
# self.lib.fix_missing_files()
|
||||
# try:
|
||||
# id = self.lib.get_entry_id_from_filepath(missing)
|
||||
# logging.info(f'Removing Entry ID {id}:\n\t{missing}')
|
||||
# self.lib.remove_entry(id)
|
||||
# self.driver.purge_item_from_navigation(ItemType.ENTRY, id)
|
||||
# deleted.append(missing)
|
||||
# except KeyError:
|
||||
# logging.info(
|
||||
# f'{ERROR} \"{id}\" was reported as missing, but is not in the file_to_entry_id map.')
|
||||
# for d in deleted:
|
||||
# self.lib.missing_files.remove(d)
|
||||
|
|
|
@ -224,10 +224,8 @@ class QtDriver(QObject):
|
|||
thread.start()
|
||||
|
||||
def open_library_from_dialog(self):
|
||||
dir = Path(
|
||||
QFileDialog.getExistingDirectory(
|
||||
None, "Open/Create Library", "/", QFileDialog.ShowDirsOnly
|
||||
)
|
||||
dir = QFileDialog.getExistingDirectory(
|
||||
None, "Open/Create Library", "/", QFileDialog.ShowDirsOnly
|
||||
)
|
||||
if dir not in (None, ""):
|
||||
self.open_library(dir)
|
||||
|
@ -522,7 +520,7 @@ class QtDriver(QObject):
|
|||
int(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter),
|
||||
QColor("#9782ff"),
|
||||
)
|
||||
self.open_library(Path(lib))
|
||||
self.open_library(lib)
|
||||
|
||||
if self.args.ci:
|
||||
# gracefully terminate the app in CI environment
|
||||
|
@ -1388,7 +1386,7 @@ class QtDriver(QObject):
|
|||
self.settings.endGroup()
|
||||
self.settings.sync()
|
||||
|
||||
def open_library(self, path: Path):
|
||||
def open_library(self, path):
|
||||
"""Opens a TagStudio library."""
|
||||
if self.lib.library_dir:
|
||||
self.save_library()
|
||||
|
@ -1397,6 +1395,14 @@ class QtDriver(QObject):
|
|||
self.main_window.statusbar.showMessage(f"Opening Library {str(path)}", 3)
|
||||
return_code = self.lib.open_library(path)
|
||||
if return_code == 1:
|
||||
# if self.args.external_preview:
|
||||
# self.init_external_preview()
|
||||
|
||||
# if len(self.lib.entries) <= 1000:
|
||||
# print(f'{INFO} Checking for missing files in Library \'{self.lib.library_dir}\'...')
|
||||
# self.lib.refresh_missing_files()
|
||||
# title_text = f'{self.base_title} - Library \'{self.lib.library_dir}\''
|
||||
# self.main_window.setWindowTitle(title_text)
|
||||
pass
|
||||
|
||||
else:
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
import typing
|
||||
from datetime import datetime as dt
|
||||
|
@ -305,7 +304,7 @@ class PreviewPanel(QWidget):
|
|||
button.setObjectName(f"path{item_key}")
|
||||
|
||||
def open_library_button_clicked(path):
|
||||
return lambda: self.driver.open_library(Path(path))
|
||||
return lambda: self.driver.open_library(path)
|
||||
|
||||
button.clicked.connect(open_library_button_clicked(full_val))
|
||||
set_button_style(button)
|
||||
|
@ -529,13 +528,10 @@ class PreviewPanel(QWidget):
|
|||
if not image:
|
||||
raise UnidentifiedImageError
|
||||
|
||||
except (FileNotFoundError, cv2.error) as e:
|
||||
self.dimensions_label.setText(f"{extension.upper()}")
|
||||
logging.info(
|
||||
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
|
||||
)
|
||||
except (
|
||||
UnidentifiedImageError,
|
||||
FileNotFoundError,
|
||||
cv2.error,
|
||||
DecompressionBombError,
|
||||
) as e:
|
||||
self.dimensions_label.setText(
|
||||
|
|
Loading…
Reference in a new issue