Compare commits
4 Commits
afb089bee6
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f7908b275 | |||
| 010bd0f374 | |||
| c24d0dd84d | |||
| 711536c74e |
@@ -5,6 +5,37 @@ All notable changes to FolderScope are documented here.
|
|||||||
The project uses [Semantic Versioning](https://semver.org/). Release tags are
|
The project uses [Semantic Versioning](https://semver.org/). Release tags are
|
||||||
formatted as `vMAJOR.MINOR.PATCH`.
|
formatted as `vMAJOR.MINOR.PATCH`.
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-07-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added persistent input history for folder and wildcard fields in Search
|
||||||
|
Options.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Renamed the executable, desktop entry, icon, and application identity from
|
||||||
|
SearchMyFiles to FolderScope.
|
||||||
|
- Preserved existing settings and search profiles when migrating to the new
|
||||||
|
application identity.
|
||||||
|
- Kept the base-folder browser button compact so the input field uses the
|
||||||
|
available width.
|
||||||
|
|
||||||
|
## [0.2.5] - 2026-07-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added an optional strict byte-by-byte verification mode for duplicate files.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Accelerated duplicate detection with size grouping, sampled block hashes, and
|
||||||
|
full SHA-256 hashing only for remaining candidates.
|
||||||
|
- Normalized overlapping search roots and deduplicated file candidates so a
|
||||||
|
path cannot be compared with itself.
|
||||||
|
- Strengthened persistent cache invalidation with nanosecond timestamps and
|
||||||
|
filesystem identity.
|
||||||
|
|
||||||
## [0.2.4] - 2026-07-25
|
## [0.2.4] - 2026-07-25
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -66,3 +97,5 @@ formatted as `vMAJOR.MINOR.PATCH`.
|
|||||||
[0.2.2]: https://gitea.forust.xyz/forust/folderscope/releases/tag/v0.2.2
|
[0.2.2]: https://gitea.forust.xyz/forust/folderscope/releases/tag/v0.2.2
|
||||||
[0.2.3]: https://gitea.forust.xyz/forust/folderscope/releases/tag/v0.2.3
|
[0.2.3]: https://gitea.forust.xyz/forust/folderscope/releases/tag/v0.2.3
|
||||||
[0.2.4]: https://gitea.forust.xyz/forust/folderscope/releases/tag/v0.2.4
|
[0.2.4]: https://gitea.forust.xyz/forust/folderscope/releases/tag/v0.2.4
|
||||||
|
[0.2.5]: https://gitea.forust.xyz/forust/folderscope/releases/tag/v0.2.5
|
||||||
|
[0.3.0]: https://gitea.forust.xyz/forust/folderscope/releases/tag/v0.3.0
|
||||||
|
|||||||
+55
-6
@@ -22,19 +22,31 @@ target_link_libraries(folderscope_core PUBLIC Qt6::Core)
|
|||||||
target_compile_options(folderscope_core PRIVATE -Wall -Wextra -Wpedantic)
|
target_compile_options(folderscope_core PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
target_include_directories(folderscope_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src")
|
target_include_directories(folderscope_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src")
|
||||||
|
|
||||||
qt_add_executable(searchmyfiles
|
qt_add_executable(folderscope
|
||||||
src/long_long_spin_box.cpp
|
src/long_long_spin_box.cpp
|
||||||
src/long_long_spin_box.h
|
src/long_long_spin_box.h
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
|
src/main_window.h
|
||||||
|
src/main_window.cpp
|
||||||
|
src/search_cache.h
|
||||||
|
src/search_cache.cpp
|
||||||
|
src/search_engine.h
|
||||||
|
src/search_engine.cpp
|
||||||
|
src/results_model.h
|
||||||
|
src/results_model.cpp
|
||||||
|
src/options_dialog.h
|
||||||
|
src/options_dialog.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(searchmyfiles PRIVATE folderscope_core Qt6::Widgets Qt6::Concurrent Qt6::DBus)
|
target_link_libraries(folderscope PRIVATE folderscope_core Qt6::Widgets Qt6::Concurrent Qt6::DBus)
|
||||||
target_compile_options(searchmyfiles PRIVATE -Wall -Wextra -Wpedantic)
|
target_compile_options(folderscope PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
target_compile_definitions(searchmyfiles PRIVATE FOLDERSCOPE_VERSION="${PROJECT_VERSION}")
|
target_compile_definitions(folderscope PRIVATE FOLDERSCOPE_VERSION="${PROJECT_VERSION}")
|
||||||
|
|
||||||
install(TARGETS searchmyfiles RUNTIME DESTINATION bin)
|
install(TARGETS folderscope RUNTIME DESTINATION bin)
|
||||||
install(FILES packaging/searchmyfiles.desktop
|
install(FILES packaging/folderscope.desktop
|
||||||
DESTINATION share/applications)
|
DESTINATION share/applications)
|
||||||
|
install(FILES packaging/folderscope.svg
|
||||||
|
DESTINATION share/icons/hicolor/scalable/apps)
|
||||||
|
|
||||||
include(CTest)
|
include(CTest)
|
||||||
if (BUILD_TESTING)
|
if (BUILD_TESTING)
|
||||||
@@ -47,6 +59,43 @@ if (BUILD_TESTING)
|
|||||||
target_compile_options(search_core_tests PRIVATE -Wall -Wextra -Wpedantic)
|
target_compile_options(search_core_tests PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
add_test(NAME search-core COMMAND search_core_tests)
|
add_test(NAME search-core COMMAND search_core_tests)
|
||||||
|
|
||||||
|
qt_add_executable(search_cache_tests
|
||||||
|
src/search_cache.cpp
|
||||||
|
src/search_cache.h
|
||||||
|
tests/search_cache_test.cpp
|
||||||
|
)
|
||||||
|
target_link_libraries(search_cache_tests PRIVATE folderscope_core Qt6::Test)
|
||||||
|
target_compile_options(search_cache_tests PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
target_include_directories(search_cache_tests PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src")
|
||||||
|
add_test(NAME search-cache COMMAND search_cache_tests)
|
||||||
|
|
||||||
|
qt_add_executable(search_engine_tests
|
||||||
|
src/search_cache.cpp
|
||||||
|
src/search_cache.h
|
||||||
|
src/search_engine.cpp
|
||||||
|
src/search_engine.h
|
||||||
|
tests/search_engine_test.cpp
|
||||||
|
)
|
||||||
|
target_link_libraries(search_engine_tests PRIVATE
|
||||||
|
folderscope_core Qt6::Concurrent Qt6::Test)
|
||||||
|
target_compile_options(search_engine_tests PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
target_include_directories(search_engine_tests PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src")
|
||||||
|
add_test(NAME search-engine COMMAND search_engine_tests)
|
||||||
|
|
||||||
|
qt_add_executable(options_dialog_tests
|
||||||
|
src/long_long_spin_box.cpp
|
||||||
|
src/long_long_spin_box.h
|
||||||
|
src/options_dialog.cpp
|
||||||
|
src/options_dialog.h
|
||||||
|
tests/options_dialog_test.cpp
|
||||||
|
)
|
||||||
|
target_link_libraries(options_dialog_tests PRIVATE
|
||||||
|
folderscope_core Qt6::Widgets Qt6::Test)
|
||||||
|
target_compile_options(options_dialog_tests PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
target_include_directories(options_dialog_tests PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src")
|
||||||
|
add_test(NAME options-dialog COMMAND options_dialog_tests)
|
||||||
|
set_tests_properties(options-dialog PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
|
||||||
|
|
||||||
qt_add_executable(long_long_spin_box_tests
|
qt_add_executable(long_long_spin_box_tests
|
||||||
src/long_long_spin_box.cpp
|
src/long_long_spin_box.cpp
|
||||||
src/long_long_spin_box.h
|
src/long_long_spin_box.h
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ single window.
|
|||||||
- search across multiple root folders;
|
- search across multiple root folders;
|
||||||
- filter by name patterns, size, timestamps, and file attributes;
|
- filter by name patterns, size, timestamps, and file attributes;
|
||||||
- inspect duplicate files and files with matching names;
|
- inspect duplicate files and files with matching names;
|
||||||
|
- optionally verify duplicate matches byte by byte in strict mode;
|
||||||
- search inside file contents, including text and hex;
|
- search inside file contents, including text and hex;
|
||||||
- reuse a persistent, automatically invalidated cache for content matches and
|
- reuse a persistent, automatically invalidated cache for content matches and
|
||||||
duplicate hashes;
|
duplicate hashes;
|
||||||
@@ -33,7 +34,7 @@ Requirements:
|
|||||||
```bash
|
```bash
|
||||||
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
|
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
|
||||||
cmake --build build -j
|
cmake --build build -j
|
||||||
./build/searchmyfiles
|
./build/folderscope
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the automated tests:
|
Run the automated tests:
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
Name=SearchMyFiles
|
Name=FolderScope
|
||||||
Comment=Fast advanced file search
|
Comment=Fast advanced file search
|
||||||
Exec=searchmyfiles
|
Exec=folderscope
|
||||||
Icon=system-search
|
Icon=folderscope
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Categories=Utility;FileTools;
|
Categories=Utility;FileTools;
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||||
|
<rect width="64" height="64" rx="8" fill="#3584e4"/>
|
||||||
|
<circle cx="28" cy="28" r="12" fill="none" stroke="#fff" stroke-width="3.5"/>
|
||||||
|
<line x1="36" y1="36" x2="50" y2="50" stroke="#fff" stroke-width="3.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 324 B |
+17
-2144
File diff suppressed because it is too large
Load Diff
+1052
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QAction>
|
||||||
|
#include <QCheckBox>
|
||||||
|
#include <QClipboard>
|
||||||
|
#include <QCloseEvent>
|
||||||
|
#include <QDBusConnection>
|
||||||
|
#include <QDBusInterface>
|
||||||
|
#include <QDBusMessage>
|
||||||
|
#include <QDesktopServices>
|
||||||
|
#include <QHeaderView>
|
||||||
|
#include <QInputDialog>
|
||||||
|
#include <QKeyEvent>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QMainWindow>
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QMenuBar>
|
||||||
|
#include <QMessageBox>
|
||||||
|
#include <QMimeData>
|
||||||
|
#include <QProgressBar>
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QSortFilterProxyModel>
|
||||||
|
#include <QStatusBar>
|
||||||
|
#include <QTableView>
|
||||||
|
#include <QThread>
|
||||||
|
#include <QToolBar>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
#include "results_model.h"
|
||||||
|
#include "search_core.h"
|
||||||
|
|
||||||
|
class SearchEngine;
|
||||||
|
class QTextEdit;
|
||||||
|
|
||||||
|
class MainWindow final : public QMainWindow {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
MainWindow();
|
||||||
|
~MainWindow() override;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void closeEvent(QCloseEvent *event) override;
|
||||||
|
void keyPressEvent(QKeyEvent *event) override;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void startRequested(const SearchOptions &options);
|
||||||
|
void stopRequested();
|
||||||
|
void clearCacheRequested();
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void showOptions();
|
||||||
|
void refreshSearch();
|
||||||
|
void findText();
|
||||||
|
void findNext();
|
||||||
|
void copyInformation();
|
||||||
|
void explorerCopy();
|
||||||
|
void explorerCut();
|
||||||
|
void startCurrentSearch();
|
||||||
|
void searchFinished(const QVector<FileRecord> &results, const QString &message);
|
||||||
|
void openSelected();
|
||||||
|
void openFolder();
|
||||||
|
void openWith();
|
||||||
|
void selectInFileManager();
|
||||||
|
void moveToTrash();
|
||||||
|
void deleteSelected();
|
||||||
|
void renameSelected();
|
||||||
|
void showProperties();
|
||||||
|
void copyPaths();
|
||||||
|
void contextMenu(const QPoint &point);
|
||||||
|
void headerContextMenu(const QPoint &point);
|
||||||
|
void exportResults();
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::optional<FileRecord> currentRecord() const;
|
||||||
|
QStringList actionChoices() const;
|
||||||
|
void executeConfiguredAction(const QString &action);
|
||||||
|
QString sizeUnitName() const;
|
||||||
|
int sizeUnitFromName(const QString &name) const;
|
||||||
|
void applyDisplayOptions();
|
||||||
|
void saveUiSettings();
|
||||||
|
QStringList selectedPaths() const;
|
||||||
|
void saveOptions();
|
||||||
|
void updateStaleSearchWarning();
|
||||||
|
void loadOptions();
|
||||||
|
|
||||||
|
SearchOptions options_;
|
||||||
|
ResultsModel *model_{};
|
||||||
|
QSortFilterProxyModel *proxy_{};
|
||||||
|
QTableView *table_{};
|
||||||
|
QAction *searchAction_{};
|
||||||
|
QAction *stopAction_{};
|
||||||
|
QAction *refreshAction_{};
|
||||||
|
QLabel *staleSearchWarning_{};
|
||||||
|
QProgressBar *progress_{};
|
||||||
|
QThread workerThread_;
|
||||||
|
SearchEngine *engine_{};
|
||||||
|
std::optional<SearchOptions> lastSearchOptions_;
|
||||||
|
QString findQuery_;
|
||||||
|
QString summarySizeUnitName_ = QStringLiteral("Automatic");
|
||||||
|
QString doubleClickAction_ = QStringLiteral("Open Properties Window");
|
||||||
|
QString enterKeyAction_ = QStringLiteral("Open Selected File");
|
||||||
|
int sizeUnit_ = 0;
|
||||||
|
int duplicateColorSet_ = 1;
|
||||||
|
bool showGmt_ = false;
|
||||||
|
bool markDuplicates_ = true;
|
||||||
|
bool addExportHeader_ = true;
|
||||||
|
bool focusOnSearchStart_ = true;
|
||||||
|
bool focusOnSearchEnd_ = true;
|
||||||
|
bool autoSizeOnSearchEnd_ = false;
|
||||||
|
bool showGrid_ = true;
|
||||||
|
bool showTooltips_ = true;
|
||||||
|
bool isSearching_ = false;
|
||||||
|
bool isStopping_ = false;
|
||||||
|
};
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
#include "options_dialog.h"
|
||||||
|
|
||||||
|
#include <QDialogButtonBox>
|
||||||
|
#include <QDropEvent>
|
||||||
|
#include <QEvent>
|
||||||
|
#include <QFileDialog>
|
||||||
|
#include <QInputDialog>
|
||||||
|
#include <QMessageBox>
|
||||||
|
#include <QMimeData>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QSizePolicy>
|
||||||
|
|
||||||
|
#include "long_long_spin_box.h"
|
||||||
|
|
||||||
|
OptionsDialog::OptionsDialog(const SearchOptions &o, QWidget *parent) : QDialog(parent)
|
||||||
|
{
|
||||||
|
setWindowTitle(tr("Search Options"));
|
||||||
|
resize(760, 560);
|
||||||
|
setupUi(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OptionsDialog::setupUi(const SearchOptions &o)
|
||||||
|
{
|
||||||
|
auto *layout = new QVBoxLayout(this);
|
||||||
|
|
||||||
|
auto *profileRow = new QWidget;
|
||||||
|
auto *profileLayout = new QHBoxLayout(profileRow);
|
||||||
|
profileLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
profileCombo_ = new QComboBox;
|
||||||
|
profileCombo_->setEditable(false);
|
||||||
|
profileCombo_->addItem(tr("(default)"));
|
||||||
|
profileCombo_->setMinimumWidth(200);
|
||||||
|
{
|
||||||
|
QSettings settings;
|
||||||
|
settings.beginGroup(QStringLiteral("profiles"));
|
||||||
|
const QStringList profiles = settings.childGroups();
|
||||||
|
for (const QString &name : profiles)
|
||||||
|
profileCombo_->addItem(name);
|
||||||
|
settings.endGroup();
|
||||||
|
}
|
||||||
|
auto *saveBtn = new QPushButton(tr("Save Profile…"));
|
||||||
|
auto *loadBtn = new QPushButton(tr("Load Profile"));
|
||||||
|
auto *deleteBtn = new QPushButton(tr("Delete Profile"));
|
||||||
|
profileLayout->addWidget(new QLabel(tr("Profile:")));
|
||||||
|
profileLayout->addWidget(profileCombo_);
|
||||||
|
profileLayout->addWidget(saveBtn);
|
||||||
|
profileLayout->addWidget(loadBtn);
|
||||||
|
profileLayout->addWidget(deleteBtn);
|
||||||
|
layout->addWidget(profileRow);
|
||||||
|
|
||||||
|
connect(saveBtn, &QPushButton::clicked, this, &OptionsDialog::saveProfile);
|
||||||
|
connect(loadBtn, &QPushButton::clicked, this, &OptionsDialog::loadProfile);
|
||||||
|
connect(deleteBtn, &QPushButton::clicked, this, [this] {
|
||||||
|
const QString name = profileCombo_->currentText();
|
||||||
|
if (name.isEmpty() || name == tr("(default)"))
|
||||||
|
return;
|
||||||
|
if (QMessageBox::question(this, tr("Delete Profile"),
|
||||||
|
tr("Delete profile \"%1\"?").arg(name))
|
||||||
|
!= QMessageBox::Yes)
|
||||||
|
return;
|
||||||
|
QSettings settings;
|
||||||
|
settings.beginGroup(QStringLiteral("profiles"));
|
||||||
|
settings.remove(name);
|
||||||
|
settings.endGroup();
|
||||||
|
profileCombo_->removeItem(profileCombo_->currentIndex());
|
||||||
|
});
|
||||||
|
|
||||||
|
auto *modeGroup = new QGroupBox(tr("Search mode"));
|
||||||
|
auto *modeLayout = new QHBoxLayout(modeGroup);
|
||||||
|
mode_ = new QComboBox;
|
||||||
|
mode_->addItems({tr("Standard search"), tr("Duplicates search"), tr("Non-Duplicates search"),
|
||||||
|
tr("Summary"), tr("Duplicate names search")});
|
||||||
|
mode_->setCurrentText(o.mode);
|
||||||
|
duplicateMode_ = new QComboBox;
|
||||||
|
duplicateMode_->addItems({tr("All files and folders"), tr("Only files"),
|
||||||
|
tr("Only identical content"), tr("Only non-identical content")});
|
||||||
|
duplicateMode_->setCurrentText(o.duplicateNameMode);
|
||||||
|
duplicateWithoutExtension_ = new QCheckBox(tr("Compare names without extension"));
|
||||||
|
duplicateWithoutExtension_->setChecked(o.duplicateNameWithoutExtension);
|
||||||
|
modeLayout->addWidget(mode_);
|
||||||
|
modeLayout->addWidget(new QLabel(tr("Duplicate-name mode:")));
|
||||||
|
modeLayout->addWidget(duplicateMode_);
|
||||||
|
modeLayout->addWidget(duplicateWithoutExtension_);
|
||||||
|
layout->addWidget(modeGroup);
|
||||||
|
|
||||||
|
auto *tabs = new QTabWidget;
|
||||||
|
layout->addWidget(tabs);
|
||||||
|
|
||||||
|
auto *filesPage = new QWidget;
|
||||||
|
auto *filesForm = new QFormLayout(filesPage);
|
||||||
|
roots_ = historyCombo(QStringLiteral("roots"), o.roots);
|
||||||
|
roots_->lineEdit()->setAcceptDrops(true);
|
||||||
|
roots_->setToolTip(tr("Drop folders here from your file manager"));
|
||||||
|
roots_->lineEdit()->installEventFilter(this);
|
||||||
|
auto *rootRow = new QWidget;
|
||||||
|
auto *rootLayout = new QHBoxLayout(rootRow);
|
||||||
|
rootLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
rootLayout->addWidget(roots_);
|
||||||
|
auto *browse = new QPushButton(tr("Browse…"));
|
||||||
|
browse->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
|
||||||
|
rootLayout->addWidget(browse);
|
||||||
|
rootLayout->setStretch(0, 1);
|
||||||
|
filesForm->addRow(tr("Base folders (separate with ;):"), rootRow);
|
||||||
|
fileMasks_ = addHistoryCombo(filesForm, tr("Files wildcard:"),
|
||||||
|
QStringLiteral("fileWildcards"), o.fileWildcards);
|
||||||
|
subfolderMasks_ = addHistoryCombo(filesForm, tr("Subfolders wildcard:"),
|
||||||
|
QStringLiteral("subfolderWildcards"), o.subfolderWildcards);
|
||||||
|
excludeFiles_ = addHistoryCombo(filesForm, tr("Exclude files:"),
|
||||||
|
QStringLiteral("excludeFiles"), o.excludeFiles);
|
||||||
|
excludeFolders_ = addHistoryCombo(filesForm, tr("Exclude folders:"),
|
||||||
|
QStringLiteral("excludeFolders"), o.excludeFolders);
|
||||||
|
includeFolders_ = addHistoryCombo(filesForm, tr("Include only folders:"),
|
||||||
|
QStringLiteral("includeFolders"), o.includeFolders);
|
||||||
|
recursive_ = new QCheckBox(tr("Search subfolders")); recursive_->setChecked(o.recursive);
|
||||||
|
findFiles_ = new QCheckBox(tr("Find files")); findFiles_->setChecked(o.findFiles);
|
||||||
|
findFolders_ = new QCheckBox(tr("Find folders")); findFolders_->setChecked(o.findFolders);
|
||||||
|
followLinks_ = new QCheckBox(tr("Follow symbolic links")); followLinks_->setChecked(o.followLinks);
|
||||||
|
retrieveOwner_ = new QCheckBox(tr("Retrieve file owner (slower)")); retrieveOwner_->setChecked(o.retrieveOwner);
|
||||||
|
accurateProgress_ = new QCheckBox(tr("Count files first for accurate progress"));
|
||||||
|
accurateProgress_->setChecked(o.accurateProgress);
|
||||||
|
useCache_ = new QCheckBox(tr("Use persistent cache for hashes and content searches"));
|
||||||
|
useCache_->setChecked(o.useCache);
|
||||||
|
strictDuplicateComparison_ =
|
||||||
|
new QCheckBox(tr("Strict duplicate comparison (byte by byte)"));
|
||||||
|
strictDuplicateComparison_->setObjectName(
|
||||||
|
QStringLiteral("strictDuplicateComparison"));
|
||||||
|
strictDuplicateComparison_->setChecked(o.strictDuplicateComparison);
|
||||||
|
maxDepth_ = new QSpinBox; maxDepth_->setRange(0, 1024); maxDepth_->setValue(o.maxDepth);
|
||||||
|
maxDepth_->setSpecialValueText(tr("Unlimited"));
|
||||||
|
maxResults_ = new QSpinBox; maxResults_->setRange(0, 100'000'000); maxResults_->setValue(o.maxResults);
|
||||||
|
maxResults_->setSpecialValueText(tr("Unlimited"));
|
||||||
|
filesForm->addRow(recursive_);
|
||||||
|
filesForm->addRow(tr("Subfolders depth:"), maxDepth_);
|
||||||
|
filesForm->addRow(findFiles_);
|
||||||
|
filesForm->addRow(findFolders_);
|
||||||
|
filesForm->addRow(followLinks_);
|
||||||
|
filesForm->addRow(retrieveOwner_);
|
||||||
|
filesForm->addRow(accurateProgress_);
|
||||||
|
filesForm->addRow(useCache_);
|
||||||
|
filesForm->addRow(strictDuplicateComparison_);
|
||||||
|
filesForm->addRow(tr("Stop after finding:"), maxResults_);
|
||||||
|
tabs->addTab(filesPage, tr("Files & folders"));
|
||||||
|
|
||||||
|
auto *contentPage = new QWidget;
|
||||||
|
auto *contentForm = new QFormLayout(contentPage);
|
||||||
|
contains_ = new QTextEdit(o.contains);
|
||||||
|
contains_->setMaximumHeight(110);
|
||||||
|
contentForm->addRow(tr("File contains:"), contains_);
|
||||||
|
binary_ = new QCheckBox(tr("Binary search (hex bytes, e.g. A2 C5 2F)")); binary_->setChecked(o.binary);
|
||||||
|
multiple_ = new QCheckBox(tr("Search multiple values (comma separated)")); multiple_->setChecked(o.multipleValues);
|
||||||
|
multipleMode_ = new QComboBox; multipleMode_->addItems({tr("Or"), tr("And")});
|
||||||
|
multipleMode_->setCurrentIndex(o.multipleAnd ? 1 : 0);
|
||||||
|
caseSensitive_ = new QCheckBox(tr("Case sensitive")); caseSensitive_->setChecked(o.caseSensitive);
|
||||||
|
contentForm->addRow(binary_);
|
||||||
|
contentForm->addRow(multiple_);
|
||||||
|
contentForm->addRow(tr("Multiple values match:"), multipleMode_);
|
||||||
|
contentForm->addRow(caseSensitive_);
|
||||||
|
tabs->addTab(contentPage, tr("File contents"));
|
||||||
|
|
||||||
|
auto *filterPage = new QWidget;
|
||||||
|
auto *filterForm = new QFormLayout(filterPage);
|
||||||
|
minSize_ = sizeSpin(o.minSize);
|
||||||
|
maxSize_ = sizeSpin(o.maxSize);
|
||||||
|
maxSize_->setSuffix(tr(" bytes (0 = any)"));
|
||||||
|
filterForm->addRow(tr("Minimum size:"), minSize_);
|
||||||
|
filterForm->addRow(tr("Maximum size:"), maxSize_);
|
||||||
|
timeField_ = new QComboBox;
|
||||||
|
timeField_->addItems({tr("Modified time"), tr("Accessed time"), tr("Metadata changed time")});
|
||||||
|
timeField_->setCurrentText(o.timeField);
|
||||||
|
filterForm->addRow(tr("Time field:"), timeField_);
|
||||||
|
lastMinutes_ = new QSpinBox;
|
||||||
|
lastMinutes_->setRange(0, 10'000'000);
|
||||||
|
lastMinutes_->setSuffix(tr(" minutes (0 = any)"));
|
||||||
|
lastMinutes_->setValue(o.lastMinutes);
|
||||||
|
filterForm->addRow(tr("Within last:"), lastMinutes_);
|
||||||
|
minTime_ = new QLineEdit(o.minTime ? QDateTime::fromSecsSinceEpoch(o.minTime).toString(Qt::ISODate) : QString{});
|
||||||
|
maxTime_ = new QLineEdit(o.maxTime ? QDateTime::fromSecsSinceEpoch(o.maxTime).toString(Qt::ISODate) : QString{});
|
||||||
|
minTime_->setPlaceholderText(tr("YYYY-MM-DDTHH:MM:SS"));
|
||||||
|
maxTime_->setPlaceholderText(tr("YYYY-MM-DDTHH:MM:SS"));
|
||||||
|
filterForm->addRow(tr("From time:"), minTime_);
|
||||||
|
filterForm->addRow(tr("To time:"), maxTime_);
|
||||||
|
hidden_ = attrCombo(o.hidden);
|
||||||
|
readonly_ = attrCombo(o.readonly);
|
||||||
|
executable_ = attrCombo(o.executable);
|
||||||
|
filterForm->addRow(tr("Hidden:"), hidden_);
|
||||||
|
filterForm->addRow(tr("Read-only:"), readonly_);
|
||||||
|
filterForm->addRow(tr("Executable:"), executable_);
|
||||||
|
tabs->addTab(filterPage, tr("Size, time & attributes"));
|
||||||
|
|
||||||
|
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
|
||||||
|
connect(buttons, &QDialogButtonBox::accepted, this, &OptionsDialog::accept);
|
||||||
|
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||||
|
connect(browse, &QPushButton::clicked, this, [this] {
|
||||||
|
const QStringList existingFolders = patterns(roots_->currentText());
|
||||||
|
const QString startFolder = existingFolders.isEmpty()
|
||||||
|
? QDir::homePath() : existingFolders.constLast();
|
||||||
|
const QString folder = QFileDialog::getExistingDirectory(
|
||||||
|
this, tr("Choose base folder"), startFolder);
|
||||||
|
if (folder.isEmpty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
QStringList folders = existingFolders;
|
||||||
|
const QString cleanPath = QDir::cleanPath(folder);
|
||||||
|
if (!folders.contains(cleanPath))
|
||||||
|
folders.push_back(cleanPath);
|
||||||
|
roots_->setEditText(folders.join(u';'));
|
||||||
|
});
|
||||||
|
layout->addWidget(buttons);
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchOptions OptionsDialog::options() const
|
||||||
|
{
|
||||||
|
SearchOptions o;
|
||||||
|
o.roots = roots_->currentText();
|
||||||
|
o.fileWildcards = fileMasks_->currentText();
|
||||||
|
o.subfolderWildcards = subfolderMasks_->currentText();
|
||||||
|
o.excludeFiles = excludeFiles_->currentText();
|
||||||
|
o.excludeFolders = excludeFolders_->currentText();
|
||||||
|
o.includeFolders = includeFolders_->currentText();
|
||||||
|
o.contains = contains_->toPlainText();
|
||||||
|
o.binary = binary_->isChecked();
|
||||||
|
o.multipleValues = multiple_->isChecked();
|
||||||
|
o.multipleAnd = multipleMode_->currentIndex() == 1;
|
||||||
|
o.caseSensitive = caseSensitive_->isChecked();
|
||||||
|
o.minSize = minSize_->value();
|
||||||
|
o.maxSize = maxSize_->value();
|
||||||
|
o.timeField = timeField_->currentText();
|
||||||
|
o.lastMinutes = lastMinutes_->value();
|
||||||
|
o.minTime = dateValue(minTime_);
|
||||||
|
o.maxTime = dateValue(maxTime_);
|
||||||
|
o.recursive = recursive_->isChecked();
|
||||||
|
o.findFiles = findFiles_->isChecked();
|
||||||
|
o.findFolders = findFolders_->isChecked();
|
||||||
|
o.followLinks = followLinks_->isChecked();
|
||||||
|
o.retrieveOwner = retrieveOwner_->isChecked();
|
||||||
|
o.accurateProgress = accurateProgress_->isChecked();
|
||||||
|
o.useCache = useCache_->isChecked();
|
||||||
|
o.strictDuplicateComparison = strictDuplicateComparison_->isChecked();
|
||||||
|
o.maxDepth = maxDepth_->value();
|
||||||
|
o.maxResults = maxResults_->value();
|
||||||
|
o.mode = mode_->currentText();
|
||||||
|
o.duplicateNameMode = duplicateMode_->currentText();
|
||||||
|
o.duplicateNameWithoutExtension = duplicateWithoutExtension_->isChecked();
|
||||||
|
o.hidden = hidden_->currentText();
|
||||||
|
o.readonly = readonly_->currentText();
|
||||||
|
o.executable = executable_->currentText();
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
void OptionsDialog::accept()
|
||||||
|
{
|
||||||
|
if (!validateDate(minTime_, tr("From time"))
|
||||||
|
|| !validateDate(maxTime_, tr("To time")))
|
||||||
|
return;
|
||||||
|
rememberHistory(QStringLiteral("roots"), roots_->currentText());
|
||||||
|
rememberHistory(QStringLiteral("fileWildcards"), fileMasks_->currentText());
|
||||||
|
rememberHistory(QStringLiteral("subfolderWildcards"), subfolderMasks_->currentText());
|
||||||
|
rememberHistory(QStringLiteral("excludeFiles"), excludeFiles_->currentText());
|
||||||
|
rememberHistory(QStringLiteral("excludeFolders"), excludeFolders_->currentText());
|
||||||
|
rememberHistory(QStringLiteral("includeFolders"), includeFolders_->currentText());
|
||||||
|
QDialog::accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
QComboBox *OptionsDialog::historyCombo(const QString &key, const QString &value)
|
||||||
|
{
|
||||||
|
auto *combo = new QComboBox;
|
||||||
|
combo->setEditable(true);
|
||||||
|
combo->setInsertPolicy(QComboBox::NoInsert);
|
||||||
|
combo->setObjectName(QStringLiteral("history%1").arg(key));
|
||||||
|
|
||||||
|
QSettings settings;
|
||||||
|
const QStringList history = settings.value(QStringLiteral("inputHistory/%1").arg(key)).toStringList();
|
||||||
|
combo->addItems(history);
|
||||||
|
combo->setEditText(value);
|
||||||
|
return combo;
|
||||||
|
}
|
||||||
|
|
||||||
|
QComboBox *OptionsDialog::addHistoryCombo(QFormLayout *layout, const QString &label,
|
||||||
|
const QString &key, const QString &value)
|
||||||
|
{
|
||||||
|
auto *combo = historyCombo(key, value);
|
||||||
|
layout->addRow(label, combo);
|
||||||
|
return combo;
|
||||||
|
}
|
||||||
|
|
||||||
|
LongLongSpinBox *OptionsDialog::sizeSpin(qint64 value)
|
||||||
|
{
|
||||||
|
auto *spin = new LongLongSpinBox;
|
||||||
|
spin->setRange(0, std::numeric_limits<qint64>::max());
|
||||||
|
spin->setSuffix(QObject::tr(" bytes"));
|
||||||
|
spin->setValue(value);
|
||||||
|
return spin;
|
||||||
|
}
|
||||||
|
|
||||||
|
qint64 OptionsDialog::dateValue(const QLineEdit *edit)
|
||||||
|
{
|
||||||
|
qint64 seconds = 0;
|
||||||
|
parseOptionalIsoDate(edit->text(), seconds);
|
||||||
|
return seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool OptionsDialog::validateDate(QLineEdit *edit, const QString &label)
|
||||||
|
{
|
||||||
|
qint64 seconds = 0;
|
||||||
|
if (parseOptionalIsoDate(edit->text(), seconds))
|
||||||
|
return true;
|
||||||
|
QMessageBox::warning(
|
||||||
|
this, tr("Invalid date"),
|
||||||
|
tr("%1 must be a valid ISO date and time, for example:\n2026-07-25T20:30:00")
|
||||||
|
.arg(label));
|
||||||
|
edit->setFocus();
|
||||||
|
edit->selectAll();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void OptionsDialog::rememberHistory(const QString &key, const QString &value)
|
||||||
|
{
|
||||||
|
if (value.isEmpty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
QSettings settings;
|
||||||
|
const QString settingsKey = QStringLiteral("inputHistory/%1").arg(key);
|
||||||
|
QStringList history = settings.value(settingsKey).toStringList();
|
||||||
|
history.removeAll(value);
|
||||||
|
history.prepend(value);
|
||||||
|
constexpr qsizetype maxHistoryEntries = 20;
|
||||||
|
if (history.size() > maxHistoryEntries)
|
||||||
|
history.erase(history.begin() + maxHistoryEntries, history.end());
|
||||||
|
settings.setValue(settingsKey, history);
|
||||||
|
}
|
||||||
|
|
||||||
|
QComboBox *OptionsDialog::attrCombo(const QString &value)
|
||||||
|
{
|
||||||
|
auto *combo = new QComboBox;
|
||||||
|
combo->addItems({QObject::tr("Any"), QObject::tr("Yes"), QObject::tr("No")});
|
||||||
|
combo->setCurrentText(value);
|
||||||
|
return combo;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool OptionsDialog::eventFilter(QObject *obj, QEvent *event)
|
||||||
|
{
|
||||||
|
if (obj == roots_->lineEdit() && event->type() == QEvent::DragEnter) {
|
||||||
|
auto *dragEnter = static_cast<QDragEnterEvent *>(event);
|
||||||
|
if (dragEnter->mimeData()->hasUrls()) {
|
||||||
|
dragEnter->acceptProposedAction();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (obj == roots_->lineEdit() && event->type() == QEvent::Drop) {
|
||||||
|
auto *drop = static_cast<QDropEvent *>(event);
|
||||||
|
if (!drop->mimeData()->hasUrls())
|
||||||
|
return false;
|
||||||
|
QStringList folders = patterns(roots_->currentText());
|
||||||
|
for (const QUrl &url : drop->mimeData()->urls()) {
|
||||||
|
if (!url.isLocalFile())
|
||||||
|
continue;
|
||||||
|
const QString cleanPath = QDir::cleanPath(url.toLocalFile());
|
||||||
|
if (QFileInfo::exists(cleanPath) && !folders.contains(cleanPath))
|
||||||
|
folders.append(cleanPath);
|
||||||
|
}
|
||||||
|
roots_->setEditText(folders.join(u';'));
|
||||||
|
drop->acceptProposedAction();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return QDialog::eventFilter(obj, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OptionsDialog::saveProfile()
|
||||||
|
{
|
||||||
|
bool ok = false;
|
||||||
|
const QString name = QInputDialog::getText(
|
||||||
|
this, tr("Save Profile"), tr("Profile name:"), QLineEdit::Normal, QString{}, &ok);
|
||||||
|
if (!ok || name.trimmed().isEmpty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
const QString profileName = name.trimmed();
|
||||||
|
const SearchOptions opt = options();
|
||||||
|
|
||||||
|
QSettings settings;
|
||||||
|
settings.beginGroup(QStringLiteral("profiles"));
|
||||||
|
settings.beginGroup(profileName);
|
||||||
|
|
||||||
|
auto save = [&](const char *key, const QString &val) { settings.setValue(QString::fromLatin1(key), val); };
|
||||||
|
auto saveBool = [&](const char *key, bool val) { settings.setValue(QString::fromLatin1(key), val); };
|
||||||
|
auto saveInt = [&](const char *key, int val) { settings.setValue(QString::fromLatin1(key), val); };
|
||||||
|
auto saveLong = [&](const char *key, qint64 val) { settings.setValue(QString::fromLatin1(key), val); };
|
||||||
|
|
||||||
|
save("roots", opt.roots);
|
||||||
|
save("fileWildcards", opt.fileWildcards);
|
||||||
|
save("subfolderWildcards", opt.subfolderWildcards);
|
||||||
|
save("excludeFiles", opt.excludeFiles);
|
||||||
|
save("excludeFolders", opt.excludeFolders);
|
||||||
|
save("includeFolders", opt.includeFolders);
|
||||||
|
save("contains", opt.contains);
|
||||||
|
save("mode", opt.mode);
|
||||||
|
save("duplicateNameMode", opt.duplicateNameMode);
|
||||||
|
save("timeField", opt.timeField);
|
||||||
|
save("hidden", opt.hidden);
|
||||||
|
save("readonly", opt.readonly);
|
||||||
|
save("executable", opt.executable);
|
||||||
|
|
||||||
|
saveBool("binary", opt.binary);
|
||||||
|
saveBool("multipleValues", opt.multipleValues);
|
||||||
|
saveBool("multipleAnd", opt.multipleAnd);
|
||||||
|
saveBool("caseSensitive", opt.caseSensitive);
|
||||||
|
saveBool("recursive", opt.recursive);
|
||||||
|
saveBool("findFiles", opt.findFiles);
|
||||||
|
saveBool("findFolders", opt.findFolders);
|
||||||
|
saveBool("followLinks", opt.followLinks);
|
||||||
|
saveBool("retrieveOwner", opt.retrieveOwner);
|
||||||
|
saveBool("accurateProgress", opt.accurateProgress);
|
||||||
|
saveBool("useCache", opt.useCache);
|
||||||
|
saveBool("duplicateNameWithoutExtension", opt.duplicateNameWithoutExtension);
|
||||||
|
saveBool("strictDuplicateComparison", opt.strictDuplicateComparison);
|
||||||
|
|
||||||
|
saveInt("lastMinutes", opt.lastMinutes);
|
||||||
|
saveInt("maxDepth", opt.maxDepth);
|
||||||
|
saveInt("maxResults", opt.maxResults);
|
||||||
|
|
||||||
|
saveLong("minSize", opt.minSize);
|
||||||
|
saveLong("maxSize", opt.maxSize);
|
||||||
|
saveLong("minTime", opt.minTime);
|
||||||
|
saveLong("maxTime", opt.maxTime);
|
||||||
|
|
||||||
|
settings.endGroup();
|
||||||
|
settings.endGroup();
|
||||||
|
|
||||||
|
if (profileCombo_->findText(profileName) < 0)
|
||||||
|
profileCombo_->addItem(profileName);
|
||||||
|
profileCombo_->setCurrentText(profileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OptionsDialog::loadProfile()
|
||||||
|
{
|
||||||
|
const QString name = profileCombo_->currentText();
|
||||||
|
if (name.isEmpty() || name == tr("(default)"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
QSettings settings;
|
||||||
|
settings.beginGroup(QStringLiteral("profiles"));
|
||||||
|
settings.beginGroup(name);
|
||||||
|
|
||||||
|
auto load = [&](const char *key) { return settings.value(QString::fromLatin1(key)).toString(); };
|
||||||
|
auto loadBool = [&](const char *key, bool def) { return settings.value(QString::fromLatin1(key), def).toBool(); };
|
||||||
|
auto loadInt = [&](const char *key, int def) { return settings.value(QString::fromLatin1(key), def).toInt(); };
|
||||||
|
auto loadLong = [&](const char *key, qint64 def) { return settings.value(QString::fromLatin1(key), def).toLongLong(); };
|
||||||
|
|
||||||
|
roots_->setEditText(load("roots"));
|
||||||
|
fileMasks_->setEditText(load("fileWildcards"));
|
||||||
|
subfolderMasks_->setEditText(load("subfolderWildcards"));
|
||||||
|
excludeFiles_->setEditText(load("excludeFiles"));
|
||||||
|
excludeFolders_->setEditText(load("excludeFolders"));
|
||||||
|
includeFolders_->setEditText(load("includeFolders"));
|
||||||
|
contains_->setText(load("contains"));
|
||||||
|
binary_->setChecked(loadBool("binary", false));
|
||||||
|
multiple_->setChecked(loadBool("multipleValues", false));
|
||||||
|
multipleMode_->setCurrentIndex(loadBool("multipleAnd", false) ? 1 : 0);
|
||||||
|
caseSensitive_->setChecked(loadBool("caseSensitive", false));
|
||||||
|
minSize_->setValue(loadLong("minSize", 0));
|
||||||
|
maxSize_->setValue(loadLong("maxSize", 0));
|
||||||
|
timeField_->setCurrentText(load("timeField"));
|
||||||
|
lastMinutes_->setValue(loadInt("lastMinutes", 0));
|
||||||
|
minTime_->setText(loadLong("minTime", 0)
|
||||||
|
? QDateTime::fromSecsSinceEpoch(loadLong("minTime", 0)).toString(Qt::ISODate) : QString{});
|
||||||
|
maxTime_->setText(loadLong("maxTime", 0)
|
||||||
|
? QDateTime::fromSecsSinceEpoch(loadLong("maxTime", 0)).toString(Qt::ISODate) : QString{});
|
||||||
|
recursive_->setChecked(loadBool("recursive", true));
|
||||||
|
findFiles_->setChecked(loadBool("findFiles", true));
|
||||||
|
findFolders_->setChecked(loadBool("findFolders", false));
|
||||||
|
followLinks_->setChecked(loadBool("followLinks", false));
|
||||||
|
retrieveOwner_->setChecked(loadBool("retrieveOwner", false));
|
||||||
|
accurateProgress_->setChecked(loadBool("accurateProgress", true));
|
||||||
|
useCache_->setChecked(loadBool("useCache", true));
|
||||||
|
strictDuplicateComparison_->setChecked(
|
||||||
|
loadBool("strictDuplicateComparison", false));
|
||||||
|
maxDepth_->setValue(loadInt("maxDepth", 0));
|
||||||
|
maxResults_->setValue(loadInt("maxResults", 0));
|
||||||
|
mode_->setCurrentText(load("mode"));
|
||||||
|
duplicateMode_->setCurrentText(load("duplicateNameMode"));
|
||||||
|
duplicateWithoutExtension_->setChecked(loadBool("duplicateNameWithoutExtension", false));
|
||||||
|
hidden_->setCurrentText(load("hidden"));
|
||||||
|
readonly_->setCurrentText(load("readonly"));
|
||||||
|
executable_->setCurrentText(load("executable"));
|
||||||
|
|
||||||
|
settings.endGroup();
|
||||||
|
settings.endGroup();
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QCheckBox>
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QDialog>
|
||||||
|
#include <QFormLayout>
|
||||||
|
#include <QGroupBox>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QSpinBox>
|
||||||
|
#include <QTabWidget>
|
||||||
|
#include <QTextEdit>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
#include "search_core.h"
|
||||||
|
|
||||||
|
class LongLongSpinBox;
|
||||||
|
|
||||||
|
class OptionsDialog final : public QDialog {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit OptionsDialog(const SearchOptions &o, QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
SearchOptions options() const;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void accept() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool eventFilter(QObject *obj, QEvent *event) override;
|
||||||
|
static QComboBox *historyCombo(const QString &key, const QString &value);
|
||||||
|
static QComboBox *addHistoryCombo(QFormLayout *layout, const QString &label,
|
||||||
|
const QString &key, const QString &value);
|
||||||
|
static LongLongSpinBox *sizeSpin(qint64 value);
|
||||||
|
static qint64 dateValue(const QLineEdit *edit);
|
||||||
|
bool validateDate(QLineEdit *edit, const QString &label);
|
||||||
|
void rememberHistory(const QString &key, const QString &value);
|
||||||
|
static QComboBox *attrCombo(const QString &value);
|
||||||
|
|
||||||
|
void setupUi(const SearchOptions &o);
|
||||||
|
void saveProfile();
|
||||||
|
void loadProfile();
|
||||||
|
|
||||||
|
QComboBox *mode_{};
|
||||||
|
QComboBox *duplicateMode_{};
|
||||||
|
QCheckBox *duplicateWithoutExtension_{};
|
||||||
|
QComboBox *roots_{};
|
||||||
|
QComboBox *fileMasks_{};
|
||||||
|
QComboBox *subfolderMasks_{};
|
||||||
|
QComboBox *excludeFiles_{};
|
||||||
|
QComboBox *excludeFolders_{};
|
||||||
|
QComboBox *includeFolders_{};
|
||||||
|
QTextEdit *contains_{};
|
||||||
|
QCheckBox *binary_{};
|
||||||
|
QCheckBox *multiple_{};
|
||||||
|
QComboBox *multipleMode_{};
|
||||||
|
QCheckBox *caseSensitive_{};
|
||||||
|
LongLongSpinBox *minSize_{};
|
||||||
|
LongLongSpinBox *maxSize_{};
|
||||||
|
QComboBox *timeField_{};
|
||||||
|
QSpinBox *lastMinutes_{};
|
||||||
|
QLineEdit *minTime_{};
|
||||||
|
QLineEdit *maxTime_{};
|
||||||
|
QCheckBox *recursive_{};
|
||||||
|
QCheckBox *findFiles_{};
|
||||||
|
QCheckBox *findFolders_{};
|
||||||
|
QCheckBox *followLinks_{};
|
||||||
|
QCheckBox *retrieveOwner_{};
|
||||||
|
QCheckBox *accurateProgress_{};
|
||||||
|
QCheckBox *useCache_{};
|
||||||
|
QCheckBox *strictDuplicateComparison_{};
|
||||||
|
QSpinBox *maxDepth_{};
|
||||||
|
QSpinBox *maxResults_{};
|
||||||
|
QComboBox *hidden_{};
|
||||||
|
QComboBox *readonly_{};
|
||||||
|
QComboBox *executable_{};
|
||||||
|
QComboBox *profileCombo_{};
|
||||||
|
};
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
#include "results_model.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QFileInfo>
|
||||||
|
|
||||||
|
ResultsModel::ResultsModel(QObject *parent) : QAbstractTableModel(parent) {}
|
||||||
|
|
||||||
|
int ResultsModel::rowCount(const QModelIndex &parent) const
|
||||||
|
{
|
||||||
|
return parent.isValid() ? 0 : rows_.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
int ResultsModel::columnCount(const QModelIndex &parent) const
|
||||||
|
{
|
||||||
|
return parent.isValid() ? 0 : ColumnCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant ResultsModel::headerData(int section, Qt::Orientation orientation, int role) const
|
||||||
|
{
|
||||||
|
if (orientation != Qt::Horizontal)
|
||||||
|
return {};
|
||||||
|
if (role == Qt::ToolTipRole) {
|
||||||
|
if (section == DuplicateNumber)
|
||||||
|
return tr("Identifies one set of identical files");
|
||||||
|
if (section == DuplicateGroup)
|
||||||
|
return tr("Most-specific base folder wins; ties use newer files, then path. 1 = preferred copy to keep");
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (role != Qt::DisplayRole)
|
||||||
|
return {};
|
||||||
|
static const QStringList names{
|
||||||
|
tr("Filename"), tr("Folder"), tr("Size"), tr("Size On Disk"), tr("Modified Time"),
|
||||||
|
tr("Created Time"), tr("Last Accessed Time"), tr("Entry Modified Time"),
|
||||||
|
tr("Attributes"), tr("Extension"), tr("Duplicate Set"), tr("Keeper Priority"),
|
||||||
|
tr("Type"), tr("File Owner"), tr("Full Path")
|
||||||
|
};
|
||||||
|
return names.value(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant ResultsModel::data(const QModelIndex &index, int role) const
|
||||||
|
{
|
||||||
|
if (!index.isValid() || index.row() >= rows_.size())
|
||||||
|
return {};
|
||||||
|
const FileRecord &r = rows_[index.row()];
|
||||||
|
if (role == Qt::UserRole)
|
||||||
|
return r.path;
|
||||||
|
if (role == Qt::BackgroundRole && r.group && markDuplicates_) {
|
||||||
|
const int colorIndex = (r.group - 1) % 64;
|
||||||
|
return duplicateColorSet_ == 1
|
||||||
|
? QColor::fromHsv((colorIndex * 137) % 360, 38 + (colorIndex % 3) * 8, 255)
|
||||||
|
: QColor::fromHsv((colorIndex * 137) % 360, 85, 205);
|
||||||
|
}
|
||||||
|
if (role == Qt::ForegroundRole && r.group && markDuplicates_)
|
||||||
|
return duplicateColorSet_ == 1 ? QColor(Qt::black) : QColor(Qt::white);
|
||||||
|
if (role == Qt::TextAlignmentRole && index.column() == Size)
|
||||||
|
return int(Qt::AlignRight | Qt::AlignVCenter);
|
||||||
|
if (role == Qt::EditRole) {
|
||||||
|
if (index.column() == Size) return r.size;
|
||||||
|
if (index.column() == SizeOnDisk) return r.sizeOnDisk;
|
||||||
|
if (index.column() == Modified) return r.modified;
|
||||||
|
if (index.column() == Created) return r.created;
|
||||||
|
if (index.column() == Accessed) return r.accessed;
|
||||||
|
if (index.column() == Changed) return r.changed;
|
||||||
|
if (index.column() == DuplicateNumber) return r.group;
|
||||||
|
if (index.column() == DuplicateGroup) return r.duplicateCopy;
|
||||||
|
return data(index, Qt::DisplayRole);
|
||||||
|
}
|
||||||
|
if (role != Qt::DisplayRole)
|
||||||
|
return {};
|
||||||
|
const auto time = [this](qint64 value) {
|
||||||
|
QDateTime dateTime = QDateTime::fromSecsSinceEpoch(value);
|
||||||
|
if (showGmt_)
|
||||||
|
dateTime = dateTime.toUTC();
|
||||||
|
return value ? dateTime.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"))
|
||||||
|
: QString{};
|
||||||
|
};
|
||||||
|
switch (index.column()) {
|
||||||
|
case Name: return r.name;
|
||||||
|
case Folder: return r.folder;
|
||||||
|
case Size: return formattedSize(r.size);
|
||||||
|
case SizeOnDisk: return formattedSize(r.sizeOnDisk);
|
||||||
|
case Modified: return time(r.modified);
|
||||||
|
case Created: return time(r.created);
|
||||||
|
case Accessed: return time(r.accessed);
|
||||||
|
case Changed: return time(r.changed);
|
||||||
|
case Attributes: return r.attributes;
|
||||||
|
case Extension: return QFileInfo(r.name).suffix();
|
||||||
|
case DuplicateNumber: return r.group ? QVariant(r.group) : QVariant{};
|
||||||
|
case DuplicateGroup: return r.duplicateCopy ? QVariant(r.duplicateCopy) : QVariant{};
|
||||||
|
case Type: return r.type;
|
||||||
|
case Owner: return r.owner;
|
||||||
|
case FullPath: return r.path;
|
||||||
|
default: return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ResultsModel::setRows(QVector<FileRecord> rows)
|
||||||
|
{
|
||||||
|
beginResetModel();
|
||||||
|
rows_ = std::move(rows);
|
||||||
|
endResetModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ResultsModel::setDisplayOptions(int sizeUnit, bool showGmt, bool markDuplicates, int colorSet)
|
||||||
|
{
|
||||||
|
sizeUnit_ = sizeUnit;
|
||||||
|
showGmt_ = showGmt;
|
||||||
|
markDuplicates_ = markDuplicates;
|
||||||
|
duplicateColorSet_ = colorSet;
|
||||||
|
if (!rows_.isEmpty())
|
||||||
|
emit dataChanged(index(0, 0), index(rows_.size() - 1, ColumnCount - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ResultsModel::formattedSize(qint64 bytes) const
|
||||||
|
{
|
||||||
|
if (sizeUnit_ == 0)
|
||||||
|
return humanSize(bytes);
|
||||||
|
static const double divisors[]{1.0, 1024.0, 1024.0 * 1024.0, 1024.0 * 1024.0 * 1024.0};
|
||||||
|
static const char *units[]{"Bytes", "KB", "MB", "GB"};
|
||||||
|
const int index = std::clamp(sizeUnit_ - 1, 0, 3);
|
||||||
|
return index == 0
|
||||||
|
? QStringLiteral("%1 Bytes").arg(bytes)
|
||||||
|
: QStringLiteral("%1 %2").arg(double(bytes) / divisors[index], 0, 'f', 2)
|
||||||
|
.arg(QString::fromLatin1(units[index]));
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QAbstractTableModel>
|
||||||
|
#include <QColor>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QVariant>
|
||||||
|
|
||||||
|
#include "search_core.h"
|
||||||
|
|
||||||
|
class ResultsModel final : public QAbstractTableModel {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
enum Column { Name, Folder, Size, SizeOnDisk, Modified, Created, Accessed, Changed,
|
||||||
|
Attributes, Extension, DuplicateNumber, DuplicateGroup, Type, Owner,
|
||||||
|
FullPath, ColumnCount };
|
||||||
|
|
||||||
|
explicit ResultsModel(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
int rowCount(const QModelIndex &parent = {}) const override;
|
||||||
|
int columnCount(const QModelIndex &parent = {}) const override;
|
||||||
|
QVariant headerData(int section, Qt::Orientation orientation, int role) const override;
|
||||||
|
QVariant data(const QModelIndex &index, int role) const override;
|
||||||
|
|
||||||
|
void setRows(QVector<FileRecord> rows);
|
||||||
|
const QVector<FileRecord> &rows() const { return rows_; }
|
||||||
|
|
||||||
|
void setDisplayOptions(int sizeUnit, bool showGmt, bool markDuplicates, int colorSet);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString formattedSize(qint64 bytes) const;
|
||||||
|
|
||||||
|
QVector<FileRecord> rows_;
|
||||||
|
int sizeUnit_ = 0;
|
||||||
|
bool showGmt_ = false;
|
||||||
|
bool markDuplicates_ = true;
|
||||||
|
int duplicateColorSet_ = 1;
|
||||||
|
};
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
#include "search_cache.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDataStream>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QSaveFile>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
SearchCache::SearchCache(QString path)
|
||||||
|
{
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
const QString cacheRoot = QStandardPaths::writableLocation(QStandardPaths::CacheLocation);
|
||||||
|
QDir().mkpath(cacheRoot);
|
||||||
|
path_ = QDir(cacheRoot).filePath(QStringLiteral("search-cache-v1.dat"));
|
||||||
|
} else {
|
||||||
|
path_ = std::move(path);
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SearchCache::findSampleHash(const FileRecord &record, QByteArray &digest)
|
||||||
|
{
|
||||||
|
if (!enabled_)
|
||||||
|
return false;
|
||||||
|
QMutexLocker lock(&mutex_);
|
||||||
|
auto it = entries_.find(record.path);
|
||||||
|
if (it == entries_.end() || !signatureMatches(*it, record)
|
||||||
|
|| it->sampleSha256.isEmpty())
|
||||||
|
return false;
|
||||||
|
it->lastUsed = QDateTime::currentSecsSinceEpoch();
|
||||||
|
digest = it->sampleSha256;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SearchCache::storeSampleHash(const FileRecord &record, const QByteArray &digest)
|
||||||
|
{
|
||||||
|
if (!enabled_ || digest.isEmpty())
|
||||||
|
return;
|
||||||
|
QMutexLocker lock(&mutex_);
|
||||||
|
SearchCacheEntry &entry = currentEntry(record);
|
||||||
|
entry.sampleSha256 = digest;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SearchCache::findHash(const FileRecord &record, QByteArray &digest)
|
||||||
|
{
|
||||||
|
if (!enabled_)
|
||||||
|
return false;
|
||||||
|
QMutexLocker lock(&mutex_);
|
||||||
|
auto it = entries_.find(record.path);
|
||||||
|
if (it == entries_.end() || !signatureMatches(*it, record) || it->sha256.isEmpty())
|
||||||
|
return false;
|
||||||
|
it->lastUsed = QDateTime::currentSecsSinceEpoch();
|
||||||
|
digest = it->sha256;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SearchCache::storeHash(const FileRecord &record, const QByteArray &digest)
|
||||||
|
{
|
||||||
|
if (!enabled_ || digest.isEmpty())
|
||||||
|
return;
|
||||||
|
QMutexLocker lock(&mutex_);
|
||||||
|
SearchCacheEntry &entry = currentEntry(record);
|
||||||
|
entry.sha256 = digest;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<bool> SearchCache::findContent(const FileRecord &record, const QByteArray &queryKey)
|
||||||
|
{
|
||||||
|
if (!enabled_)
|
||||||
|
return std::nullopt;
|
||||||
|
QMutexLocker lock(&mutex_);
|
||||||
|
auto it = entries_.find(record.path);
|
||||||
|
if (it == entries_.end() || !signatureMatches(*it, record))
|
||||||
|
return std::nullopt;
|
||||||
|
const auto match = it->contentMatches.constFind(queryKey);
|
||||||
|
if (match == it->contentMatches.cend())
|
||||||
|
return std::nullopt;
|
||||||
|
it->lastUsed = QDateTime::currentSecsSinceEpoch();
|
||||||
|
return *match;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SearchCache::storeContent(const FileRecord &record, const QByteArray &queryKey, bool matched)
|
||||||
|
{
|
||||||
|
if (!enabled_)
|
||||||
|
return;
|
||||||
|
QMutexLocker lock(&mutex_);
|
||||||
|
SearchCacheEntry &entry = currentEntry(record);
|
||||||
|
entry.contentMatches.insert(queryKey, matched);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SearchCache::save()
|
||||||
|
{
|
||||||
|
if (!enabled_)
|
||||||
|
return;
|
||||||
|
QMutexLocker lock(&mutex_);
|
||||||
|
prune();
|
||||||
|
QSaveFile file(path_);
|
||||||
|
if (!file.open(QIODevice::WriteOnly))
|
||||||
|
return;
|
||||||
|
QDataStream stream(&file);
|
||||||
|
stream.setVersion(QDataStream::Qt_6_0);
|
||||||
|
stream << quint32(0x46534348) << quint32(2) << quint32(entries_.size());
|
||||||
|
for (auto it = entries_.cbegin(); it != entries_.cend(); ++it) {
|
||||||
|
stream << it.key() << it->size << it->modifiedNs << it->device << it->inode
|
||||||
|
<< it->lastUsed << it->sampleSha256 << it->sha256;
|
||||||
|
stream << quint32(it->contentMatches.size());
|
||||||
|
for (auto match = it->contentMatches.cbegin(); match != it->contentMatches.cend(); ++match)
|
||||||
|
stream << match.key() << match.value();
|
||||||
|
}
|
||||||
|
if (stream.status() == QDataStream::Ok)
|
||||||
|
file.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SearchCache::clear()
|
||||||
|
{
|
||||||
|
QMutexLocker lock(&mutex_);
|
||||||
|
entries_.clear();
|
||||||
|
QFile::remove(path_);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SearchCache::signatureMatches(const SearchCacheEntry &entry, const FileRecord &record)
|
||||||
|
{
|
||||||
|
return entry.size == record.size
|
||||||
|
&& entry.modifiedNs == record.modifiedNs
|
||||||
|
&& entry.device == record.device
|
||||||
|
&& entry.inode == record.inode;
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchCacheEntry &SearchCache::currentEntry(const FileRecord &record)
|
||||||
|
{
|
||||||
|
SearchCacheEntry &entry = entries_[record.path];
|
||||||
|
if (!signatureMatches(entry, record)) {
|
||||||
|
entry = {};
|
||||||
|
entry.size = record.size;
|
||||||
|
entry.modifiedNs = record.modifiedNs;
|
||||||
|
entry.device = record.device;
|
||||||
|
entry.inode = record.inode;
|
||||||
|
}
|
||||||
|
entry.lastUsed = QDateTime::currentSecsSinceEpoch();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SearchCache::load()
|
||||||
|
{
|
||||||
|
QFile file(path_);
|
||||||
|
if (!file.open(QIODevice::ReadOnly))
|
||||||
|
return;
|
||||||
|
QDataStream stream(&file);
|
||||||
|
stream.setVersion(QDataStream::Qt_6_0);
|
||||||
|
quint32 magic = 0;
|
||||||
|
quint32 version = 0;
|
||||||
|
quint32 count = 0;
|
||||||
|
stream >> magic >> version >> count;
|
||||||
|
if (magic != 0x46534348 || version != 2 || count > 200'000)
|
||||||
|
return;
|
||||||
|
for (quint32 index = 0; index < count && stream.status() == QDataStream::Ok; ++index) {
|
||||||
|
QString path;
|
||||||
|
SearchCacheEntry entry;
|
||||||
|
quint32 contentCount = 0;
|
||||||
|
stream >> path >> entry.size >> entry.modifiedNs >> entry.device >> entry.inode
|
||||||
|
>> entry.lastUsed >> entry.sampleSha256 >> entry.sha256;
|
||||||
|
stream >> contentCount;
|
||||||
|
if (contentCount > 256)
|
||||||
|
return;
|
||||||
|
for (quint32 contentIndex = 0; contentIndex < contentCount; ++contentIndex) {
|
||||||
|
QByteArray key;
|
||||||
|
bool matched = false;
|
||||||
|
stream >> key >> matched;
|
||||||
|
entry.contentMatches.insert(key, matched);
|
||||||
|
}
|
||||||
|
entries_.insert(path, std::move(entry));
|
||||||
|
}
|
||||||
|
if (stream.status() != QDataStream::Ok)
|
||||||
|
entries_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SearchCache::prune()
|
||||||
|
{
|
||||||
|
constexpr qsizetype maxEntries = 100'000;
|
||||||
|
if (entries_.size() <= maxEntries)
|
||||||
|
return;
|
||||||
|
QVector<QPair<qint64, QString>> ages;
|
||||||
|
ages.reserve(entries_.size());
|
||||||
|
for (auto it = entries_.cbegin(); it != entries_.cend(); ++it)
|
||||||
|
ages.push_back({it->lastUsed, it.key()});
|
||||||
|
std::sort(ages.begin(), ages.end(),
|
||||||
|
[](const auto &left, const auto &right) { return left.first > right.first; });
|
||||||
|
for (qsizetype index = maxEntries; index < ages.size(); ++index)
|
||||||
|
entries_.remove(ages[index].second);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QMutex>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#include "search_core.h"
|
||||||
|
|
||||||
|
struct SearchCacheEntry {
|
||||||
|
qint64 size = -1;
|
||||||
|
qint64 modifiedNs = -1;
|
||||||
|
quint64 device = 0;
|
||||||
|
quint64 inode = 0;
|
||||||
|
qint64 lastUsed = 0;
|
||||||
|
QByteArray sampleSha256;
|
||||||
|
QByteArray sha256;
|
||||||
|
QHash<QByteArray, bool> contentMatches;
|
||||||
|
};
|
||||||
|
|
||||||
|
class SearchCache {
|
||||||
|
public:
|
||||||
|
explicit SearchCache(QString path = {});
|
||||||
|
|
||||||
|
void setEnabled(bool enabled) { enabled_ = enabled; }
|
||||||
|
bool enabled() const { return enabled_; }
|
||||||
|
|
||||||
|
bool findSampleHash(const FileRecord &record, QByteArray &digest);
|
||||||
|
void storeSampleHash(const FileRecord &record, const QByteArray &digest);
|
||||||
|
bool findHash(const FileRecord &record, QByteArray &digest);
|
||||||
|
void storeHash(const FileRecord &record, const QByteArray &digest);
|
||||||
|
std::optional<bool> findContent(const FileRecord &record, const QByteArray &queryKey);
|
||||||
|
void storeContent(const FileRecord &record, const QByteArray &queryKey, bool matched);
|
||||||
|
void save();
|
||||||
|
void clear();
|
||||||
|
|
||||||
|
private:
|
||||||
|
static bool signatureMatches(const SearchCacheEntry &entry, const FileRecord &record);
|
||||||
|
SearchCacheEntry ¤tEntry(const FileRecord &record);
|
||||||
|
void load();
|
||||||
|
void prune();
|
||||||
|
|
||||||
|
QHash<QString, SearchCacheEntry> entries_;
|
||||||
|
QMutex mutex_;
|
||||||
|
QString path_;
|
||||||
|
bool enabled_ = true;
|
||||||
|
};
|
||||||
@@ -2,11 +2,16 @@
|
|||||||
|
|
||||||
#include <QCryptographicHash>
|
#include <QCryptographicHash>
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
#include <QRegularExpression>
|
#include <QRegularExpression>
|
||||||
|
#include <QSet>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
#include <pwd.h>
|
#include <pwd.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
|
||||||
QStringList patterns(const QString &text)
|
QStringList patterns(const QString &text)
|
||||||
{
|
{
|
||||||
@@ -54,6 +59,129 @@ bool wildcardMatch(const QString &value, const QStringList &items, Qt::CaseSensi
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
struct RootCandidate {
|
||||||
|
QString path;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool isDescendantPath(const QString &parent, const QString &child,
|
||||||
|
QString *relativePath = nullptr)
|
||||||
|
{
|
||||||
|
const QString relative = QDir(parent).relativeFilePath(child);
|
||||||
|
const bool descendant = relative != QStringLiteral(".")
|
||||||
|
&& relative != QStringLiteral("..")
|
||||||
|
&& !relative.startsWith(QStringLiteral("../"))
|
||||||
|
&& !QDir::isAbsolutePath(relative);
|
||||||
|
if (descendant && relativePath)
|
||||||
|
*relativePath = relative;
|
||||||
|
return descendant;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool canCollapseInto(const RootCandidate &parent, const RootCandidate &child,
|
||||||
|
const SearchOptions &options, const QStringList &excludedFolders,
|
||||||
|
Qt::CaseSensitivity cs)
|
||||||
|
{
|
||||||
|
if (!QFileInfo(parent.path).isDir())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
QString relative;
|
||||||
|
if (!isDescendantPath(parent.path, child.path, &relative))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
QString current = parent.path;
|
||||||
|
const QStringList segments = relative.split(u'/', Qt::SkipEmptyParts);
|
||||||
|
for (const QString &segment : segments) {
|
||||||
|
current = QDir(current).filePath(segment);
|
||||||
|
const QFileInfo info(current);
|
||||||
|
if ((!options.followLinks && info.isSymLink())
|
||||||
|
|| (!excludedFolders.isEmpty()
|
||||||
|
&& (wildcardMatch(info.fileName(), excludedFolders, cs)
|
||||||
|
|| wildcardMatch(QDir::cleanPath(current), excludedFolders, cs)))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EffectiveRoots effectiveSearchRoots(const SearchOptions &options)
|
||||||
|
{
|
||||||
|
QVector<RootCandidate> unique;
|
||||||
|
QSet<QString> identities;
|
||||||
|
int skipped = 0;
|
||||||
|
for (const QString &rootText : patterns(options.roots)) {
|
||||||
|
const QFileInfo info(rootText);
|
||||||
|
const QString path = QDir::cleanPath(info.absoluteFilePath());
|
||||||
|
const QString canonical = info.canonicalFilePath();
|
||||||
|
const QString identity = canonical.isEmpty()
|
||||||
|
? path : QDir::cleanPath(canonical);
|
||||||
|
if (identities.contains(identity)) {
|
||||||
|
++skipped;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
identities.insert(identity);
|
||||||
|
unique.push_back({path});
|
||||||
|
}
|
||||||
|
|
||||||
|
const QStringList subfolderMasks = patterns(options.subfolderWildcards).isEmpty()
|
||||||
|
? QStringList{QStringLiteral("*")} : patterns(options.subfolderWildcards);
|
||||||
|
const bool universalSubfolderMask =
|
||||||
|
subfolderMasks.contains(QStringLiteral("*"));
|
||||||
|
if (!options.recursive || options.maxDepth > 0 || options.maxResults > 0
|
||||||
|
|| !universalSubfolderMask) {
|
||||||
|
EffectiveRoots result;
|
||||||
|
result.skipped = skipped;
|
||||||
|
for (const RootCandidate &root : unique) {
|
||||||
|
result.paths.push_back(root.path);
|
||||||
|
result.priorityPaths.push_back(root.path);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Qt::CaseSensitivity cs =
|
||||||
|
options.caseSensitive ? Qt::CaseSensitive : Qt::CaseInsensitive;
|
||||||
|
const QStringList excludedFolders = patterns(options.excludeFolders);
|
||||||
|
EffectiveRoots result;
|
||||||
|
result.skipped = skipped;
|
||||||
|
for (const RootCandidate &root : unique)
|
||||||
|
result.priorityPaths.push_back(root.path);
|
||||||
|
for (qsizetype childIndex = 0; childIndex < unique.size(); ++childIndex) {
|
||||||
|
bool redundant = false;
|
||||||
|
for (qsizetype parentIndex = 0; parentIndex < unique.size(); ++parentIndex) {
|
||||||
|
if (parentIndex == childIndex)
|
||||||
|
continue;
|
||||||
|
if (canCollapseInto(unique[parentIndex], unique[childIndex], options,
|
||||||
|
excludedFolders, cs)) {
|
||||||
|
redundant = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (redundant) {
|
||||||
|
++result.skipped;
|
||||||
|
} else {
|
||||||
|
result.paths.push_back(unique[childIndex].path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
int preferredRootIndex(const QString &path, const QStringList &roots)
|
||||||
|
{
|
||||||
|
const QString normalizedPath = QDir::cleanPath(QFileInfo(path).absoluteFilePath());
|
||||||
|
int bestIndex = -1;
|
||||||
|
qsizetype bestLength = 0;
|
||||||
|
for (qsizetype index = 0; index < roots.size(); ++index) {
|
||||||
|
const QString root = QDir::cleanPath(QFileInfo(roots[index]).absoluteFilePath());
|
||||||
|
if (normalizedPath != root && !isDescendantPath(root, normalizedPath))
|
||||||
|
continue;
|
||||||
|
if (root.size() > bestLength) {
|
||||||
|
bestIndex = int(index);
|
||||||
|
bestLength = root.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestIndex;
|
||||||
|
}
|
||||||
|
|
||||||
bool shouldShowDuplicateResult(bool copiesOnly, int keeperPriority)
|
bool shouldShowDuplicateResult(bool copiesOnly, int keeperPriority)
|
||||||
{
|
{
|
||||||
if (keeperPriority < 1)
|
if (keeperPriority < 1)
|
||||||
@@ -99,6 +227,26 @@ bool isCancelled(const std::atomic_bool *cancelled)
|
|||||||
{
|
{
|
||||||
return cancelled && cancelled->load(std::memory_order_relaxed);
|
return cancelled && cancelled->load(std::memory_order_relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
qint64 modifiedNanoseconds(const struct stat &st)
|
||||||
|
{
|
||||||
|
return qint64(st.st_mtim.tv_sec) * 1'000'000'000LL + st.st_mtim.tv_nsec;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool signatureMatches(const FileRecord &record, const struct stat &st)
|
||||||
|
{
|
||||||
|
return record.size == st.st_size
|
||||||
|
&& record.modifiedNs == modifiedNanoseconds(st)
|
||||||
|
&& record.device == quint64(st.st_dev)
|
||||||
|
&& record.inode == quint64(st.st_ino);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool statMatches(const FileRecord &record)
|
||||||
|
{
|
||||||
|
struct stat st {};
|
||||||
|
const QByteArray nativePath = QFile::encodeName(record.path);
|
||||||
|
return ::stat(nativePath.constData(), &st) == 0 && signatureMatches(record, st);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool fileContains(const QString &path, const SearchOptions &options,
|
bool fileContains(const QString &path, const SearchOptions &options,
|
||||||
@@ -187,6 +335,46 @@ QByteArray sha256(const QString &path, const std::atomic_bool *cancelled)
|
|||||||
return isCancelled(cancelled) ? QByteArray{} : hash.result();
|
return isCancelled(cancelled) ? QByteArray{} : hash.result();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool fileSignatureMatches(const FileRecord &record)
|
||||||
|
{
|
||||||
|
return statMatches(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray sha256(const FileRecord &record, const std::atomic_bool *cancelled)
|
||||||
|
{
|
||||||
|
if (isCancelled(cancelled) || !statMatches(record))
|
||||||
|
return {};
|
||||||
|
const QByteArray digest = sha256(record.path, cancelled);
|
||||||
|
return !digest.isEmpty() && statMatches(record) ? digest : QByteArray{};
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray sampleSha256(const FileRecord &record, const std::atomic_bool *cancelled)
|
||||||
|
{
|
||||||
|
if (isCancelled(cancelled) || record.size <= duplicateSampleThreshold
|
||||||
|
|| !statMatches(record))
|
||||||
|
return {};
|
||||||
|
|
||||||
|
QFile file(record.path);
|
||||||
|
if (!file.open(QIODevice::ReadOnly))
|
||||||
|
return {};
|
||||||
|
|
||||||
|
const qint64 middleOffset = (record.size - duplicateSampleBlockSize) / 2;
|
||||||
|
const std::array<qint64, 3> offsets{
|
||||||
|
0, middleOffset, record.size - duplicateSampleBlockSize
|
||||||
|
};
|
||||||
|
QCryptographicHash hash(QCryptographicHash::Sha256);
|
||||||
|
for (const qint64 offset : offsets) {
|
||||||
|
if (isCancelled(cancelled) || !file.seek(offset))
|
||||||
|
return {};
|
||||||
|
const QByteArray block = file.read(duplicateSampleBlockSize);
|
||||||
|
if (block.size() != duplicateSampleBlockSize)
|
||||||
|
return {};
|
||||||
|
hash.addData(block);
|
||||||
|
}
|
||||||
|
const QByteArray digest = hash.result();
|
||||||
|
return !isCancelled(cancelled) && statMatches(record) ? digest : QByteArray{};
|
||||||
|
}
|
||||||
|
|
||||||
bool parseOptionalIsoDate(const QString &text, qint64 &seconds)
|
bool parseOptionalIsoDate(const QString &text, qint64 &seconds)
|
||||||
{
|
{
|
||||||
const QString trimmed = text.trimmed();
|
const QString trimmed = text.trimmed();
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
#include <optional>
|
#include <optional>
|
||||||
#include <sys/types.h>
|
#include <sys/types.h>
|
||||||
|
|
||||||
|
inline constexpr qint64 duplicateSampleBlockSize = 64LL * 1024;
|
||||||
|
inline constexpr qint64 duplicateSampleThreshold = 4LL * 1024 * 1024;
|
||||||
|
|
||||||
struct SearchOptions {
|
struct SearchOptions {
|
||||||
QString roots = QDir::homePath();
|
QString roots = QDir::homePath();
|
||||||
QString fileWildcards = QStringLiteral("*");
|
QString fileWildcards = QStringLiteral("*");
|
||||||
@@ -41,6 +44,7 @@ struct SearchOptions {
|
|||||||
QString mode = QStringLiteral("Standard search");
|
QString mode = QStringLiteral("Standard search");
|
||||||
QString duplicateNameMode = QStringLiteral("All files and folders");
|
QString duplicateNameMode = QStringLiteral("All files and folders");
|
||||||
bool duplicateNameWithoutExtension = false;
|
bool duplicateNameWithoutExtension = false;
|
||||||
|
bool strictDuplicateComparison = false;
|
||||||
bool showDuplicateCopiesOnly = true;
|
bool showDuplicateCopiesOnly = true;
|
||||||
bool includeSubfoldersInSummary = false;
|
bool includeSubfoldersInSummary = false;
|
||||||
QString hidden = QStringLiteral("Any");
|
QString hidden = QStringLiteral("Any");
|
||||||
@@ -60,6 +64,9 @@ struct FileRecord {
|
|||||||
qint64 created = 0;
|
qint64 created = 0;
|
||||||
qint64 accessed = 0;
|
qint64 accessed = 0;
|
||||||
qint64 changed = 0;
|
qint64 changed = 0;
|
||||||
|
qint64 modifiedNs = 0;
|
||||||
|
quint64 device = 0;
|
||||||
|
quint64 inode = 0;
|
||||||
QString type;
|
QString type;
|
||||||
QString owner;
|
QString owner;
|
||||||
QString attributes;
|
QString attributes;
|
||||||
@@ -67,6 +74,12 @@ struct FileRecord {
|
|||||||
int duplicateCopy = 0;
|
int duplicateCopy = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct EffectiveRoots {
|
||||||
|
QStringList paths;
|
||||||
|
QStringList priorityPaths;
|
||||||
|
int skipped = 0;
|
||||||
|
};
|
||||||
|
|
||||||
Q_DECLARE_METATYPE(SearchOptions)
|
Q_DECLARE_METATYPE(SearchOptions)
|
||||||
Q_DECLARE_METATYPE(FileRecord)
|
Q_DECLARE_METATYPE(FileRecord)
|
||||||
Q_DECLARE_METATYPE(QVector<FileRecord>)
|
Q_DECLARE_METATYPE(QVector<FileRecord>)
|
||||||
@@ -74,6 +87,8 @@ Q_DECLARE_METATYPE(QVector<FileRecord>)
|
|||||||
QStringList patterns(const QString &text);
|
QStringList patterns(const QString &text);
|
||||||
QStringList exclusionPatterns(const QString &text);
|
QStringList exclusionPatterns(const QString &text);
|
||||||
bool wildcardMatch(const QString &value, const QStringList &items, Qt::CaseSensitivity cs);
|
bool wildcardMatch(const QString &value, const QStringList &items, Qt::CaseSensitivity cs);
|
||||||
|
EffectiveRoots effectiveSearchRoots(const SearchOptions &options);
|
||||||
|
int preferredRootIndex(const QString &path, const QStringList &roots);
|
||||||
QString humanSize(qint64 bytes);
|
QString humanSize(qint64 bytes);
|
||||||
QString ownerName(uid_t uid);
|
QString ownerName(uid_t uid);
|
||||||
std::optional<QByteArray> hexNeedle(QString text);
|
std::optional<QByteArray> hexNeedle(QString text);
|
||||||
@@ -82,5 +97,9 @@ bool fileContains(const QString &path, const SearchOptions &options,
|
|||||||
bool filesEqual(const QString &leftPath, const QString &rightPath,
|
bool filesEqual(const QString &leftPath, const QString &rightPath,
|
||||||
const std::atomic_bool *cancelled = nullptr);
|
const std::atomic_bool *cancelled = nullptr);
|
||||||
QByteArray sha256(const QString &path, const std::atomic_bool *cancelled = nullptr);
|
QByteArray sha256(const QString &path, const std::atomic_bool *cancelled = nullptr);
|
||||||
|
QByteArray sha256(const FileRecord &record, const std::atomic_bool *cancelled = nullptr);
|
||||||
|
QByteArray sampleSha256(const FileRecord &record,
|
||||||
|
const std::atomic_bool *cancelled = nullptr);
|
||||||
|
bool fileSignatureMatches(const FileRecord &record);
|
||||||
bool parseOptionalIsoDate(const QString &text, qint64 &seconds);
|
bool parseOptionalIsoDate(const QString &text, qint64 &seconds);
|
||||||
bool shouldShowDuplicateResult(bool copiesOnly, int keeperPriority);
|
bool shouldShowDuplicateResult(bool copiesOnly, int keeperPriority);
|
||||||
|
|||||||
@@ -0,0 +1,596 @@
|
|||||||
|
#include "search_engine.h"
|
||||||
|
|
||||||
|
#include <QCryptographicHash>
|
||||||
|
#include <QDataStream>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QThread>
|
||||||
|
#include <QThreadPool>
|
||||||
|
#include <QtConcurrent>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <functional>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
struct HashedRecord {
|
||||||
|
QByteArray digest;
|
||||||
|
FileRecord record;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool keepFirst(const FileRecord &left, const FileRecord &right,
|
||||||
|
const QStringList &priorityRoots)
|
||||||
|
{
|
||||||
|
const int leftRoot = preferredRootIndex(left.path, priorityRoots);
|
||||||
|
const int rightRoot = preferredRootIndex(right.path, priorityRoots);
|
||||||
|
const int leftPriority = leftRoot < 0 ? priorityRoots.size() : leftRoot;
|
||||||
|
const int rightPriority = rightRoot < 0 ? priorityRoots.size() : rightRoot;
|
||||||
|
if (leftPriority != rightPriority)
|
||||||
|
return leftPriority < rightPriority;
|
||||||
|
if (left.modified != right.modified)
|
||||||
|
return left.modified > right.modified;
|
||||||
|
return left.path < right.path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SearchEngine::search(const SearchOptions &o)
|
||||||
|
{
|
||||||
|
cancelled_.store(false, std::memory_order_relaxed);
|
||||||
|
const auto finishIfCancelled = [this] {
|
||||||
|
if (!cancelled_.load(std::memory_order_relaxed))
|
||||||
|
return false;
|
||||||
|
emit finished({}, tr("Search stopped"));
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
cancelledByLimit_ = false;
|
||||||
|
cache_.setEnabled(o.useCache);
|
||||||
|
cacheHits_.store(0, std::memory_order_relaxed);
|
||||||
|
cacheMisses_.store(0, std::memory_order_relaxed);
|
||||||
|
const auto started = std::chrono::steady_clock::now();
|
||||||
|
QVector<FileRecord> candidates;
|
||||||
|
QSet<QString> candidatePaths;
|
||||||
|
const EffectiveRoots roots = effectiveSearchRoots(o);
|
||||||
|
const Qt::CaseSensitivity cs = o.caseSensitive ? Qt::CaseSensitive : Qt::CaseInsensitive;
|
||||||
|
const QStringList fileMasks = patterns(o.fileWildcards).isEmpty()
|
||||||
|
? QStringList{QStringLiteral("*")} : patterns(o.fileWildcards);
|
||||||
|
const QStringList subfolderMasks = patterns(o.subfolderWildcards).isEmpty()
|
||||||
|
? QStringList{QStringLiteral("*")} : patterns(o.subfolderWildcards);
|
||||||
|
const QStringList excludedFiles = exclusionPatterns(o.excludeFiles);
|
||||||
|
const QStringList excludedFolders = patterns(o.excludeFolders);
|
||||||
|
const QStringList includedFolders = patterns(o.includeFolders);
|
||||||
|
quint64 scanned = 0;
|
||||||
|
quint64 totalEntries = 0;
|
||||||
|
|
||||||
|
if (o.accurateProgress) {
|
||||||
|
emit phaseProgress(tr("Counting files"), 0, 0);
|
||||||
|
for (const QString &rootText : roots.paths) {
|
||||||
|
if (cancelled_.load(std::memory_order_relaxed))
|
||||||
|
break;
|
||||||
|
std::error_code ec;
|
||||||
|
const fs::path root = fs::path(rootText.toStdString());
|
||||||
|
fs::directory_options dirOptions = fs::directory_options::skip_permission_denied;
|
||||||
|
if (o.followLinks)
|
||||||
|
dirOptions |= fs::directory_options::follow_directory_symlink;
|
||||||
|
if (!o.recursive) {
|
||||||
|
for (fs::directory_iterator it(root, dirOptions, ec), end;
|
||||||
|
it != end && !ec && !cancelled_.load(std::memory_order_relaxed);
|
||||||
|
it.increment(ec)) {
|
||||||
|
++totalEntries;
|
||||||
|
if (totalEntries % 2048 == 0)
|
||||||
|
emit phaseProgress(tr("Counting files"), totalEntries, 0);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (fs::recursive_directory_iterator it(root, dirOptions, ec), end;
|
||||||
|
it != end && !ec && !cancelled_.load(std::memory_order_relaxed); it.increment(ec)) {
|
||||||
|
if (it->is_directory(ec)) {
|
||||||
|
const QString name = QString::fromStdString(it->path().filename().string());
|
||||||
|
const QString fullPath = QString::fromStdString(it->path().string());
|
||||||
|
if ((!excludedFolders.isEmpty()
|
||||||
|
&& (wildcardMatch(name, excludedFolders, cs)
|
||||||
|
|| wildcardMatch(fullPath, excludedFolders, cs)))
|
||||||
|
|| (o.maxDepth > 0 && it.depth() >= o.maxDepth - 1)) {
|
||||||
|
it.disable_recursion_pending();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
++totalEntries;
|
||||||
|
if (totalEntries % 2048 == 0)
|
||||||
|
emit phaseProgress(tr("Counting files"), totalEntries, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit phaseProgress(tr("Scanning files"), 0, totalEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const QString &rootText : roots.paths) {
|
||||||
|
if (cancelled_.load(std::memory_order_relaxed))
|
||||||
|
break;
|
||||||
|
std::error_code ec;
|
||||||
|
fs::path root = fs::path(rootText.toStdString());
|
||||||
|
fs::directory_options dirOptions = fs::directory_options::skip_permission_denied;
|
||||||
|
if (o.followLinks)
|
||||||
|
dirOptions |= fs::directory_options::follow_directory_symlink;
|
||||||
|
|
||||||
|
auto process = [&](const fs::directory_entry &entry, const fs::path &baseRoot) {
|
||||||
|
if (cancelled_.load(std::memory_order_relaxed))
|
||||||
|
return;
|
||||||
|
const QString path = QDir::cleanPath(QFileInfo(
|
||||||
|
QString::fromStdString(entry.path().string())).absoluteFilePath());
|
||||||
|
const QString name = QString::fromStdString(entry.path().filename().string());
|
||||||
|
const bool isDir = entry.is_directory(ec);
|
||||||
|
if ((isDir && !o.findFolders) || (!isDir && !o.findFiles))
|
||||||
|
return;
|
||||||
|
const fs::path parentPath = entry.path().parent_path();
|
||||||
|
const bool isBaseFolder = parentPath == baseRoot;
|
||||||
|
const QString parentName = QString::fromStdString(parentPath.filename().string());
|
||||||
|
if (!isBaseFolder && !wildcardMatch(parentName, subfolderMasks, cs))
|
||||||
|
return;
|
||||||
|
if (!wildcardMatch(name, fileMasks, cs))
|
||||||
|
return;
|
||||||
|
if (!excludedFiles.isEmpty() && wildcardMatch(name, excludedFiles, cs))
|
||||||
|
return;
|
||||||
|
if (!includedFolders.isEmpty()) {
|
||||||
|
const QString folderPath = isDir ? path : QFileInfo(path).absolutePath();
|
||||||
|
const QString folderName = isDir ? name : QFileInfo(path).dir().dirName();
|
||||||
|
if (!wildcardMatch(folderPath, includedFolders, cs)
|
||||||
|
&& !wildcardMatch(folderName, includedFolders, cs))
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct stat st {};
|
||||||
|
const QByteArray nativePath = QFile::encodeName(path);
|
||||||
|
if (::stat(nativePath.constData(), &st) != 0)
|
||||||
|
return;
|
||||||
|
const qint64 size = isDir ? 0 : st.st_size;
|
||||||
|
if (o.minSize > 0 && size < o.minSize)
|
||||||
|
return;
|
||||||
|
if (o.maxSize > 0 && size > o.maxSize)
|
||||||
|
return;
|
||||||
|
const qint64 selectedTime = o.timeField == QStringLiteral("Accessed time")
|
||||||
|
? st.st_atime
|
||||||
|
: o.timeField == QStringLiteral("Metadata changed time") ? st.st_ctime : st.st_mtime;
|
||||||
|
const qint64 now = QDateTime::currentSecsSinceEpoch();
|
||||||
|
if (o.lastMinutes > 0 && selectedTime < now - qint64(o.lastMinutes) * 60)
|
||||||
|
return;
|
||||||
|
if (o.minTime > 0 && selectedTime < o.minTime)
|
||||||
|
return;
|
||||||
|
if (o.maxTime > 0 && selectedTime > o.maxTime)
|
||||||
|
return;
|
||||||
|
const bool hidden = name.startsWith(u'.');
|
||||||
|
const bool readOnly = !(st.st_mode & S_IWUSR);
|
||||||
|
const bool executable = st.st_mode & S_IXUSR;
|
||||||
|
const auto attrMatches = [](const QString &wanted, bool actual) {
|
||||||
|
return wanted == QStringLiteral("Any") || (wanted == QStringLiteral("Yes")) == actual;
|
||||||
|
};
|
||||||
|
if (!attrMatches(o.hidden, hidden)
|
||||||
|
|| !attrMatches(o.readonly, readOnly)
|
||||||
|
|| !attrMatches(o.executable, executable))
|
||||||
|
return;
|
||||||
|
if (candidatePaths.contains(path))
|
||||||
|
return;
|
||||||
|
candidatePaths.insert(path);
|
||||||
|
|
||||||
|
QString attrs;
|
||||||
|
attrs += isDir ? u'd' : u'-';
|
||||||
|
attrs += hidden ? u'h' : u'-';
|
||||||
|
attrs += readOnly ? u'r' : u'-';
|
||||||
|
attrs += executable ? u'x' : u'-';
|
||||||
|
attrs += entry.is_symlink(ec) ? u'l' : u'-';
|
||||||
|
candidates.push_back({
|
||||||
|
path, name, QFileInfo(path).absolutePath(), size,
|
||||||
|
isDir ? 0 : qint64(st.st_blocks) * 512,
|
||||||
|
st.st_mtime,
|
||||||
|
QFileInfo(path).birthTime().isValid() ? QFileInfo(path).birthTime().toSecsSinceEpoch() : 0,
|
||||||
|
st.st_atime, st.st_ctime,
|
||||||
|
qint64(st.st_mtim.tv_sec) * 1'000'000'000LL + st.st_mtim.tv_nsec,
|
||||||
|
quint64(st.st_dev), quint64(st.st_ino),
|
||||||
|
isDir ? QStringLiteral("Folder") : QStringLiteral("File"),
|
||||||
|
o.retrieveOwner ? ownerName(st.st_uid) : QString{}, attrs, 0, 0
|
||||||
|
});
|
||||||
|
if (o.maxResults > 0 && o.contains.isEmpty() && candidates.size() >= o.maxResults)
|
||||||
|
cancelledByLimit_ = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!o.recursive) {
|
||||||
|
for (fs::directory_iterator it(root, dirOptions, ec), end;
|
||||||
|
it != end && !ec && !cancelled_.load(std::memory_order_relaxed)
|
||||||
|
&& !cancelledByLimit_;
|
||||||
|
it.increment(ec)) {
|
||||||
|
process(*it, root);
|
||||||
|
if (++scanned % 512 == 0) {
|
||||||
|
if (totalEntries)
|
||||||
|
emit phaseProgress(tr("Scanning files"), scanned, totalEntries);
|
||||||
|
else
|
||||||
|
emit progress(QString::fromStdString(it->path().string()), scanned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (fs::recursive_directory_iterator it(root, dirOptions, ec), end;
|
||||||
|
it != end && !ec && !cancelled_.load(std::memory_order_relaxed)
|
||||||
|
&& !cancelledByLimit_; it.increment(ec)) {
|
||||||
|
const QString name = QString::fromStdString(it->path().filename().string());
|
||||||
|
if (it->is_directory(ec)) {
|
||||||
|
const QString fullPath = QString::fromStdString(it->path().string());
|
||||||
|
if (!excludedFolders.isEmpty()
|
||||||
|
&& (wildcardMatch(name, excludedFolders, cs)
|
||||||
|
|| wildcardMatch(fullPath, excludedFolders, cs))) {
|
||||||
|
it.disable_recursion_pending();
|
||||||
|
}
|
||||||
|
if (o.maxDepth > 0 && it.depth() >= o.maxDepth - 1)
|
||||||
|
it.disable_recursion_pending();
|
||||||
|
}
|
||||||
|
process(*it, root);
|
||||||
|
if (++scanned % 512 == 0) {
|
||||||
|
if (totalEntries)
|
||||||
|
emit phaseProgress(tr("Scanning files"), scanned, totalEntries);
|
||||||
|
else
|
||||||
|
emit progress(QString::fromStdString(it->path().string()), scanned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cancelledByLimit_)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalEntries)
|
||||||
|
emit phaseProgress(tr("Scanning files"), std::min(scanned, totalEntries), totalEntries);
|
||||||
|
|
||||||
|
if (finishIfCancelled())
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!o.contains.isEmpty() && !o.findFolders) {
|
||||||
|
emit phaseProgress(tr("Searching file contents"), 0, candidates.size());
|
||||||
|
std::atomic<qsizetype> checked = 0;
|
||||||
|
const qsizetype total = candidates.size();
|
||||||
|
QFuture<FileRecord> future = QtConcurrent::filtered(
|
||||||
|
candidates, [this, o, &checked, total](const FileRecord &r) {
|
||||||
|
if (cancelled_.load(std::memory_order_relaxed))
|
||||||
|
return false;
|
||||||
|
const bool matched = cachedFileContains(r, o);
|
||||||
|
const qsizetype done = checked.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||||
|
if (done == total || done % 32 == 0)
|
||||||
|
emit phaseProgress(tr("Searching file contents"), done, total);
|
||||||
|
return matched;
|
||||||
|
});
|
||||||
|
future.waitForFinished();
|
||||||
|
if (finishIfCancelled())
|
||||||
|
return;
|
||||||
|
candidates = future.results();
|
||||||
|
if (o.maxResults > 0 && candidates.size() > o.maxResults)
|
||||||
|
candidates.resize(o.maxResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
QThreadPool hashPool;
|
||||||
|
const int idealThreads = QThread::idealThreadCount();
|
||||||
|
hashPool.setMaxThreadCount(std::clamp(
|
||||||
|
idealThreads > 0 ? idealThreads / 2 : 1, 1, 4));
|
||||||
|
|
||||||
|
QVector<FileRecord> results;
|
||||||
|
if (o.mode == QStringLiteral("Duplicates search")
|
||||||
|
|| o.mode == QStringLiteral("Non-Duplicates search")) {
|
||||||
|
std::unordered_map<qint64, QVector<FileRecord>> bySize;
|
||||||
|
QSet<QString> duplicateInputPaths;
|
||||||
|
for (const auto &record : candidates) {
|
||||||
|
if (record.type == QStringLiteral("File")
|
||||||
|
&& !duplicateInputPaths.contains(record.path)) {
|
||||||
|
duplicateInputPaths.insert(record.path);
|
||||||
|
bySize[record.size].push_back(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QVector<FileRecord> sampleCandidates;
|
||||||
|
QVector<FileRecord> fullHashCandidates;
|
||||||
|
for (const auto &[size, records] : bySize) {
|
||||||
|
if (records.size() < 2)
|
||||||
|
continue;
|
||||||
|
if (size > duplicateSampleThreshold)
|
||||||
|
sampleCandidates += records;
|
||||||
|
else
|
||||||
|
fullHashCandidates += records;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::atomic<qsizetype> sampled = 0;
|
||||||
|
const qsizetype sampleTotal = sampleCandidates.size();
|
||||||
|
if (sampleTotal)
|
||||||
|
emit phaseProgress(tr("Sampling duplicate candidates"), 0, sampleTotal);
|
||||||
|
const auto samples = QtConcurrent::blockingMapped(
|
||||||
|
&hashPool, sampleCandidates,
|
||||||
|
[this, &sampled, sampleTotal](const FileRecord &record) {
|
||||||
|
HashedRecord result{cachedSampleSha256(record), record};
|
||||||
|
const qsizetype done = sampled.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||||
|
if (done == sampleTotal || done % 16 == 0)
|
||||||
|
emit phaseProgress(tr("Sampling duplicate candidates"), done, sampleTotal);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
if (finishIfCancelled())
|
||||||
|
return;
|
||||||
|
|
||||||
|
QHash<qint64, QHash<QByteArray, QVector<FileRecord>>> bySample;
|
||||||
|
for (const auto &sample : samples)
|
||||||
|
if (!sample.digest.isEmpty())
|
||||||
|
bySample[sample.record.size][sample.digest].push_back(sample.record);
|
||||||
|
for (const auto &sameSize : bySample)
|
||||||
|
for (const auto &sameSample : sameSize)
|
||||||
|
if (sameSample.size() > 1)
|
||||||
|
fullHashCandidates += sameSample;
|
||||||
|
|
||||||
|
std::atomic<qsizetype> hashed = 0;
|
||||||
|
const qsizetype hashTotal = fullHashCandidates.size();
|
||||||
|
if (hashTotal)
|
||||||
|
emit phaseProgress(tr("Hashing duplicate candidates"), 0, hashTotal);
|
||||||
|
const auto hashes = QtConcurrent::blockingMapped(
|
||||||
|
&hashPool, fullHashCandidates,
|
||||||
|
[this, &hashed, hashTotal](const FileRecord &record) {
|
||||||
|
HashedRecord result{cachedSha256(record), record};
|
||||||
|
const qsizetype done = hashed.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||||
|
if (done == hashTotal || done % 16 == 0)
|
||||||
|
emit phaseProgress(tr("Hashing duplicate candidates"), done, hashTotal);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
if (finishIfCancelled())
|
||||||
|
return;
|
||||||
|
|
||||||
|
QHash<qint64, QHash<QByteArray, QVector<FileRecord>>> byHash;
|
||||||
|
for (const auto &hash : hashes)
|
||||||
|
if (!hash.digest.isEmpty())
|
||||||
|
byHash[hash.record.size][hash.digest].push_back(hash.record);
|
||||||
|
|
||||||
|
int group = 1;
|
||||||
|
QSet<QString> duplicatedPaths;
|
||||||
|
for (const auto &sameSize : byHash) {
|
||||||
|
for (const auto &hashCandidates : sameSize) {
|
||||||
|
if (cancelled_.load(std::memory_order_relaxed))
|
||||||
|
break;
|
||||||
|
if (hashCandidates.size() < 2)
|
||||||
|
continue;
|
||||||
|
QVector<QVector<FileRecord>> exactGroups;
|
||||||
|
if (!o.strictDuplicateComparison) {
|
||||||
|
exactGroups.push_back(hashCandidates);
|
||||||
|
} else {
|
||||||
|
for (const auto &record : hashCandidates) {
|
||||||
|
if (cancelled_.load(std::memory_order_relaxed))
|
||||||
|
break;
|
||||||
|
bool placed = false;
|
||||||
|
for (auto &exact : exactGroups) {
|
||||||
|
if (fileSignatureMatches(exact.front())
|
||||||
|
&& fileSignatureMatches(record)
|
||||||
|
&& filesEqual(exact.front().path, record.path, &cancelled_)
|
||||||
|
&& fileSignatureMatches(exact.front())
|
||||||
|
&& fileSignatureMatches(record)) {
|
||||||
|
exact.push_back(record);
|
||||||
|
placed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!placed)
|
||||||
|
exactGroups.push_back({record});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (auto &exact : exactGroups) {
|
||||||
|
if (cancelled_.load(std::memory_order_relaxed))
|
||||||
|
break;
|
||||||
|
if (exact.size() < 2)
|
||||||
|
continue;
|
||||||
|
std::stable_sort(exact.begin(), exact.end(),
|
||||||
|
[&roots](const FileRecord &left,
|
||||||
|
const FileRecord &right) {
|
||||||
|
return keepFirst(left, right, roots.priorityPaths);
|
||||||
|
});
|
||||||
|
int duplicateCopy = 1;
|
||||||
|
for (auto &record : exact) {
|
||||||
|
record.group = group;
|
||||||
|
record.duplicateCopy = duplicateCopy++;
|
||||||
|
duplicatedPaths.insert(record.path);
|
||||||
|
if (o.mode == QStringLiteral("Duplicates search")
|
||||||
|
&& shouldShowDuplicateResult(
|
||||||
|
o.showDuplicateCopiesOnly, record.duplicateCopy)) {
|
||||||
|
results.push_back(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
++group;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (finishIfCancelled())
|
||||||
|
return;
|
||||||
|
if (o.mode == QStringLiteral("Non-Duplicates search")) {
|
||||||
|
for (const auto &record : candidates)
|
||||||
|
if (record.type == QStringLiteral("File") && !duplicatedPaths.contains(record.path))
|
||||||
|
results.push_back(record);
|
||||||
|
}
|
||||||
|
} else if (o.mode == QStringLiteral("Duplicate names search")) {
|
||||||
|
QHash<QString, QVector<FileRecord>> byName;
|
||||||
|
for (const auto &record : candidates) {
|
||||||
|
const QString key = o.duplicateNameWithoutExtension
|
||||||
|
? QFileInfo(record.name).completeBaseName().toCaseFolded()
|
||||||
|
: record.name.toCaseFolded();
|
||||||
|
byName[key].push_back(record);
|
||||||
|
}
|
||||||
|
int group = 1;
|
||||||
|
quint64 namesDone = 0;
|
||||||
|
emit phaseProgress(tr("Comparing duplicate names"), 0, byName.size());
|
||||||
|
for (auto sameName : byName) {
|
||||||
|
if (cancelled_.load(std::memory_order_relaxed))
|
||||||
|
break;
|
||||||
|
++namesDone;
|
||||||
|
if (namesDone % 32 == 0 || namesDone == quint64(byName.size()))
|
||||||
|
emit phaseProgress(tr("Comparing duplicate names"), namesDone, byName.size());
|
||||||
|
if (o.duplicateNameMode != QStringLiteral("All files and folders"))
|
||||||
|
sameName.erase(std::remove_if(sameName.begin(), sameName.end(),
|
||||||
|
[](const FileRecord &r) { return r.type != QStringLiteral("File"); }), sameName.end());
|
||||||
|
if (sameName.size() < 2)
|
||||||
|
continue;
|
||||||
|
std::stable_sort(sameName.begin(), sameName.end(),
|
||||||
|
[&roots](const FileRecord &left,
|
||||||
|
const FileRecord &right) {
|
||||||
|
return keepFirst(left, right, roots.priorityPaths);
|
||||||
|
});
|
||||||
|
if (o.duplicateNameMode.contains(QStringLiteral("identical"), Qt::CaseInsensitive)) {
|
||||||
|
bool identical = std::all_of(
|
||||||
|
sameName.cbegin(), sameName.cend(),
|
||||||
|
[&sameName](const FileRecord &record) {
|
||||||
|
return record.size == sameName.front().size;
|
||||||
|
});
|
||||||
|
if (identical && sameName.front().size > duplicateSampleThreshold) {
|
||||||
|
const auto samples = QtConcurrent::blockingMapped(
|
||||||
|
&hashPool, sameName, [this](const FileRecord &record) {
|
||||||
|
return cachedSampleSha256(record);
|
||||||
|
});
|
||||||
|
identical = !samples.isEmpty() && !samples.front().isEmpty()
|
||||||
|
&& std::all_of(samples.cbegin(), samples.cend(),
|
||||||
|
[&samples](const QByteArray &digest) {
|
||||||
|
return digest == samples.front();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
QVector<QByteArray> hashes;
|
||||||
|
if (identical) {
|
||||||
|
hashes = QtConcurrent::blockingMapped(
|
||||||
|
&hashPool, sameName, [this](const FileRecord &record) {
|
||||||
|
return cachedSha256(record);
|
||||||
|
});
|
||||||
|
identical = !hashes.isEmpty() && !hashes.front().isEmpty()
|
||||||
|
&& std::all_of(hashes.cbegin(), hashes.cend(),
|
||||||
|
[&hashes](const QByteArray &digest) {
|
||||||
|
return digest == hashes.front();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (identical && o.strictDuplicateComparison) {
|
||||||
|
const FileRecord &first = sameName.front();
|
||||||
|
for (qsizetype index = 1; identical && index < sameName.size(); ++index) {
|
||||||
|
const FileRecord &record = sameName[index];
|
||||||
|
identical = fileSignatureMatches(first)
|
||||||
|
&& fileSignatureMatches(record)
|
||||||
|
&& filesEqual(first.path, record.path, &cancelled_)
|
||||||
|
&& fileSignatureMatches(first)
|
||||||
|
&& fileSignatureMatches(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const bool wantsNonIdentical = o.duplicateNameMode.startsWith(QStringLiteral("Only non"));
|
||||||
|
if (identical == wantsNonIdentical)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int duplicateCopy = 1;
|
||||||
|
for (auto &record : sameName) {
|
||||||
|
record.group = group;
|
||||||
|
record.duplicateCopy = duplicateCopy++;
|
||||||
|
results.push_back(record);
|
||||||
|
}
|
||||||
|
++group;
|
||||||
|
}
|
||||||
|
if (finishIfCancelled())
|
||||||
|
return;
|
||||||
|
} else if (o.mode == QStringLiteral("Summary")) {
|
||||||
|
QHash<QString, QVector<FileRecord>> byFolder;
|
||||||
|
for (const auto &record : candidates) {
|
||||||
|
if (cancelled_.load(std::memory_order_relaxed))
|
||||||
|
break;
|
||||||
|
byFolder[record.folder].push_back(record);
|
||||||
|
if (o.includeSubfoldersInSummary) {
|
||||||
|
QDir parent(record.folder);
|
||||||
|
while (parent.cdUp()) {
|
||||||
|
const QString ancestor = parent.absolutePath();
|
||||||
|
bool belongsToRoot = false;
|
||||||
|
for (const QString &root : roots.paths) {
|
||||||
|
if (ancestor == QDir(root).absolutePath()) {
|
||||||
|
belongsToRoot = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
byFolder[ancestor].push_back(record);
|
||||||
|
if (belongsToRoot)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (auto it = byFolder.cbegin(); it != byFolder.cend(); ++it) {
|
||||||
|
if (cancelled_.load(std::memory_order_relaxed))
|
||||||
|
break;
|
||||||
|
qint64 total = 0;
|
||||||
|
qint64 totalOnDisk = 0;
|
||||||
|
qint64 newest = 0;
|
||||||
|
for (const auto &record : it.value()) {
|
||||||
|
total += record.size;
|
||||||
|
totalOnDisk += record.sizeOnDisk;
|
||||||
|
newest = std::max(newest, record.modified);
|
||||||
|
}
|
||||||
|
const QFileInfo info(it.key());
|
||||||
|
results.push_back({it.key(), info.fileName(), info.absolutePath(), total, totalOnDisk,
|
||||||
|
newest, 0, 0, 0, 0, 0, 0,
|
||||||
|
tr("%1 files").arg(it.value().size()), {}, {}, 0, 0});
|
||||||
|
}
|
||||||
|
if (finishIfCancelled())
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
results = std::move(candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finishIfCancelled())
|
||||||
|
return;
|
||||||
|
const double seconds = std::chrono::duration<double>(
|
||||||
|
std::chrono::steady_clock::now() - started).count();
|
||||||
|
cache_.save();
|
||||||
|
QString message = tr("%1 results, %2 entries scanned in %3 seconds; cache: %4 hits")
|
||||||
|
.arg(results.size()).arg(scanned).arg(seconds, 0, 'f', 2)
|
||||||
|
.arg(cacheHits_.load(std::memory_order_relaxed));
|
||||||
|
if (roots.skipped > 0)
|
||||||
|
message += tr("; %1 redundant roots skipped").arg(roots.skipped);
|
||||||
|
emit finished(results, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray SearchEngine::cachedSampleSha256(const FileRecord &record)
|
||||||
|
{
|
||||||
|
QByteArray digest;
|
||||||
|
if (cache_.findSampleHash(record, digest)) {
|
||||||
|
if (!fileSignatureMatches(record))
|
||||||
|
return {};
|
||||||
|
cacheHits_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
return digest;
|
||||||
|
}
|
||||||
|
cacheMisses_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
digest = sampleSha256(record, &cancelled_);
|
||||||
|
if (!digest.isEmpty() && !cancelled_.load(std::memory_order_relaxed))
|
||||||
|
cache_.storeSampleHash(record, digest);
|
||||||
|
return digest;
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray SearchEngine::cachedSha256(const FileRecord &record)
|
||||||
|
{
|
||||||
|
QByteArray digest;
|
||||||
|
if (cache_.findHash(record, digest)) {
|
||||||
|
if (!fileSignatureMatches(record))
|
||||||
|
return {};
|
||||||
|
cacheHits_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
return digest;
|
||||||
|
}
|
||||||
|
cacheMisses_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
digest = sha256(record, &cancelled_);
|
||||||
|
if (!digest.isEmpty() && !cancelled_.load(std::memory_order_relaxed))
|
||||||
|
cache_.storeHash(record, digest);
|
||||||
|
return digest;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SearchEngine::cachedFileContains(const FileRecord &record, const SearchOptions &o)
|
||||||
|
{
|
||||||
|
QByteArray queryData;
|
||||||
|
QDataStream stream(&queryData, QIODevice::WriteOnly);
|
||||||
|
stream << o.contains << o.binary << o.multipleValues << o.multipleAnd << o.caseSensitive;
|
||||||
|
const QByteArray queryKey = QCryptographicHash::hash(queryData, QCryptographicHash::Sha256);
|
||||||
|
if (const auto cached = cache_.findContent(record, queryKey)) {
|
||||||
|
if (!fileSignatureMatches(record))
|
||||||
|
return false;
|
||||||
|
cacheHits_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
return *cached;
|
||||||
|
}
|
||||||
|
cacheMisses_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
const bool matched = fileContains(record.path, o, &cancelled_);
|
||||||
|
if (!cancelled_.load(std::memory_order_relaxed))
|
||||||
|
cache_.storeContent(record, queryKey, matched);
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QVector>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include "search_core.h"
|
||||||
|
#include "search_cache.h"
|
||||||
|
|
||||||
|
class SearchEngine final : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit SearchEngine(QObject *parent = nullptr, QString cachePath = {})
|
||||||
|
: QObject(parent), cache_(std::move(cachePath)) {}
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
void stop() { cancelled_.store(true, std::memory_order_relaxed); }
|
||||||
|
void clearCache()
|
||||||
|
{
|
||||||
|
cache_.clear();
|
||||||
|
emit cacheCleared();
|
||||||
|
}
|
||||||
|
|
||||||
|
void search(const SearchOptions &o);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void progress(const QString &path, quint64 scanned);
|
||||||
|
void phaseProgress(const QString &phase, quint64 completed, quint64 total);
|
||||||
|
void cacheCleared();
|
||||||
|
void finished(const QVector<FileRecord> &results, const QString &message);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QByteArray cachedSampleSha256(const FileRecord &record);
|
||||||
|
QByteArray cachedSha256(const FileRecord &record);
|
||||||
|
bool cachedFileContains(const FileRecord &record, const SearchOptions &o);
|
||||||
|
|
||||||
|
SearchCache cache_;
|
||||||
|
std::atomic<quint64> cacheHits_ = 0;
|
||||||
|
std::atomic<quint64> cacheMisses_ = 0;
|
||||||
|
std::atomic_bool cancelled_ = false;
|
||||||
|
bool cancelledByLimit_ = false;
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#include "options_dialog.h"
|
||||||
|
|
||||||
|
#include <QCheckBox>
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QtTest>
|
||||||
|
|
||||||
|
class OptionsDialogTest final : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void exposesStrictDuplicateComparison()
|
||||||
|
{
|
||||||
|
SearchOptions options;
|
||||||
|
QVERIFY(!options.strictDuplicateComparison);
|
||||||
|
options.strictDuplicateComparison = true;
|
||||||
|
|
||||||
|
OptionsDialog dialog(options);
|
||||||
|
auto *strict = dialog.findChild<QCheckBox *>(
|
||||||
|
QStringLiteral("strictDuplicateComparison"));
|
||||||
|
QVERIFY(strict);
|
||||||
|
QVERIFY(strict->isChecked());
|
||||||
|
QVERIFY(dialog.options().strictDuplicateComparison);
|
||||||
|
|
||||||
|
strict->setChecked(false);
|
||||||
|
QVERIFY(!dialog.options().strictDuplicateComparison);
|
||||||
|
}
|
||||||
|
|
||||||
|
void remembersInputHistory()
|
||||||
|
{
|
||||||
|
QSettings settings;
|
||||||
|
settings.remove(QStringLiteral("inputHistory"));
|
||||||
|
|
||||||
|
SearchOptions options;
|
||||||
|
OptionsDialog first(options);
|
||||||
|
auto *roots = first.findChild<QComboBox *>(QStringLiteral("historyroots"));
|
||||||
|
QVERIFY(roots);
|
||||||
|
roots->setEditText(QStringLiteral("/tmp/first;/tmp/second"));
|
||||||
|
QVERIFY(QMetaObject::invokeMethod(&first, "accept"));
|
||||||
|
|
||||||
|
QCOMPARE(settings.value(QStringLiteral("inputHistory/roots")).toStringList(),
|
||||||
|
QStringList{QStringLiteral("/tmp/first;/tmp/second")});
|
||||||
|
|
||||||
|
OptionsDialog second(options);
|
||||||
|
auto *history = second.findChild<QComboBox *>(QStringLiteral("historyroots"));
|
||||||
|
QVERIFY(history);
|
||||||
|
QCOMPARE(history->itemText(0), QStringLiteral("/tmp/first;/tmp/second"));
|
||||||
|
|
||||||
|
history->setEditText(QStringLiteral("/tmp/new"));
|
||||||
|
QVERIFY(QMetaObject::invokeMethod(&second, "accept"));
|
||||||
|
QCOMPARE(settings.value(QStringLiteral("inputHistory/roots")).toStringList(),
|
||||||
|
(QStringList{QStringLiteral("/tmp/new"),
|
||||||
|
QStringLiteral("/tmp/first;/tmp/second")}));
|
||||||
|
|
||||||
|
settings.remove(QStringLiteral("inputHistory"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
QTEST_MAIN(OptionsDialogTest)
|
||||||
|
|
||||||
|
#include "options_dialog_test.moc"
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
#include "search_cache.h"
|
||||||
|
|
||||||
|
#include <QDataStream>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QTemporaryDir>
|
||||||
|
#include <QtTest>
|
||||||
|
|
||||||
|
class SearchCacheTest final : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
private:
|
||||||
|
static FileRecord record()
|
||||||
|
{
|
||||||
|
FileRecord value;
|
||||||
|
value.path = QStringLiteral("/tmp/cache-test.bin");
|
||||||
|
value.size = 1234;
|
||||||
|
value.modifiedNs = 5678;
|
||||||
|
value.device = 9;
|
||||||
|
value.inode = 10;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void storesAndReloadsBothHashes()
|
||||||
|
{
|
||||||
|
QTemporaryDir directory;
|
||||||
|
QVERIFY(directory.isValid());
|
||||||
|
const QString path = directory.filePath(QStringLiteral("cache.dat"));
|
||||||
|
const FileRecord value = record();
|
||||||
|
const QByteArray sample("sample");
|
||||||
|
const QByteArray full("full");
|
||||||
|
const QByteArray query("query");
|
||||||
|
|
||||||
|
{
|
||||||
|
SearchCache cache(path);
|
||||||
|
cache.storeSampleHash(value, sample);
|
||||||
|
cache.storeHash(value, full);
|
||||||
|
cache.storeContent(value, query, true);
|
||||||
|
cache.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchCache cache(path);
|
||||||
|
QByteArray digest;
|
||||||
|
QVERIFY(cache.findSampleHash(value, digest));
|
||||||
|
QCOMPARE(digest, sample);
|
||||||
|
QVERIFY(cache.findHash(value, digest));
|
||||||
|
QCOMPARE(digest, full);
|
||||||
|
QCOMPARE(cache.findContent(value, query), std::optional<bool>(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
void invalidatesEverySignatureField()
|
||||||
|
{
|
||||||
|
QTemporaryDir directory;
|
||||||
|
QVERIFY(directory.isValid());
|
||||||
|
SearchCache cache(directory.filePath(QStringLiteral("cache.dat")));
|
||||||
|
const FileRecord value = record();
|
||||||
|
cache.storeSampleHash(value, QByteArray("sample"));
|
||||||
|
cache.storeHash(value, QByteArray("full"));
|
||||||
|
|
||||||
|
const auto misses = [&cache](FileRecord changed) {
|
||||||
|
QByteArray digest;
|
||||||
|
return !cache.findSampleHash(changed, digest)
|
||||||
|
&& !cache.findHash(changed, digest);
|
||||||
|
};
|
||||||
|
|
||||||
|
FileRecord changed = value;
|
||||||
|
++changed.size;
|
||||||
|
QVERIFY(misses(changed));
|
||||||
|
changed = value;
|
||||||
|
++changed.modifiedNs;
|
||||||
|
QVERIFY(misses(changed));
|
||||||
|
changed = value;
|
||||||
|
++changed.device;
|
||||||
|
QVERIFY(misses(changed));
|
||||||
|
changed = value;
|
||||||
|
++changed.inode;
|
||||||
|
QVERIFY(misses(changed));
|
||||||
|
}
|
||||||
|
|
||||||
|
void ignoresVersionOneData()
|
||||||
|
{
|
||||||
|
QTemporaryDir directory;
|
||||||
|
QVERIFY(directory.isValid());
|
||||||
|
const QString path = directory.filePath(QStringLiteral("cache.dat"));
|
||||||
|
QFile file(path);
|
||||||
|
QVERIFY(file.open(QIODevice::WriteOnly));
|
||||||
|
QDataStream stream(&file);
|
||||||
|
stream.setVersion(QDataStream::Qt_6_0);
|
||||||
|
stream << quint32(0x46534348) << quint32(1) << quint32(0);
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
SearchCache cache(path);
|
||||||
|
QByteArray digest;
|
||||||
|
QVERIFY(!cache.findHash(record(), digest));
|
||||||
|
cache.storeHash(record(), QByteArray("full"));
|
||||||
|
cache.save();
|
||||||
|
|
||||||
|
QFile saved(path);
|
||||||
|
QVERIFY(saved.open(QIODevice::ReadOnly));
|
||||||
|
QDataStream savedStream(&saved);
|
||||||
|
savedStream.setVersion(QDataStream::Qt_6_0);
|
||||||
|
quint32 magic = 0;
|
||||||
|
quint32 version = 0;
|
||||||
|
savedStream >> magic >> version;
|
||||||
|
QCOMPARE(magic, quint32(0x46534348));
|
||||||
|
QCOMPARE(version, quint32(2));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
QTEST_GUILESS_MAIN(SearchCacheTest)
|
||||||
|
|
||||||
|
#include "search_cache_test.moc"
|
||||||
@@ -5,6 +5,35 @@
|
|||||||
#include <QTemporaryDir>
|
#include <QTemporaryDir>
|
||||||
#include <QtTest>
|
#include <QtTest>
|
||||||
|
|
||||||
|
#include <sys/stat.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
FileRecord recordFor(const QString &path)
|
||||||
|
{
|
||||||
|
struct stat st {};
|
||||||
|
const QByteArray nativePath = QFile::encodeName(path);
|
||||||
|
if (::stat(nativePath.constData(), &st) != 0)
|
||||||
|
return {};
|
||||||
|
FileRecord record;
|
||||||
|
record.path = path;
|
||||||
|
record.size = st.st_size;
|
||||||
|
record.modified = st.st_mtime;
|
||||||
|
record.modifiedNs =
|
||||||
|
qint64(st.st_mtim.tv_sec) * 1'000'000'000LL + st.st_mtim.tv_nsec;
|
||||||
|
record.device = st.st_dev;
|
||||||
|
record.inode = st.st_ino;
|
||||||
|
record.type = QStringLiteral("File");
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool writeFile(const QString &path, const QByteArray &data)
|
||||||
|
{
|
||||||
|
QFile file(path);
|
||||||
|
return file.open(QIODevice::WriteOnly)
|
||||||
|
&& file.write(data) == data.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class SearchCoreTest final : public QObject {
|
class SearchCoreTest final : public QObject {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
@@ -23,6 +52,113 @@ private slots:
|
|||||||
Qt::CaseSensitive));
|
Qt::CaseSensitive));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void collapsesRedundantSearchRoots()
|
||||||
|
{
|
||||||
|
QTemporaryDir directory;
|
||||||
|
QVERIFY(directory.isValid());
|
||||||
|
QDir root(directory.path());
|
||||||
|
QVERIFY(root.mkpath(QStringLiteral("child")));
|
||||||
|
QVERIFY(root.mkpath(QStringLiteral("misc")));
|
||||||
|
|
||||||
|
SearchOptions options;
|
||||||
|
const QString child = directory.filePath(QStringLiteral("child"));
|
||||||
|
const QString misc = directory.filePath(QStringLiteral("misc"));
|
||||||
|
options.roots = QStringLiteral("%1;%2/;%1;%3")
|
||||||
|
.arg(child, directory.path(), misc);
|
||||||
|
options.excludeFolders = QStringLiteral("unrelated");
|
||||||
|
|
||||||
|
const EffectiveRoots effective = effectiveSearchRoots(options);
|
||||||
|
QCOMPARE(effective.paths, QStringList({directory.path()}));
|
||||||
|
QCOMPARE(effective.skipped, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
void preservesNestedRootsThatExpandCoverage()
|
||||||
|
{
|
||||||
|
QTemporaryDir directory;
|
||||||
|
QVERIFY(directory.isValid());
|
||||||
|
QDir root(directory.path());
|
||||||
|
QVERIFY(root.mkpath(QStringLiteral("child")));
|
||||||
|
const QString child = directory.filePath(QStringLiteral("child"));
|
||||||
|
|
||||||
|
SearchOptions options;
|
||||||
|
options.roots = directory.path() + u';' + child;
|
||||||
|
|
||||||
|
options.recursive = false;
|
||||||
|
QCOMPARE(effectiveSearchRoots(options).paths.size(), 2);
|
||||||
|
|
||||||
|
options.recursive = true;
|
||||||
|
options.maxDepth = 1;
|
||||||
|
QCOMPARE(effectiveSearchRoots(options).paths.size(), 2);
|
||||||
|
|
||||||
|
options.maxDepth = 0;
|
||||||
|
options.maxResults = 1;
|
||||||
|
QCOMPARE(effectiveSearchRoots(options).paths.size(), 2);
|
||||||
|
|
||||||
|
options.maxResults = 0;
|
||||||
|
options.subfolderWildcards = QStringLiteral("matched-*");
|
||||||
|
QCOMPARE(effectiveSearchRoots(options).paths.size(), 2);
|
||||||
|
|
||||||
|
options.subfolderWildcards = QStringLiteral("*");
|
||||||
|
options.excludeFolders = QStringLiteral("child");
|
||||||
|
QCOMPARE(effectiveSearchRoots(options).paths.size(), 2);
|
||||||
|
|
||||||
|
options.excludeFolders = QStringLiteral("unrelated");
|
||||||
|
QCOMPARE(effectiveSearchRoots(options).paths,
|
||||||
|
QStringList({directory.path()}));
|
||||||
|
}
|
||||||
|
|
||||||
|
void respectsSymlinksWhenCollapsingRoots()
|
||||||
|
{
|
||||||
|
QTemporaryDir directory;
|
||||||
|
QTemporaryDir external;
|
||||||
|
QVERIFY(directory.isValid());
|
||||||
|
QVERIFY(external.isValid());
|
||||||
|
QDir externalRoot(external.path());
|
||||||
|
QVERIFY(externalRoot.mkpath(QStringLiteral("child")));
|
||||||
|
const QString link = directory.filePath(QStringLiteral("link"));
|
||||||
|
QVERIFY(QFile::link(external.path(), link));
|
||||||
|
const QString linkedChild = QDir(link).filePath(QStringLiteral("child"));
|
||||||
|
|
||||||
|
SearchOptions options;
|
||||||
|
options.roots = directory.path() + u';' + linkedChild;
|
||||||
|
options.followLinks = false;
|
||||||
|
QCOMPARE(effectiveSearchRoots(options).paths.size(), 2);
|
||||||
|
|
||||||
|
options.followLinks = true;
|
||||||
|
QCOMPARE(effectiveSearchRoots(options).paths,
|
||||||
|
QStringList({directory.path()}));
|
||||||
|
|
||||||
|
options.roots = external.path() + u';' + link;
|
||||||
|
const EffectiveRoots aliases = effectiveSearchRoots(options);
|
||||||
|
QCOMPARE(aliases.paths.size(), 1);
|
||||||
|
QCOMPARE(aliases.skipped, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void prefersTheMostSpecificConfiguredRoot()
|
||||||
|
{
|
||||||
|
QTemporaryDir directory;
|
||||||
|
QVERIFY(directory.isValid());
|
||||||
|
QDir root(directory.path());
|
||||||
|
QVERIFY(root.mkpath(QStringLiteral("Temp")));
|
||||||
|
QVERIFY(root.mkpath(QStringLiteral("misc")));
|
||||||
|
const QString temp = directory.filePath(QStringLiteral("Temp"));
|
||||||
|
const QString misc = directory.filePath(QStringLiteral("misc"));
|
||||||
|
const QStringList roots{directory.path(), temp, misc};
|
||||||
|
|
||||||
|
QCOMPARE(preferredRootIndex(
|
||||||
|
directory.filePath(QStringLiteral("base.mp4")), roots), 0);
|
||||||
|
QCOMPARE(preferredRootIndex(
|
||||||
|
QDir(temp).filePath(QStringLiteral("copy.mp4")), roots), 1);
|
||||||
|
QCOMPARE(preferredRootIndex(
|
||||||
|
QDir(misc).filePath(QStringLiteral("copy.mp4")), roots), 2);
|
||||||
|
QCOMPARE(preferredRootIndex(
|
||||||
|
directory.filePath(QStringLiteral("unrelated/file.mp4")), roots), 0);
|
||||||
|
|
||||||
|
const QStringList similarRoots{directory.filePath(QStringLiteral("foo"))};
|
||||||
|
QCOMPARE(preferredRootIndex(
|
||||||
|
directory.filePath(QStringLiteral("foobar/file.mp4")), similarRoots), -1);
|
||||||
|
}
|
||||||
|
|
||||||
void parsesOptionalIsoDates()
|
void parsesOptionalIsoDates()
|
||||||
{
|
{
|
||||||
qint64 seconds = -1;
|
qint64 seconds = -1;
|
||||||
@@ -108,6 +244,72 @@ private slots:
|
|||||||
QVERIFY(!filesEqual(path, path, &cancelled));
|
QVERIFY(!filesEqual(path, path, &cancelled));
|
||||||
QVERIFY(sha256(path, &cancelled).isEmpty());
|
QVERIFY(sha256(path, &cancelled).isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void samplesLargeFilesAndValidatesSignatures()
|
||||||
|
{
|
||||||
|
QTemporaryDir directory;
|
||||||
|
QVERIFY(directory.isValid());
|
||||||
|
const QByteArray content(duplicateSampleThreshold + 1024 * 1024, 'x');
|
||||||
|
const QString firstPath = directory.filePath(QStringLiteral("first.bin"));
|
||||||
|
const QString samePath = directory.filePath(QStringLiteral("same.bin"));
|
||||||
|
const QString outsidePath = directory.filePath(QStringLiteral("outside.bin"));
|
||||||
|
const QString startPath = directory.filePath(QStringLiteral("start.bin"));
|
||||||
|
const QString middlePath = directory.filePath(QStringLiteral("middle.bin"));
|
||||||
|
const QString endPath = directory.filePath(QStringLiteral("end.bin"));
|
||||||
|
|
||||||
|
QVERIFY(writeFile(firstPath, content));
|
||||||
|
QVERIFY(writeFile(samePath, content));
|
||||||
|
|
||||||
|
QByteArray outside = content;
|
||||||
|
outside[1024 * 1024] = 'y';
|
||||||
|
QVERIFY(writeFile(outsidePath, outside));
|
||||||
|
QByteArray start = content;
|
||||||
|
start[0] = 'y';
|
||||||
|
QVERIFY(writeFile(startPath, start));
|
||||||
|
QByteArray middle = content;
|
||||||
|
middle[(content.size() - duplicateSampleBlockSize) / 2] = 'y';
|
||||||
|
QVERIFY(writeFile(middlePath, middle));
|
||||||
|
QByteArray end = content;
|
||||||
|
end[end.size() - 1] = 'y';
|
||||||
|
QVERIFY(writeFile(endPath, end));
|
||||||
|
|
||||||
|
const FileRecord first = recordFor(firstPath);
|
||||||
|
const QByteArray sample = sampleSha256(first);
|
||||||
|
QVERIFY(!sample.isEmpty());
|
||||||
|
QCOMPARE(sampleSha256(recordFor(samePath)), sample);
|
||||||
|
QCOMPARE(sampleSha256(recordFor(outsidePath)), sample);
|
||||||
|
QVERIFY(sampleSha256(recordFor(startPath)) != sample);
|
||||||
|
QVERIFY(sampleSha256(recordFor(middlePath)) != sample);
|
||||||
|
QVERIFY(sampleSha256(recordFor(endPath)) != sample);
|
||||||
|
QVERIFY(sha256(recordFor(outsidePath)) != sha256(first));
|
||||||
|
|
||||||
|
std::atomic_bool cancelled = true;
|
||||||
|
QVERIFY(sampleSha256(first, &cancelled).isEmpty());
|
||||||
|
|
||||||
|
const QString shortPath = directory.filePath(QStringLiteral("short.bin"));
|
||||||
|
QVERIFY(writeFile(shortPath, QByteArray("short")));
|
||||||
|
QVERIFY(sampleSha256(recordFor(shortPath)).isEmpty());
|
||||||
|
QVERIFY(!sha256(recordFor(shortPath)).isEmpty());
|
||||||
|
|
||||||
|
const QString emptyPath = directory.filePath(QStringLiteral("empty.bin"));
|
||||||
|
QVERIFY(writeFile(emptyPath, {}));
|
||||||
|
QVERIFY(sampleSha256(recordFor(emptyPath)).isEmpty());
|
||||||
|
QVERIFY(!sha256(recordFor(emptyPath)).isEmpty());
|
||||||
|
|
||||||
|
const QString replacedPath = directory.filePath(QStringLiteral("replaced.bin"));
|
||||||
|
QVERIFY(writeFile(replacedPath, content));
|
||||||
|
const FileRecord replaced = recordFor(replacedPath);
|
||||||
|
QVERIFY(QFile::remove(replacedPath));
|
||||||
|
QVERIFY(writeFile(replacedPath, content));
|
||||||
|
QVERIFY(!fileSignatureMatches(replaced));
|
||||||
|
QVERIFY(sha256(replaced).isEmpty());
|
||||||
|
QVERIFY(sampleSha256(replaced).isEmpty());
|
||||||
|
|
||||||
|
FileRecord missing = first;
|
||||||
|
missing.path = directory.filePath(QStringLiteral("missing.bin"));
|
||||||
|
QVERIFY(sha256(missing).isEmpty());
|
||||||
|
QVERIFY(sampleSha256(missing).isEmpty());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
QTEST_GUILESS_MAIN(SearchCoreTest)
|
QTEST_GUILESS_MAIN(SearchCoreTest)
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
#include "search_engine.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QElapsedTimer>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QSignalSpy>
|
||||||
|
#include <QTemporaryDir>
|
||||||
|
#include <QtTest>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
bool writeFile(const QString &path, const QByteArray &data)
|
||||||
|
{
|
||||||
|
QFile file(path);
|
||||||
|
return file.open(QIODevice::WriteOnly)
|
||||||
|
&& file.write(data) == data.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
QVector<FileRecord> runSearch(SearchEngine &engine, SearchOptions options)
|
||||||
|
{
|
||||||
|
QSignalSpy finished(&engine, &SearchEngine::finished);
|
||||||
|
engine.search(options);
|
||||||
|
if (finished.size() != 1)
|
||||||
|
return {};
|
||||||
|
return finished.takeFirst().at(0).value<QVector<FileRecord>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
QSet<QString> namesOf(const QVector<FileRecord> &records)
|
||||||
|
{
|
||||||
|
QSet<QString> names;
|
||||||
|
for (const auto &record : records)
|
||||||
|
names.insert(record.name);
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchOptions optionsFor(const QString &root, const QString &mode)
|
||||||
|
{
|
||||||
|
SearchOptions options;
|
||||||
|
options.roots = root;
|
||||||
|
options.mode = mode;
|
||||||
|
options.recursive = true;
|
||||||
|
options.accurateProgress = false;
|
||||||
|
options.showDuplicateCopiesOnly = false;
|
||||||
|
options.useCache = false;
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SearchEngineTest final : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void filtersBySampleThenFullHashAndSupportsStrictMode()
|
||||||
|
{
|
||||||
|
QTemporaryDir directory;
|
||||||
|
QVERIFY(directory.isValid());
|
||||||
|
QVERIFY(writeFile(directory.filePath(QStringLiteral("duplicate-a.bin")),
|
||||||
|
QByteArray("same content")));
|
||||||
|
QVERIFY(writeFile(directory.filePath(QStringLiteral("duplicate-b.bin")),
|
||||||
|
QByteArray("same content")));
|
||||||
|
QVERIFY(writeFile(directory.filePath(QStringLiteral("unique-size.bin")),
|
||||||
|
QByteArray("unique")));
|
||||||
|
|
||||||
|
const QByteArray large(duplicateSampleThreshold + 1024 * 1024, 'x');
|
||||||
|
QByteArray different = large;
|
||||||
|
different[1024 * 1024] = 'y';
|
||||||
|
QVERIFY(writeFile(directory.filePath(QStringLiteral("sample-a.bin")), large));
|
||||||
|
QVERIFY(writeFile(directory.filePath(QStringLiteral("sample-b.bin")), different));
|
||||||
|
|
||||||
|
SearchEngine engine(nullptr, directory.filePath(QStringLiteral("cache.dat")));
|
||||||
|
SearchOptions options = optionsFor(
|
||||||
|
directory.path(), QStringLiteral("Duplicates search"));
|
||||||
|
QVector<FileRecord> results = runSearch(engine, options);
|
||||||
|
QCOMPARE(namesOf(results),
|
||||||
|
QSet<QString>({QStringLiteral("duplicate-a.bin"),
|
||||||
|
QStringLiteral("duplicate-b.bin")}));
|
||||||
|
|
||||||
|
options.strictDuplicateComparison = true;
|
||||||
|
results = runSearch(engine, options);
|
||||||
|
QCOMPARE(namesOf(results),
|
||||||
|
QSet<QString>({QStringLiteral("duplicate-a.bin"),
|
||||||
|
QStringLiteral("duplicate-b.bin")}));
|
||||||
|
|
||||||
|
options.mode = QStringLiteral("Non-Duplicates search");
|
||||||
|
results = runSearch(engine, options);
|
||||||
|
QCOMPARE(namesOf(results),
|
||||||
|
QSet<QString>({QStringLiteral("unique-size.bin"),
|
||||||
|
QStringLiteral("sample-a.bin"),
|
||||||
|
QStringLiteral("sample-b.bin")}));
|
||||||
|
}
|
||||||
|
|
||||||
|
void appliesContentPipelineToDuplicateNames()
|
||||||
|
{
|
||||||
|
QTemporaryDir directory;
|
||||||
|
QVERIFY(directory.isValid());
|
||||||
|
QDir root(directory.path());
|
||||||
|
QVERIFY(root.mkpath(QStringLiteral("one")));
|
||||||
|
QVERIFY(root.mkpath(QStringLiteral("two")));
|
||||||
|
const QString first =
|
||||||
|
directory.filePath(QStringLiteral("one/report.bin"));
|
||||||
|
const QString second =
|
||||||
|
directory.filePath(QStringLiteral("two/report.bin"));
|
||||||
|
QVERIFY(writeFile(first, QByteArray("same")));
|
||||||
|
QVERIFY(writeFile(second, QByteArray("same")));
|
||||||
|
|
||||||
|
SearchEngine engine(nullptr, directory.filePath(QStringLiteral("cache.dat")));
|
||||||
|
SearchOptions options = optionsFor(
|
||||||
|
directory.path(), QStringLiteral("Duplicate names search"));
|
||||||
|
options.duplicateNameMode = QStringLiteral("Only identical content");
|
||||||
|
QVector<FileRecord> results = runSearch(engine, options);
|
||||||
|
QCOMPARE(results.size(), 2);
|
||||||
|
|
||||||
|
QVERIFY(writeFile(second, QByteArray("diff")));
|
||||||
|
results = runSearch(engine, options);
|
||||||
|
QVERIFY(results.isEmpty());
|
||||||
|
|
||||||
|
options.duplicateNameMode = QStringLiteral("Only non-identical content");
|
||||||
|
results = runSearch(engine, options);
|
||||||
|
QCOMPARE(results.size(), 2);
|
||||||
|
|
||||||
|
options.strictDuplicateComparison = true;
|
||||||
|
results = runSearch(engine, options);
|
||||||
|
QCOMPARE(results.size(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
void assignsKeeperPriorityFromConfiguredRoots()
|
||||||
|
{
|
||||||
|
QTemporaryDir directory;
|
||||||
|
QTemporaryDir cacheDirectory;
|
||||||
|
QVERIFY(directory.isValid());
|
||||||
|
QVERIFY(cacheDirectory.isValid());
|
||||||
|
QDir root(directory.path());
|
||||||
|
QVERIFY(root.mkpath(QStringLiteral("Temp")));
|
||||||
|
QVERIFY(root.mkpath(QStringLiteral("misc")));
|
||||||
|
const QString temp = directory.filePath(QStringLiteral("Temp"));
|
||||||
|
const QString misc = directory.filePath(QStringLiteral("misc"));
|
||||||
|
const QByteArray content("same");
|
||||||
|
const QString basePath = directory.filePath(QStringLiteral("base.mp4"));
|
||||||
|
const QString tempPath = QDir(temp).filePath(QStringLiteral("temp.mp4"));
|
||||||
|
const QString miscPath = QDir(misc).filePath(QStringLiteral("misc.mp4"));
|
||||||
|
QVERIFY(writeFile(basePath, content));
|
||||||
|
QVERIFY(writeFile(tempPath, content));
|
||||||
|
QVERIFY(writeFile(miscPath, content));
|
||||||
|
|
||||||
|
SearchEngine engine(nullptr, cacheDirectory.filePath(QStringLiteral("cache.dat")));
|
||||||
|
SearchOptions options = optionsFor(
|
||||||
|
QStringList({directory.path(), temp, misc}).join(u';'),
|
||||||
|
QStringLiteral("Duplicates search"));
|
||||||
|
QVector<FileRecord> results = runSearch(engine, options);
|
||||||
|
QCOMPARE(results.size(), 3);
|
||||||
|
|
||||||
|
QHash<QString, int> priorities;
|
||||||
|
for (const FileRecord &record : results)
|
||||||
|
priorities.insert(record.path, record.duplicateCopy);
|
||||||
|
QCOMPARE(priorities.value(basePath), 1);
|
||||||
|
QCOMPARE(priorities.value(tempPath), 2);
|
||||||
|
QCOMPARE(priorities.value(miscPath), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
void prefersNewerFilesWhenRootPriorityTies()
|
||||||
|
{
|
||||||
|
QTemporaryDir directory;
|
||||||
|
QTemporaryDir cacheDirectory;
|
||||||
|
QVERIFY(directory.isValid());
|
||||||
|
QVERIFY(cacheDirectory.isValid());
|
||||||
|
QDir root(directory.path());
|
||||||
|
QVERIFY(root.mkpath(QStringLiteral("child")));
|
||||||
|
const QString older = QDir(directory.filePath(QStringLiteral("child")))
|
||||||
|
.filePath(QStringLiteral("older.mp4"));
|
||||||
|
const QString newer = directory.filePath(QStringLiteral("newer.mp4"));
|
||||||
|
QVERIFY(writeFile(older, QByteArray("same")));
|
||||||
|
QVERIFY(writeFile(newer, QByteArray("same")));
|
||||||
|
QFile olderFile(older);
|
||||||
|
QVERIFY(olderFile.open(QIODevice::ReadOnly));
|
||||||
|
QVERIFY(olderFile.setFileTime(QDateTime::fromSecsSinceEpoch(1),
|
||||||
|
QFileDevice::FileModificationTime));
|
||||||
|
olderFile.close();
|
||||||
|
|
||||||
|
SearchEngine engine(nullptr, cacheDirectory.filePath(QStringLiteral("cache.dat")));
|
||||||
|
SearchOptions options = optionsFor(
|
||||||
|
directory.path(), QStringLiteral("Duplicates search"));
|
||||||
|
const QVector<FileRecord> results = runSearch(engine, options);
|
||||||
|
QCOMPARE(results.size(), 2);
|
||||||
|
QHash<QString, int> priorities;
|
||||||
|
for (const FileRecord &record : results)
|
||||||
|
priorities.insert(record.path, record.duplicateCopy);
|
||||||
|
QCOMPARE(priorities.value(newer), 1);
|
||||||
|
QCOMPARE(priorities.value(older), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
void reusesSampleAndFullHashesOnWarmSearch()
|
||||||
|
{
|
||||||
|
QTemporaryDir directory;
|
||||||
|
QTemporaryDir cacheDirectory;
|
||||||
|
QVERIFY(directory.isValid());
|
||||||
|
QVERIFY(cacheDirectory.isValid());
|
||||||
|
const QByteArray content(duplicateSampleThreshold + 1024 * 1024, 'x');
|
||||||
|
const QString first = directory.filePath(QStringLiteral("first.bin"));
|
||||||
|
const QString second = directory.filePath(QStringLiteral("second.bin"));
|
||||||
|
QVERIFY(writeFile(first, content));
|
||||||
|
QVERIFY(writeFile(second, content));
|
||||||
|
const QString cachePath =
|
||||||
|
cacheDirectory.filePath(QStringLiteral("duplicate-cache.dat"));
|
||||||
|
SearchEngine engine(nullptr, cachePath);
|
||||||
|
SearchOptions options = optionsFor(
|
||||||
|
directory.path(), QStringLiteral("Duplicates search"));
|
||||||
|
options.useCache = true;
|
||||||
|
|
||||||
|
QSignalSpy finished(&engine, &SearchEngine::finished);
|
||||||
|
QElapsedTimer timer;
|
||||||
|
timer.start();
|
||||||
|
engine.search(options);
|
||||||
|
const qint64 coldMilliseconds = timer.elapsed();
|
||||||
|
QCOMPARE(finished.size(), 1);
|
||||||
|
finished.clear();
|
||||||
|
|
||||||
|
if (::geteuid() != 0) {
|
||||||
|
QVERIFY(QFile::setPermissions(first, {}));
|
||||||
|
QVERIFY(QFile::setPermissions(second, {}));
|
||||||
|
}
|
||||||
|
timer.restart();
|
||||||
|
engine.search(options);
|
||||||
|
const qint64 warmMilliseconds = timer.elapsed();
|
||||||
|
QCOMPARE(finished.size(), 1);
|
||||||
|
const QList<QVariant> arguments = finished.takeFirst();
|
||||||
|
QCOMPARE(arguments.at(0).value<QVector<FileRecord>>().size(), 2);
|
||||||
|
QVERIFY(arguments.at(1).toString().contains(QStringLiteral("cache: 4 hits")));
|
||||||
|
qInfo().noquote()
|
||||||
|
<< QStringLiteral("duplicate benchmark: cold %1 ms, warm %2 ms")
|
||||||
|
.arg(coldMilliseconds)
|
||||||
|
.arg(warmMilliseconds);
|
||||||
|
|
||||||
|
if (::geteuid() != 0) {
|
||||||
|
options.strictDuplicateComparison = true;
|
||||||
|
finished.clear();
|
||||||
|
engine.search(options);
|
||||||
|
QCOMPARE(finished.size(), 1);
|
||||||
|
QVERIFY(finished.takeFirst().at(0).value<QVector<FileRecord>>().isEmpty());
|
||||||
|
}
|
||||||
|
QFile::remove(cachePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
void doesNotFullyHashDifferentSamplesOrSizes()
|
||||||
|
{
|
||||||
|
QTemporaryDir directory;
|
||||||
|
QTemporaryDir cacheDirectory;
|
||||||
|
QVERIFY(directory.isValid());
|
||||||
|
QVERIFY(cacheDirectory.isValid());
|
||||||
|
const QByteArray firstContent(
|
||||||
|
duplicateSampleThreshold + 1024 * 1024, 'x');
|
||||||
|
QByteArray secondContent = firstContent;
|
||||||
|
secondContent[0] = 'y';
|
||||||
|
QVERIFY(writeFile(directory.filePath(QStringLiteral("large-a.bin")),
|
||||||
|
firstContent));
|
||||||
|
QVERIFY(writeFile(directory.filePath(QStringLiteral("large-b.bin")),
|
||||||
|
secondContent));
|
||||||
|
QVERIFY(writeFile(directory.filePath(QStringLiteral("small-a.bin")),
|
||||||
|
QByteArray("one")));
|
||||||
|
QVERIFY(writeFile(directory.filePath(QStringLiteral("small-b.bin")),
|
||||||
|
QByteArray("different size")));
|
||||||
|
|
||||||
|
SearchEngine engine(
|
||||||
|
nullptr, cacheDirectory.filePath(QStringLiteral("cache.dat")));
|
||||||
|
SearchOptions options = optionsFor(
|
||||||
|
directory.path(), QStringLiteral("Duplicates search"));
|
||||||
|
options.useCache = true;
|
||||||
|
QSignalSpy finished(&engine, &SearchEngine::finished);
|
||||||
|
engine.search(options);
|
||||||
|
QCOMPARE(finished.size(), 1);
|
||||||
|
finished.clear();
|
||||||
|
|
||||||
|
engine.search(options);
|
||||||
|
QCOMPARE(finished.size(), 1);
|
||||||
|
const QList<QVariant> arguments = finished.takeFirst();
|
||||||
|
QVERIFY(arguments.at(0).value<QVector<FileRecord>>().isEmpty());
|
||||||
|
QVERIFY(arguments.at(1).toString().contains(QStringLiteral("cache: 2 hits")));
|
||||||
|
}
|
||||||
|
|
||||||
|
void collapsesCurrentOverlappingRootLayoutBeforeHashing()
|
||||||
|
{
|
||||||
|
QTemporaryDir directory;
|
||||||
|
QTemporaryDir cacheDirectory;
|
||||||
|
QVERIFY(directory.isValid());
|
||||||
|
QVERIFY(cacheDirectory.isValid());
|
||||||
|
QDir root(directory.path());
|
||||||
|
const QStringList children{
|
||||||
|
QStringLiteral(".Prt"), QStringLiteral("misc"), QStringLiteral("Music"),
|
||||||
|
QStringLiteral("Буфер"), QStringLiteral("Temp")
|
||||||
|
};
|
||||||
|
QStringList roots{directory.path()};
|
||||||
|
for (qsizetype index = 0; index < children.size(); ++index) {
|
||||||
|
QVERIFY(root.mkpath(children[index]));
|
||||||
|
const QString child = directory.filePath(children[index]);
|
||||||
|
roots.push_back(child);
|
||||||
|
QVERIFY(writeFile(
|
||||||
|
QDir(child).filePath(QStringLiteral("unique.bin")),
|
||||||
|
QByteArray(duplicateSampleThreshold + index + 1,
|
||||||
|
char('a' + index))));
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchEngine engine(
|
||||||
|
nullptr, cacheDirectory.filePath(QStringLiteral("cache.dat")));
|
||||||
|
SearchOptions options = optionsFor(
|
||||||
|
roots.join(u';'), QStringLiteral("Duplicates search"));
|
||||||
|
options.accurateProgress = true;
|
||||||
|
options.excludeFolders =
|
||||||
|
directory.filePath(QStringLiteral("Meta"));
|
||||||
|
QSignalSpy phases(&engine, &SearchEngine::phaseProgress);
|
||||||
|
QSignalSpy finished(&engine, &SearchEngine::finished);
|
||||||
|
QElapsedTimer timer;
|
||||||
|
timer.start();
|
||||||
|
engine.search(options);
|
||||||
|
const qint64 elapsedMilliseconds = timer.elapsed();
|
||||||
|
|
||||||
|
QCOMPARE(finished.size(), 1);
|
||||||
|
const QList<QVariant> arguments = finished.takeFirst();
|
||||||
|
QVERIFY(arguments.at(0).value<QVector<FileRecord>>().isEmpty());
|
||||||
|
QVERIFY(arguments.at(1).toString().contains(
|
||||||
|
QStringLiteral("5 redundant roots skipped")));
|
||||||
|
quint64 scanningCompleted = 0;
|
||||||
|
quint64 scanningTotal = 0;
|
||||||
|
for (const QList<QVariant> &phase : phases) {
|
||||||
|
const QString name = phase.at(0).toString();
|
||||||
|
QVERIFY(name != QStringLiteral("Sampling duplicate candidates"));
|
||||||
|
QVERIFY(name != QStringLiteral("Hashing duplicate candidates"));
|
||||||
|
if (name == QStringLiteral("Scanning files")) {
|
||||||
|
scanningCompleted =
|
||||||
|
std::max(scanningCompleted, phase.at(1).toULongLong());
|
||||||
|
scanningTotal = phase.at(2).toULongLong();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QVERIFY(scanningTotal > 0);
|
||||||
|
QCOMPARE(scanningCompleted, scanningTotal);
|
||||||
|
qInfo().noquote()
|
||||||
|
<< QStringLiteral("overlapping-roots benchmark: %1 ms, 5 large files, 0 hash phases")
|
||||||
|
.arg(elapsedMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
void deduplicatesCandidatesWhenNestedRootsCannotBeCollapsed()
|
||||||
|
{
|
||||||
|
QTemporaryDir directory;
|
||||||
|
QTemporaryDir cacheDirectory;
|
||||||
|
QVERIFY(directory.isValid());
|
||||||
|
QVERIFY(cacheDirectory.isValid());
|
||||||
|
QDir root(directory.path());
|
||||||
|
QVERIFY(root.mkpath(QStringLiteral("child")));
|
||||||
|
const QString child = directory.filePath(QStringLiteral("child"));
|
||||||
|
QVERIFY(writeFile(QDir(child).filePath(QStringLiteral("only.bin")),
|
||||||
|
QByteArray(duplicateSampleThreshold + 1, 'x')));
|
||||||
|
|
||||||
|
SearchEngine engine(
|
||||||
|
nullptr, cacheDirectory.filePath(QStringLiteral("cache.dat")));
|
||||||
|
SearchOptions options = optionsFor(
|
||||||
|
directory.path() + u';' + child, QStringLiteral("Standard search"));
|
||||||
|
options.maxDepth = 2;
|
||||||
|
QVector<FileRecord> results = runSearch(engine, options);
|
||||||
|
QCOMPARE(results.size(), 1);
|
||||||
|
QCOMPARE(results.front().name, QStringLiteral("only.bin"));
|
||||||
|
|
||||||
|
options.mode = QStringLiteral("Duplicates search");
|
||||||
|
QSignalSpy phases(&engine, &SearchEngine::phaseProgress);
|
||||||
|
QSignalSpy finished(&engine, &SearchEngine::finished);
|
||||||
|
engine.search(options);
|
||||||
|
QCOMPARE(finished.size(), 1);
|
||||||
|
const QList<QVariant> arguments = finished.takeFirst();
|
||||||
|
QVERIFY(arguments.at(0).value<QVector<FileRecord>>().isEmpty());
|
||||||
|
QVERIFY(!arguments.at(1).toString().contains(
|
||||||
|
QStringLiteral("redundant roots skipped")));
|
||||||
|
for (const QList<QVariant> &phase : phases) {
|
||||||
|
const QString name = phase.at(0).toString();
|
||||||
|
QVERIFY(name != QStringLiteral("Sampling duplicate candidates"));
|
||||||
|
QVERIFY(name != QStringLiteral("Hashing duplicate candidates"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void leavesUnreadableCandidatesAsNonDuplicates()
|
||||||
|
{
|
||||||
|
if (::geteuid() == 0)
|
||||||
|
QSKIP("Root can read files regardless of their permission bits");
|
||||||
|
QTemporaryDir directory;
|
||||||
|
QTemporaryDir cacheDirectory;
|
||||||
|
QVERIFY(directory.isValid());
|
||||||
|
QVERIFY(cacheDirectory.isValid());
|
||||||
|
const QString first = directory.filePath(QStringLiteral("first.bin"));
|
||||||
|
const QString second = directory.filePath(QStringLiteral("second.bin"));
|
||||||
|
QVERIFY(writeFile(first, QByteArray("blocked")));
|
||||||
|
QVERIFY(writeFile(second, QByteArray("blocked")));
|
||||||
|
QVERIFY(QFile::setPermissions(first, {}));
|
||||||
|
QVERIFY(QFile::setPermissions(second, {}));
|
||||||
|
|
||||||
|
SearchEngine engine(
|
||||||
|
nullptr, cacheDirectory.filePath(QStringLiteral("cache.dat")));
|
||||||
|
SearchOptions options = optionsFor(
|
||||||
|
directory.path(), QStringLiteral("Duplicates search"));
|
||||||
|
QVERIFY(runSearch(engine, options).isEmpty());
|
||||||
|
options.mode = QStringLiteral("Non-Duplicates search");
|
||||||
|
QCOMPARE(namesOf(runSearch(engine, options)),
|
||||||
|
QSet<QString>({QStringLiteral("first.bin"),
|
||||||
|
QStringLiteral("second.bin")}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
QTEST_GUILESS_MAIN(SearchEngineTest)
|
||||||
|
|
||||||
|
#include "search_engine_test.moc"
|
||||||
Reference in New Issue
Block a user