diff --git a/src/main.py b/src/main.py
index 731dcce..a2acb68 100644
--- a/src/main.py
+++ b/src/main.py
@@ -124,11 +124,16 @@ class AddOutcome(Enum):
``OVERWRITTEN_ALL`` is an overwrite where the user also asked to apply the
choice to every following file ("Yes to All"); loops use it to stop
prompting, keeping the Qt button enum out of caller code.
+
+ ``UNCHANGED`` means an entry with that name already held byte-identical
+ content, so the add was a no-op: the archive is left clean (not dirtied)
+ so a later save can skip the full repack.
"""
NEW = auto()
OVERWRITTEN = auto()
OVERWRITTEN_ALL = auto()
+ UNCHANGED = auto()
SKIPPED = auto()
FAILED = auto()
@@ -557,6 +562,17 @@ def _save(self, path):
if tab.unsaved:
tab.save()
+ # A BIG save is a full repack of the entire archive. When we're writing
+ # back over the same file with nothing pending, the result is identical
+ # to what's already on disk, so skip the rewrite. Save As targets a new
+ # path, so it always writes.
+ writing_in_place = self.path is not None and os.path.normcase(
+ os.path.abspath(path)
+ ) == os.path.normcase(os.path.abspath(self.path))
+ if writing_in_place and not self.archive.modified_entries:
+ QMessageBox.information(self, "Done", "No changes to save.")
+ return True
+
try:
self.archive.save(path)
QMessageBox.information(self, "Done", "Archive has been saved")
@@ -731,15 +747,17 @@ def merge_archives(self):
files.reverse()
new_names = []
overwritten_names = []
+ unchanged_names = []
for file in files:
- new, overwritten = self._merge_archives(file)
+ new, overwritten, unchanged = self._merge_archives(file)
new_names.extend(new)
overwritten_names.extend(overwritten)
+ unchanged_names.extend(unchanged)
self.settings.last_dir = os.path.dirname(files[0])
self.listwidget.add_files(new_names + overwritten_names)
self.update_archive_name()
- self._show_add_summary(new_names, overwritten_names)
+ self._show_add_summary(new_names, overwritten_names, unchanged_names)
def _merge_archives(self, path):
if self.settings.large_archive:
@@ -751,6 +769,7 @@ def _merge_archives(self, path):
skip_all = False
new_names = []
overwritten_names = []
+ unchanged_names = []
files = archive.file_list()
length = len(files)
text_box = QMessageBox(
@@ -771,7 +790,6 @@ def _merge_archives(self, path):
f"File: ({index + 1}/{length})
Processing: {file}"
)
QApplication.processEvents()
- overwrote = False
if self.archive.file_exists(file):
default = (
OverwriteDefault.OVERWRITE
@@ -780,6 +798,14 @@ def _merge_archives(self, path):
)
if default is OverwriteDefault.SKIP:
continue
+
+ incoming = archive.read_file(file)
+ if self.archive.read_file(file) == incoming:
+ # Identical content already present: skip without dirtying
+ # the archive, so a later save can be avoided entirely.
+ unchanged_names.append(file)
+ continue
+
if default is OverwriteDefault.ASK:
ret = QMessageBox.question(
self,
@@ -796,13 +822,10 @@ def _merge_archives(self, path):
skip_all = True
self.archive.remove_file(file)
- overwrote = True
-
- self.archive.add_file(file, archive.read_file(file))
-
- if overwrote:
+ self.archive.add_file(file, incoming)
overwritten_names.append(file)
else:
+ self.archive.add_file(file, archive.read_file(file))
new_names.append(file)
size = self.archive.archive_memory_size()
@@ -827,7 +850,7 @@ def _merge_archives(self, path):
)
text_box.accept()
- return new_names, overwritten_names
+ return new_names, overwritten_names, unchanged_names
def new(self):
self._new()
@@ -944,6 +967,7 @@ def _add_folder(self, url, *, show_summary=True):
common_dir = os.path.dirname(url)
new_names = []
overwritten_names = []
+ unchanged_names = []
for root, _, files in os.walk(url):
for f in files:
full_path = os.path.join(root, f)
@@ -952,9 +976,10 @@ def _add_folder(self, url, *, show_summary=True):
full_path, name, blank=False, skip_all=skip_all, undoable=False
)
- new, overwritten = self._tally(outcome, name)
+ new, overwritten, unchanged = self._tally(outcome, name)
new_names.extend(new)
overwritten_names.extend(overwritten)
+ unchanged_names.extend(unchanged)
if outcome is AddOutcome.OVERWRITTEN_ALL:
skip_all = True
@@ -962,52 +987,64 @@ def _add_folder(self, url, *, show_summary=True):
self.listwidget.add_files(new_names + overwritten_names)
self.update_archive_name()
if show_summary:
- self._show_add_summary(new_names, overwritten_names)
- return new_names, overwritten_names
+ self._show_add_summary(new_names, overwritten_names, unchanged_names)
+ return new_names, overwritten_names, unchanged_names
def add_file_to_archive(self, url, name, blank=False, skip_all=False, undoable=True):
"""Add ``url`` to the archive as ``name``.
Returns an :class:`AddOutcome` describing what actually happened to the
archive. ``OVERWRITTEN_ALL`` additionally tells loops to stop prompting.
+ ``UNCHANGED`` means the new bytes matched the existing entry, so the
+ archive was left untouched.
"""
replaced_data = None
overwrite_all = skip_all
existed = self.archive.file_exists(name)
if existed:
replaced_data = self.archive.read_file(name)
- if not skip_all:
- default = self.settings.add_overwrite_default
- if default is OverwriteDefault.SKIP:
- return AddOutcome.SKIPPED
- if default is OverwriteDefault.OVERWRITE:
- overwrite_all = True
- else:
- ret = QMessageBox.question(
- self,
- "Overwrite file?",
- f"{name} already exists, overwrite?",
- QMessageBox.StandardButton.Yes
- | QMessageBox.StandardButton.No
- | QMessageBox.StandardButton.YesToAll,
- QMessageBox.StandardButton.No,
- )
- if ret == QMessageBox.StandardButton.No:
- return AddOutcome.SKIPPED
- overwrite_all = ret == QMessageBox.StandardButton.YesToAll
-
- self.archive.remove_file(name)
+ # Honour a "skip existing" default before reading the new bytes, so
+ # the no-overwrite workflow stays a pure no-op.
+ if not skip_all and self.settings.add_overwrite_default is OverwriteDefault.SKIP:
+ return AddOutcome.SKIPPED
try:
if blank:
- self.archive.add_file(name, b"")
+ new_data = b""
else:
with open(url, "rb") as f:
- self.archive.add_file(name, f.read())
+ new_data = f.read()
except Exception as e:
QMessageBox.warning(self, "Error", str(e))
return AddOutcome.FAILED
+ if existed:
+ if new_data == replaced_data:
+ # Re-adding identical content: leave the entry alone so the
+ # archive isn't dirtied and the next save can be skipped.
+ return AddOutcome.UNCHANGED
+
+ if not skip_all and self.settings.add_overwrite_default is OverwriteDefault.ASK:
+ ret = QMessageBox.question(
+ self,
+ "Overwrite file?",
+ f"{name} already exists, overwrite?",
+ QMessageBox.StandardButton.Yes
+ | QMessageBox.StandardButton.No
+ | QMessageBox.StandardButton.YesToAll,
+ QMessageBox.StandardButton.No,
+ )
+ if ret == QMessageBox.StandardButton.No:
+ return AddOutcome.SKIPPED
+ overwrite_all = ret == QMessageBox.StandardButton.YesToAll
+ else:
+ # skip_all, or the OVERWRITE default: overwrite without asking.
+ overwrite_all = True
+
+ self.archive.remove_file(name)
+
+ self.archive.add_file(name, new_data)
+
if undoable:
self.undo_stack.push(AddFileCommand(name, self.archive.read_file(name), replaced_data))
self.update_undo_redo_actions()
@@ -1017,24 +1054,39 @@ def add_file_to_archive(self, url, name, blank=False, skip_all=False, undoable=T
return AddOutcome.OVERWRITTEN_ALL if overwrite_all else AddOutcome.OVERWRITTEN
@staticmethod
- def _tally(outcome: AddOutcome, name: str) -> tuple[list[str], list[str]]:
- """Map one add's outcome to a ``(new_names, overwritten_names)`` pair."""
+ def _tally(outcome: AddOutcome, name: str) -> tuple[list[str], list[str], list[str]]:
+ """Map one add's outcome to a ``(new, overwritten, unchanged)`` triple."""
if outcome is AddOutcome.NEW:
- return [name], []
+ return [name], [], []
if outcome.overwrote:
- return [], [name]
- return [], []
-
- def _show_add_summary(self, new_names: list[str], overwritten_names: list[str]) -> None:
+ return [], [name], []
+ if outcome is AddOutcome.UNCHANGED:
+ return [], [], [name]
+ return [], [], []
+
+ def _show_add_summary(
+ self,
+ new_names: list[str],
+ overwritten_names: list[str],
+ unchanged_names: list[str] = None,
+ ) -> None:
if not self.settings.show_add_summary:
return
- total = len(new_names) + len(overwritten_names)
- if total == 0:
+ unchanged_names = unchanged_names or []
+ added = len(new_names) + len(overwritten_names)
+ if added + len(unchanged_names) == 0:
return
message = (
- f"Added {total} file(s) — {len(new_names)} new, {len(overwritten_names)} overwritten."
+ f"Added {added} file(s) — {len(new_names)} new, {len(overwritten_names)} overwritten."
)
- AddSummaryDialog(message, new_names, overwritten_names, parent=self).exec()
+ if unchanged_names:
+ message += (
+ f" Skipped {len(unchanged_names)} unchanged file(s) "
+ "(identical content, archive not modified)."
+ )
+ AddSummaryDialog(
+ message, new_names, overwritten_names, unchanged_names, parent=self
+ ).exec()
def is_file_selected(self):
if not self.listwidget.active_list.is_file_selected():
@@ -1411,6 +1463,7 @@ def dropEvent(self, event: QDropEvent):
yes_to_all = False
new_names = []
overwritten_names = []
+ unchanged_names = []
for url in md.urls():
local_file = url.toLocalFile()
@@ -1430,17 +1483,19 @@ def dropEvent(self, event: QDropEvent):
if suggested_name
else self._add_file(local_file, ask_name=not yes_to_all, show_summary=False)
)
- new, names = self._tally(outcome, name)
+ new, names, unchanged = self._tally(outcome, name)
new_names.extend(new)
overwritten_names.extend(names)
+ unchanged_names.extend(unchanged)
if outcome is AddOutcome.OVERWRITTEN_ALL:
yes_to_all = True
else:
- new, names = self._add_folder(local_file, show_summary=False)
+ new, names, unchanged = self._add_folder(local_file, show_summary=False)
new_names.extend(new)
overwritten_names.extend(names)
+ unchanged_names.extend(unchanged)
- self._show_add_summary(new_names, overwritten_names)
+ self._show_add_summary(new_names, overwritten_names, unchanged_names)
event.acceptProposedAction()
diff --git a/src/misc.py b/src/misc.py
index 1dc8f4f..bdf05af 100644
--- a/src/misc.py
+++ b/src/misc.py
@@ -268,8 +268,10 @@ def __init__(
message: str,
new_names: list[str],
overwritten_names: list[str],
+ unchanged_names: list[str] = None,
parent=None,
):
+ unchanged_names = unchanged_names or []
super().__init__(parent)
self.setWindowTitle("Files added")
self.setWindowFlag(Qt.WindowType.WindowMaximizeButtonHint, True)
@@ -291,7 +293,7 @@ def __init__(
header.addWidget(label, 1)
layout.addLayout(header)
- details_text = self._build_details(new_names, overwritten_names)
+ details_text = self._build_details(new_names, overwritten_names, unchanged_names)
self.details = QTextEdit()
self.details.setReadOnly(True)
@@ -311,11 +313,14 @@ def __init__(
self.resize(420, 160)
@staticmethod
- def _build_details(new_names: list[str], overwritten_names: list[str]) -> str:
- """New files first, then a separator, then overwritten files.
+ def _build_details(
+ new_names: list[str], overwritten_names: list[str], unchanged_names: list[str] = None
+ ) -> str:
+ """New files first, then overwritten, then unchanged, separated by rules.
Empty sections are omitted.
"""
+ unchanged_names = unchanged_names or []
sections = []
if new_names:
sections.append(
@@ -326,6 +331,11 @@ def _build_details(new_names: list[str], overwritten_names: list[str]) -> str:
f"Overwritten files ({len(overwritten_names)}):\n"
+ "\n".join(f" {name}" for name in overwritten_names)
)
+ if unchanged_names:
+ sections.append(
+ f"Unchanged files ({len(unchanged_names)}):\n"
+ + "\n".join(f" {name}" for name in unchanged_names)
+ )
return ("\n" + "─" * 40 + "\n").join(sections)
def _toggle_details(self, shown: bool) -> None:
diff --git a/src/tabs/cah_tab.py b/src/tabs/cah_tab.py
index 0845146..e41c980 100644
--- a/src/tabs/cah_tab.py
+++ b/src/tabs/cah_tab.py
@@ -338,7 +338,7 @@ def save(self):
# Generate bytes and save
cah_bytes = self.hero.write()
- self.archive.edit_file(self.name, cah_bytes)
+ self.commit_to_archive(cah_bytes)
super().save()
except Exception as e:
QMessageBox.critical(self, "Error", str(e))
diff --git a/src/tabs/generic_tab.py b/src/tabs/generic_tab.py
index d448770..1ff54a5 100644
--- a/src/tabs/generic_tab.py
+++ b/src/tabs/generic_tab.py
@@ -87,6 +87,18 @@ def save(self):
self.main.tabs.setTabText(self.main.tabs.currentIndex(), self.name)
self.main.update_archive_name()
+ def commit_to_archive(self, content: bytes):
+ """Push edited content into the archive, but only mark the entry
+ modified when the bytes actually changed. Marking an unchanged entry
+ dirties the archive and forces a full repack on the next save, so a
+ no-op edit (open + save, or edit-then-revert) would needlessly rewrite
+ the whole BIG.
+ """
+ if self.archive.file_exists(self.name) and self.archive.read_file(self.name) == content:
+ return
+
+ self.archive.edit_file(self.name, content)
+
def search(self):
pass
diff --git a/src/tabs/map_tab.py b/src/tabs/map_tab.py
index fab119a..1f75556 100644
--- a/src/tabs/map_tab.py
+++ b/src/tabs/map_tab.py
@@ -32,7 +32,7 @@ def generate_layout(self, preview=False):
def save(self):
with open(self.path, "rb") as f:
data = f.read()
- self.archive.edit_file(self.name, data)
+ self.commit_to_archive(data)
self.data = data
super().save()
diff --git a/src/tabs/text_tab.py b/src/tabs/text_tab.py
index 8066aa9..6a1324a 100644
--- a/src/tabs/text_tab.py
+++ b/src/tabs/text_tab.py
@@ -461,7 +461,7 @@ def save(self):
data = self.text_widget.text()
string = encode_string(data, self.main.settings.encoding)
- self.archive.edit_file(self.name, string)
+ self.commit_to_archive(string)
super().save()