Allow recording the original hash

This commit is contained in:
Timmy Welch 2025-01-05 20:32:49 -08:00
parent e8e21eb1b6
commit 8b3cda0495
17 changed files with 291 additions and 28 deletions

View File

@ -24,6 +24,12 @@ class Archiver(Protocol):
"""
enabled: bool = True
"""
If self.path is a single file that can be hashed.
For example directories cannot be hashed.
"""
hashable: bool = True
def __init__(self) -> None:
self.path = pathlib.Path()

View File

@ -12,6 +12,8 @@ logger = logging.getLogger(__name__)
class FolderArchiver(Archiver):
"""Folder implementation"""
hashable = False
def __init__(self) -> None:
super().__init__()
self.comment_file_name = "ComicTaggerFolderComment.txt"

View File

@ -15,7 +15,9 @@
# limitations under the License.
from __future__ import annotations
import hashlib
import importlib.util
import inspect
import io
import itertools
import logging
@ -27,7 +29,7 @@ from collections.abc import Iterable
from comicapi import utils
from comicapi.archivers import Archiver, UnknownArchiver, ZipArchiver
from comicapi.genericmetadata import GenericMetadata
from comicapi.genericmetadata import GenericMetadata, Hash
from comicapi.tags import Tag
from comictaggerlib.ctversion import version
@ -124,11 +126,15 @@ class ComicArchive:
pil_available = True
def __init__(
self, path: pathlib.Path | str | Archiver, default_image_path: pathlib.Path | str | None = None
self,
path: pathlib.Path | str | Archiver,
default_image_path: pathlib.Path | str | None = None,
hash_archive: str = "",
) -> None:
self.md: dict[str, GenericMetadata] = {}
self.page_count: int | None = None
self.page_list: list[str] = []
self.hash_archive = hash_archive
self.reset_cache()
self.default_image_path = default_image_path
@ -230,7 +236,7 @@ class ComicArchive:
if not tags[tag_id].enabled:
return False
self.apply_archive_info_to_metadata(metadata, True, True)
self.apply_archive_info_to_metadata(metadata, True, True, hash_archive=self.hash_archive)
return tags[tag_id].write_tags(metadata, self.archiver)
def has_tags(self, tag_id: str) -> bool:
@ -333,11 +339,34 @@ class ComicArchive:
return self.page_count
def apply_archive_info_to_metadata(
self, md: GenericMetadata, calc_page_sizes: bool = False, detect_double_page: bool = False
self,
md: GenericMetadata,
calc_page_sizes: bool = False,
detect_double_page: bool = False,
*,
hash_archive: str = "",
) -> None:
hash_archive = hash_archive
md.page_count = self.get_number_of_pages()
md.apply_default_page_list(self.get_page_name_list())
if not calc_page_sizes or not self.seems_to_be_a_comic_archive():
if not self.seems_to_be_a_comic_archive():
return
if hash_archive in hashlib.algorithms_available and not md.original_hash:
hasher = getattr(hashlib, hash_archive, hash_archive)
try:
with self.archiver.path.open("b+r") as archive:
digest = utils.file_digest(archive, hasher)
if len(inspect.signature(digest.hexdigest).parameters) > 0:
length = digest.name.rpartition("_")[2]
if not length.isdigit():
length = "128"
md.original_hash = Hash(digest.name, digest.hexdigest(int(length) // 8)) # type: ignore[call-arg]
else:
md.original_hash = Hash(digest.name, digest.hexdigest())
except Exception:
logger.exception("Failed to calculate original hash for '%s'", self.archiver.path)
if not calc_page_sizes:
return
for p in md.pages:

View File

@ -23,6 +23,7 @@ from __future__ import annotations
import copy
import dataclasses
import hashlib
import logging
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, Union, overload
@ -136,6 +137,24 @@ class MetadataOrigin(NamedTuple):
return self.name
class Hash(NamedTuple):
name: str
hash: str
def __str__(self) -> str:
return self.name + ":" + self.hash
@classmethod
def parse(cls, string: str) -> Hash:
name, _, parsed_hash = string.partition(":")
if name in hashlib.algorithms_available:
return Hash(name, parsed_hash)
return Hash("", "")
def __bool__(self) -> bool:
return all(self)
@dataclasses.dataclass
class GenericMetadata:
writer_synonyms = ("writer", "plotter", "scripter", "script")
@ -151,6 +170,7 @@ class GenericMetadata:
data_origin: MetadataOrigin | None = None
issue_id: str | None = None
series_id: str | None = None
original_hash: Hash | None = None
series: str | None = None
series_aliases: set[str] = dataclasses.field(default_factory=set)
@ -285,6 +305,9 @@ class GenericMetadata:
self.issue_id = assign(self.issue_id, new_md.issue_id)
self.series_id = assign(self.series_id, new_md.series_id)
# This should not usually be set by a talker or other online datasource
self.original_hash = assign(self.original_hash, new_md.original_hash)
self.series = assign(self.series, new_md.series)
self.series_aliases = assign_list(self.series_aliases, new_md.series_aliases)
@ -450,6 +473,7 @@ class GenericMetadata:
add_string("data_origin", self.data_origin)
add_string("series", self.series)
add_string("original_hash", self.original_hash)
add_string("series_aliases", ",".join(self.series_aliases))
add_string("issue", self.issue)
add_string("issue_count", self.issue_count)

View File

@ -21,7 +21,7 @@ from typing import Any
from comicapi import utils
from comicapi.archivers import Archiver
from comicapi.genericmetadata import GenericMetadata, PageMetadata
from comicapi.genericmetadata import GenericMetadata, Hash, PageMetadata
from comicapi.tags import Tag
logger = logging.getLogger(__name__)
@ -37,6 +37,7 @@ class ComicRack(Tag):
self.file = "ComicInfo.xml"
self.supported_attributes = {
"original_hash",
"series",
"issue",
"issue_count",
@ -244,7 +245,11 @@ class ComicRack(Tag):
assign("BlackAndWhite", "Yes" if md.black_and_white else None)
assign("AgeRating", md.maturity_rating)
assign("CommunityRating", md.critical_rating)
assign("ScanInformation", md.scan_info)
scan_info = md.scan_info or ""
if md.original_hash:
scan_info += f" {md.original_hash}"
assign("ScanInformation", scan_info)
assign("PageCount", md.page_count)
@ -326,7 +331,16 @@ class ComicRack(Tag):
md.manga = utils.xlate(get("Manga"))
md.maturity_rating = utils.xlate(get("AgeRating"))
md.critical_rating = utils.xlate_float(get("CommunityRating"))
md.scan_info = utils.xlate(get("ScanInformation"))
scan_info_list = (utils.xlate(get("ScanInformation")) or "").split()
for word in scan_info_list.copy():
original_hash = Hash.parse(word)
if original_hash:
md.original_hash = original_hash
scan_info_list.remove(word)
break
if scan_info_list:
md.scan_info = " ".join(scan_info_list)
md.is_empty = False
md.page_count = utils.xlate_int(get("PageCount"))

View File

@ -14,6 +14,7 @@ class Tag:
"data_origin",
"issue_id",
"series_id",
"original_hash",
"series",
"series_aliases",
"issue",

View File

@ -15,6 +15,7 @@
# limitations under the License.
from __future__ import annotations
import hashlib
import json
import logging
import os
@ -47,6 +48,45 @@ except ImportError:
if sys.version_info < (3, 11):
def file_digest(fileobj, digest, /, *, _bufsize=2**18):
"""Hash the contents of a file-like object. Returns a digest object.
*fileobj* must be a file-like object opened for reading in binary mode.
It accepts file objects from open(), io.BytesIO(), and SocketIO objects.
The function may bypass Python's I/O and use the file descriptor *fileno*
directly.
*digest* must either be a hash algorithm name as a *str*, a hash
constructor, or a callable that returns a hash object.
"""
# On Linux we could use AF_ALG sockets and sendfile() to archive zero-copy
# hashing with hardware acceleration.
if isinstance(digest, str):
digestobj = hashlib.new(digest)
else:
digestobj = digest()
if hasattr(fileobj, "getbuffer"):
# io.BytesIO object, use zero-copy buffer
digestobj.update(fileobj.getbuffer())
return digestobj
# Only binary files implement readinto().
if not (hasattr(fileobj, "readinto") and hasattr(fileobj, "readable") and fileobj.readable()):
raise ValueError(f"'{fileobj!r}' is not a file-like object in binary reading mode.")
# binary file, socket.SocketIO object
# Note: socket I/O uses different syscalls than file I/O.
buf = bytearray(_bufsize) # Reusable buffer to reduce allocations.
view = memoryview(buf)
while True:
size = fileobj.readinto(buf)
if size == 0:
break # EOF
digestobj.update(view[:size])
return digestobj
class StrEnum(str, Enum):
"""
Enum where members are also (and must be) strings
@ -92,9 +132,10 @@ if sys.version_info < (3, 11):
return self.value
else:
from enum import StrEnum as s
from enum import StrEnum as _StrEnum
from hashlib import file_digest
class StrEnum(s):
class StrEnum(_StrEnum):
@classmethod
def _missing_(cls, value: Any) -> str | None:
if not isinstance(value, str):
@ -600,3 +641,40 @@ def load_publishers() -> None:
update_publishers(json.loads((comicapi.data.data_path / "publishers.json").read_text("utf-8")))
except Exception:
logger.exception("Failed to load publishers.json; The are no publishers or imprints loaded")
__all__ = (
"load_publishers",
"file_digest",
"Parser",
"ImprintDict",
"os_sorted",
"parse_filename",
"norm_fold",
"combine_notes",
"parse_date_str",
"shorten_path",
"path_to_short_str",
"get_page_name_list",
"get_recursive_filelist",
"add_to_path",
"remove_from_path",
"xlate_int",
"xlate_float",
"xlate",
"split",
"split_urls",
"remove_articles",
"sanitize_title",
"titles_match",
"unique_file",
"parse_version",
"countries",
"languages",
"get_language_from_iso",
"get_language_iso",
"get_country_from_iso",
"get_publisher",
"update_publishers",
"load_publishers",
)

View File

@ -182,7 +182,9 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
self.teDescription.setText(match.description)
def set_cover_image(self) -> None:
ca = ComicArchive(self.current_match_set.original_path)
ca = ComicArchive(
self.current_match_set.original_path, hash_archive=self.config.Runtime_Options__preferred_hash
)
self.archiveCoverWidget.set_archive(ca)
def current_match(self) -> IssueResult:
@ -225,7 +227,9 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
def save_match(self) -> None:
match = self.current_match()
ca = ComicArchive(self.current_match_set.original_path)
ca = ComicArchive(
self.current_match_set.original_path, hash_archive=self.config.Runtime_Options__preferred_hash
)
md, error = self.parent().read_selected_tags(self._tags, ca)
if error is not None:
logger.error("Failed to load tags for %s: %s", ca.path, error)

View File

@ -184,7 +184,7 @@ class CLI:
if i != "s":
# save the data!
# we know at this point, that the file is all good to go
ca = ComicArchive(match_set.original_path)
ca = ComicArchive(match_set.original_path, hash_archive=self.config.Runtime_Options__preferred_hash)
md, match_set.tags_read = self.create_local_metadata(ca, self.config.Runtime_Options__tags_read)
ct_md = self.fetch_metadata(match_set.online_results[int(i) - 1].issue_id)
@ -790,7 +790,9 @@ class CLI:
logger.error("Cannot find %s", filename)
return Result(command, Status.read_failure, pathlib.Path(filename)), match_results
ca = ComicArchive(filename, str(graphics_path / "nocover.png"))
ca = ComicArchive(
filename, str(graphics_path / "nocover.png"), hash_archive=self.config.Runtime_Options__preferred_hash
)
if not ca.seems_to_be_a_comic_archive():
logger.error("Sorry, but %s is not a comic archive!", filename)

View File

@ -17,6 +17,7 @@
from __future__ import annotations
import argparse
import hashlib
import logging
import os
import platform
@ -83,6 +84,20 @@ def register_runtime(parser: settngs.Manager) -> None:
help='Enable the expiremental "quick tagger"',
file=False,
)
parser.add_setting(
"--enable-embedding-hashes",
action=argparse.BooleanOptionalAction,
default=False,
help="Enable embedding hashes in metadata (currently only CR/CIX has support)",
file=False,
)
parser.add_setting(
"--preferred-hash",
default="shake_256",
choices=hashlib.algorithms_available,
help="The type of embedded hash to save when --enable-embedding-hashes is set\n\n",
file=False,
)
parser.add_setting("-q", "--quiet", action="store_true", help="Don't say much (for print mode).", file=False)
parser.add_setting(
"-j",
@ -307,6 +322,9 @@ def validate_commandline_settings(config: settngs.Config[ct_ns], parser: settngs
if config[0].Runtime_Options__recursive:
config[0].Runtime_Options__files = utils.get_recursive_filelist(config[0].Runtime_Options__files)
if not config[0].Runtime_Options__enable_embedding_hashes:
config[0].Runtime_Options__preferred_hash = ""
# take a crack at finding rar exe if it's not in the path
if not utils.which("rar"):
if platform.system() == "Windows":

View File

@ -27,6 +27,7 @@ def general(parser: settngs.Manager) -> None:
def internal(parser: settngs.Manager) -> None:
# automatic settings
parser.add_setting("install_id", default=uuid.uuid4().hex, cmdline=False)
parser.add_setting("embedded_hash_type", default="shake_256", cmdline=False)
parser.add_setting("write_tags", default=["cr"], cmdline=False)
parser.add_setting("read_tags", default=["cr"], cmdline=False)
parser.add_setting("last_opened_folder", default="", cmdline=False)

View File

@ -21,6 +21,8 @@ class SettngsNS(settngs.TypedNS):
Runtime_Options__config: comictaggerlib.ctsettings.types.ComicTaggerPaths
Runtime_Options__verbose: int
Runtime_Options__enable_quick_tag: bool
Runtime_Options__enable_embedding_hashes: bool
Runtime_Options__preferred_hash: str
Runtime_Options__quiet: bool
Runtime_Options__json: bool
Runtime_Options__raw: bool
@ -47,6 +49,7 @@ class SettngsNS(settngs.TypedNS):
Quick_Tag__exact_only: bool
internal__install_id: str
internal__embedded_hash_type: str
internal__write_tags: list[str]
internal__read_tags: list[str]
internal__last_opened_folder: str
@ -143,6 +146,8 @@ class Runtime_Options(typing.TypedDict):
config: comictaggerlib.ctsettings.types.ComicTaggerPaths
verbose: int
enable_quick_tag: bool
enable_embedding_hashes: bool
preferred_hash: str
quiet: bool
json: bool
raw: bool
@ -173,6 +178,7 @@ class Quick_Tag(typing.TypedDict):
class internal(typing.TypedDict):
install_id: str
embedded_hash_type: str
write_tags: list[str]
read_tags: list[str]
last_opened_folder: str

View File

@ -274,7 +274,9 @@ class FileSelectionList(QtWidgets.QWidget):
if self.is_list_dupe(path):
return self.get_current_list_row(path)
ca = ComicArchive(path, str(graphics_path / "nocover.png"))
ca = ComicArchive(
path, str(graphics_path / "nocover.png"), hash_archive=self.config.Runtime_Options__preferred_hash
)
if ca.seems_to_be_a_comic_archive():
row: int = self.twList.rowCount()

View File

@ -170,7 +170,7 @@ class PageListEditor(QtWidgets.QWidget):
return
md = GenericMetadata(pages=self.get_page_list())
double_pages = [bool(x.double_page) for x in md.pages]
self.comic_archive.apply_archive_info_to_metadata(md, True, True)
self.comic_archive.apply_archive_info_to_metadata(md, True, True, hash_archive="")
self.set_data(self.comic_archive, pages_list=md.pages)
if double_pages != [bool(x.double_page) for x in md.pages]:
self.modified.emit()

View File

@ -17,6 +17,7 @@
from __future__ import annotations
import functools
import hashlib
import logging
import operator
import os
@ -38,7 +39,7 @@ import comictaggerlib.ui
from comicapi import utils
from comicapi.comicarchive import ComicArchive, tags
from comicapi.filenameparser import FileNameParser
from comicapi.genericmetadata import Credit, GenericMetadata
from comicapi.genericmetadata import Credit, GenericMetadata, Hash
from comicapi.issuestring import IssueString
from comictaggerlib import ctsettings, ctversion
from comictaggerlib.applicationlogwindow import ApplicationLogWindow, QTextEditLogger
@ -93,6 +94,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.md_attributes = {
"data_origin": None,
"issue_id": None,
"original_hash": (self.cbHashName, self.leOriginalHash),
"series": self.leSeries,
"issue": self.leIssueNum,
"title": self.leTitle,
@ -229,6 +231,8 @@ class TaggerWindow(QtWidgets.QMainWindow):
for tag_id in config[0].internal__read_tags.copy():
if tag_id not in self.enabled_tags():
config[0].internal__read_tags.remove(tag_id)
if self.config[0].Runtime_Options__preferred_hash:
self.config[0].internal__embedded_hash_type = self.config[0].Runtime_Options__preferred_hash
self.selected_write_tags: list[str] = config[0].internal__write_tags or [self.enabled_tags()[0]]
self.selected_read_tags: list[str] = config[0].internal__read_tags or [self.enabled_tags()[0]]
@ -432,6 +436,16 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.setWindowTitle(f"{self.appName} - {self.comic_archive.path}{mod_str}{ro_str}")
def toggle_enable_embedding_hashes(self) -> None:
self.config[0].Runtime_Options__enable_embedding_hashes = self.actionEnableEmbeddingHashes.isChecked()
enable_widget(self.md_attributes["original_hash"], self.config[0].Runtime_Options__enable_embedding_hashes)
if not self.leOriginalHash.text().strip():
self.cbHashName.setCurrentText(self.config[0].internal__embedded_hash_type)
if self.config[0].Runtime_Options__enable_embedding_hashes:
self.config[0].Runtime_Options__preferred_hash = self.config[0].internal__embedded_hash_type
else:
self.config[0].Runtime_Options__preferred_hash = ""
def config_menus(self) -> None:
# File Menu
self.actionAutoTag.triggered.connect(self.auto_tag)
@ -453,8 +467,11 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.actionLiteralSearch.triggered.connect(self.literal_search)
self.actionParse_Filename.triggered.connect(self.use_filename)
self.actionParse_Filename_split_words.triggered.connect(self.use_filename_split)
self.actionReCalcPageDims.triggered.connect(self.recalc_page_dimensions)
self.actionReCalcArchiveInfo.triggered.connect(self.recalc_archive_info)
self.actionSearchOnline.triggered.connect(self.query_online)
self.actionEnableEmbeddingHashes: QtWidgets.QAction
self.actionEnableEmbeddingHashes.triggered.connect(self.toggle_enable_embedding_hashes)
self.actionEnableEmbeddingHashes.setChecked(self.config[0].Runtime_Options__enable_embedding_hashes)
# Window Menu
self.actionLogWindow.triggered.connect(self.log_window.show)
self.actionPageBrowser.triggered.connect(self.show_page_browser)
@ -658,8 +675,6 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.page_browser.set_comic_archive(self.comic_archive)
self.page_browser.metadata = self.metadata
if self.comic_archive is not None:
self.page_list_editor.set_data(self.comic_archive, self.metadata.pages)
self.metadata_to_form()
self.clear_dirty_flag() # also updates the app title
self.update_info_box()
@ -680,7 +695,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.actionCopyTags.setEnabled(enabled)
self.actionParse_Filename.setEnabled(enabled)
self.actionParse_Filename_split_words.setEnabled(enabled)
self.actionReCalcPageDims.setEnabled(enabled)
self.actionReCalcArchiveInfo.setEnabled(enabled)
self.actionRemoveAuto.setEnabled(enabled)
self.actionRename.setEnabled(enabled)
self.actionRepackage.setEnabled(enabled)
@ -795,6 +810,9 @@ class TaggerWindow(QtWidgets.QMainWindow):
md = self.metadata
original_hash = md.original_hash or Hash("", "")
self.cbHashName.setCurrentText(original_hash.name or self.config[0].internal__embedded_hash_type)
assign_text(self.leOriginalHash, original_hash.hash)
assign_text(self.leSeries, md.series)
assign_text(self.leIssueNum, md.issue)
assign_text(self.leIssueCount, md.issue_count)
@ -876,9 +894,12 @@ class TaggerWindow(QtWidgets.QMainWindow):
continue
self.add_new_credit_entry(row, credit)
if self.comic_archive:
self.page_list_editor.set_data(self.comic_archive, self.metadata.pages)
self.twCredits.setSortingEnabled(True)
self.update_credit_colors()
self.toggle_enable_embedding_hashes()
def add_new_credit_entry(self, row: int, credit: Credit) -> None:
self.twCredits.insertRow(row)
@ -921,6 +942,11 @@ class TaggerWindow(QtWidgets.QMainWindow):
# copy the data from the form into the metadata
md = GenericMetadata()
md.is_empty = False
if utils.xlate(self.cbHashName.currentText()) and utils.xlate(self.leOriginalHash.text()):
md.original_hash = Hash(
utils.xlate(self.cbHashName.currentText()) or "", utils.xlate(self.leOriginalHash.text()) or ""
)
md.alternate_number = utils.xlate(IssueString(self.leAltIssueNum.text()).as_string())
md.issue = utils.xlate(IssueString(self.leIssueNum.text()).as_string())
md.issue_count = utils.xlate_int(self.leIssueCount.text())
@ -1233,6 +1259,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.update_credit_colors()
self.page_list_editor.select_read_tags(self.selected_write_tags)
self.toggle_enable_embedding_hashes()
def cell_double_clicked(self, r: int, c: int) -> None:
self.edit_credit()
@ -1403,6 +1430,9 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.adjust_tags_combo()
self.cbHashName: QtWidgets.QComboBox
self.cbHashName.addItems(sorted(hashlib.algorithms_available))
# Add talker entries
for t_id, talker in self.talkers.items():
self.cbx_sources.addItem(talker.name, t_id)
@ -2102,12 +2132,22 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.metadata = CBLTransformer(self.metadata, self.config[0]).apply()
self.metadata_to_form()
def recalc_page_dimensions(self) -> None:
def recalc_archive_info(self) -> None:
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
for p in self.metadata.pages:
p.byte_size = None
p.height = None
p.width = None
if self.comic_archive and self.config[0].Runtime_Options__preferred_hash:
self.metadata.original_hash = None
self.comic_archive.apply_archive_info_to_metadata(
self.metadata, True, hash_archive=self.cbHashName.currentText()
)
original_hash = self.metadata.original_hash or Hash("", "")
self.leOriginalHash.setText(original_hash.hash)
self.cbHashName.setCurrentText(original_hash.name or self.config[0].internal__embedded_hash_type)
self.page_list_editor.set_data(self.comic_archive, self.metadata.pages)
self.set_dirty_flag()
QtWidgets.QApplication.restoreOverrideCursor()

View File

@ -6,7 +6,7 @@ import io
import logging
import traceback
import webbrowser
from collections.abc import Sequence
from collections.abc import Collection, Sequence
from PyQt5.QtCore import QUrl
from PyQt5.QtWidgets import QWidget
@ -147,7 +147,7 @@ if qt_available:
active_palette = None
def enable_widget(widget: QtWidgets.QWidget | list[QtWidgets.QWidget], enable: bool) -> None:
def enable_widget(widget: QtWidgets.QWidget | Collection[QtWidgets.QWidget], enable: bool) -> None:
if isinstance(widget, Sequence):
for w in widget:
_enable_widget(w, enable)

View File

@ -955,7 +955,7 @@ Source</string>
<item>
<layout class="QFormLayout" name="formLayout_8">
<property name="fieldGrowthPolicy">
<enum>QFormLayout::ExpandingFieldsGrow</enum>
<enum>QFormLayout::AllNonFixedFieldsGrow</enum>
</property>
<item row="0" column="0">
<widget class="QLabel" name="label_20">
@ -970,6 +970,30 @@ Source</string>
<item row="0" column="1">
<widget class="QLineEdit" name="leScanInfo"/>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="leOriginalHash"/>
</item>
<item row="2" column="0">
<widget class="QLabel" name="lblOriginalHash">
<property name="text">
<string>Original Hash</string>
</property>
<property name="buddy">
<cstring>leOriginalHash</cstring>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="cbHashName">
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="lblHashName">
<property name="text">
<string>Hash Type</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
@ -1428,7 +1452,8 @@ Source</string>
<addaction name="actionLiteralSearch"/>
<addaction name="separator"/>
<addaction name="actionApplyCBLTransform"/>
<addaction name="actionReCalcPageDims"/>
<addaction name="actionReCalcArchiveInfo"/>
<addaction name="actionEnableEmbeddingHashes"/>
</widget>
<widget class="QMenu" name="menuWindow">
<property name="title">
@ -1717,9 +1742,12 @@ Source</string>
<string>Ctrl+Shift+F</string>
</property>
</action>
<action name="actionReCalcPageDims">
<action name="actionReCalcArchiveInfo">
<property name="text">
<string>Re-Calculate Page Dimensions</string>
<string>Re-Calculate Archive Information</string>
</property>
<property name="toolTip">
<string>Only includes page size info and original hash, original hash wil be overwritten</string>
</property>
<property name="shortcut">
<string>Ctrl+Shift+R</string>
@ -1814,6 +1842,14 @@ Source</string>
<string>Ctrl+S</string>
</property>
</action>
<action name="actionEnableEmbeddingHashes">
<property name="checkable">
<bool>true</bool>
</property>
<property name="text">
<string>Enable Embedding Hashes</string>
</property>
</action>
</widget>
<layoutdefault spacing="6" margin="11"/>
<customwidgets>