Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 711536c74e | |||
| afb089bee6 | |||
| 79001370b8 | |||
| 07fc419f1e | |||
| 70d728f9c3 | |||
| e0d7cbb29a | |||
| 918e081834 | |||
| d2863997e8 | |||
| 7e6875ee1c | |||
| 168c5ad3ac | |||
| 5726ff3f62 | |||
| 0b93c8c124 | |||
| 4cb6fdf540 | |||
| d2e3106632 | |||
| 26c5af792e |
@@ -1,9 +1,12 @@
|
||||
name: release
|
||||
name: ci-release
|
||||
|
||||
"on":
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v[0-9]*.[0-9]*.[0-9]*"
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -16,7 +19,8 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Verify tag matches VERSION
|
||||
- name: Verify release tag matches VERSION
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
run: |
|
||||
tag="${GITHUB_REF##*/}"
|
||||
@@ -30,6 +34,10 @@ jobs:
|
||||
shell: bash
|
||||
run: cmake --build build --parallel
|
||||
|
||||
- name: Run tests
|
||||
shell: bash
|
||||
run: ctest --test-dir build --output-on-failure
|
||||
|
||||
- name: Smoke test GUI
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -38,6 +46,10 @@ jobs:
|
||||
status="$?"
|
||||
test "$status" = 0 || test "$status" = 124
|
||||
|
||||
- name: Verify Linux binary
|
||||
shell: bash
|
||||
run: test -x build/searchmyfiles
|
||||
|
||||
- name: Lint YAML
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -45,9 +57,10 @@ jobs:
|
||||
-v "$PWD:/work" \
|
||||
-w /work \
|
||||
cytopia/yamllint:latest \
|
||||
-c .yamllint .
|
||||
-c .yamllint .gitea
|
||||
|
||||
linux-amd64:
|
||||
publish-linux-amd64:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: verify
|
||||
runs-on: [self-hosted, linux, arch, homelab]
|
||||
env:
|
||||
@@ -131,9 +144,3 @@ jobs:
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${asset}"
|
||||
done
|
||||
|
||||
- name: Upload workflow artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: folderscope-linux-amd64
|
||||
path: dist/*
|
||||
@@ -1,52 +0,0 @@
|
||||
name: ci
|
||||
|
||||
"on":
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-linux-amd64:
|
||||
runs-on: [self-hosted, linux, arch, homelab]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure release build
|
||||
shell: bash
|
||||
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: cmake --build build --parallel
|
||||
|
||||
- name: Smoke test GUI
|
||||
shell: bash
|
||||
run: |
|
||||
set +e
|
||||
QT_QPA_PLATFORM=offscreen timeout 3s ./build/searchmyfiles
|
||||
status="$?"
|
||||
test "$status" = 0 || test "$status" = 124
|
||||
|
||||
- name: Upload Linux binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: folderscope-linux-amd64
|
||||
path: build/searchmyfiles
|
||||
|
||||
lint-yaml:
|
||||
runs-on: [self-hosted, linux, arch, homelab]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Lint workflow files
|
||||
shell: bash
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "$PWD:/work" \
|
||||
-w /work \
|
||||
cytopia/yamllint:latest \
|
||||
-c .yamllint .
|
||||
@@ -1 +1,7 @@
|
||||
/build/
|
||||
/cmake-build-*/
|
||||
/out/
|
||||
/.idea/
|
||||
/.vscode/
|
||||
/CMakeLists.txt.user
|
||||
/CMakeLists.txt.user.*
|
||||
|
||||
@@ -5,6 +5,52 @@ All notable changes to FolderScope are documented here.
|
||||
The project uses [Semantic Versioning](https://semver.org/). Release tags are
|
||||
formatted as `vMAJOR.MINOR.PATCH`.
|
||||
|
||||
## [0.2.4] - 2026-07-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- Corrected result-table context menu positioning.
|
||||
- Added validation for ISO date filters instead of silently accepting invalid
|
||||
input.
|
||||
- Replaced the 2 GB size-filter limit with precise 64-bit inputs.
|
||||
- Preserved visible table order when acting on multiple selected files.
|
||||
- Made release preparation reject untracked files.
|
||||
|
||||
### Added
|
||||
|
||||
- Added automated tests for search primitives, large size inputs, and release
|
||||
safeguards.
|
||||
- Added the test suite to the CI verification gate.
|
||||
|
||||
## [0.2.3] - 2026-07-25
|
||||
|
||||
### Added
|
||||
|
||||
- Added a persistent cache for duplicate hashes and file-content matches with
|
||||
automatic invalidation when files change.
|
||||
- Added cache controls and a context menu on column headers for per-column and
|
||||
global automatic width.
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed duplicate result columns to `Duplicate Set` and `Keeper Priority`,
|
||||
with tooltips explaining which copy is preferred.
|
||||
|
||||
## [0.2.2] - 2026-07-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- Scoped workflow YAML linting to the `.gitea` source directory.
|
||||
- Combined CI and tag-gated release publishing into one workflow.
|
||||
|
||||
## [0.2.1] - 2026-07-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- Removed the incompatible workflow-artifact upload step from Gitea Actions.
|
||||
- Kept the release gate fail-closed: version validation, build, GUI smoke test,
|
||||
and YAML lint must all pass before Gitea release assets are published.
|
||||
|
||||
## [0.2.0] - 2026-07-25
|
||||
|
||||
### Added
|
||||
@@ -16,3 +62,7 @@ formatted as `vMAJOR.MINOR.PATCH`.
|
||||
reporting.
|
||||
|
||||
[0.2.0]: https://gitea.forust.xyz/forust/folderscope/releases/tag/v0.2.0
|
||||
[0.2.1]: https://gitea.forust.xyz/forust/folderscope/releases/tag/v0.2.1
|
||||
[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.4]: https://gitea.forust.xyz/forust/folderscope/releases/tag/v0.2.4
|
||||
|
||||
+50
-1
@@ -14,14 +14,63 @@ set(CMAKE_AUTOMOC ON)
|
||||
|
||||
find_package(Qt6 REQUIRED COMPONENTS Widgets Concurrent DBus)
|
||||
|
||||
add_library(folderscope_core STATIC
|
||||
src/search_core.cpp
|
||||
src/search_core.h
|
||||
)
|
||||
target_link_libraries(folderscope_core PUBLIC Qt6::Core)
|
||||
target_compile_options(folderscope_core PRIVATE -Wall -Wextra -Wpedantic)
|
||||
target_include_directories(folderscope_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src")
|
||||
|
||||
qt_add_executable(searchmyfiles
|
||||
src/long_long_spin_box.cpp
|
||||
src/long_long_spin_box.h
|
||||
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 Qt6::Widgets Qt6::Concurrent Qt6::DBus)
|
||||
target_link_libraries(searchmyfiles PRIVATE folderscope_core Qt6::Widgets Qt6::Concurrent Qt6::DBus)
|
||||
target_compile_options(searchmyfiles PRIVATE -Wall -Wextra -Wpedantic)
|
||||
target_compile_definitions(searchmyfiles PRIVATE FOLDERSCOPE_VERSION="${PROJECT_VERSION}")
|
||||
|
||||
install(TARGETS searchmyfiles RUNTIME DESTINATION bin)
|
||||
install(FILES packaging/searchmyfiles.desktop
|
||||
DESTINATION share/applications)
|
||||
install(FILES packaging/searchmyfiles.svg
|
||||
DESTINATION share/icons/hicolor/scalable/apps)
|
||||
|
||||
include(CTest)
|
||||
if (BUILD_TESTING)
|
||||
find_package(Qt6 REQUIRED COMPONENTS Test)
|
||||
|
||||
qt_add_executable(search_core_tests
|
||||
tests/search_core_test.cpp
|
||||
)
|
||||
target_link_libraries(search_core_tests PRIVATE folderscope_core Qt6::Test)
|
||||
target_compile_options(search_core_tests PRIVATE -Wall -Wextra -Wpedantic)
|
||||
add_test(NAME search-core COMMAND search_core_tests)
|
||||
|
||||
qt_add_executable(long_long_spin_box_tests
|
||||
src/long_long_spin_box.cpp
|
||||
src/long_long_spin_box.h
|
||||
tests/long_long_spin_box_test.cpp
|
||||
)
|
||||
target_link_libraries(long_long_spin_box_tests PRIVATE Qt6::Widgets Qt6::Test)
|
||||
target_compile_options(long_long_spin_box_tests PRIVATE -Wall -Wextra -Wpedantic)
|
||||
target_include_directories(long_long_spin_box_tests PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src")
|
||||
add_test(NAME long-long-spin-box COMMAND long_long_spin_box_tests)
|
||||
set_tests_properties(long-long-spin-box PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
|
||||
|
||||
add_test(NAME release-rejects-untracked
|
||||
COMMAND bash "${CMAKE_CURRENT_SOURCE_DIR}/tests/release_dirty_test.sh"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/scripts/release.sh")
|
||||
endif()
|
||||
|
||||
@@ -13,8 +13,10 @@ single window.
|
||||
- filter by name patterns, size, timestamps, and file attributes;
|
||||
- inspect duplicate files and files with matching names;
|
||||
- search inside file contents, including text and hex;
|
||||
- reuse a persistent, automatically invalidated cache for content matches and
|
||||
duplicate hashes;
|
||||
- show folder summaries and duplicate groups;
|
||||
- sort, resize, hide, and restore columns;
|
||||
- sort, resize, hide, and restore columns, including per-column header actions;
|
||||
- export results to CSV, TSV/TXT, HTML, XML, and JSON;
|
||||
- open files and folders, copy paths, and use context actions from the results
|
||||
table;
|
||||
@@ -34,6 +36,12 @@ cmake --build build -j
|
||||
./build/searchmyfiles
|
||||
```
|
||||
|
||||
Run the automated tests:
|
||||
|
||||
```bash
|
||||
ctest --test-dir build --output-on-failure
|
||||
```
|
||||
|
||||
Install into the current system:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -3,7 +3,6 @@ Type=Application
|
||||
Name=SearchMyFiles
|
||||
Comment=Fast advanced file search
|
||||
Exec=searchmyfiles
|
||||
Icon=system-search
|
||||
Icon=searchmyfiles
|
||||
Terminal=false
|
||||
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 |
+1
-1
@@ -9,7 +9,7 @@ fi
|
||||
version="$1"
|
||||
tag="v${version}"
|
||||
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo "Refusing to release with uncommitted changes." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
#include "long_long_spin_box.h"
|
||||
|
||||
#include <QLineEdit>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
LongLongSpinBox::LongLongSpinBox(QWidget *parent) : QAbstractSpinBox(parent)
|
||||
{
|
||||
setValue(0);
|
||||
connect(this, &QAbstractSpinBox::editingFinished, this, &LongLongSpinBox::commitText);
|
||||
}
|
||||
|
||||
qint64 LongLongSpinBox::value() const
|
||||
{
|
||||
qint64 parsed = value_;
|
||||
return valueFromText(lineEdit()->text(), parsed) ? parsed : value_;
|
||||
}
|
||||
|
||||
void LongLongSpinBox::setValue(qint64 value)
|
||||
{
|
||||
value_ = std::clamp(value, minimum_, maximum_);
|
||||
lineEdit()->setText(textFromValue(value_));
|
||||
}
|
||||
|
||||
void LongLongSpinBox::setRange(qint64 minimum, qint64 maximum)
|
||||
{
|
||||
minimum_ = minimum;
|
||||
maximum_ = std::max(minimum, maximum);
|
||||
setValue(value_);
|
||||
}
|
||||
|
||||
void LongLongSpinBox::setSingleStep(qint64 step)
|
||||
{
|
||||
singleStep_ = std::max<qint64>(1, step);
|
||||
}
|
||||
|
||||
void LongLongSpinBox::setSuffix(const QString &suffix)
|
||||
{
|
||||
suffix_ = suffix;
|
||||
setValue(value());
|
||||
}
|
||||
|
||||
void LongLongSpinBox::setSpecialValueText(const QString &text)
|
||||
{
|
||||
specialValueText_ = text;
|
||||
setValue(value());
|
||||
}
|
||||
|
||||
QValidator::State LongLongSpinBox::validate(QString &input, int &position) const
|
||||
{
|
||||
Q_UNUSED(position);
|
||||
if (!specialValueText_.isEmpty() && input == specialValueText_)
|
||||
return QValidator::Acceptable;
|
||||
|
||||
QString number = input;
|
||||
if (!suffix_.isEmpty() && number.endsWith(suffix_))
|
||||
number.chop(suffix_.size());
|
||||
number = number.trimmed();
|
||||
if (number.isEmpty() || number == QStringLiteral("-"))
|
||||
return QValidator::Intermediate;
|
||||
|
||||
qint64 parsed = 0;
|
||||
if (!valueFromText(input, parsed))
|
||||
return QValidator::Invalid;
|
||||
return parsed >= minimum_ && parsed <= maximum_
|
||||
? QValidator::Acceptable : QValidator::Invalid;
|
||||
}
|
||||
|
||||
void LongLongSpinBox::fixup(QString &input) const
|
||||
{
|
||||
qint64 parsed = value_;
|
||||
input = valueFromText(input, parsed)
|
||||
? textFromValue(std::clamp(parsed, minimum_, maximum_))
|
||||
: textFromValue(value_);
|
||||
}
|
||||
|
||||
void LongLongSpinBox::stepBy(int steps)
|
||||
{
|
||||
commitText();
|
||||
qint64 next = value_;
|
||||
if (steps > 0) {
|
||||
const qint64 count = steps;
|
||||
next = singleStep_ > (maximum_ - value_) / count
|
||||
? maximum_ : value_ + singleStep_ * count;
|
||||
} else if (steps < 0) {
|
||||
const qint64 count = -qint64(steps);
|
||||
next = singleStep_ > (value_ - minimum_) / count
|
||||
? minimum_ : value_ - singleStep_ * count;
|
||||
}
|
||||
setValue(next);
|
||||
}
|
||||
|
||||
QAbstractSpinBox::StepEnabled LongLongSpinBox::stepEnabled() const
|
||||
{
|
||||
StepEnabled enabled = StepNone;
|
||||
const qint64 current = value();
|
||||
if (current > minimum_)
|
||||
enabled |= StepDownEnabled;
|
||||
if (current < maximum_)
|
||||
enabled |= StepUpEnabled;
|
||||
return enabled;
|
||||
}
|
||||
|
||||
QString LongLongSpinBox::textFromValue(qint64 value) const
|
||||
{
|
||||
if (value == minimum_ && !specialValueText_.isEmpty())
|
||||
return specialValueText_;
|
||||
return QString::number(value) + suffix_;
|
||||
}
|
||||
|
||||
bool LongLongSpinBox::valueFromText(const QString &text, qint64 &value) const
|
||||
{
|
||||
if (!specialValueText_.isEmpty() && text == specialValueText_) {
|
||||
value = minimum_;
|
||||
return true;
|
||||
}
|
||||
QString number = text;
|
||||
if (!suffix_.isEmpty() && number.endsWith(suffix_))
|
||||
number.chop(suffix_.size());
|
||||
bool ok = false;
|
||||
const qint64 parsed = number.trimmed().toLongLong(&ok);
|
||||
if (ok)
|
||||
value = parsed;
|
||||
return ok;
|
||||
}
|
||||
|
||||
void LongLongSpinBox::commitText()
|
||||
{
|
||||
setValue(value());
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include <QAbstractSpinBox>
|
||||
#include <QValidator>
|
||||
|
||||
#include <limits>
|
||||
|
||||
class LongLongSpinBox final : public QAbstractSpinBox {
|
||||
public:
|
||||
explicit LongLongSpinBox(QWidget *parent = nullptr);
|
||||
|
||||
qint64 value() const;
|
||||
void setValue(qint64 value);
|
||||
void setRange(qint64 minimum, qint64 maximum);
|
||||
void setSingleStep(qint64 step);
|
||||
void setSuffix(const QString &suffix);
|
||||
void setSpecialValueText(const QString &text);
|
||||
|
||||
protected:
|
||||
QValidator::State validate(QString &input, int &position) const override;
|
||||
void fixup(QString &input) const override;
|
||||
void stepBy(int steps) override;
|
||||
StepEnabled stepEnabled() const override;
|
||||
|
||||
private:
|
||||
QString textFromValue(qint64 value) const;
|
||||
bool valueFromText(const QString &text, qint64 &value) const;
|
||||
void commitText();
|
||||
|
||||
qint64 minimum_ = 0;
|
||||
qint64 maximum_ = std::numeric_limits<qint64>::max();
|
||||
qint64 value_ = 0;
|
||||
qint64 singleStep_ = 1;
|
||||
QString suffix_;
|
||||
QString specialValueText_;
|
||||
};
|
||||
+3
-1972
File diff suppressed because it is too large
Load Diff
+1048
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,433 @@
|
||||
#include "options_dialog.h"
|
||||
|
||||
#include <QDialogButtonBox>
|
||||
#include <QDropEvent>
|
||||
#include <QEvent>
|
||||
#include <QFileDialog>
|
||||
#include <QInputDialog>
|
||||
#include <QMessageBox>
|
||||
#include <QMimeData>
|
||||
#include <QSettings>
|
||||
|
||||
#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_ = new QLineEdit(o.roots);
|
||||
roots_->setAcceptDrops(true);
|
||||
roots_->setToolTip(tr("Drop folders here from your file manager"));
|
||||
roots_->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…"));
|
||||
rootLayout->addWidget(browse);
|
||||
filesForm->addRow(tr("Base folders (separate with ;):"), rootRow);
|
||||
fileMasks_ = addLine(filesForm, tr("Files wildcard:"), o.fileWildcards);
|
||||
subfolderMasks_ = addLine(filesForm, tr("Subfolders wildcard:"), o.subfolderWildcards);
|
||||
excludeFiles_ = addLine(filesForm, tr("Exclude files:"), o.excludeFiles);
|
||||
excludeFolders_ = addLine(filesForm, tr("Exclude folders:"), o.excludeFolders);
|
||||
includeFolders_ = addLine(filesForm, tr("Include only folders:"), 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);
|
||||
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(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_->text());
|
||||
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_->setText(folders.join(u';'));
|
||||
});
|
||||
layout->addWidget(buttons);
|
||||
}
|
||||
|
||||
SearchOptions OptionsDialog::options() const
|
||||
{
|
||||
SearchOptions o;
|
||||
o.roots = roots_->text();
|
||||
o.fileWildcards = fileMasks_->text();
|
||||
o.subfolderWildcards = subfolderMasks_->text();
|
||||
o.excludeFiles = excludeFiles_->text();
|
||||
o.excludeFolders = excludeFolders_->text();
|
||||
o.includeFolders = includeFolders_->text();
|
||||
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.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;
|
||||
QDialog::accept();
|
||||
}
|
||||
|
||||
QLineEdit *OptionsDialog::addLine(QFormLayout *layout, const QString &label, const QString &value)
|
||||
{
|
||||
auto *edit = new QLineEdit(value);
|
||||
layout->addRow(label, edit);
|
||||
return edit;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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_ && event->type() == QEvent::DragEnter) {
|
||||
auto *dragEnter = static_cast<QDragEnterEvent *>(event);
|
||||
if (dragEnter->mimeData()->hasUrls()) {
|
||||
dragEnter->acceptProposedAction();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (obj == roots_ && event->type() == QEvent::Drop) {
|
||||
auto *drop = static_cast<QDropEvent *>(event);
|
||||
if (!drop->mimeData()->hasUrls())
|
||||
return false;
|
||||
QStringList folders = patterns(roots_->text());
|
||||
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_->setText(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);
|
||||
|
||||
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_->setText(load("roots"));
|
||||
fileMasks_->setText(load("fileWildcards"));
|
||||
subfolderMasks_->setText(load("subfolderWildcards"));
|
||||
excludeFiles_->setText(load("excludeFiles"));
|
||||
excludeFolders_->setText(load("excludeFolders"));
|
||||
includeFolders_->setText(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));
|
||||
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,75 @@
|
||||
#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 QLineEdit *addLine(QFormLayout *layout, const QString &label, const QString &value);
|
||||
static LongLongSpinBox *sizeSpin(qint64 value);
|
||||
static qint64 dateValue(const QLineEdit *edit);
|
||||
bool validateDate(QLineEdit *edit, const QString &label);
|
||||
static QComboBox *attrCombo(const QString &value);
|
||||
|
||||
void setupUi(const SearchOptions &o);
|
||||
void saveProfile();
|
||||
void loadProfile();
|
||||
|
||||
QComboBox *mode_{};
|
||||
QComboBox *duplicateMode_{};
|
||||
QCheckBox *duplicateWithoutExtension_{};
|
||||
QLineEdit *roots_{};
|
||||
QLineEdit *fileMasks_{};
|
||||
QLineEdit *subfolderMasks_{};
|
||||
QLineEdit *excludeFiles_{};
|
||||
QLineEdit *excludeFolders_{};
|
||||
QLineEdit *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_{};
|
||||
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("Order based on the base-folder list; 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,159 @@
|
||||
#include "search_cache.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDataStream>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QSaveFile>
|
||||
#include <QStandardPaths>
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
SearchCache::SearchCache()
|
||||
{
|
||||
const QString cacheRoot = QStandardPaths::writableLocation(QStandardPaths::CacheLocation);
|
||||
QDir().mkpath(cacheRoot);
|
||||
path_ = QDir(cacheRoot).filePath(QStringLiteral("search-cache-v1.dat"));
|
||||
load();
|
||||
}
|
||||
|
||||
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(1) << quint32(entries_.size());
|
||||
for (auto it = entries_.cbegin(); it != entries_.cend(); ++it) {
|
||||
stream << it.key() << it->size << it->modified << it->lastUsed << 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.modified == record.modified;
|
||||
}
|
||||
|
||||
SearchCacheEntry &SearchCache::currentEntry(const FileRecord &record)
|
||||
{
|
||||
SearchCacheEntry &entry = entries_[record.path];
|
||||
if (!signatureMatches(entry, record)) {
|
||||
entry = {};
|
||||
entry.size = record.size;
|
||||
entry.modified = record.modified;
|
||||
}
|
||||
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 != 1 || 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.modified >> entry.lastUsed >> 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,42 @@
|
||||
#pragma once
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QHash>
|
||||
#include <QMutex>
|
||||
#include <QString>
|
||||
|
||||
#include "search_core.h"
|
||||
|
||||
struct SearchCacheEntry {
|
||||
qint64 size = -1;
|
||||
qint64 modified = -1;
|
||||
qint64 lastUsed = 0;
|
||||
QByteArray sha256;
|
||||
QHash<QByteArray, bool> contentMatches;
|
||||
};
|
||||
|
||||
class SearchCache {
|
||||
public:
|
||||
SearchCache();
|
||||
|
||||
void setEnabled(bool enabled) { enabled_ = enabled; }
|
||||
bool enabled() const { return enabled_; }
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,202 @@
|
||||
#include "search_core.h"
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QDateTime>
|
||||
#include <QFile>
|
||||
#include <QRegularExpression>
|
||||
|
||||
#include <algorithm>
|
||||
#include <pwd.h>
|
||||
|
||||
QStringList patterns(const QString &text)
|
||||
{
|
||||
QStringList out;
|
||||
QString item;
|
||||
bool quoted = false;
|
||||
for (QChar ch : text) {
|
||||
if (ch == u'"') {
|
||||
quoted = !quoted;
|
||||
} else if (!quoted && (ch == u';' || ch == u',')) {
|
||||
if (!item.trimmed().isEmpty())
|
||||
out.push_back(item.trimmed());
|
||||
item.clear();
|
||||
} else {
|
||||
item += ch;
|
||||
}
|
||||
}
|
||||
if (!item.trimmed().isEmpty())
|
||||
out.push_back(item.trimmed());
|
||||
return out;
|
||||
}
|
||||
|
||||
QStringList exclusionPatterns(const QString &text)
|
||||
{
|
||||
QString normalized = text;
|
||||
normalized.replace(QRegularExpression(QStringLiteral("\\s+")), QStringLiteral(";"));
|
||||
QStringList result = patterns(normalized);
|
||||
for (QString &pattern : result) {
|
||||
if (!pattern.contains(u'*') && !pattern.contains(u'?') && !pattern.contains(u'/'))
|
||||
pattern = QStringLiteral("*.") + pattern.remove(QRegularExpression(QStringLiteral("^\\.")));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool wildcardMatch(const QString &value, const QStringList &items, Qt::CaseSensitivity cs)
|
||||
{
|
||||
for (const QString &pattern : items) {
|
||||
const auto regex = QRegularExpression(
|
||||
QRegularExpression::wildcardToRegularExpression(pattern),
|
||||
cs == Qt::CaseInsensitive ? QRegularExpression::CaseInsensitiveOption
|
||||
: QRegularExpression::NoPatternOption);
|
||||
if (regex.match(value).hasMatch())
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool shouldShowDuplicateResult(bool copiesOnly, int keeperPriority)
|
||||
{
|
||||
if (keeperPriority < 1)
|
||||
return false;
|
||||
return !copiesOnly || keeperPriority > 1;
|
||||
}
|
||||
|
||||
QString humanSize(qint64 bytes)
|
||||
{
|
||||
static const QStringList units{QStringLiteral("B"), QStringLiteral("KB"),
|
||||
QStringLiteral("MB"), QStringLiteral("GB"),
|
||||
QStringLiteral("TB")};
|
||||
double value = static_cast<double>(bytes);
|
||||
int unit = 0;
|
||||
while (value >= 1024.0 && unit < units.size() - 1) {
|
||||
value /= 1024.0;
|
||||
++unit;
|
||||
}
|
||||
return unit == 0 ? QStringLiteral("%1 B").arg(bytes)
|
||||
: QStringLiteral("%1 %2").arg(value, 0, 'f', 2).arg(units[unit]);
|
||||
}
|
||||
|
||||
QString ownerName(uid_t uid)
|
||||
{
|
||||
struct passwd pwd {};
|
||||
struct passwd *result = nullptr;
|
||||
QByteArray buffer(16384, Qt::Uninitialized);
|
||||
if (getpwuid_r(uid, &pwd, buffer.data(), static_cast<size_t>(buffer.size()), &result) == 0 && result)
|
||||
return QString::fromLocal8Bit(result->pw_name);
|
||||
return QString::number(uid);
|
||||
}
|
||||
|
||||
std::optional<QByteArray> hexNeedle(QString text)
|
||||
{
|
||||
text.remove(QRegularExpression(QStringLiteral("\\s+")));
|
||||
if (text.size() % 2 != 0 || !QRegularExpression(QStringLiteral("^[0-9a-fA-F]*$")).match(text).hasMatch())
|
||||
return std::nullopt;
|
||||
return QByteArray::fromHex(text.toLatin1());
|
||||
}
|
||||
|
||||
namespace {
|
||||
bool isCancelled(const std::atomic_bool *cancelled)
|
||||
{
|
||||
return cancelled && cancelled->load(std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
bool fileContains(const QString &path, const SearchOptions &options,
|
||||
const std::atomic_bool *cancelled)
|
||||
{
|
||||
if (isCancelled(cancelled))
|
||||
return false;
|
||||
if (options.contains.isEmpty())
|
||||
return true;
|
||||
|
||||
const QStringList terms = options.multipleValues
|
||||
? patterns(options.contains) : QStringList{options.contains};
|
||||
QList<QByteArray> needles;
|
||||
for (const QString &term : terms) {
|
||||
if (options.binary) {
|
||||
const auto bytes = hexNeedle(term);
|
||||
if (!bytes)
|
||||
return false;
|
||||
needles.push_back(*bytes);
|
||||
} else {
|
||||
needles.push_back(term.toUtf8());
|
||||
}
|
||||
}
|
||||
if (needles.isEmpty())
|
||||
return true;
|
||||
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
return false;
|
||||
|
||||
QVector<bool> found(needles.size(), false);
|
||||
qsizetype overlap = 1;
|
||||
for (const QByteArray &needle : needles)
|
||||
overlap = std::max(overlap, needle.size());
|
||||
QByteArray tail;
|
||||
while (!file.atEnd() && !isCancelled(cancelled)) {
|
||||
QByteArray data = tail + file.read(1024 * 1024);
|
||||
if (!options.caseSensitive && !options.binary)
|
||||
data = data.toLower();
|
||||
for (qsizetype i = 0; i < needles.size(); ++i) {
|
||||
QByteArray needle = needles[i];
|
||||
if (!options.caseSensitive && !options.binary)
|
||||
needle = needle.toLower();
|
||||
if (!found[i] && data.contains(needle))
|
||||
found[i] = true;
|
||||
}
|
||||
const bool matched = options.multipleAnd
|
||||
? std::all_of(found.cbegin(), found.cend(), [](bool foundTerm) { return foundTerm; })
|
||||
: std::any_of(found.cbegin(), found.cend(), [](bool foundTerm) { return foundTerm; });
|
||||
if (matched)
|
||||
return true;
|
||||
tail = data.right(overlap - 1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool filesEqual(const QString &leftPath, const QString &rightPath,
|
||||
const std::atomic_bool *cancelled)
|
||||
{
|
||||
if (isCancelled(cancelled))
|
||||
return false;
|
||||
QFile left(leftPath);
|
||||
QFile right(rightPath);
|
||||
if (!left.open(QIODevice::ReadOnly) || !right.open(QIODevice::ReadOnly)
|
||||
|| left.size() != right.size())
|
||||
return false;
|
||||
while (!left.atEnd() && !isCancelled(cancelled)) {
|
||||
const QByteArray leftData = left.read(1024 * 1024);
|
||||
const QByteArray rightData = right.read(leftData.size());
|
||||
if (leftData != rightData)
|
||||
return false;
|
||||
}
|
||||
return !isCancelled(cancelled) && right.atEnd();
|
||||
}
|
||||
|
||||
QByteArray sha256(const QString &path, const std::atomic_bool *cancelled)
|
||||
{
|
||||
if (isCancelled(cancelled))
|
||||
return {};
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
return {};
|
||||
QCryptographicHash hash(QCryptographicHash::Sha256);
|
||||
while (!file.atEnd() && !isCancelled(cancelled))
|
||||
hash.addData(file.read(1024 * 1024));
|
||||
return isCancelled(cancelled) ? QByteArray{} : hash.result();
|
||||
}
|
||||
|
||||
bool parseOptionalIsoDate(const QString &text, qint64 &seconds)
|
||||
{
|
||||
const QString trimmed = text.trimmed();
|
||||
if (trimmed.isEmpty()) {
|
||||
seconds = 0;
|
||||
return true;
|
||||
}
|
||||
const QDateTime parsed = QDateTime::fromString(trimmed, Qt::ISODate);
|
||||
if (!parsed.isValid())
|
||||
return false;
|
||||
seconds = parsed.toSecsSinceEpoch();
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
#pragma once
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QDir>
|
||||
#include <QMetaType>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVector>
|
||||
|
||||
#include <atomic>
|
||||
#include <optional>
|
||||
#include <sys/types.h>
|
||||
|
||||
struct SearchOptions {
|
||||
QString roots = QDir::homePath();
|
||||
QString fileWildcards = QStringLiteral("*");
|
||||
QString subfolderWildcards = QStringLiteral("*");
|
||||
QString excludeFiles;
|
||||
QString excludeFolders;
|
||||
QString includeFolders;
|
||||
QString contains;
|
||||
bool binary = false;
|
||||
bool multipleValues = false;
|
||||
bool multipleAnd = false;
|
||||
bool caseSensitive = false;
|
||||
qint64 minSize = 0;
|
||||
qint64 maxSize = 0;
|
||||
qint64 minTime = 0;
|
||||
qint64 maxTime = 0;
|
||||
QString timeField = QStringLiteral("Modified time");
|
||||
int lastMinutes = 0;
|
||||
bool recursive = true;
|
||||
bool findFiles = true;
|
||||
bool findFolders = false;
|
||||
bool followLinks = false;
|
||||
bool retrieveOwner = false;
|
||||
bool accurateProgress = true;
|
||||
bool useCache = true;
|
||||
int maxDepth = 0;
|
||||
int maxResults = 0;
|
||||
QString mode = QStringLiteral("Standard search");
|
||||
QString duplicateNameMode = QStringLiteral("All files and folders");
|
||||
bool duplicateNameWithoutExtension = false;
|
||||
bool showDuplicateCopiesOnly = true;
|
||||
bool includeSubfoldersInSummary = false;
|
||||
QString hidden = QStringLiteral("Any");
|
||||
QString readonly = QStringLiteral("Any");
|
||||
QString executable = QStringLiteral("Any");
|
||||
|
||||
bool operator==(const SearchOptions &) const = default;
|
||||
};
|
||||
|
||||
struct FileRecord {
|
||||
QString path;
|
||||
QString name;
|
||||
QString folder;
|
||||
qint64 size = 0;
|
||||
qint64 sizeOnDisk = 0;
|
||||
qint64 modified = 0;
|
||||
qint64 created = 0;
|
||||
qint64 accessed = 0;
|
||||
qint64 changed = 0;
|
||||
QString type;
|
||||
QString owner;
|
||||
QString attributes;
|
||||
int group = 0;
|
||||
int duplicateCopy = 0;
|
||||
};
|
||||
|
||||
Q_DECLARE_METATYPE(SearchOptions)
|
||||
Q_DECLARE_METATYPE(FileRecord)
|
||||
Q_DECLARE_METATYPE(QVector<FileRecord>)
|
||||
|
||||
QStringList patterns(const QString &text);
|
||||
QStringList exclusionPatterns(const QString &text);
|
||||
bool wildcardMatch(const QString &value, const QStringList &items, Qt::CaseSensitivity cs);
|
||||
QString humanSize(qint64 bytes);
|
||||
QString ownerName(uid_t uid);
|
||||
std::optional<QByteArray> hexNeedle(QString text);
|
||||
bool fileContains(const QString &path, const SearchOptions &options,
|
||||
const std::atomic_bool *cancelled = nullptr);
|
||||
bool filesEqual(const QString &leftPath, const QString &rightPath,
|
||||
const std::atomic_bool *cancelled = nullptr);
|
||||
QByteArray sha256(const QString &path, const std::atomic_bool *cancelled = nullptr);
|
||||
bool parseOptionalIsoDate(const QString &text, qint64 &seconds);
|
||||
bool shouldShowDuplicateResult(bool copiesOnly, int keeperPriority);
|
||||
@@ -0,0 +1,452 @@
|
||||
#include "search_engine.h"
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QDataStream>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QThread>
|
||||
#include <QtConcurrent>
|
||||
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <sys/stat.h>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
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;
|
||||
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 : patterns(o.roots)) {
|
||||
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 : patterns(o.roots)) {
|
||||
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 = QString::fromStdString(entry.path().string());
|
||||
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;
|
||||
|
||||
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,
|
||||
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);
|
||||
}
|
||||
|
||||
QVector<FileRecord> results;
|
||||
if (o.mode == QStringLiteral("Duplicates search")
|
||||
|| o.mode == QStringLiteral("Non-Duplicates search")) {
|
||||
std::unordered_map<qint64, QVector<FileRecord>> bySize;
|
||||
for (const auto &record : candidates)
|
||||
if (record.type == QStringLiteral("File"))
|
||||
bySize[record.size].push_back(record);
|
||||
quint64 hashTotal = 0;
|
||||
for (const auto &[size, records] : bySize) {
|
||||
Q_UNUSED(size);
|
||||
if (records.size() > 1)
|
||||
hashTotal += records.size();
|
||||
}
|
||||
std::atomic<quint64> hashed = 0;
|
||||
if (hashTotal)
|
||||
emit phaseProgress(tr("Checking duplicates"), 0, hashTotal);
|
||||
int group = 1;
|
||||
QSet<QString> duplicatedPaths;
|
||||
for (auto &[size, sameSize] : bySize) {
|
||||
if (cancelled_.load(std::memory_order_relaxed))
|
||||
break;
|
||||
Q_UNUSED(size);
|
||||
if (sameSize.size() < 2)
|
||||
continue;
|
||||
auto hashes = QtConcurrent::blockingMapped(sameSize, [this, &hashed, hashTotal](const FileRecord &r) {
|
||||
auto result = std::pair<QByteArray, FileRecord>{cachedSha256(r), r};
|
||||
const quint64 done = hashed.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||
if (done == hashTotal || done % 16 == 0)
|
||||
emit phaseProgress(tr("Checking duplicates"), done, hashTotal);
|
||||
return result;
|
||||
});
|
||||
if (cancelled_.load(std::memory_order_relaxed))
|
||||
break;
|
||||
QHash<QByteArray, QVector<FileRecord>> sameHash;
|
||||
for (auto &[hash, record] : hashes)
|
||||
if (!hash.isEmpty())
|
||||
sameHash[hash].push_back(record);
|
||||
for (const auto &hashCandidates : sameHash) {
|
||||
if (cancelled_.load(std::memory_order_relaxed))
|
||||
break;
|
||||
if (hashCandidates.size() < 2)
|
||||
continue;
|
||||
QVector<QVector<FileRecord>> exactGroups;
|
||||
for (const auto &record : hashCandidates) {
|
||||
if (cancelled_.load(std::memory_order_relaxed))
|
||||
break;
|
||||
bool placed = false;
|
||||
for (auto &exact : exactGroups) {
|
||||
if (filesEqual(exact.front().path, record.path, &cancelled_)) {
|
||||
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;
|
||||
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;
|
||||
if (o.duplicateNameMode.contains(QStringLiteral("identical"), Qt::CaseInsensitive)) {
|
||||
const QByteArray firstHash = cachedSha256(sameName.front());
|
||||
bool identical = !firstHash.isEmpty();
|
||||
for (qsizetype i = 1; identical && i < sameName.size(); ++i) {
|
||||
identical = cachedSha256(sameName[i]) == firstHash
|
||||
&& filesEqual(
|
||||
sameName.front().path, sameName[i].path, &cancelled_);
|
||||
}
|
||||
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 : patterns(o.roots)) {
|
||||
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, 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();
|
||||
emit finished(results, 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)));
|
||||
}
|
||||
|
||||
QByteArray SearchEngine::cachedSha256(const FileRecord &record)
|
||||
{
|
||||
QByteArray digest;
|
||||
if (cache_.findHash(record, digest)) {
|
||||
cacheHits_.fetch_add(1, std::memory_order_relaxed);
|
||||
return digest;
|
||||
}
|
||||
cacheMisses_.fetch_add(1, std::memory_order_relaxed);
|
||||
digest = sha256(record.path, &cancelled_);
|
||||
if (!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)) {
|
||||
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,43 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QVector>
|
||||
#include <QString>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
|
||||
#include "search_core.h"
|
||||
#include "search_cache.h"
|
||||
|
||||
class SearchEngine final : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit SearchEngine(QObject *parent = nullptr) : QObject(parent) {}
|
||||
|
||||
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 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,40 @@
|
||||
#include "long_long_spin_box.h"
|
||||
|
||||
#include <QLineEdit>
|
||||
#include <QtTest>
|
||||
|
||||
class LongLongSpinBoxTest final : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void preservesValuesAboveTwoGigabytes()
|
||||
{
|
||||
LongLongSpinBox spin;
|
||||
spin.setRange(0, std::numeric_limits<qint64>::max());
|
||||
spin.setSuffix(QStringLiteral(" bytes"));
|
||||
spin.setValue(5'000'000'000);
|
||||
QCOMPARE(spin.value(), qint64(5'000'000'000));
|
||||
QCOMPARE(spin.findChild<QLineEdit *>()->text(), QStringLiteral("5000000000 bytes"));
|
||||
spin.stepUp();
|
||||
QCOMPARE(spin.value(), qint64(5'000'000'001));
|
||||
|
||||
spin.findChild<QLineEdit *>()->setText(QStringLiteral("6000000000 bytes"));
|
||||
QVERIFY(QMetaObject::invokeMethod(&spin, "editingFinished", Qt::DirectConnection));
|
||||
QCOMPARE(spin.value(), qint64(6'000'000'000));
|
||||
}
|
||||
|
||||
void clampsAtRangeEdges()
|
||||
{
|
||||
LongLongSpinBox spin;
|
||||
spin.setRange(0, 10);
|
||||
spin.setValue(10);
|
||||
spin.stepUp();
|
||||
QCOMPARE(spin.value(), qint64(10));
|
||||
spin.setValue(-50);
|
||||
QCOMPARE(spin.value(), qint64(0));
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_MAIN(LongLongSpinBoxTest)
|
||||
|
||||
#include "long_long_spin_box_test.moc"
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
release_script="$(realpath "$1")"
|
||||
test_directory="$(mktemp -d)"
|
||||
trap 'rm -rf "$test_directory"' EXIT
|
||||
|
||||
git -C "$test_directory" init -q
|
||||
git -C "$test_directory" config user.name "FolderScope Tests"
|
||||
git -C "$test_directory" config user.email "tests@folderscope.invalid"
|
||||
printf '9.9.9\n' > "$test_directory/VERSION"
|
||||
printf '# Changelog\n\n## [9.9.9]\n' > "$test_directory/CHANGELOG.md"
|
||||
git -C "$test_directory" add VERSION CHANGELOG.md
|
||||
git -C "$test_directory" commit -q -m "test fixture"
|
||||
touch "$test_directory/untracked"
|
||||
|
||||
if (cd "$test_directory" && "$release_script" 9.9.9 >/dev/null 2>&1); then
|
||||
echo "release.sh accepted a repository with untracked files" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,115 @@
|
||||
#include "search_core.h"
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QFile>
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
|
||||
class SearchCoreTest final : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void parsesPatternsAndExclusions()
|
||||
{
|
||||
QCOMPARE(patterns(QStringLiteral("/a; \"/b,with-comma\" , /c")),
|
||||
QStringList({QStringLiteral("/a"), QStringLiteral("/b,with-comma"),
|
||||
QStringLiteral("/c")}));
|
||||
QCOMPARE(exclusionPatterns(QStringLiteral("tmp log *.bak")),
|
||||
QStringList({QStringLiteral("*.tmp"), QStringLiteral("*.log"),
|
||||
QStringLiteral("*.bak")}));
|
||||
QVERIFY(wildcardMatch(QStringLiteral("Report.TXT"), {QStringLiteral("*.txt")},
|
||||
Qt::CaseInsensitive));
|
||||
QVERIFY(!wildcardMatch(QStringLiteral("Report.TXT"), {QStringLiteral("*.txt")},
|
||||
Qt::CaseSensitive));
|
||||
}
|
||||
|
||||
void parsesOptionalIsoDates()
|
||||
{
|
||||
qint64 seconds = -1;
|
||||
QVERIFY(parseOptionalIsoDate(QString{}, seconds));
|
||||
QCOMPARE(seconds, 0);
|
||||
QVERIFY(parseOptionalIsoDate(QStringLiteral("2026-07-25T20:30:00"), seconds));
|
||||
QVERIFY(seconds > 0);
|
||||
QVERIFY(!parseOptionalIsoDate(QStringLiteral("2026-not-a-date"), seconds));
|
||||
}
|
||||
|
||||
void selectsDuplicateRowsForDisplay()
|
||||
{
|
||||
QVERIFY(!shouldShowDuplicateResult(true, 0));
|
||||
QVERIFY(!shouldShowDuplicateResult(true, 1));
|
||||
QVERIFY(shouldShowDuplicateResult(true, 2));
|
||||
|
||||
QVERIFY(!shouldShowDuplicateResult(false, 0));
|
||||
QVERIFY(shouldShowDuplicateResult(false, 1));
|
||||
QVERIFY(shouldShowDuplicateResult(false, 2));
|
||||
}
|
||||
|
||||
void searchesContentsAndHashes()
|
||||
{
|
||||
QTemporaryDir directory;
|
||||
QVERIFY(directory.isValid());
|
||||
const QString firstPath = directory.filePath(QStringLiteral("first.bin"));
|
||||
const QString secondPath = directory.filePath(QStringLiteral("second.bin"));
|
||||
const QString differentPath = directory.filePath(QStringLiteral("different.bin"));
|
||||
const QByteArray content = QByteArray(1024 * 1024 - 2, 'x')
|
||||
+ QByteArray("Alpha needle BETA");
|
||||
|
||||
QFile first(firstPath);
|
||||
QVERIFY(first.open(QIODevice::WriteOnly));
|
||||
QCOMPARE(first.write(content), content.size());
|
||||
first.close();
|
||||
|
||||
QFile second(secondPath);
|
||||
QVERIFY(second.open(QIODevice::WriteOnly));
|
||||
QCOMPARE(second.write(content), content.size());
|
||||
second.close();
|
||||
|
||||
QFile different(differentPath);
|
||||
QVERIFY(different.open(QIODevice::WriteOnly));
|
||||
QCOMPARE(different.write("different"), qint64(9));
|
||||
different.close();
|
||||
|
||||
SearchOptions options;
|
||||
options.contains = QStringLiteral("needle");
|
||||
QVERIFY(fileContains(firstPath, options));
|
||||
options.contains = QStringLiteral("alpha,beta");
|
||||
options.multipleValues = true;
|
||||
options.multipleAnd = true;
|
||||
QVERIFY(fileContains(firstPath, options));
|
||||
options.caseSensitive = true;
|
||||
QVERIFY(!fileContains(firstPath, options));
|
||||
options.binary = true;
|
||||
options.multipleValues = false;
|
||||
options.multipleAnd = false;
|
||||
options.contains = QStringLiteral("41 6c 70 68 61");
|
||||
QVERIFY(fileContains(firstPath, options));
|
||||
options.contains = QStringLiteral("ABC");
|
||||
QVERIFY(!fileContains(firstPath, options));
|
||||
|
||||
QVERIFY(filesEqual(firstPath, secondPath));
|
||||
QVERIFY(!filesEqual(firstPath, differentPath));
|
||||
QCOMPARE(sha256(firstPath), QCryptographicHash::hash(content, QCryptographicHash::Sha256));
|
||||
}
|
||||
|
||||
void cancelsFileOperations()
|
||||
{
|
||||
QTemporaryDir directory;
|
||||
QVERIFY(directory.isValid());
|
||||
const QString path = directory.filePath(QStringLiteral("file.bin"));
|
||||
QFile file(path);
|
||||
QVERIFY(file.open(QIODevice::WriteOnly));
|
||||
QCOMPARE(file.write("needle"), qint64(6));
|
||||
file.close();
|
||||
|
||||
std::atomic_bool cancelled = true;
|
||||
SearchOptions options;
|
||||
options.contains = QStringLiteral("needle");
|
||||
QVERIFY(!fileContains(path, options, &cancelled));
|
||||
QVERIFY(!filesEqual(path, path, &cancelled));
|
||||
QVERIFY(sha256(path, &cancelled).isEmpty());
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_GUILESS_MAIN(SearchCoreTest)
|
||||
|
||||
#include "search_core_test.moc"
|
||||
Reference in New Issue
Block a user